diff --git a/src/main/java/ch/njol/skript/effects/EffOperations.java b/src/main/java/ch/njol/skript/effects/EffOperations.java new file mode 100644 index 00000000000..8b9adc359d2 --- /dev/null +++ b/src/main/java/ch/njol/skript/effects/EffOperations.java @@ -0,0 +1,235 @@ +package ch.njol.skript.effects; + +import ch.njol.skript.Skript; +import ch.njol.skript.classes.Changer.ChangeMode; +import ch.njol.skript.classes.Changer.ChangerUtils; +import ch.njol.skript.config.Node; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.arithmetic.ExprArithmetic; +import ch.njol.skript.lang.Effect; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.skript.lang.SyntaxStringBuilder; +import ch.njol.skript.registrations.Classes; +import ch.njol.skript.util.LiteralUtils; +import ch.njol.skript.util.Patterns; +import ch.njol.util.Kleenean; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.lang.arithmetic.Arithmetics; +import org.skriptlang.skript.lang.arithmetic.Operation; +import org.skriptlang.skript.lang.arithmetic.OperationInfo; +import org.skriptlang.skript.lang.arithmetic.Operator; +import org.skriptlang.skript.log.runtime.SyntaxRuntimeErrorProducer; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +@Name("Operations") +@Description("Perform multiplication, division, or exponentiation operations on variable objects " + + "(i.e. numbers, vectors, timespans, and other objects from addons). " + + "Literals cannot be used on the left-hand side.") +@Example(""" + set {_num} to 1 + multiply {_num} by 10 + divide {_num} by 5 + raise {_num} to the power of 2 + """) +@Example(""" + set {_nums::*} to 15, 21 and 30 + divide {_nums::*} by 3 + multiply {_nums::*} by 5 + raise {_nums::*} to the power of 3 + """) +@Example(""" + set {_vector} to vector(1,1,1) + multiply {_vector} by vector(4,8,16) + divide {_vector} by 2 + """) +@Example(""" + set {_timespan} to 1 hour + multiply {_timespan} by 3 + """) +@Example(""" + # Will error due to literal + multiply 1 by 2 + divide 10 by {_num} + """) +@Since("INSERT VERSION") +public class EffOperations extends Effect implements SyntaxRuntimeErrorProducer { + + private static final Patterns PATTERNS = new Patterns<>(new Object[][]{ + {"multiply %~objects% by %object%", Operator.MULTIPLICATION}, + {"divide %~objects% by %object%", Operator.DIVISION}, + {"raise %~objects% to [the] (power|exponent) [of] %object%", Operator.EXPONENTIATION} + }); + + static { + Skript.registerEffect(EffOperations.class, PATTERNS.getPatterns()); + } + + private Operator operator; + private Expression left; + private Class[] leftAccepts; + private Expression right; + private Node node; + private Operation operation = null; + private OperationInfo operationInfo; + + @Override + public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { + operator = PATTERNS.getInfo(matchedPattern); + node = getParser().getNode(); + left = exprs[0]; + right = LiteralUtils.defendExpression(exprs[1]); + + leftAccepts = left.acceptChange(ChangeMode.SET); + // Ensure 'left' is changeable + if (leftAccepts == null) { + Skript.error("'" + left + "' cannot be set to anything and therefore cannot be " + getOperatorName() + "."); + return false; + } else if (leftAccepts.length == 0) { + throw new IllegalStateException("An expression should never return an empty array for a ChangeMode of 'SET'"); + } + // Ensure the accepted classes of 'left' are non-array classes + for (int i = 0; i < leftAccepts.length; i++) { + if (leftAccepts[i].isArray()) { + leftAccepts[i] = leftAccepts[i].getComponentType(); + } + } + + Class leftType = left.getReturnType(); + Class rightType = right.getReturnType(); + + if (leftType.isArray()) + leftType = leftType.getComponentType(); + + if (leftType.equals(Object.class) && rightType.equals(Object.class)) { + // 'left' and 'right' return 'Object.class' thus making operation checks non-applicable + // However, we can check to make sure any of the registered operations return types are applicable + // for 'left's acceptedClasses + Class[] allReturnTypes = Arithmetics.getAllReturnTypes(operator).toArray(Class[]::new); + if (!ChangerUtils.acceptsChangeTypes(leftAccepts, allReturnTypes)) { + Skript.error(left + " cannot be " + getOperatorName() + "."); + return false; + } + return LiteralUtils.canInitSafely(right); + } else if (leftType.equals(Object.class) || rightType.equals(Object.class)) { + Class[] returnTypes; + if (leftType.equals(Object.class)) { + returnTypes = Arithmetics.getOperations(operator).stream() + .filter(info -> info.getRight().isAssignableFrom(rightType)) + .map(OperationInfo::getReturnType) + .toArray(Class[]::new); + } else { + returnTypes = Arithmetics.getOperations(operator, leftType).stream() + .map(OperationInfo::getReturnType) + .toArray(Class[]::new); + } + + if (returnTypes.length == 0) { + noOperationError(left, leftType, rightType); + return false; + } + if (!ChangerUtils.acceptsChangeTypes(leftAccepts, returnTypes)) { + genericParseError(left, rightType); + return false; + } + } else { + operationInfo = Arithmetics.lookupOperationInfo(operator, leftType, rightType, leftAccepts); + if (operationInfo == null || !ChangerUtils.acceptsChangeTypes(leftAccepts, operationInfo.getReturnType())) { + genericParseError(left, rightType); + return false; + } + } + return LiteralUtils.canInitSafely(right); + } + + @Override + protected void execute(Event event) { + Object rightObject = right.getSingle(event); + if (rightObject == null) + return; + + Class rightType = rightObject.getClass(); + + Map, Operation> cachedOperations = new HashMap<>(); + Set> invalidTypes = new HashSet<>(); + + Function changerFunction = (leftInput) -> { + Class leftType = leftInput.getClass(); + if (invalidTypes.contains(leftType)) { + printArithmeticError(leftType, rightType); + return leftInput; + } + Operation operation = cachedOperations.get(leftType); + if (operation == null) { + //noinspection unchecked + OperationInfo operationInfo = (OperationInfo) Arithmetics.lookupOperationInfo(operator, leftType, rightType, leftAccepts); + if (operationInfo == null) { + printArithmeticError(leftType, rightType); + invalidTypes.add(leftType); + return leftInput; + } + operation = operationInfo.getOperation(); + cachedOperations.put(leftType, operation); + } + return operation.calculate(leftInput, rightObject); + }; + //noinspection unchecked,rawtypes + left.changeInPlace(event, (Function) changerFunction); + } + + @Override + public Node getNode() { + return node; + } + + private void printArithmeticError(Class left, Class right) { + String error = ExprArithmetic.getArithmeticErrorMessage(operator, left, right); + if (error != null) + error(error); + } + + private void genericParseError(Expression leftExpr, Class rightType) { + Skript.error("'" + leftExpr + "' cannot be " + getOperatorName() + " by " + + Classes.getSuperClassInfo(rightType).getName().withIndefiniteArticle() + "."); + } + + private void noOperationError(Expression leftExpr, Class leftType, Class rightType) { + String error = ExprArithmetic.getArithmeticErrorMessage(operator, leftType, rightType); + if (error != null) { + Skript.error(error); + } else { + genericParseError(leftExpr, rightType); + } + } + + private String getOperatorName() { + return switch (operator) { + case MULTIPLICATION -> "multiplied"; + case DIVISION -> "divided"; + case EXPONENTIATION -> "exponentiated"; + default -> ""; + }; + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + SyntaxStringBuilder builder = new SyntaxStringBuilder(event, debug); + switch (operator) { + case MULTIPLICATION -> builder.append("multiply", left, "by"); + case DIVISION -> builder.append("divide", left, "by"); + case EXPONENTIATION -> builder.append("raise", left, "to the power of"); + } + builder.append(right); + return builder.toString(); + } + +} diff --git a/src/main/java/ch/njol/skript/expressions/arithmetic/ExprArithmetic.java b/src/main/java/ch/njol/skript/expressions/arithmetic/ExprArithmetic.java index 4916139842b..3fd4205d1d8 100644 --- a/src/main/java/ch/njol/skript/expressions/arithmetic/ExprArithmetic.java +++ b/src/main/java/ch/njol/skript/expressions/arithmetic/ExprArithmetic.java @@ -304,12 +304,22 @@ protected T[] get(Event event) { } private boolean error(Class firstClass, Class secondClass) { - ClassInfo first = Classes.getSuperClassInfo(firstClass), second = Classes.getSuperClassInfo(secondClass); - if (first.getC() != Object.class && second.getC() != Object.class) // errors with "object" are not very useful and often misleading - Skript.error(operator.getName() + " can't be performed on " + first.getName().withIndefiniteArticle() + " and " + second.getName().withIndefiniteArticle()); + String error = getArithmeticErrorMessage(operator, firstClass, secondClass); + if (error != null) + Skript.error(error); return false; } + public static @Nullable String getArithmeticErrorMessage(Operator operator, Class left, Class right) { + ClassInfo first = Classes.getSuperClassInfo(left); + ClassInfo second = Classes.getSuperClassInfo(right); + if (!first.getC().equals(Object.class) && !second.getC().equals(Object.class)) { + return operator.getName() + " can't be performed on " + first.getName().withIndefiniteArticle() + " and " + + second.getName().withIndefiniteArticle(); + } + return null; + } + @Override public Class getReturnType() { return returnType; diff --git a/src/main/java/org/skriptlang/skript/lang/arithmetic/Arithmetics.java b/src/main/java/org/skriptlang/skript/lang/arithmetic/Arithmetics.java index a30cd499133..5c486c188b8 100644 --- a/src/main/java/org/skriptlang/skript/lang/arithmetic/Arithmetics.java +++ b/src/main/java/org/skriptlang/skript/lang/arithmetic/Arithmetics.java @@ -129,6 +129,23 @@ public static OperationInfo lookupOperationInfo(Operator oper return info != null ? info.getConverted(leftClass, rightClass, returnType) : null; } + public static @Nullable OperationInfo lookupOperationInfo( + Operator operator, + Class leftClass, + Class rightClass, + Class ... possibleReturnTypes + ) { + OperationInfo info = lookupOperationInfo(operator, leftClass, rightClass); + if (info == null) + return null; + for (Class returnType : possibleReturnTypes) { + OperationInfo convertedInfo = info.getConverted(leftClass, rightClass, returnType); + if (convertedInfo != null) + return convertedInfo; + } + return null; + } + @Nullable @SuppressWarnings("unchecked") public static OperationInfo lookupOperationInfo(Operator operator, Class leftClass, Class rightClass) { diff --git a/src/test/skript/tests/syntaxes/effects/EffOperations.sk b/src/test/skript/tests/syntaxes/effects/EffOperations.sk new file mode 100644 index 00000000000..fdd5a0f250c --- /dev/null +++ b/src/test/skript/tests/syntaxes/effects/EffOperations.sk @@ -0,0 +1,142 @@ +test "operations effect numbers by literal numbers": + set {_num} to 1 + multiply {_num} by 15 + assert {_num} is 15 with "Failed multiplication for single number by literal number" + divide {_num} by 5 + assert {_num} is 3 with "Failed division for single number by literal number" + raise {_num} to the power of 2 + assert {_num} is 9 with "Failed exponentiation for single number by literal number" + + set {_nums::*} to 2, 3, 4 and 5 + multiply {_nums::*} by 10 + assert {_nums::*} is (20, 30, 40 and 50) with "Failed multiplication for number list by literal number" + divide {_nums::*} by 5 + assert {_nums::*} is (4, 6, 8 and 10) with "Failed division for number list by literal number" + raise {_nums::*} to the power of 2 + assert {_nums::*} is (16, 36, 64 and 100) with "Failed exponentiation for number list by literal number" + +test "operations effect numbers by variable numbers": + set {_num} to 1 + set {_multiply} to 10 + set {_divide} to 5 + set {_raise} to 2 + multiply {_num} by {_multiply} + assert {_num} is 10 with "Failed multiplication for single number by variable number" + divide {_num} by {_divide} + assert {_num} is 2 with "Failed division for single number by variable number" + raise {_num} to the power of {_raise} + assert {_num} is 4 with "Failed exponentiation for single number by variable number" + + set {_nums::*} to 2, 3, 4 and 5 + multiply {_nums::*} by {_multiply} + assert {_nums::*} is (20, 30, 40 and 50) with "Failed multiplication for number list by variable number" + divide {_nums::*} by {_divide} + assert {_nums::*} is (4, 6, 8 and 10) with "Failed division for number list by variable number" + raise {_nums::*} to the power of {_raise} + assert {_nums::*} is (16, 36, 64 and 100) with "Failed exponentiation for number list by variable number" + +test "operations effect vectors by function vectors": + set {_vector} to vector(1,1,1) + multiply {_vector} by vector(4,6,8) + assert {_vector} is vector(4,6,8) with "Failed multiplication for single vector by function vector" + divide {_vector} by vector(2,3,4) + assert {_vector} is vector(2,2,2) with "Failed division for single vector by function vector" + + set {_vectors::*} to vector(2,2,2), vector(3,3,3) and vector(4,4,4) + multiply {_vectors::*} by vector(2,5,10) + assert {_vectors::*} is (vector(4,10,20), vector(6,15,30) and vector(8,20,40)) with "Failed multiplication for vector list by function vector" + divide {_vectors::*} by vector(1,5,5) + assert {_vectors::*} is (vector(4,2,4), vector(6,3,6) and vector(8,4,8)) with "Failed division for vector list by function vector" + +test "operations effect vectors by variable vectors": + set {_vector} to vector(1,1,1) + set {_multiply} to vector(10,20,30) + set {_divide} to vector(5,5,5) + multiply {_vector} by {_multiply} + assert {_vector} is vector(10,20,30) with "Failed multiplication for single vector by vector" + divide {_vector} by {_divide} + assert {_vector} is vector(2,4,6) with "Failed division for single vector by vector" + + set {_vectors::*} to vector(2,2,2), vector(3,3,3) and vector(4,4,4) + multiply {_vectors::*} by {_multiply} + assert {_vectors::*} is (vector(20,40,60), vector(30,60,90) and vector(40,80,120)) with "Failed multiplication for vector list by variable vector" + divide {_vectors::*} by {_divide} + assert {_vectors::*} is (vector(4,8,12), vector(6,12,18) and vector(8,16,24)) with "Failed division for vector list by variable vector" + +test "operations effect vectors by literal numbers": + set {_vector} to vector(1,1,1) + multiply {_vector} by 10 + assert {_vector} is vector(10,10,10) with "Failed multiplication for single vector by literal number" + divide {_vector} by 2 + assert {_vector} is vector(5,5,5) with "Failed division for single vector by literal number" + + set {_vectors::*} to vector(2,2,2), vector(3,3,3) and vector(4,4,4) + multiply {_vectors::*} by 4 + assert {_vectors::*} is (vector(8,8,8), vector(12,12,12) and vector(16,16,16)) with "Failed multiplication for vector list by literal number" + divide {_vectors::*} by 2 + assert {_vectors::*} is (vector(4,4,4), vector(6,6,6) and vector(8,8,8)) with "Failed division for vector list by literal number" + +test "operations effect vectors by variable numbers": + set {_vector} to vector(1,1,1) + set {_multiply} to 15 + set {_divide} to 3 + multiply {_vector} by {_multiply} + assert {_vector} is vector(15,15,15) with "Failed multiplication for single vector by variable number" + divide {_vector} by {_divide} + assert {_vector} is vector(5,5,5) with "Failed division for single vector by variable number" + + set {_vectors::*} to vector(2,2,2), vector(3,3,3) and vector(4,4,4) + multiply {_vectors::*} by {_multiply} + assert {_vectors::*} is (vector(30,30,30), vector(45,45,45) and vector(60,60,60)) with "Failed multiplication for vector list by variable number" + divide {_vectors::*} by {_divide} + assert {_vectors::*} is (vector(10,10,10), vector(15,15,15) and vector(20,20,20)) with "Failed division for vector list by variable number" + +test "operations effect timespans by literal numbers": + set {_timespan} to 1 hour + multiply {_timespan} by 4 + assert {_timespan} is 4 hours with "Failed multiplication for single timespan by literal number" + divide {_timespan} by 2 + assert {_timespan} is 2 hours with "Failed division for single timespan by literal number" + + set {_timespans::*} to (1 minute), (5 minutes) and (10 minutes) + multiply {_timespans::*} by 5 + assert {_timespans::*} is ((5 minutes), (25 minutes) and (50 minutes)) with "Failed multiplication for timespan list by literal number" + divide {_timespans::*} by 2 + assert {_timespans::*} is ((2 minutes and 30 seconds), (12 minutes and 30 seconds) and (25 minutes)) with "Failed division for timespan list by literal number" + +test "operations effect timespans by variable numbers": + set {_timespan} to 1 hour + set {_multiply} to 6 + set {_divide} to 2 + multiply {_timespan} by {_multiply} + assert {_timespan} is 6 hours with "Failed multiplication for single timespan by variable number" + divide {_timespan} by {_divide} + assert {_timespan} is 3 hours with "Failed division for single timespan by variable number" + + set {_timespans::*} to (1 minute), (5 minutes) and (10 minutes) + multiply {_timespans::*} by {_multiply} + assert {_timespans::*} is ((6 minutes), (30 minutes) and (60 minutes)) with "Failed multiplication for timespan list by variable number" + divide {_timespans::*} by {_divide} + assert {_timespans::*} is ((3 minutes), (15 minutes) and (30 minutes)) with "Failed division for timespan list by variable number" + +test "operations effect literals error": + parse: + multiply 1 by 2 + assert last parse logs is set with "Operations should not work for left side literals - multiplication" + + parse: + divide 20 by 4 + assert last parse logs is set with "Operations should not work for left side literals - division" + + parse: + raise 3 to the power of 100 + assert last parse logs is set with "Operations should not work for left side literals - exponentiation" + +test "operations effect expression errors": + parse: + multiply (the hunger level of (random element out of all players)) by 1 hour + assert last parse logs contains "'the food level of a random element of all entities of type player' cannot be multiplied by a time span." with "Hunger level should not be able to be multiplied by timespan" + + parse: + divide all worlds by 2 + assert last parse logs contains "'worlds' cannot be set to anything and therefore cannot be divided." with "ExprWorlds should not have a 'SET' ChangeMode"