From 9081ec8055386802fd6669a5ba0afecbb3cbaccd Mon Sep 17 00:00:00 2001 From: Andreas Troelsen Date: Wed, 7 Apr 2021 21:52:49 +0200 Subject: [PATCH] Add support for custom formulas. This commit re-frames the formula concept used by the wave growth, swarm amount, and boss health wave configuration properties. It fundamentally changes how these values are calculated, from a static, compile-time set of enum values and hardcoded expressions, to a powerful math expression feature that supports constants, variables, operators, and functions. In part to remain backwards compatible with existing MobArena setups, and in part for a better user experience, the old enum-based expressions are relocated into a new file, `formulas.yml`, as _macros_. The file is written to the plugin folder if missing, and it contains a formula for each of the legacy values for each of the enums. Additionally, it has a global section with some predefined macros for inspiration's sake. The goal of this file is to allow people to define new formulas and reuse them in their wave configurations instead of having to duplicate the same formulas again and again. Parts of the system are extensible. It is possible for other plugins to register additional constants, variables, operators, and functions. Closes #460 Closes #461 --- changelog.md | 3 + .../garbagemule/MobArena/MASpawnThread.java | 2 +- .../com/garbagemule/MobArena/MobArena.java | 28 ++ .../MobArena/formula/ArgumentMismatch.java | 36 ++ .../MobArena/formula/BinaryFormula.java | 24 ++ .../MobArena/formula/BinaryFunction.java | 17 + .../MobArena/formula/BinaryOperation.java | 7 + .../MobArena/formula/BinaryOperator.java | 21 + .../MobArena/formula/Environment.java | 170 ++++++++ .../garbagemule/MobArena/formula/Formula.java | 9 + .../MobArena/formula/FormulaError.java | 30 ++ .../MobArena/formula/FormulaMacros.java | 98 +++++ .../MobArena/formula/FormulaManager.java | 81 ++++ .../MobArena/formula/Formulas.java | 37 ++ .../garbagemule/MobArena/formula/Lexeme.java | 20 + .../garbagemule/MobArena/formula/Lexer.java | 213 ++++++++++ .../MobArena/formula/LexerError.java | 9 + .../garbagemule/MobArena/formula/Parser.java | 338 ++++++++++++++++ .../MobArena/formula/ParserError.java | 9 + .../garbagemule/MobArena/formula/Token.java | 77 ++++ .../MobArena/formula/TokenType.java | 12 + .../MobArena/formula/UnaryFormula.java | 21 + .../MobArena/formula/UnaryFunction.java | 17 + .../MobArena/formula/UnaryOperation.java | 7 + .../MobArena/formula/UnaryOperator.java | 19 + .../MobArena/formula/UnexpectedToken.java | 15 + .../MobArena/formula/UnknownToken.java | 23 ++ .../formula/UnmatchedParenthesis.java | 16 + .../MobArena/formula/ValueFormula.java | 18 + .../MobArena/waves/WaveParser.java | 78 ++-- .../MobArena/waves/types/BossWave.java | 33 +- .../MobArena/waves/types/DefaultWave.java | 13 +- .../MobArena/waves/types/SwarmWave.java | 13 +- src/main/resources/formulas.yml | 34 ++ .../MobArena/formula/FormulaMacrosIT.java | 260 ++++++++++++ .../MobArena/formula/FormulaManagerIT.java | 372 ++++++++++++++++++ .../MobArena/formula/LexemeMatcher.java | 43 ++ .../MobArena/formula/LexerConstantTest.java | 75 ++++ .../MobArena/formula/LexerFunctionTest.java | 130 ++++++ .../MobArena/formula/LexerNumberTest.java | 103 +++++ .../MobArena/formula/LexerOperatorTest.java | 176 +++++++++ .../formula/LexerParenthesisTest.java | 135 +++++++ .../MobArena/formula/LexerVariableTest.java | 109 +++++ .../MobArena/formula/LexerWhitespaceTest.java | 42 ++ src/test/resources/formulas.yml | 19 + 45 files changed, 2940 insertions(+), 72 deletions(-) create mode 100644 src/main/java/com/garbagemule/MobArena/formula/ArgumentMismatch.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/BinaryFormula.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/BinaryFunction.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/BinaryOperation.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/BinaryOperator.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/Environment.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/Formula.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/FormulaError.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/FormulaMacros.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/FormulaManager.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/Formulas.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/Lexeme.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/Lexer.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/LexerError.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/Parser.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/ParserError.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/Token.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/TokenType.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/UnaryFormula.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/UnaryFunction.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/UnaryOperation.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/UnaryOperator.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/UnexpectedToken.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/UnknownToken.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/UnmatchedParenthesis.java create mode 100644 src/main/java/com/garbagemule/MobArena/formula/ValueFormula.java create mode 100644 src/main/resources/formulas.yml create mode 100644 src/test/java/com/garbagemule/MobArena/formula/FormulaMacrosIT.java create mode 100644 src/test/java/com/garbagemule/MobArena/formula/FormulaManagerIT.java create mode 100644 src/test/java/com/garbagemule/MobArena/formula/LexemeMatcher.java create mode 100644 src/test/java/com/garbagemule/MobArena/formula/LexerConstantTest.java create mode 100644 src/test/java/com/garbagemule/MobArena/formula/LexerFunctionTest.java create mode 100644 src/test/java/com/garbagemule/MobArena/formula/LexerNumberTest.java create mode 100644 src/test/java/com/garbagemule/MobArena/formula/LexerOperatorTest.java create mode 100644 src/test/java/com/garbagemule/MobArena/formula/LexerParenthesisTest.java create mode 100644 src/test/java/com/garbagemule/MobArena/formula/LexerVariableTest.java create mode 100644 src/test/java/com/garbagemule/MobArena/formula/LexerWhitespaceTest.java create mode 100644 src/test/resources/formulas.yml diff --git a/changelog.md b/changelog.md index 2809d48..cc61c32 100644 --- a/changelog.md +++ b/changelog.md @@ -11,6 +11,9 @@ These changes will (most likely) be included in the next version. ## [Unreleased] +### Added +- It is now possible to write custom formulas for wave growth in Default Wave, swarm amounts in Swarm Waves, and boss health in Boss Waves, allowing for much more control and fine-tuning. The formulas support various session-related variables as well as various mathematical operators and functions. Formulas can be predefined as macros in the new `formulas.yml` file. Check the wiki for details. + ### Fixed - Arena signs in unloaded or missing worlds no longer break the startup procedure. Sign data is stored in a new format that MobArena will automatically migrate to on a per-world basis during startup. diff --git a/src/main/java/com/garbagemule/MobArena/MASpawnThread.java b/src/main/java/com/garbagemule/MobArena/MASpawnThread.java index 76f2bf6..e486f4d 100644 --- a/src/main/java/com/garbagemule/MobArena/MASpawnThread.java +++ b/src/main/java/com/garbagemule/MobArena/MASpawnThread.java @@ -248,7 +248,7 @@ public class MASpawnThread implements Runnable switch (w.getType()){ case BOSS: BossWave bw = (BossWave) w; - double maxHealth = bw.getMaxHealth(playerCount); + double maxHealth = bw.getHealth().evaluate(arena); MABoss boss = monsterManager.addBoss(e, maxHealth); HealthBar healthbar = createsHealthBar.create(e, bw.getBossName()); arena.getPlayersInArena().forEach(healthbar::addPlayer); diff --git a/src/main/java/com/garbagemule/MobArena/MobArena.java b/src/main/java/com/garbagemule/MobArena/MobArena.java index 9a3373f..ae6c1ff 100644 --- a/src/main/java/com/garbagemule/MobArena/MobArena.java +++ b/src/main/java/com/garbagemule/MobArena/MobArena.java @@ -2,6 +2,8 @@ package com.garbagemule.MobArena; import com.garbagemule.MobArena.commands.CommandHandler; import com.garbagemule.MobArena.config.LoadsConfigFile; +import com.garbagemule.MobArena.formula.FormulaMacros; +import com.garbagemule.MobArena.formula.FormulaManager; import com.garbagemule.MobArena.framework.Arena; import com.garbagemule.MobArena.framework.ArenaMaster; import com.garbagemule.MobArena.listeners.MAGlobalListener; @@ -60,6 +62,8 @@ public class MobArena extends JavaPlugin private Messenger messenger; private ThingManager thingman; private ThingPickerManager pickman; + private FormulaManager formman; + private FormulaMacros macros; private SignListeners signListeners; @@ -71,6 +75,8 @@ public class MobArena extends JavaPlugin pickman.register(new ThingGroupPickerParser(pickman)); pickman.register(new RandomThingPickerParser(pickman, random)); pickman.register(new NothingPickerParser()); + + formman = FormulaManager.createDefault(); } public void onEnable() { @@ -97,6 +103,7 @@ public class MobArena extends JavaPlugin private void setup() { try { createDataFolder(); + setupFormulaMacros(); setupArenaMaster(); setupCommandHandler(); @@ -121,6 +128,10 @@ public class MobArena extends JavaPlugin } } + private void setupFormulaMacros() { + macros = FormulaMacros.create(this); + } + private void setupArenaMaster() { arenaMaster = new ArenaMasterImpl(this); } @@ -173,6 +184,7 @@ public class MobArena extends JavaPlugin try { reloadConfig(); reloadGlobalMessenger(); + reloadFormulaMacros(); reloadArenaMaster(); reloadAnnouncementsFile(); reloadSigns(); @@ -198,6 +210,14 @@ public class MobArena extends JavaPlugin messenger = new Messenger(prefix); } + private void reloadFormulaMacros() { + try { + macros.reload(); + } catch (IOException e) { + throw new RuntimeException("There was an error reloading the formulas-file:\n" + e.getMessage()); + } + } + private void reloadArenaMaster() { arenaMaster.getArenas().forEach(Arena::forceEnd); arenaMaster.initialize(); @@ -281,4 +301,12 @@ public class MobArena extends JavaPlugin public ThingPickerManager getThingPickerManager() { return pickman; } + + public FormulaManager getFormulaManager() { + return formman; + } + + public FormulaMacros getFormulaMacros() { + return macros; + } } diff --git a/src/main/java/com/garbagemule/MobArena/formula/ArgumentMismatch.java b/src/main/java/com/garbagemule/MobArena/formula/ArgumentMismatch.java new file mode 100644 index 0000000..1cfe38a --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/ArgumentMismatch.java @@ -0,0 +1,36 @@ +package com.garbagemule.MobArena.formula; + +class ArgumentMismatch extends FormulaError { + + private static final String few = "Not enough arguments for %s(%s), expected %d, got %d"; + private static final String many = "Too many arguments for %s(%s), expected %d, got %d"; + + ArgumentMismatch(Lexeme lexeme, int expected, int counted, String input) { + super(message(lexeme, expected, counted), input, lexeme.pos); + } + + private static String message(Lexeme lexeme, int expected, int counted) { + String name = lexeme.value; + String args = args(expected); + String template = (counted < expected) ? few : many; + return String.format(template, name, args, expected, counted); + } + + private static String args(int count) { + if (count == 0) { + return ""; + } + + char current = 'a'; + StringBuilder result = new StringBuilder(); + result.append(current); + + for (int i = 1; i < count; i++) { + current++; + result.append(",").append(current); + } + + return result.toString(); + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/BinaryFormula.java b/src/main/java/com/garbagemule/MobArena/formula/BinaryFormula.java new file mode 100644 index 0000000..843735d --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/BinaryFormula.java @@ -0,0 +1,24 @@ +package com.garbagemule.MobArena.formula; + +import com.garbagemule.MobArena.framework.Arena; + +class BinaryFormula implements Formula { + + private final BinaryOperation operation; + private final Formula left; + private final Formula right; + + BinaryFormula(BinaryOperation operation, Formula left, Formula right) { + this.operation = operation; + this.left = left; + this.right = right; + } + + @Override + public double evaluate(Arena arena) { + double a = left.evaluate(arena); + double b = right.evaluate(arena); + return operation.apply(a, b); + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/BinaryFunction.java b/src/main/java/com/garbagemule/MobArena/formula/BinaryFunction.java new file mode 100644 index 0000000..43858ce --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/BinaryFunction.java @@ -0,0 +1,17 @@ +package com.garbagemule.MobArena.formula; + +class BinaryFunction { + + final String name; + final BinaryOperation operation; + + BinaryFunction(String name, BinaryOperation operation) { + this.name = name; + this.operation = operation; + } + + Formula create(Formula left, Formula right) { + return new BinaryFormula(operation, left, right); + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/BinaryOperation.java b/src/main/java/com/garbagemule/MobArena/formula/BinaryOperation.java new file mode 100644 index 0000000..9a06a27 --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/BinaryOperation.java @@ -0,0 +1,7 @@ +package com.garbagemule.MobArena.formula; + +import java.util.function.BiFunction; + +@FunctionalInterface +public interface BinaryOperation extends BiFunction { +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/BinaryOperator.java b/src/main/java/com/garbagemule/MobArena/formula/BinaryOperator.java new file mode 100644 index 0000000..979840f --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/BinaryOperator.java @@ -0,0 +1,21 @@ +package com.garbagemule.MobArena.formula; + +class BinaryOperator { + + final String symbol; + final int precedence; + final boolean left; + final BinaryOperation operation; + + BinaryOperator(String symbol, int precedence, boolean left, BinaryOperation operation) { + this.symbol = symbol; + this.precedence = precedence; + this.left = left; + this.operation = operation; + } + + Formula create(Formula left, Formula right) { + return new BinaryFormula(operation, left, right); + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/Environment.java b/src/main/java/com/garbagemule/MobArena/formula/Environment.java new file mode 100644 index 0000000..f0b7a30 --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/Environment.java @@ -0,0 +1,170 @@ +package com.garbagemule.MobArena.formula; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class Environment { + + final List unary; + final List binary; + final List symbols; + + final Map constants; + final Map variables; + final Map unaryOperators; + final Map binaryOperators; + final Map unaryFunctions; + final Map binaryFunctions; + + private Environment() { + unary = new ArrayList<>(); + binary = new ArrayList<>(); + symbols = Arrays.asList( + Token.LEFT_PAREN, + Token.RIGHT_PAREN, + Token.COMMA + ); + + constants = new HashMap<>(); + variables = new HashMap<>(); + unaryOperators = new HashMap<>(); + binaryOperators = new HashMap<>(); + unaryFunctions = new HashMap<>(); + binaryFunctions = new HashMap<>(); + } + + void registerConstant(String name, double value) { + constants.put(name, value); + } + + void registerVariable(String name, Formula formula) { + variables.put(name, formula); + } + + void registerUnaryOperator(String symbol, int precedence, UnaryOperation operation) { + unaryOperators.put(symbol, new UnaryOperator(symbol, precedence, operation)); + registerOperatorToken(TokenType.UNARY_OPERATOR, symbol, unary); + } + + void registerBinaryOperator(String symbol, int precedence, boolean left, BinaryOperation operation) { + binaryOperators.put(symbol, new BinaryOperator(symbol, precedence, left, operation)); + registerOperatorToken(TokenType.BINARY_OPERATOR, symbol, binary); + } + + void registerUnaryFunction(String name, UnaryOperation operation) { + unaryFunctions.put(name, new UnaryFunction(name, operation)); + } + + void registerBinaryFunction(String name, BinaryOperation operation) { + binaryFunctions.put(name, new BinaryFunction(name, operation)); + } + + boolean isConstant(String identifier) { + return constants.containsKey(identifier); + } + + boolean isVariable(String identifier) { + return variables.containsKey(identifier); + } + + boolean isUnaryOperator(String symbol) { + return unaryOperators.containsKey(symbol); + } + + boolean isBinaryOperator(String symbol) { + return binaryOperators.containsKey(symbol); + } + + boolean isUnaryFunction(String identifier) { + return unaryFunctions.containsKey(identifier); + } + + boolean isBinaryFunction(String identifier) { + return binaryFunctions.containsKey(identifier); + } + + boolean isFunction(String identifier) { + return isUnaryFunction(identifier) + || isBinaryFunction(identifier); + } + + double getConstant(String identifier) { + return constants.get(identifier); + } + + Formula getVariable(String identifier) { + return variables.get(identifier); + } + + UnaryOperator getUnaryOperator(String symbol) { + return unaryOperators.get(symbol); + } + + BinaryOperator getBinaryOperator(String symbol) { + return binaryOperators.get(symbol); + } + + UnaryFunction getUnaryFunction(String identifier) { + return unaryFunctions.get(identifier); + } + + BinaryFunction getBinaryFunction(String identifier) { + return binaryFunctions.get(identifier); + } + + private void registerOperatorToken(TokenType type, String operator, List operators) { + int i; + for (i = 0; i < operators.size(); i++) { + if (operators.get(i).symbol.length() < operator.length()) { + break; + } + } + String escaped = operator.replaceAll("", "\\\\"); + String trimmed = escaped.substring(0, escaped.length() - 1); + Token token = new Token(type, trimmed, operator); + operators.add(i, token); + } + + @SuppressWarnings("Convert2MethodRef") + static Environment createDefault() { + Environment result = new Environment(); + + // Constants + result.registerConstant("pi", Math.PI); + result.registerConstant("e", Math.E); + + // Unary operators + result.registerUnaryOperator("+", 4, value -> +value); + result.registerUnaryOperator("-", 4, value -> -value); + + // Binary operators + result.registerBinaryOperator("+", 2, true, (a, b) -> a + b); + result.registerBinaryOperator("-", 2, true, (a, b) -> a - b); + result.registerBinaryOperator("*", 3, true, (a, b) -> a * b); + result.registerBinaryOperator("/", 3, true, (a, b) -> a / b); + result.registerBinaryOperator("%", 3, true, (a, b) -> a % b); + result.registerBinaryOperator("^", 4, false, (a, b) -> Math.pow(a, b)); + + // Unary functions + result.registerUnaryFunction("sqrt", Math::sqrt); + result.registerUnaryFunction("abs", Math::abs); + + result.registerUnaryFunction("ceil", Math::ceil); + result.registerUnaryFunction("floor", Math::floor); + result.registerUnaryFunction("round", value -> (double) Math.round(value)); + + result.registerUnaryFunction("sin", Math::sin); + result.registerUnaryFunction("cos", Math::cos); + result.registerUnaryFunction("tan", Math::tan); + + // Binary functions + result.registerBinaryFunction("min", Math::min); + result.registerBinaryFunction("max", Math::max); + + return result; + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/Formula.java b/src/main/java/com/garbagemule/MobArena/formula/Formula.java new file mode 100644 index 0000000..f0114a6 --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/Formula.java @@ -0,0 +1,9 @@ +package com.garbagemule.MobArena.formula; + +import com.garbagemule.MobArena.framework.Arena; + +public interface Formula { + + double evaluate(Arena arena); + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/FormulaError.java b/src/main/java/com/garbagemule/MobArena/formula/FormulaError.java new file mode 100644 index 0000000..904663a --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/FormulaError.java @@ -0,0 +1,30 @@ +package com.garbagemule.MobArena.formula; + +import java.util.Arrays; + +class FormulaError extends IllegalArgumentException { + + private final String input; + private final int pos; + + FormulaError(String message, String input, int pos) { + super(message); + this.input = input; + this.pos = pos; + } + + @Override + public String getMessage() { + String arrow = arrow(pos + 1); + String template = "%s\n%s\n%s"; + return String.format(template, super.getMessage(), input, arrow); + } + + private String arrow(int length) { + char[] value = new char[length]; + Arrays.fill(value, ' '); + value[length - 1] = '^'; + return new String(value); + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/FormulaMacros.java b/src/main/java/com/garbagemule/MobArena/formula/FormulaMacros.java new file mode 100644 index 0000000..197d28c --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/FormulaMacros.java @@ -0,0 +1,98 @@ +package com.garbagemule.MobArena.formula; + +import com.garbagemule.MobArena.MobArena; +import org.yaml.snakeyaml.Yaml; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +public class FormulaMacros { + + private static final String FILENAME = "formulas.yml"; + + private final Path file; + private final Map> macros; + + private FormulaMacros(Path file) { + this.file = file; + this.macros = new HashMap<>(); + } + + public void reload() throws IOException { + byte[] bytes = Files.readAllBytes(file); + String content = new String(bytes); + + Yaml yaml = new Yaml(); + Map raw = yaml.load(content); + Map> converted = convert(raw); + + macros.clear(); + macros.putAll(converted); + } + + private Map> convert(Map raw) { + Map> result = new HashMap<>(); + + for (Map.Entry entry : raw.entrySet()) { + String section = String.valueOf(entry.getKey()); + Map macros = convert(entry.getValue()); + if (macros != null) { + result.put(section, macros); + } + } + + return result; + } + + private Map convert(Object raw) { + if (raw instanceof Map) { + Map macros = new HashMap<>(); + for (Map.Entry entry : ((Map) raw).entrySet()) { + String macro = String.valueOf(entry.getKey()); + String formula = String.valueOf(entry.getValue()); + macros.put(macro, formula); + } + return macros; + } + return null; + } + + public String get(String key) { + return get(null, key); + } + + public String get(String section, String key) { + if (key == null) { + return null; + } + if (section != null) { + String macro = lookup(section, key); + if (macro != null) { + return macro; + } + } + return lookup("global", key); + } + + private String lookup(String section, String key) { + Map specific = macros.get(section); + if (specific == null) { + return null; + } + return specific.get(key); + } + + public static FormulaMacros create(MobArena plugin) { + File file = new File(plugin.getDataFolder(), FILENAME); + if (!file.exists()) { + plugin.getLogger().info(FILENAME + " not found, creating default..."); + plugin.saveResource(FILENAME, false); + } + return new FormulaMacros(file.toPath()); + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/FormulaManager.java b/src/main/java/com/garbagemule/MobArena/formula/FormulaManager.java new file mode 100644 index 0000000..6c3bb9e --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/FormulaManager.java @@ -0,0 +1,81 @@ +package com.garbagemule.MobArena.formula; + +import com.garbagemule.MobArena.framework.Arena; + +import java.util.List; + +public class FormulaManager { + + private final Environment env; + private final Lexer lexer; + private final Parser parser; + + FormulaManager( + Environment env, + Lexer lexer, + Parser parser + ) { + this.env = env; + this.lexer = lexer; + this.parser = parser; + } + + @SuppressWarnings("unused") + public void registerConstant(String name, double value) { + env.registerConstant(name, value); + } + + @SuppressWarnings("unused") + public void registerVariable(String name, Formula formula) { + env.registerVariable(name, formula); + } + + @SuppressWarnings("unused") + public void registerUnaryOperator(String symbol, int precedence, UnaryOperation operation) { + env.registerUnaryOperator(symbol, precedence, operation); + } + + @SuppressWarnings("unused") + public void registerBinaryOperator(String symbol, int precedence, boolean left, BinaryOperation operation) { + env.registerBinaryOperator(symbol, precedence, left, operation); + } + + @SuppressWarnings("unused") + public void registerUnaryFunction(String name, UnaryOperation operation) { + env.registerUnaryFunction(name, operation); + } + + @SuppressWarnings("unused") + public void registerBinaryFunction(String name, BinaryOperation operation) { + env.registerBinaryFunction(name, operation); + } + + public Formula parse(String input) { + List infix = lexer.tokenize(input); + return parser.parse(input, infix); + } + + public static FormulaManager createDefault() { + Environment env = Environment.createDefault(); + + // Wave number variables + env.registerVariable("current-wave", a -> a.getWaveManager().getWaveNumber()); + env.registerVariable("final-wave", a -> a.getWaveManager().getFinalWave()); + + // Player count variables + env.registerVariable("initial-players", Arena::getPlayerCount); + env.registerVariable("live-players", a -> a.getPlayersInArena().size()); + env.registerVariable("dead-players", a -> a.getPlayerCount() - a.getPlayersInArena().size()); + env.registerVariable("min-players", Arena::getMinPlayers); + env.registerVariable("max-players", Arena::getMaxPlayers); + + // Monster count variables + env.registerVariable("live-monsters", a -> a.getMonsterManager().getMonsters().size()); + + Lexer lexer = new Lexer(env); + Parser parser = new Parser(env); + + return new FormulaManager(env, lexer, parser); + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/Formulas.java b/src/main/java/com/garbagemule/MobArena/formula/Formulas.java new file mode 100644 index 0000000..4e60905 --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/Formulas.java @@ -0,0 +1,37 @@ +package com.garbagemule.MobArena.formula; + +public class Formulas { + + /** + * Default "old" style wave growth. Equivalent to the formula: + *
{@code  + }
+ */ + public static final Formula DEFAULT_WAVE_GROWTH = (arena) -> { + int players = arena.getPlayerCount(); + int wave = arena.getWaveManager().getWaveNumber(); + return players + wave; + }; + + /** + * Default "low" swarm amount. Equivalent to the formula: + *
{@code max(1,  / 2) * 10}
+ */ + public static final Formula DEFAULT_SWARM_AMOUNT = (arena) -> { + int players = arena.getPlayerCount(); + return Math.max(1, players / 2) * 10; + }; + + /** + * Default "medium" boss health. Equivalent to the formula: + *
{@code ( + 1) 20 * 15}
+ */ + public static final Formula DEFAULT_BOSS_HEALTH = (arena) -> { + int players = arena.getPlayerCount(); + return (players + 1) * 20 * 8; + }; + + private Formulas() { + // OK BOSS + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/Lexeme.java b/src/main/java/com/garbagemule/MobArena/formula/Lexeme.java new file mode 100644 index 0000000..cb62d95 --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/Lexeme.java @@ -0,0 +1,20 @@ +package com.garbagemule.MobArena.formula; + +class Lexeme { + + final Token token; + final String value; + final int pos; + + Lexeme(Token token, String value, int pos) { + this.token = token; + this.value = value; + this.pos = pos; + } + + @Override + public String toString() { + return token.type + " '" + value + "'"; + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/Lexer.java b/src/main/java/com/garbagemule/MobArena/formula/Lexer.java new file mode 100644 index 0000000..9112dfc --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/Lexer.java @@ -0,0 +1,213 @@ +package com.garbagemule.MobArena.formula; + +import java.util.ArrayList; +import java.util.List; + +class Lexer { + + private final Environment env; + + private List result; + private String input; + private int pos; + + Lexer(Environment env) { + this.env = env; + } + + List tokenize(String input) { + this.result = new ArrayList<>(); + this.input = input; + this.pos = 0; + + tokenize(); + + List result = this.result; + this.result = null; + this.input = null; + this.pos = -1; + + return result; + } + + private void tokenize() { + while (pos < input.length()) { + skipWhitespace(); + nextToken(); + } + } + + private void skipWhitespace() { + for (int i = pos; i < input.length(); i++) { + char current = input.charAt(i); + if (Character.isWhitespace(current)) { + continue; + } + pos = i; + return; + } + } + + private void nextToken() { + if (nextNumber()) { + return; + } + if (nextIdentifier()) { + return; + } + if (nextVariable()) { + return; + } + if (nextOperator()) { + return; + } + if (nextSymbol()) { + return; + } + String message = String.format("Unexpected token in column %d", pos + 1); + throw new LexerError(message, input, pos); + } + + private boolean nextNumber() { + char first = input.charAt(pos); + if (!Character.isDigit(first)) { + return false; + } + + String chunk = input.substring(pos); + int end = Token.NUMBER.match(chunk); + if (end < 0) { + String message = String.format("Invalid number in column %d", pos + 1); + throw new LexerError(message, input, pos); + } + + String lexeme = input.substring(pos, pos + end); + result.add(new Lexeme(Token.NUMBER, lexeme, pos)); + pos += end; + return true; + } + + private boolean nextIdentifier() { + char first = input.charAt(pos); + if (!Character.isLetter(first)) { + return false; + } + + String chunk = input.substring(pos); + int end = Token.IDENTIFIER.match(chunk); + if (end < 0) { + String message = String.format("Invalid identifier in column %d", pos + 1); + throw new LexerError(message, input, pos); + } + + if (pos + end < input.length()) { + if (input.charAt(pos + end) == '>') { + String message = String.format("Unmatched right bracket in column %d", pos + 1); + throw new LexerError(message, input, pos); + } + } + + String identifier = input.substring(pos, pos + end); + if (!env.isConstant(identifier) && !env.isFunction(identifier)) { + throw new UnknownToken("identifier", identifier, input, pos); + } + + result.add(new Lexeme(Token.IDENTIFIER, identifier, pos)); + pos += end; + return true; + } + + private boolean nextVariable() { + char first = input.charAt(pos); + if (first == '>') { + String message = String.format("Unmatched right bracket in column %d", pos + 1); + throw new LexerError(message, input, pos); + } + if (first != '<') { + return false; + } + + String chunk = input.substring(pos); + int end = Token.VARIABLE.match(chunk); + if (end < 0) { + String message = String.format("Invalid variable in column %d", pos + 1); + throw new LexerError(message, input, pos); + } + if (end >= chunk.length() || chunk.charAt(end) != '>') { + String message = String.format("Unmatched left bracket in column %d", pos + 1); + throw new LexerError(message, input, pos); + } + + String identifier = input.substring(pos + 1, pos + end); + if (!env.isVariable(identifier)) { + String varible = "<" + identifier + ">"; + throw new UnknownToken("variable", varible, input, pos); + } + + result.add(new Lexeme(Token.VARIABLE, identifier, pos)); + pos += end + 1; + return true; + } + + private boolean nextOperator() { + if (isUnaryTokenExpected()) { + return nextUnaryOperator(); + } else { + return nextBinaryOperator(); + } + } + + private boolean nextUnaryOperator() { + return nextToken(env.unary); + } + + private boolean nextBinaryOperator() { + return nextToken(env.binary); + } + + private boolean nextSymbol() { + return nextToken(env.symbols); + } + + private boolean nextToken(List tokens) { + String chunk = input.substring(pos); + for (Token token : tokens) { + int end = token.match(chunk); + if (end < 0) { + continue; + } + + String symbol = input.substring(pos, pos + end); + result.add(new Lexeme(token, symbol, pos)); + pos += end; + return true; + } + return false; + } + + private boolean isUnaryTokenExpected() { + if (result.isEmpty()) { + return true; + } + + Lexeme previous = result.get(result.size() - 1); + + switch (previous.token.type) { + case LEFT_PAREN: + case UNARY_OPERATOR: + case BINARY_OPERATOR: + case COMMA: { + return true; + } + case NUMBER: + case IDENTIFIER: + case VARIABLE: + case RIGHT_PAREN: { + return false; + } + } + + throw new UnknownToken("symbol", previous, input); + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/LexerError.java b/src/main/java/com/garbagemule/MobArena/formula/LexerError.java new file mode 100644 index 0000000..c24a71d --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/LexerError.java @@ -0,0 +1,9 @@ +package com.garbagemule.MobArena.formula; + +class LexerError extends FormulaError { + + LexerError(String message, String input, int pos) { + super(message, input, pos); + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/Parser.java b/src/main/java/com/garbagemule/MobArena/formula/Parser.java new file mode 100644 index 0000000..b6eb5e8 --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/Parser.java @@ -0,0 +1,338 @@ +package com.garbagemule.MobArena.formula; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; + +class Parser { + + private final Environment env; + + private Deque output; + private Deque stack; + private Deque args; + private String source; + private List input; + + Parser(Environment env) { + this.env = env; + } + + Formula parse(String source, List input) { + this.output = new ArrayDeque<>(); + this.stack = new ArrayDeque<>(); + this.args = new ArrayDeque<>(); + this.source = source; + this.input = input; + + Formula result = parse(); + this.output = null; + this.stack = null; + this.args = null; + this.source = null; + this.input = null; + + return result; + } + + private Formula parse() { + for (Lexeme lexeme : input) { + switch (lexeme.token.type) { + case NUMBER: { + number(lexeme); + break; + } + case IDENTIFIER: { + identifier(lexeme); + break; + } + case VARIABLE: { + variable(lexeme); + break; + } + case UNARY_OPERATOR: { + unary(lexeme); + break; + } + case BINARY_OPERATOR: { + binary(lexeme); + break; + } + case LEFT_PAREN: { + left(lexeme); + break; + } + case RIGHT_PAREN: { + right(lexeme); + break; + } + case COMMA: { + comma(lexeme); + break; + } + default: { + throw new UnexpectedToken(lexeme, source); + } + } + } + + // Once we get to the end of the infix expression, we'll need to pop + // off any remaining operators on the stack. Anything other than an + // operator token is an error, since parenthesized expressions would + // have already been completely resolved during conversion. + while (!stack.isEmpty()) { + Lexeme top = stack.peek(); + if (popIfOperator(top)) { + continue; + } + if (top.token.type == TokenType.LEFT_PAREN) { + throw new UnmatchedParenthesis(top, source); + } + throw new UnexpectedToken(top, source); + } + + if (output.size() != 1) { + throw new IllegalArgumentException("wtf bitchhhh"); + } + + return output.pop(); + } + + private void number(Lexeme lexeme) { + // Number tokens go straight to the output. + double value = Double.parseDouble(lexeme.value); + Formula formula = new ValueFormula(value); + output.push(formula); + } + + private void identifier(Lexeme lexeme) { + // Identifiers are either constants or functions. + String identifier = lexeme.value; + + if (env.isConstant(identifier)) { + // Constants go straight to the output. + double value = env.getConstant(identifier); + Formula formula = new ValueFormula(value); + output.push(formula); + return; + } + + if (env.isFunction(identifier)) { + // Functions go on the stack. + stack.push(lexeme); + args.push(1); + return; + } + + throw new UnknownToken("identifier", lexeme, source); + } + + private void variable(Lexeme lexeme) { + // Variables go straight to the output. + String identifier = lexeme.value; + + if (env.isVariable(identifier)) { + Formula formula = env.getVariable(identifier); + output.push(formula); + return; + } + + throw new UnknownToken("variable", lexeme, source); + } + + private void unary(Lexeme lexeme) { + String symbol = lexeme.value; + + if (!env.isUnaryOperator(symbol)) { + throw new UnknownToken("unary operator", lexeme, source); + } + + stack.push(lexeme); + } + + private void binary(Lexeme lexeme) { + String symbol = lexeme.value; + + if (!env.isBinaryOperator(symbol)) { + throw new UnknownToken("binary operator", lexeme, source); + } + + BinaryOperator current = env.getBinaryOperator(symbol); + + while (!stack.isEmpty()) { + Lexeme peek = stack.peek(); + String top = peek.value; + + if (peek.token.type == TokenType.UNARY_OPERATOR) { + if (!env.isUnaryOperator(top)) { + throw new UnknownToken("unary operator", peek, source); + } + + // It would be abuse of semantics to call unary operators + // "associative", but if we treat them as though they are + // right-associative, we can get robust behavior that will + // result in -2^4 evaluating to -16 (rather than 16) if we + // assign unary minus and exponentiation the same operator + // precedence. + UnaryOperator candidate = env.getUnaryOperator(top); + if (candidate.precedence > current.precedence) { + popIfOperator(peek); + continue; + } + break; + } + + if (peek.token.type == TokenType.BINARY_OPERATOR) { + if (!env.isBinaryOperator(top)) { + throw new UnknownToken("binary operator", peek, source); + } + + // Other binary operators get popped if they have a higher + // precedence, or if they have equal precedence and are + // left-associative. + BinaryOperator candidate = env.getBinaryOperator(top); + if (candidate.precedence > current.precedence) { + popIfOperator(peek); + continue; + } + if (candidate.precedence == current.precedence && current.left) { + popIfOperator(peek); + continue; + } + break; + } + + // A left parenthesis means we need to resolve before a + // new expression can begin. A right parenthesis means + // we need to resolve before the current expression ends. + // Either way, we don't pop anything. + if (peek.token.type == TokenType.LEFT_PAREN || peek.token.type == TokenType.RIGHT_PAREN) { + break; + } + + throw new UnexpectedToken(peek, source); + } + + stack.push(lexeme); + } + + private void left(Lexeme lexeme) { + // Left parentheses go on the stack. + stack.push(lexeme); + } + + private void right(Lexeme lexeme) { + if (stack.isEmpty()) { + throw new UnmatchedParenthesis(lexeme, source); + } + + // Right parentheses act as terminators in much the same way + // infix operators with low precedence do. + while (!stack.isEmpty()) { + Lexeme peek = stack.peek(); + + // Operators go straight to the output. + if (popIfOperator(peek)) { + continue; + } + + if (peek.token.type == TokenType.LEFT_PAREN) { + // When we hit a left parenthesis, the expression that + // the current right parenthesis belongs to is resolved, + // so it just needs to get popped. Then, if the top of + // the stack is a function token, it is also popped. + stack.pop(); + + if (!stack.isEmpty()) { + popIfFunction(stack.peek()); + } + + break; + } + + throw new UnexpectedToken(peek, source); + } + } + + private void comma(Lexeme lexeme) { + while (!stack.isEmpty()) { + Lexeme top = stack.peek(); + TokenType type = top.token.type; + + if (popIfOperator(top)) { + continue; + } + + if (type == TokenType.LEFT_PAREN) { + break; + } + + if (type == TokenType.RIGHT_PAREN) { + throw new UnexpectedToken(top, source); + } + + throw new UnexpectedToken(lexeme, source); + } + + args.push(args.pop() + 1); + } + + private boolean popIfOperator(Lexeme lexeme) { + String symbol = lexeme.value; + + if (lexeme.token.type == TokenType.UNARY_OPERATOR) { + if (env.isUnaryOperator(symbol)) { + UnaryOperator operator = env.getUnaryOperator(symbol); + Formula argument = output.pop(); + output.push(operator.create(argument)); + stack.pop(); + return true; + } + throw new UnknownToken("unary operator", lexeme, source); + } + + if (lexeme.token.type == TokenType.BINARY_OPERATOR) { + if (env.isBinaryOperator(symbol)) { + BinaryOperator operator = env.getBinaryOperator(symbol); + Formula right = output.pop(); + Formula left = output.pop(); + output.push(operator.create(left, right)); + stack.pop(); + return true; + } + throw new UnknownToken("binary operator", lexeme, source); + } + + return false; + } + + private void popIfFunction(Lexeme lexeme) { + if (lexeme.token.type != TokenType.IDENTIFIER) { + return; + } + + String identifier = lexeme.value; + + if (env.isUnaryFunction(identifier)) { + UnaryFunction function = env.getUnaryFunction(identifier); + Integer counted = args.pop(); + if (counted != 1) { + throw new ArgumentMismatch(lexeme, 1, counted, source); + } + Formula argument = output.pop(); + output.push(function.create(argument)); + stack.pop(); + } else if (env.isBinaryFunction(identifier)) { + BinaryFunction function = env.getBinaryFunction(identifier); + Integer counted = args.pop(); + if (counted != 2) { + throw new ArgumentMismatch(lexeme, 2, counted, source); + } + Formula right = output.pop(); + Formula left = output.pop(); + output.push(function.create(left, right)); + stack.pop(); + } + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/ParserError.java b/src/main/java/com/garbagemule/MobArena/formula/ParserError.java new file mode 100644 index 0000000..61dc18e --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/ParserError.java @@ -0,0 +1,9 @@ +package com.garbagemule.MobArena.formula; + +class ParserError extends FormulaError { + + ParserError(String message, String input, Lexeme lexeme) { + super(message, input, lexeme.pos); + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/Token.java b/src/main/java/com/garbagemule/MobArena/formula/Token.java new file mode 100644 index 0000000..3456bdc --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/Token.java @@ -0,0 +1,77 @@ +package com.garbagemule.MobArena.formula; + +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +class Token { + + static final Token LEFT_PAREN = new Token(TokenType.LEFT_PAREN, "\\(", "("); + static final Token RIGHT_PAREN = new Token(TokenType.RIGHT_PAREN, "\\)", ")"); + static final Token COMMA = new Token(TokenType.COMMA, "\\,", ","); + + static final Token NUMBER = new Token( + TokenType.NUMBER, + "([0-9]+[.])?[0-9]+([eE][-+]?[0-9]+)?" + ); + + static final Token IDENTIFIER = new Token( + TokenType.IDENTIFIER, + "\\p{L}([0-9]|\\p{L})*" + ); + + static final Token VARIABLE = new Token( + TokenType.VARIABLE, + "[<]\\p{L}(([0-9_-]|\\p{L})*([0-9]|\\p{L})+)?" + ); + + final TokenType type; + final Pattern pattern; + final String symbol; + + Token(TokenType type, String regex, String symbol) { + this.type = type; + this.pattern = Pattern.compile("^" + regex); + this.symbol = symbol; + } + + Token(TokenType type, String regex) { + this(type, regex, null); + } + + int match(CharSequence input) { + Matcher m = pattern.matcher(input); + if (m.find()) { + return m.end(); + } + return -1; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Token token = (Token) o; + return type == token.type + && Objects.equals(symbol, token.symbol); + } + + @Override + public int hashCode() { + return Objects.hash(type, symbol); + } + + @Override + public String toString() { + if (symbol == null) { + return type.toString(); + } else { + return type.toString() + " '" + symbol + "'"; + } + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/TokenType.java b/src/main/java/com/garbagemule/MobArena/formula/TokenType.java new file mode 100644 index 0000000..d13fd0e --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/TokenType.java @@ -0,0 +1,12 @@ +package com.garbagemule.MobArena.formula; + +enum TokenType { + NUMBER, + IDENTIFIER, + VARIABLE, + UNARY_OPERATOR, + BINARY_OPERATOR, + LEFT_PAREN, + RIGHT_PAREN, + COMMA +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/UnaryFormula.java b/src/main/java/com/garbagemule/MobArena/formula/UnaryFormula.java new file mode 100644 index 0000000..1f17a81 --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/UnaryFormula.java @@ -0,0 +1,21 @@ +package com.garbagemule.MobArena.formula; + +import com.garbagemule.MobArena.framework.Arena; + +class UnaryFormula implements Formula { + + private final UnaryOperation operation; + private final Formula argument; + + UnaryFormula(UnaryOperation operation, Formula argument) { + this.operation = operation; + this.argument = argument; + } + + @Override + public double evaluate(Arena arena) { + double value = argument.evaluate(arena); + return operation.apply(value); + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/UnaryFunction.java b/src/main/java/com/garbagemule/MobArena/formula/UnaryFunction.java new file mode 100644 index 0000000..a3cb4c2 --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/UnaryFunction.java @@ -0,0 +1,17 @@ +package com.garbagemule.MobArena.formula; + +class UnaryFunction { + + final String name; + final UnaryOperation operation; + + UnaryFunction(String name, UnaryOperation operation) { + this.name = name; + this.operation = operation; + } + + Formula create(Formula argument) { + return new UnaryFormula(operation, argument); + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/UnaryOperation.java b/src/main/java/com/garbagemule/MobArena/formula/UnaryOperation.java new file mode 100644 index 0000000..f27f200 --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/UnaryOperation.java @@ -0,0 +1,7 @@ +package com.garbagemule.MobArena.formula; + +import java.util.function.Function; + +@FunctionalInterface +public interface UnaryOperation extends Function { +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/UnaryOperator.java b/src/main/java/com/garbagemule/MobArena/formula/UnaryOperator.java new file mode 100644 index 0000000..113e24f --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/UnaryOperator.java @@ -0,0 +1,19 @@ +package com.garbagemule.MobArena.formula; + +class UnaryOperator { + + final String symbol; + final int precedence; + final UnaryOperation operation; + + UnaryOperator(String symbol, int precedence, UnaryOperation operation) { + this.symbol = symbol; + this.precedence = precedence; + this.operation = operation; + } + + Formula create(Formula argument) { + return new UnaryFormula(operation, argument); + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/UnexpectedToken.java b/src/main/java/com/garbagemule/MobArena/formula/UnexpectedToken.java new file mode 100644 index 0000000..6fc4795 --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/UnexpectedToken.java @@ -0,0 +1,15 @@ +package com.garbagemule.MobArena.formula; + +class UnexpectedToken extends FormulaError { + + private static final String template = "Unexpected token '%s' in column %d"; + + UnexpectedToken(Lexeme lexeme, String input) { + super(message(lexeme), input, lexeme.pos); + } + + private static String message(Lexeme lexeme) { + return String.format(template, lexeme.value, lexeme.pos + 1); + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/UnknownToken.java b/src/main/java/com/garbagemule/MobArena/formula/UnknownToken.java new file mode 100644 index 0000000..924abe9 --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/UnknownToken.java @@ -0,0 +1,23 @@ +package com.garbagemule.MobArena.formula; + +class UnknownToken extends FormulaError { + + private static final String template = "Unknown %s '%s' in column %d"; + + UnknownToken(String type, Lexeme lexeme, String input) { + super(message(type, lexeme), input, lexeme.pos); + } + + UnknownToken(String type, String value, String input, int pos) { + super(message(type, value, pos), input, pos); + } + + private static String message(String type, Lexeme lexeme) { + return message(type, lexeme.value, lexeme.pos); + } + + private static String message(String type, String identifier, int pos) { + return String.format(template, type, identifier, pos + 1); + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/UnmatchedParenthesis.java b/src/main/java/com/garbagemule/MobArena/formula/UnmatchedParenthesis.java new file mode 100644 index 0000000..4f4c9ea --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/UnmatchedParenthesis.java @@ -0,0 +1,16 @@ +package com.garbagemule.MobArena.formula; + +class UnmatchedParenthesis extends FormulaError { + + private static final String template = "Unmatched %s parenthesis '%s' in column %d"; + + UnmatchedParenthesis(Lexeme lexeme, String input) { + super(message(lexeme), input, lexeme.pos); + } + + private static String message(Lexeme lexeme) { + String side = (lexeme.token.type == TokenType.LEFT_PAREN) ? "left" : "right"; + return String.format(template, side, lexeme.value, lexeme.pos + 1); + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/formula/ValueFormula.java b/src/main/java/com/garbagemule/MobArena/formula/ValueFormula.java new file mode 100644 index 0000000..0204524 --- /dev/null +++ b/src/main/java/com/garbagemule/MobArena/formula/ValueFormula.java @@ -0,0 +1,18 @@ +package com.garbagemule.MobArena.formula; + +import com.garbagemule.MobArena.framework.Arena; + +class ValueFormula implements Formula { + + private final double value; + + ValueFormula(double value) { + this.value = value; + } + + @Override + public double evaluate(Arena arena) { + return value; + } + +} diff --git a/src/main/java/com/garbagemule/MobArena/waves/WaveParser.java b/src/main/java/com/garbagemule/MobArena/waves/WaveParser.java index 666977e..fca3599 100644 --- a/src/main/java/com/garbagemule/MobArena/waves/WaveParser.java +++ b/src/main/java/com/garbagemule/MobArena/waves/WaveParser.java @@ -1,6 +1,7 @@ package com.garbagemule.MobArena.waves; import com.garbagemule.MobArena.ConfigError; +import com.garbagemule.MobArena.formula.Formula; import com.garbagemule.MobArena.framework.Arena; import com.garbagemule.MobArena.region.ArenaRegion; import com.garbagemule.MobArena.things.InvalidThingInputString; @@ -12,10 +13,7 @@ import com.garbagemule.MobArena.util.PotionEffectParser; import com.garbagemule.MobArena.util.Slugs; import com.garbagemule.MobArena.waves.ability.Ability; import com.garbagemule.MobArena.waves.ability.AbilityManager; -import com.garbagemule.MobArena.waves.enums.BossHealth; -import com.garbagemule.MobArena.waves.enums.SwarmAmount; import com.garbagemule.MobArena.waves.enums.WaveBranch; -import com.garbagemule.MobArena.waves.enums.WaveGrowth; import com.garbagemule.MobArena.waves.enums.WaveType; import com.garbagemule.MobArena.waves.types.BossWave; import com.garbagemule.MobArena.waves.types.DefaultWave; @@ -171,16 +169,21 @@ public class WaveParser } // Grab the WaveGrowth - String grw = config.getString("growth", null); - if (grw != null && !grw.isEmpty()) { - try { - WaveGrowth growth = WaveGrowth.valueOf(grw.toUpperCase()); - result.setGrowth(growth); - } catch (IllegalArgumentException e) { - throw new ConfigError("Failed to parse wave growth for wave " + name + " of arena " + arena.configName() + ": " + grw); - } + String growth = config.getString("growth", null); + if (growth == null || growth.isEmpty()) { + growth = " + "; } else { - result.setGrowth(WaveGrowth.MEDIUM); + String macro = arena.getPlugin().getFormulaMacros().get("wave-growth", growth); + if (macro != null) { + growth = macro; + } + } + try { + Formula formula = arena.getPlugin().getFormulaManager().parse(growth); + result.setGrowth(formula); + } catch (IllegalArgumentException e) { + String message = String.format("Failed to parse wave growth for wave %s of arena %s: %s\n%s", name, arena.configName(), growth, e.getMessage()); + throw new ConfigError(message); } return result; @@ -198,16 +201,21 @@ public class WaveParser SwarmWave result = new SwarmWave(monster); // Grab SwarmAmount - String amnt = config.getString("amount", null); - if (amnt != null && !amnt.isEmpty()) { - try { - SwarmAmount amount = SwarmAmount.valueOf(amnt.toUpperCase()); - result.setAmount(amount); - } catch (IllegalArgumentException e) { - throw new ConfigError("Failed to parse wave amount for wave " + name + " of arena " + arena.configName() + ": " + amnt); - } + String amount = config.getString("amount", null); + if (amount == null || amount.isEmpty()) { + amount = " * 5"; } else { - result.setAmount(SwarmAmount.LOW); + String macro = arena.getPlugin().getFormulaMacros().get("swarm-amount", amount); + if (macro != null) { + amount = macro; + } + } + try { + Formula formula = arena.getPlugin().getFormulaManager().parse(amount); + result.setAmount(formula); + } catch (IllegalArgumentException e) { + String message = String.format("Failed to parse wave amount for wave %s of arena %s: %s\n%s", name, arena.configName(), amount, e.getMessage()); + throw new ConfigError(message); } return result; @@ -261,20 +269,21 @@ public class WaveParser } // Grab the boss health - String healthString = config.getString("health", null); - if (healthString != null && !healthString.isEmpty()) { - try { - BossHealth health = BossHealth.valueOf(healthString.toUpperCase()); - result.setHealth(health); - } catch (IllegalArgumentException e) { - int flatHealth = config.getInt("health", -1); - if (flatHealth < 0) { - throw new ConfigError("Failed to parse boss health for wave " + name + " of arena " + arena.configName() + ": " + healthString); - } - result.setFlatHealth(flatHealth); - } + String health = config.getString("health", null); + if (health == null || health.isEmpty()) { + health = "( + 1) * 20 * 8"; } else { - result.setHealth(BossHealth.MEDIUM); + String macro = arena.getPlugin().getFormulaMacros().get("boss-health", health); + if (macro != null) { + health = macro; + } + } + try { + Formula formula = arena.getPlugin().getFormulaManager().parse(health); + result.setHealth(formula); + } catch (IllegalArgumentException e) { + String message = String.format("Failed to parse boss health for wave %s of arena %s: %s\n%s", name, arena.configName(), health, e.getMessage()); + throw new ConfigError(message); } // And the abilities. @@ -588,7 +597,6 @@ public class WaveParser result.setFirstWave(1); result.setPriority(1); result.setFrequency(1); - result.setGrowth(WaveGrowth.OLD); result.setHealthMultiplier(1D); result.setAmountMultiplier(1D); diff --git a/src/main/java/com/garbagemule/MobArena/waves/types/BossWave.java b/src/main/java/com/garbagemule/MobArena/waves/types/BossWave.java index 693911f..d216858 100644 --- a/src/main/java/com/garbagemule/MobArena/waves/types/BossWave.java +++ b/src/main/java/com/garbagemule/MobArena/waves/types/BossWave.java @@ -1,6 +1,8 @@ package com.garbagemule.MobArena.waves.types; import com.garbagemule.MobArena.Msg; +import com.garbagemule.MobArena.formula.Formula; +import com.garbagemule.MobArena.formula.Formulas; import com.garbagemule.MobArena.framework.Arena; import com.garbagemule.MobArena.things.Thing; import com.garbagemule.MobArena.things.ThingPicker; @@ -11,7 +13,6 @@ import com.garbagemule.MobArena.waves.MACreature; import com.garbagemule.MobArena.waves.Wave; import com.garbagemule.MobArena.waves.ability.Ability; import com.garbagemule.MobArena.waves.ability.AbilityInfo; -import com.garbagemule.MobArena.waves.enums.BossHealth; import com.garbagemule.MobArena.waves.enums.WaveType; import org.bukkit.inventory.ItemStack; @@ -29,9 +30,7 @@ public class BossWave extends AbstractWave private MACreature monster; private Set bosses; - private boolean useHealthMultiplier; - private int healthMultiplier; - private int flatHealth; + private Formula health; private List abilities; private boolean activated, abilityAnnounce; @@ -47,11 +46,8 @@ public class BossWave extends AbstractWave this.abilities = new ArrayList<>(); this.activated = false; this.abilityAnnounce = false; + this.health = Formulas.DEFAULT_BOSS_HEALTH; this.setType(WaveType.BOSS); - - this.useHealthMultiplier = true; - this.healthMultiplier = 0; - this.flatHealth = 0; } @Override @@ -69,21 +65,12 @@ public class BossWave extends AbstractWave this.bossName = bossName; } - public int getMaxHealth(int playerCount) { - if (useHealthMultiplier) { - return (playerCount + 1) * 20 * healthMultiplier; - } - return flatHealth; + public Formula getHealth() { + return health; } - public void setHealth(BossHealth health) { - this.healthMultiplier = health.getMultiplier(); - this.useHealthMultiplier = true; - } - - public void setFlatHealth(int flatHealth) { - this.flatHealth = flatHealth; - this.useHealthMultiplier = false; + public void setHealth(Formula health) { + this.health = health; } public void addMABoss(MABoss boss) { @@ -160,9 +147,7 @@ public class BossWave extends AbstractWave } result.abilityInterval = this.abilityInterval; result.abilityAnnounce = this.abilityAnnounce; - result.useHealthMultiplier = this.useHealthMultiplier; - result.healthMultiplier = this.healthMultiplier; - result.flatHealth = this.flatHealth; + result.health = this.health; result.reward = this.reward; result.drops = this.drops; result.bossName = this.bossName; diff --git a/src/main/java/com/garbagemule/MobArena/waves/types/DefaultWave.java b/src/main/java/com/garbagemule/MobArena/waves/types/DefaultWave.java index 829a874..4168bcc 100644 --- a/src/main/java/com/garbagemule/MobArena/waves/types/DefaultWave.java +++ b/src/main/java/com/garbagemule/MobArena/waves/types/DefaultWave.java @@ -1,10 +1,11 @@ package com.garbagemule.MobArena.waves.types; +import com.garbagemule.MobArena.formula.Formula; +import com.garbagemule.MobArena.formula.Formulas; import com.garbagemule.MobArena.framework.Arena; import com.garbagemule.MobArena.waves.AbstractWave; import com.garbagemule.MobArena.waves.MACreature; import com.garbagemule.MobArena.waves.Wave; -import com.garbagemule.MobArena.waves.enums.WaveGrowth; import com.garbagemule.MobArena.waves.enums.WaveType; import java.util.HashMap; @@ -15,12 +16,12 @@ import java.util.SortedMap; public class DefaultWave extends AbstractWave { private SortedMap monsterMap; - private WaveGrowth growth; + private Formula growth; private boolean fixed; public DefaultWave(SortedMap monsterMap) { this.monsterMap = monsterMap; - this.growth = WaveGrowth.OLD; + this.growth = Formulas.DEFAULT_WAVE_GROWTH; this.setType(WaveType.DEFAULT); } @@ -29,7 +30,7 @@ public class DefaultWave extends AbstractWave if (fixed) return getFixed(); // Get the amount of monsters to spawn. - int toSpawn = (int) Math.max(1D, growth.getAmount(wave, playerCount) * super.getAmountMultiplier()); + int toSpawn = (int) Math.max(1D, (growth.evaluate(arena) * super.getAmountMultiplier())); // Grab the total probability sum. int total = monsterMap.lastKey(); @@ -71,11 +72,11 @@ public class DefaultWave extends AbstractWave return result; } - public WaveGrowth getGrowth() { + public Formula getGrowth() { return growth; } - public void setGrowth(WaveGrowth growth) { + public void setGrowth(Formula growth) { this.growth = growth; this.fixed = false; } diff --git a/src/main/java/com/garbagemule/MobArena/waves/types/SwarmWave.java b/src/main/java/com/garbagemule/MobArena/waves/types/SwarmWave.java index e7378c0..2df595f 100644 --- a/src/main/java/com/garbagemule/MobArena/waves/types/SwarmWave.java +++ b/src/main/java/com/garbagemule/MobArena/waves/types/SwarmWave.java @@ -1,10 +1,11 @@ package com.garbagemule.MobArena.waves.types; +import com.garbagemule.MobArena.formula.Formula; +import com.garbagemule.MobArena.formula.Formulas; import com.garbagemule.MobArena.framework.Arena; import com.garbagemule.MobArena.waves.AbstractWave; import com.garbagemule.MobArena.waves.MACreature; import com.garbagemule.MobArena.waves.Wave; -import com.garbagemule.MobArena.waves.enums.SwarmAmount; import com.garbagemule.MobArena.waves.enums.WaveType; import java.util.HashMap; @@ -13,11 +14,11 @@ import java.util.Map; public class SwarmWave extends AbstractWave { private MACreature monster; - private SwarmAmount amount; + private Formula amount; public SwarmWave(MACreature monster) { this.monster = monster; - this.amount = SwarmAmount.LOW; + this.amount = Formulas.DEFAULT_SWARM_AMOUNT; this.setType(WaveType.SWARM); } @@ -27,17 +28,17 @@ public class SwarmWave extends AbstractWave Map result = new HashMap<>(); // Add the monster and the swarm amount. - int toSpawn = (int) Math.max(1D, amount.getAmount(playerCount) * super.getAmountMultiplier()); + int toSpawn = (int) Math.max(1D, (amount.evaluate(arena) * super.getAmountMultiplier())); result.put(monster, toSpawn); return result; } - public SwarmAmount getAmount() { + public Formula getAmount() { return amount; } - public void setAmount(SwarmAmount amount) { + public void setAmount(Formula amount) { this.amount = amount; } diff --git a/src/main/resources/formulas.yml b/src/main/resources/formulas.yml new file mode 100644 index 0000000..8e98276 --- /dev/null +++ b/src/main/resources/formulas.yml @@ -0,0 +1,34 @@ +# These formulas work for all properties. If a global formula has the +# same name as a property-specific formula, it is the property-specific +# formula that will be used when parsing the given property. +global: + wave-squared: ^ 2 + wave-inverted: max(1, - ) + five-each: * 5 + double-team: / 2 + top-up: max(1, 10 - ) + dead-man-walking: max(1, ) + +# These formulas only work in the "growth" property of Default Waves. +wave-growth: + old: + + slow: min(ceil( / 2) + 1, 13) * ^ 0.5 + medium: min(ceil( / 2) + 1, 13) * ^ 0.65 + fast: min(ceil( / 2) + 1, 13) * ^ 0.8 + psycho: min(ceil( / 2) + 1, 13) * ^ 1.2 + +# These formulas only work in the "amount" property of Swarm Waves. +swarm-amount: + low: max(1, floor( / 2)) * 10 + medium: max(1, floor( / 2)) * 20 + high: max(1, floor( / 2)) * 30 + psycho: max(1, floor( / 2)) * 60 + +# These formulas only work in the "health" property of Boss Waves. +boss-health: + verylow: ( + 1) * 20 * 4 + low: ( + 1) * 20 * 8 + medium: ( + 1) * 20 * 15 + high: ( + 1) * 20 * 25 + veryhigh: ( + 1) * 20 * 40 + psycho: ( + 1) * 20 * 60 diff --git a/src/test/java/com/garbagemule/MobArena/formula/FormulaMacrosIT.java b/src/test/java/com/garbagemule/MobArena/formula/FormulaMacrosIT.java new file mode 100644 index 0000000..cf277ca --- /dev/null +++ b/src/test/java/com/garbagemule/MobArena/formula/FormulaMacrosIT.java @@ -0,0 +1,260 @@ +package com.garbagemule.MobArena.formula; + +import com.garbagemule.MobArena.MobArena; +import com.garbagemule.MobArena.MonsterManager; +import com.garbagemule.MobArena.framework.Arena; +import com.garbagemule.MobArena.waves.WaveManager; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.*; +import static org.mockito.Mockito.*; + +@RunWith(Enclosed.class) +public class FormulaMacrosIT { + + static MobArena plugin; + static Arena arena; + static FormulaMacros macros; + static FormulaManager parser; + + static int finalWave = 13; + static int currentWave = finalWave - 2; + static int liveMonsters = 9; + static int initialPlayers = 7; + static int livePlayers = initialPlayers - 2; + + @BeforeClass + public static void setup() throws IOException { + plugin = mock(MobArena.class); + File resources = new File("src/main/resources"); + when(plugin.getDataFolder()).thenReturn(resources); + + arena = mock(Arena.class); + WaveManager wm = mock(WaveManager.class); + when(wm.getWaveNumber()).thenReturn(currentWave); + when(wm.getFinalWave()).thenReturn(finalWave); + when(arena.getWaveManager()).thenReturn(wm); + Set monsters = new HashSet<>(); + for (int i = 0; i < liveMonsters; i++) { + monsters.add(mock(LivingEntity.class)); + } + MonsterManager mm = mock(MonsterManager.class); + when(mm.getMonsters()).thenReturn(monsters); + when(arena.getMonsterManager()).thenReturn(mm); + Set players = new HashSet<>(); + for (int i = 0; i < livePlayers; i++) { + players.add(mock(Player.class)); + } + when(arena.getPlayersInArena()).thenReturn(players); + when(arena.getPlayerCount()).thenReturn(initialPlayers); + + macros = FormulaMacros.create(plugin); + macros.reload(); + + parser = FormulaManager.createDefault(); + } + + @RunWith(Parameterized.class) + public static class Global { + + @Parameters(name = "{0}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {"wave-squared", currentWave * currentWave}, + {"wave-inverted", finalWave - currentWave}, + {"five-each", livePlayers * 5}, + {"double-team", (double) livePlayers / 2}, + {"top-up", 10 - liveMonsters}, + {"dead-man-walking", initialPlayers - livePlayers}, + }); + } + + String macro; + double expected; + + public Global(String macro, double expected) { + this.macro = macro; + this.expected = expected; + } + + @Test + public void test() { + String value = macros.get("global", macro); + Formula formula = parser.parse(value); + + double result = formula.evaluate(arena); + + assertThat(result, equalTo(expected)); + } + + } + + @RunWith(Parameterized.class) + public static class WaveGrowth { + + @Parameters(name = "{0}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {"slow", 0.5}, + {"medium", 0.65}, + {"fast", 0.8}, + {"psycho", 1.2}, + }); + } + + String macro; + double exponent; + + public WaveGrowth(String macro, double exponent) { + this.macro = macro; + this.exponent = exponent; + } + + @Test + public void test() { + String value = macros.get("wave-growth", macro); + Formula formula = parser.parse(value); + + double result = (int) formula.evaluate(arena); + + double base = (int) Math.ceil(initialPlayers / 2.0) + 1; + double expected = (int) (base * Math.pow(currentWave, exponent)); + assertThat(result, equalTo(expected)); + } + + } + + /** + * Old wave growth formula is different, so we'll just have a + * different test for it all-together. + */ + public static class WaveGrowthOld { + + @Test + public void oldWaveGrowth() { + String value = macros.get("wave-growth", "old"); + Formula formula = parser.parse(value); + + double result = formula.evaluate(arena); + + double expected = currentWave + initialPlayers; + assertThat(result, equalTo(expected)); + } + + } + + @RunWith(Parameterized.class) + public static class SwarmAmount { + + @Parameters(name = "{0}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {"low", 10}, + {"medium", 20}, + {"high", 30}, + {"psycho", 60}, + }); + } + + String macro; + double multiplier; + + public SwarmAmount(String macro, double multiplier) { + this.macro = macro; + this.multiplier = multiplier; + } + + @Test + public void test() { + String value = macros.get("swarm-amount", macro); + Formula formula = parser.parse(value); + + double result = formula.evaluate(arena); + + double expected = (double) (initialPlayers / 2) * multiplier; + assertThat(result, equalTo(expected)); + } + + } + + @RunWith(Parameterized.class) + public static class BossHealth { + + @Parameters(name = "{0}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {"verylow", 4}, + {"low", 8}, + {"medium", 15}, + {"high", 25}, + {"veryhigh", 40}, + {"psycho", 60}, + }); + } + + String macro; + double multiplier; + + public BossHealth(String macro, double multiplier) { + this.macro = macro; + this.multiplier = multiplier; + } + + @Test + public void test() { + String value = macros.get("boss-health", macro); + Formula formula = parser.parse(value); + + double result = formula.evaluate(arena); + + double expected = (initialPlayers + 1) * 20 * multiplier; + assertThat(result, equalTo(expected)); + } + + } + + /** + * Try actually loading a non-default formulas.yml with a couple + * of different types of formulas in it to test that the loading + * itself actually works. + */ + public static class TestFile { + + @Test + public void loadsUnorthodoxFile() throws IOException { + plugin = mock(MobArena.class); + File resources = new File("src/test/resources"); + when(plugin.getDataFolder()).thenReturn(resources); + + FormulaMacros subject = FormulaMacros.create(plugin); + subject.reload(); + + assertThat(subject.get("numbers", "one"), equalTo("1")); + assertThat(subject.get("numbers", "two"), equalTo("2")); + assertThat(subject.get("constants", "three-point-one-four"), equalTo("pi")); + assertThat(subject.get("constants", "eulers-number"), equalTo("e")); + assertThat(subject.get("variables", "live"), equalTo("")); + assertThat(subject.get("variables", "max"), equalTo("")); + assertThat(subject.get("operators", "two-plus-two"), equalTo("2 + 2")); + assertThat(subject.get("operators", "one-times-two"), equalTo("1 * 2")); + assertThat(subject.get("functions", "square-root"), equalTo("sqrt(9)")); + assertThat(subject.get("functions", "maximum"), equalTo("max(1, 2)")); + } + + } + +} diff --git a/src/test/java/com/garbagemule/MobArena/formula/FormulaManagerIT.java b/src/test/java/com/garbagemule/MobArena/formula/FormulaManagerIT.java new file mode 100644 index 0000000..a2c3d77 --- /dev/null +++ b/src/test/java/com/garbagemule/MobArena/formula/FormulaManagerIT.java @@ -0,0 +1,372 @@ +package com.garbagemule.MobArena.formula; + +import com.garbagemule.MobArena.MonsterManager; +import com.garbagemule.MobArena.framework.Arena; +import com.garbagemule.MobArena.waves.WaveManager; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.*; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.*; + +@RunWith(Enclosed.class) +public class FormulaManagerIT { + + static Arena arena; + static FormulaManager subject; + + static int finalWave = 13; + static int currentWave = finalWave - 2; + static int liveMonsters = 9; + static int initialPlayers = 7; + static int livePlayers = initialPlayers - 2; + static int deadPlayers = initialPlayers - livePlayers; + static int minPlayers = 3; + static int maxPlayers = initialPlayers + 3; + + @BeforeClass + public static void setup() { + arena = mock(Arena.class); + WaveManager wm = mock(WaveManager.class); + when(wm.getWaveNumber()).thenReturn(currentWave); + when(wm.getFinalWave()).thenReturn(finalWave); + when(arena.getWaveManager()).thenReturn(wm); + Set monsters = new HashSet<>(); + for (int i = 0; i < liveMonsters; i++) { + monsters.add(mock(LivingEntity.class)); + } + MonsterManager mm = mock(MonsterManager.class); + when(mm.getMonsters()).thenReturn(monsters); + when(arena.getMonsterManager()).thenReturn(mm); + Set players = new HashSet<>(); + for (int i = 0; i < livePlayers; i++) { + players.add(mock(Player.class)); + } + when(arena.getPlayersInArena()).thenReturn(players); + when(arena.getPlayerCount()).thenReturn(initialPlayers); + when(arena.getMinPlayers()).thenReturn(minPlayers); + when(arena.getMaxPlayers()).thenReturn(maxPlayers); + + subject = FormulaManager.createDefault(); + } + + @RunWith(Parameterized.class) + public static class NumberLiterals { + + @Parameters(name = "{0}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {"0"}, + {"1"}, + {"-1"}, + {"1337"}, + {"3.14"}, + {"1e4"}, + {"-1e4"}, + {"1e-4"}, + {"-1e-4"}, + }); + } + + String input; + double expected; + + public NumberLiterals(String input) { + this.input = input; + this.expected = Double.parseDouble(input); + } + + @Test + public void test() { + Formula formula = subject.parse(input); + double result = formula.evaluate(arena); + + assertThat(result, equalTo(expected)); + } + + } + + @RunWith(Parameterized.class) + public static class DefaultConstants { + + @Parameters(name = "{0} = {1}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {"pi", Math.PI}, + {"e", Math.E}, + {"pi^e", Math.pow(Math.PI, Math.E)}, + }); + } + + String input; + double expected; + + public DefaultConstants(String input, double expected) { + this.input = input; + this.expected = expected; + } + + @Test + public void test() { + Formula formula = subject.parse(input); + double result = formula.evaluate(arena); + + assertThat(result, equalTo(expected)); + } + + } + + @RunWith(Parameterized.class) + public static class DefaultVariables { + + @Parameters(name = "{0} = {1}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {"", currentWave}, + {"", finalWave}, + {"", initialPlayers}, + {"", livePlayers}, + {"", deadPlayers}, + {"", minPlayers}, + {"", maxPlayers}, + {"", liveMonsters}, + }); + } + + String input; + double expected; + + public DefaultVariables(String input, double expected) { + this.input = input; + this.expected = expected; + } + + @Test + public void test() { + Formula formula = subject.parse(input); + double result = formula.evaluate(arena); + + assertThat(result, equalTo(expected)); + } + + } + + /** + * With custom variables, we are manipulating the internal + * state of the manager, so we need to use a local subject. + */ + public static class CustomVariables { + + FormulaManager subject; + + @Before + public void setup() { + subject = FormulaManager.createDefault(); + } + + @Test + public void resolveRegisteredCustomVariable() { + subject.registerVariable("bob", a -> 7.5); + + Formula formula = subject.parse("2.5 + "); + double result = formula.evaluate(arena); + + double expected = 10; + assertThat(result, equalTo(expected)); + } + + @Test + public void throwsOnUnknownCustomVariable() { + assertThrows( + UnknownToken.class, + () -> subject.parse("2 + ") + ); + } + + } + + @RunWith(Parameterized.class) + public static class DefaultUnaryOperators { + + @Parameters(name = "{0} = {1}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {"1 + +1.2", 1 + +1.2}, + {"1 + -1.2", 1 + -1.2}, + }); + } + + String input; + double expected; + + public DefaultUnaryOperators(String input, double expected) { + this.input = input; + this.expected = expected; + } + + @Test + public void test() { + Formula formula = subject.parse(input); + double result = formula.evaluate(arena); + + assertThat(result, equalTo(expected)); + } + + } + + @RunWith(Parameterized.class) + public static class DefaultOperators { + + @Parameters(name = "{0} = {1}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {"1+-2", 1 + -2}, + {"3-+4", 3 - +4}, + {"3*7.5", 3 * 7.5}, + {"10/2.5", 10 / 2.5}, + {"9%4", 9 % 4}, + {"2^-8", Math.pow(2, -8)}, + {"-2^-8", -Math.pow(2, -8)}, + {"(-2)^-8", Math.pow(-2, -8)}, + }); + } + + String input; + double expected; + + public DefaultOperators(String input, double expected) { + this.input = input; + this.expected = expected; + } + + @Test + public void test() { + Formula formula = subject.parse(input); + double result = formula.evaluate(arena); + + assertThat(result, equalTo(expected)); + } + + } + + @RunWith(Parameterized.class) + public static class DefaultUnaryFunctions { + + @Parameters(name = "{0} = {1}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {"sqrt(4)", 2}, + {"sqrt(9)", 3}, + {"abs(2)", 2}, + {"abs(-2)", 2}, + {"ceil(8.2)", 9}, + {"ceil(8.7)", 9}, + {"floor(8.2)", 8}, + {"floor(8.7)", 8}, + {"round(8.2)", 8}, + {"round(8.7)", 9}, + {"sin(pi / 2)", Math.sin(Math.PI / 2)}, + {"cos(pi / 3)", Math.cos(Math.PI / 3)}, + {"tan(pi / 4)", Math.tan(Math.PI / 4)}, + }); + } + + String input; + double expected; + + public DefaultUnaryFunctions(String input, double expected) { + this.input = input; + this.expected = expected; + } + + @Test + public void test() { + Formula formula = subject.parse(input); + double result = formula.evaluate(arena); + + assertThat(result, equalTo(expected)); + } + + } + + @RunWith(Parameterized.class) + public static class DefaultBinaryFunctions { + + @Parameters(name = "{0} = {1}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {"min(1, 2)", 1}, + {"min(2, 1)", 1}, + {"max(1, 2)", 2}, + {"max(2, 1)", 2}, + }); + } + + String input; + double expected; + + public DefaultBinaryFunctions(String input, double expected) { + this.input = input; + this.expected = expected; + } + + @Test + public void test() { + Formula formula = subject.parse(input); + double result = formula.evaluate(arena); + + assertThat(result, equalTo(expected)); + } + + } + + /** + * With custom functions, we are manipulating the internal + * state of the manager, so we need to use a local subject. + */ + public static class CustomFunctions { + + FormulaManager subject; + + @Before + public void setup() { + subject = FormulaManager.createDefault(); + } + + @Test + public void resolveRegisteredCustomFunctions() { + subject.registerUnaryFunction("flip", a -> -a); + subject.registerBinaryFunction("car", (a, b) -> a); + subject.registerBinaryFunction("cdr", (a, b) -> b); + + Formula formula = subject.parse("flip(car(1, 2) + cdr(3, 4))"); + double result = formula.evaluate(arena); + + double expected = -(1 + 4); + assertThat(result, equalTo(expected)); + } + + @Test + public void throwsOnUnknownCustomFunction() { + assertThrows( + UnknownToken.class, + () -> subject.parse("flip(1)") + ); + } + + } + +} diff --git a/src/test/java/com/garbagemule/MobArena/formula/LexemeMatcher.java b/src/test/java/com/garbagemule/MobArena/formula/LexemeMatcher.java new file mode 100644 index 0000000..7245226 --- /dev/null +++ b/src/test/java/com/garbagemule/MobArena/formula/LexemeMatcher.java @@ -0,0 +1,43 @@ +package com.garbagemule.MobArena.formula; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +import java.util.Objects; + +public class LexemeMatcher extends TypeSafeMatcher { + + private final TokenType type; + private final String value; + + private LexemeMatcher(TokenType type, String value) { + this.type = type; + this.value = value; + } + + @Override + protected boolean matchesSafely(Lexeme item) { + if (item.token.type != type) { + return false; + } + if (value == null) { + return true; + } + return Objects.equals(item.value, value); + } + + @Override + public void describeTo(Description description) { + description.appendText(type.name() + " '" + value + "'"); + } + + public static Matcher matches(TokenType type, String value) { + return new LexemeMatcher(type, value); + } + + public static Matcher matches(TokenType type) { + return new LexemeMatcher(type, null); + } + +} diff --git a/src/test/java/com/garbagemule/MobArena/formula/LexerConstantTest.java b/src/test/java/com/garbagemule/MobArena/formula/LexerConstantTest.java new file mode 100644 index 0000000..a4baf8b --- /dev/null +++ b/src/test/java/com/garbagemule/MobArena/formula/LexerConstantTest.java @@ -0,0 +1,75 @@ +package com.garbagemule.MobArena.formula; + +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +import java.util.Arrays; +import java.util.List; + +import static com.garbagemule.MobArena.formula.LexemeMatcher.matches; +import static com.garbagemule.MobArena.formula.TokenType.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.junit.Assert.assertThrows; + +@RunWith(Enclosed.class) +public class LexerConstantTest { + + static Environment env; + static Lexer subject; + + @RunWith(Parameterized.class) + public static class ConstantLiterals { + + @Before + public void setup() { + env = Environment.createDefault(); + subject = new Lexer(env); + } + + @Parameters(name = "{0}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {"pi"}, + {"e"}, + }); + } + + String input; + + public ConstantLiterals(String input) { + this.input = input; + } + + @Test + public void test() { + List result = subject.tokenize(input); + + assertThat(result, contains(matches(IDENTIFIER, input))); + } + + } + + public static class InvalidConstants { + + @Before + public void setup() { + env = Environment.createDefault(); + subject = new Lexer(env); + } + + @Test + public void unknownConstant() { + assertThrows( + UnknownToken.class, + () -> subject.tokenize("pie") + ); + } + + } + +} diff --git a/src/test/java/com/garbagemule/MobArena/formula/LexerFunctionTest.java b/src/test/java/com/garbagemule/MobArena/formula/LexerFunctionTest.java new file mode 100644 index 0000000..d3177b8 --- /dev/null +++ b/src/test/java/com/garbagemule/MobArena/formula/LexerFunctionTest.java @@ -0,0 +1,130 @@ +package com.garbagemule.MobArena.formula; + +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +import java.util.List; + +import static com.garbagemule.MobArena.formula.LexemeMatcher.matches; +import static com.garbagemule.MobArena.formula.TokenType.*; +import static java.util.Arrays.asList; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.junit.Assert.assertThrows; + +@RunWith(Enclosed.class) +public class LexerFunctionTest { + + static Environment env; + static Lexer subject; + + @RunWith(Parameterized.class) + public static class UnaryFunctions { + + @Before + public void setup() { + env = Environment.createDefault(); + subject = new Lexer(env); + } + + @Parameters(name = "{0}") + public static Iterable data() { + return asList(new Object[][]{ + {"sqrt"}, + {"abs"}, + {"ceil"}, + {"floor"}, + {"round"}, + {"sin"}, + {"cos"}, + {"tan"}, + }); + } + + String name; + String input; + + public UnaryFunctions(String name) { + this.name = name; + this.input = name + "(1.2)"; + } + + @Test + public void test() { + List result = subject.tokenize(input); + + assertThat(result, contains(asList( + matches(IDENTIFIER, name), + matches(LEFT_PAREN), + matches(NUMBER, "1.2"), + matches(RIGHT_PAREN) + ))); + } + + } + + @RunWith(Parameterized.class) + public static class BinaryFunctions { + + @Before + public void setup() { + env = Environment.createDefault(); + subject = new Lexer(env); + } + + @Parameters(name = "{0}") + public static Iterable data() { + return asList(new Object[][]{ + {"min"}, + {"max"}, + }); + } + + String name; + String input; + + public BinaryFunctions(String name) { + this.name = name; + this.input = name + "(1, 2)"; + } + + @Test + public void test() { + List result = subject.tokenize(input); + + assertThat(result, contains(asList( + matches(IDENTIFIER, name), + matches(LEFT_PAREN), + matches(NUMBER, "1"), + matches(COMMA), + matches(NUMBER, "2"), + matches(RIGHT_PAREN) + ))); + } + + } + + public static class InvalidFunctions { + + @Before + public void setup() { + env = Environment.createDefault(); + subject = new Lexer(env); + } + + @Test + public void unknownFunction() { + assertThrows( + UnknownToken.class, + () -> subject.tokenize("best(1.2)") + ); + } + + } + + +} diff --git a/src/test/java/com/garbagemule/MobArena/formula/LexerNumberTest.java b/src/test/java/com/garbagemule/MobArena/formula/LexerNumberTest.java new file mode 100644 index 0000000..ba24037 --- /dev/null +++ b/src/test/java/com/garbagemule/MobArena/formula/LexerNumberTest.java @@ -0,0 +1,103 @@ +package com.garbagemule.MobArena.formula; + +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +import java.util.List; + +import static com.garbagemule.MobArena.formula.LexemeMatcher.matches; +import static com.garbagemule.MobArena.formula.TokenType.*; +import static java.util.Arrays.asList; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; + +@RunWith(Enclosed.class) +public class LexerNumberTest { + + static Environment env; + static Lexer subject; + + @RunWith(Parameterized.class) + public static class PositiveNumberLiterals { + + @Before + public void setup() { + env = Environment.createDefault(); + subject = new Lexer(env); + } + + @Parameters(name = "{0}") + public static Iterable data() { + return asList(new Object[][]{ + {"0"}, + {"1"}, + {"1337"}, + {"3.14"}, + {"1e4"}, + {"1e-4"}, + {"1.2e4"}, + }); + } + + String input; + + public PositiveNumberLiterals(String input) { + this.input = input; + } + + @Test + public void test() { + List result = subject.tokenize(input); + + assertThat(result, contains(matches(NUMBER, input))); + } + + } + + @RunWith(Parameterized.class) + public static class NegativeNumberLiterals { + + @Before + public void setup() { + env = Environment.createDefault(); + subject = new Lexer(env); + } + + @Parameters(name = "{0}") + public static Iterable data() { + return asList(new Object[][]{ + {"-0"}, + {"-1"}, + {"-1337"}, + {"-3.14"}, + {"-1e4"}, + {"-1e-4"}, + {"-1.2e4"}, + }); + } + + String input; + String number; + + public NegativeNumberLiterals(String input) { + this.input = input; + this.number = input.substring(1); + } + + @Test + public void test() { + List result = subject.tokenize(input); + + assertThat(result, contains(asList( + matches(UNARY_OPERATOR, "-"), + matches(NUMBER, number) + ))); + } + + } + +} diff --git a/src/test/java/com/garbagemule/MobArena/formula/LexerOperatorTest.java b/src/test/java/com/garbagemule/MobArena/formula/LexerOperatorTest.java new file mode 100644 index 0000000..19568d6 --- /dev/null +++ b/src/test/java/com/garbagemule/MobArena/formula/LexerOperatorTest.java @@ -0,0 +1,176 @@ +package com.garbagemule.MobArena.formula; + +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +import java.util.Arrays; +import java.util.List; + +import static com.garbagemule.MobArena.formula.LexemeMatcher.matches; +import static com.garbagemule.MobArena.formula.TokenType.*; +import static java.util.Arrays.asList; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.junit.Assert.assertThrows; + +@RunWith(Enclosed.class) +public class LexerOperatorTest { + + static Environment env; + static Lexer subject; + + @RunWith(Parameterized.class) + public static class BinaryOperators { + + @Before + public void setup() { + env = Environment.createDefault(); + subject = new Lexer(env); + } + + @Parameters(name = "{0}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {"+"}, + {"-"}, + {"*"}, + {"/"}, + {"%"}, + {"^"}, + }); + } + + String symbol; + String input; + + public BinaryOperators(String symbol) { + this.symbol = symbol; + this.input = "1" + symbol + "2"; + } + + @Test + public void test() { + List result = subject.tokenize(input); + + assertThat(result, contains(asList( + matches(NUMBER, "1"), + matches(BINARY_OPERATOR, symbol), + matches(NUMBER, "2") + ))); + } + + } + + @RunWith(Parameterized.class) + public static class UnaryOperators { + + @Before + public void setup() { + env = Environment.createDefault(); + subject = new Lexer(env); + } + + @Parameters(name = "{0}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {"+"}, + {"-"}, + }); + } + + String symbol; + String input; + + public UnaryOperators(String symbol) { + this.symbol = symbol; + this.input = symbol + "1"; + } + + @Test + public void test() { + List result = subject.tokenize(input); + + assertThat(result, contains(asList( + matches(UNARY_OPERATOR, symbol), + matches(NUMBER, "1") + ))); + } + + } + + public static class OperatorAmbiguity { + + @Before + public void setup() { + env = Environment.createDefault(); + subject = new Lexer(env); + } + + @Test + public void longestMatchInfix() { + env.registerBinaryOperator("--", 2, true, null); + + List result = subject.tokenize("1---2"); + + assertThat(result, contains(asList( + matches(NUMBER, "1"), + matches(BINARY_OPERATOR, "--"), + matches(UNARY_OPERATOR, "-"), + matches(NUMBER, "2") + ))); + } + + @Test + public void longestMatchPrefix() { + env.registerUnaryOperator("--", 4, null); + + List result = subject.tokenize("1---2"); + + assertThat(result, contains(asList( + matches(NUMBER, "1"), + matches(BINARY_OPERATOR, "-"), + matches(UNARY_OPERATOR, "--"), + matches(NUMBER, "2") + ))); + } + + @Test + public void longestMatchBoth() { + env.registerUnaryOperator("--", 4, null); + env.registerBinaryOperator("--", 2, true, null); + + List result = subject.tokenize("1---2"); + + assertThat(result, contains(asList( + matches(NUMBER, "1"), + matches(BINARY_OPERATOR, "--"), + matches(UNARY_OPERATOR, "-"), + matches(NUMBER, "2") + ))); + } + + } + + public static class InvalidOperators { + + @Before + public void setup() { + env = Environment.createDefault(); + subject = new Lexer(env); + } + + @Test + public void invalidOperator() { + assertThrows( + LexerError.class, + () -> subject.tokenize("1@2") + ); + } + + } + +} diff --git a/src/test/java/com/garbagemule/MobArena/formula/LexerParenthesisTest.java b/src/test/java/com/garbagemule/MobArena/formula/LexerParenthesisTest.java new file mode 100644 index 0000000..9e37bbc --- /dev/null +++ b/src/test/java/com/garbagemule/MobArena/formula/LexerParenthesisTest.java @@ -0,0 +1,135 @@ +package com.garbagemule.MobArena.formula; + +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; + +import java.util.List; + +import static com.garbagemule.MobArena.formula.LexemeMatcher.matches; +import static com.garbagemule.MobArena.formula.TokenType.*; +import static java.util.Arrays.asList; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; + +@RunWith(Enclosed.class) +public class LexerParenthesisTest { + + static Environment env; + static Lexer subject; + + public static class ParenthesizedExpressions { + + @Before + public void setup() { + env = Environment.createDefault(); + subject = new Lexer(env); + } + + @Test + public void parenthesizedPositiveNumberLiteral() { + String input = "(2)"; + + List result = subject.tokenize(input); + + assertThat(result, contains(asList( + matches(LEFT_PAREN), + matches(NUMBER, "2"), + matches(RIGHT_PAREN) + ))); + } + + @Test + public void parenthesizedNegativeNumberLiteral() { + String input = "(-2)"; + + List result = subject.tokenize(input); + + assertThat(result, contains(asList( + matches(LEFT_PAREN), + matches(UNARY_OPERATOR, "-"), + matches(NUMBER, "2"), + matches(RIGHT_PAREN) + ))); + } + + @Test + public void negatedParenthesizedPositiveNumberLiteral() { + String input = "-(2)"; + + List result = subject.tokenize(input); + + assertThat(result, contains(asList( + matches(UNARY_OPERATOR, "-"), + matches(LEFT_PAREN), + matches(NUMBER, "2"), + matches(RIGHT_PAREN) + ))); + } + + @Test + public void simpleExpression() { + String input = "(2+3)"; + + List result = subject.tokenize(input); + + assertThat(result, contains(asList( + matches(LEFT_PAREN), + matches(NUMBER, "2"), + matches(BINARY_OPERATOR, "+"), + matches(NUMBER, "3"), + matches(RIGHT_PAREN) + ))); + } + + @Test + public void nestedExpression() { + String input = "(((2+3)))"; + + List result = subject.tokenize(input); + + assertThat(result, contains(asList( + matches(LEFT_PAREN), + matches(LEFT_PAREN), + matches(LEFT_PAREN), + matches(NUMBER, "2"), + matches(BINARY_OPERATOR, "+"), + matches(NUMBER, "3"), + matches(RIGHT_PAREN), + matches(RIGHT_PAREN), + matches(RIGHT_PAREN) + ))); + } + + @Test + public void multipleExpressions() { + String input = "(2+3)*-(4-5)^(6/7)"; + + List result = subject.tokenize(input); + + assertThat(result, contains(asList( + matches(LEFT_PAREN), + matches(NUMBER, "2"), + matches(BINARY_OPERATOR, "+"), + matches(NUMBER, "3"), + matches(RIGHT_PAREN), + matches(BINARY_OPERATOR, "*"), + matches(UNARY_OPERATOR, "-"), + matches(LEFT_PAREN), + matches(NUMBER, "4"), + matches(BINARY_OPERATOR, "-"), + matches(NUMBER, "5"), + matches(RIGHT_PAREN), + matches(BINARY_OPERATOR, "^"), + matches(LEFT_PAREN), + matches(NUMBER, "6"), + matches(BINARY_OPERATOR, "/"), + matches(NUMBER, "7"), + matches(RIGHT_PAREN) + ))); + } + + } + +} diff --git a/src/test/java/com/garbagemule/MobArena/formula/LexerVariableTest.java b/src/test/java/com/garbagemule/MobArena/formula/LexerVariableTest.java new file mode 100644 index 0000000..a95bb99 --- /dev/null +++ b/src/test/java/com/garbagemule/MobArena/formula/LexerVariableTest.java @@ -0,0 +1,109 @@ +package com.garbagemule.MobArena.formula; + +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +import java.util.Arrays; +import java.util.List; + +import static com.garbagemule.MobArena.formula.LexemeMatcher.matches; +import static com.garbagemule.MobArena.formula.TokenType.*; +import static java.util.Arrays.asList; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.junit.Assert.assertThrows; + +@RunWith(Enclosed.class) +public class LexerVariableTest { + + static Environment env; + static Lexer subject; + + public static class VariableExpressions { + + @Before + public void setup() { + env = Environment.createDefault(); + subject = new Lexer(env); + } + + @Test + public void simpleVariableExpression() { + env.registerVariable("a", null); + String input = ""; + + List result = subject.tokenize(input); + + assertThat(result, contains(matches(VARIABLE, "a"))); + } + + @Test + public void multiVariableExpression() { + env.registerVariable("a", null); + env.registerVariable("b", null); + String input = "+"; + + List result = subject.tokenize(input); + + assertThat(result, contains(asList( + matches(VARIABLE, "a"), + matches(BINARY_OPERATOR, "+"), + matches(VARIABLE, "b") + ))); + } + + @Test + public void unknownVariable() { + assertThrows( + FormulaError.class, + () -> subject.tokenize("") + ); + } + + } + + @RunWith(Parameterized.class) + public static class InvalidVariables { + + @Before + public void setup() { + env = Environment.createDefault(); + subject = new Lexer(env); + } + + @Parameters(name = "{0}") + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {"<"}, + {">"}, + {""}, + {"<"}, + {">"}, + {""}, + {"< a>"}, + }); + } + + String input; + + public InvalidVariables(String input) { + this.input = input; + } + + @Test + public void test() { + env.registerVariable("a", null); + assertThrows( + LexerError.class, + () -> subject.tokenize(input) + ); + } + + } + +} diff --git a/src/test/java/com/garbagemule/MobArena/formula/LexerWhitespaceTest.java b/src/test/java/com/garbagemule/MobArena/formula/LexerWhitespaceTest.java new file mode 100644 index 0000000..5be5056 --- /dev/null +++ b/src/test/java/com/garbagemule/MobArena/formula/LexerWhitespaceTest.java @@ -0,0 +1,42 @@ +package com.garbagemule.MobArena.formula; + +import org.junit.Before; +import org.junit.Test; + +import java.util.List; + +import static com.garbagemule.MobArena.formula.LexemeMatcher.matches; +import static com.garbagemule.MobArena.formula.TokenType.*; +import static java.util.Arrays.asList; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; + +public class LexerWhitespaceTest { + + Environment env; + Lexer subject; + + @Before + public void setup() { + env = Environment.createDefault(); + subject = new Lexer(env); + } + + @Test + public void ignoresWhitespace() { + String input = " 1+ 5 - 2\t ^ \n8"; + + List result = subject.tokenize(input); + + assertThat(result, contains(asList( + matches(NUMBER, "1"), + matches(BINARY_OPERATOR, "+"), + matches(NUMBER, "5"), + matches(BINARY_OPERATOR, "-"), + matches(NUMBER, "2"), + matches(BINARY_OPERATOR, "^"), + matches(NUMBER, "8") + ))); + } + +} diff --git a/src/test/resources/formulas.yml b/src/test/resources/formulas.yml new file mode 100644 index 0000000..3b7cb74 --- /dev/null +++ b/src/test/resources/formulas.yml @@ -0,0 +1,19 @@ +numbers: + one: 1 + two: 2 + +constants: + three-point-one-four: pi + eulers-number: e + +variables: + live: + max: + +operators: + two-plus-two: 2 + 2 + one-times-two: 1 * 2 + +functions: + square-root: sqrt(9) + maximum: max(1, 2)