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)