diff --git a/common/src/main/java/revxrsal/commands/Lamp.java b/common/src/main/java/revxrsal/commands/Lamp.java index 1fcea1c0..689d0b1b 100644 --- a/common/src/main/java/revxrsal/commands/Lamp.java +++ b/common/src/main/java/revxrsal/commands/Lamp.java @@ -612,6 +612,8 @@ public Builder() { responseHandler(CompletionStageResponseHandler.INSTANCE); responseHandler(OptionalResponseHandler.INSTANCE); commandCondition(PermissionConditionChecker.INSTANCE); + commandCondition(CooldownCondition.INSTANCE); + hooks().onPostCommandExecuted(CooldownCondition.INSTANCE); accept(KotlinFeatureRegistry.INSTANCE); } diff --git a/common/src/main/java/revxrsal/commands/annotation/Cooldown.java b/common/src/main/java/revxrsal/commands/annotation/Cooldown.java new file mode 100644 index 00000000..cf2fb19a --- /dev/null +++ b/common/src/main/java/revxrsal/commands/annotation/Cooldown.java @@ -0,0 +1,54 @@ +/* + * This file is part of sweeper, licensed under the MIT License. + * + * Copyright (c) Revxrsal + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package revxrsal.commands.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +/** + * Adds a cooldown to the command. + */ +@DistributeOnMethods +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.TYPE}) +public @interface Cooldown { + + /** + * The cooldown value + * + * @return The command cooldown value + */ + long value(); + + /** + * The time unit in which the cooldown goes for. + * + * @return The time unit for the cooldown + */ + TimeUnit unit() default TimeUnit.SECONDS; + +} diff --git a/common/src/main/java/revxrsal/commands/command/CooldownCondition.java b/common/src/main/java/revxrsal/commands/command/CooldownCondition.java new file mode 100644 index 00000000..25b49a03 --- /dev/null +++ b/common/src/main/java/revxrsal/commands/command/CooldownCondition.java @@ -0,0 +1,70 @@ +/* + * This file is part of lamp, licensed under the MIT License. + * + * Copysecond (c) Revxrsal + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the seconds + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copysecond notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package revxrsal.commands.command; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import revxrsal.commands.annotation.Cooldown; +import revxrsal.commands.exception.CooldownException; +import revxrsal.commands.hook.PostCommandExecutedHook; +import revxrsal.commands.node.ExecutionContext; +import revxrsal.commands.process.CommandCondition; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +@ApiStatus.Internal +public enum CooldownCondition implements CommandCondition, PostCommandExecutedHook { + + INSTANCE; + + private static final ScheduledExecutorService COOLDOWN_POOL = Executors.newSingleThreadScheduledExecutor(); + private final Map> cooldowns = new ConcurrentHashMap<>(); + + @Override + public void onPostExecuted(@NotNull ExecutableCommand command, @NotNull ExecutionContext context) { + Cooldown cooldown = command.annotations().get(Cooldown.class); + if (cooldown == null || cooldown.value() == 0) return; + Map spans = cooldowns.computeIfAbsent(context.actor().uniqueId(), u -> new ConcurrentHashMap<>()); + spans.put(command.hashCode(), System.currentTimeMillis()); + COOLDOWN_POOL.schedule(() -> spans.remove(command.hashCode()), cooldown.value(), cooldown.unit()); + } + + @Override public void test(@NotNull ExecutionContext context) { + Cooldown cooldown = context.command().annotations().get(Cooldown.class); + if (cooldown == null || cooldown.value() == 0) return; + UUID uuid = context.actor().uniqueId(); + Map spans = cooldowns.get(uuid); + if (spans == null) return; + long created = spans.get(context.command().hashCode()); + long passed = System.currentTimeMillis() - created; + long left = cooldown.unit().toMillis(cooldown.value()) - passed; + if (left > 0 && left < 1000) + left = 1000L; // for formatting + throw new CooldownException(left); + } +} diff --git a/common/src/main/java/revxrsal/commands/exception/CooldownException.java b/common/src/main/java/revxrsal/commands/exception/CooldownException.java new file mode 100644 index 00000000..b3470837 --- /dev/null +++ b/common/src/main/java/revxrsal/commands/exception/CooldownException.java @@ -0,0 +1,86 @@ +/* + * This file is part of lamp, licensed under the MIT License. + * + * Copysecond (c) Revxrsal + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the seconds + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copysecond notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package revxrsal.commands.exception; + +import org.jetbrains.annotations.NotNull; +import revxrsal.commands.annotation.Cooldown; +import revxrsal.commands.command.CommandActor; + +import java.util.concurrent.TimeUnit; + +import static revxrsal.commands.util.Preconditions.notNull; + +/** + * Thrown when the {@link CommandActor} has to wait before executing a + * command again. This is set by {@link Cooldown}. + */ +@ThrowableFromCommand +public class CooldownException extends RuntimeException { + + /** + * The time left (in milliseconds) + */ + private final long timeLeft; + + /** + * Creates a new {@link CooldownException} with the given timestamp + * in milliseconds + * + * @param timeLeft The time left in milliseconds + */ + public CooldownException(long timeLeft) { + this.timeLeft = timeLeft; + } + + /** + * Creates a new {@link CooldownException} with the given timestamp + * in any unit + * + * @param unit The time unit in which the time left is given + * @param timeLeft The time left in the given unit + */ + public CooldownException(TimeUnit unit, long timeLeft) { + this.timeLeft = unit.toMillis(timeLeft); + } + + /** + * Returns the time left before being able to execute again + * + * @return Time left in milliseconds + */ + public long getTimeLeftMillis() { + return timeLeft; + } + + /** + * Returns the time left in the given unit + * + * @param unit Unit to convert to + * @return The time left + */ + public long getTimeLeft(@NotNull TimeUnit unit) { + notNull(unit, "unit"); + return unit.convert(timeLeft, TimeUnit.MILLISECONDS); + } +} diff --git a/common/src/main/java/revxrsal/commands/exception/DefaultExceptionHandler.java b/common/src/main/java/revxrsal/commands/exception/DefaultExceptionHandler.java index 12cc3b73..3d52c140 100644 --- a/common/src/main/java/revxrsal/commands/exception/DefaultExceptionHandler.java +++ b/common/src/main/java/revxrsal/commands/exception/DefaultExceptionHandler.java @@ -27,6 +27,11 @@ import revxrsal.commands.command.CommandActor; import revxrsal.commands.node.ParameterNode; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.StringJoiner; + public class DefaultExceptionHandler extends RuntimeExceptionAdapter { @HandleException @@ -135,9 +140,49 @@ public void onUnknownParameter(@NotNull UnknownParameterException e, @NotNull A actor.error("Unknown flag: " + e.name()); } + @HandleException + public void onCooldown(@NotNull CooldownException e, @NotNull A actor) { + actor.error("You must wait " + formatTimeFancy(e.getTimeLeftMillis()) + " before using this command again."); + } + @HandleException public void onSendable(@NotNull SendableException e, @NotNull A actor) { e.sendTo(actor); } + public static String formatTimeFancy(long time) { + Duration d = Duration.ofMillis(time); + long hours = d.toHours(); + long minutes = d.minusHours(hours).getSeconds() / 60; + long seconds = d.minusMinutes(minutes).minusHours(hours).getSeconds(); + List words = new ArrayList<>(); + if (hours != 0) + words.add(hours + plural(hours, " hour")); + if (minutes != 0) + words.add(minutes + plural(minutes, " minute")); + if (seconds != 0) + words.add(seconds + plural(seconds, " second")); + return toFancyString(words); + } + + public static String toFancyString(List list) { + StringJoiner builder = new StringJoiner(", "); + if (list.isEmpty()) return ""; + if (list.size() == 1) return list.get(0).toString(); + for (int i = 0; i < list.size(); i++) { + T el = list.get(i); + if (i + 1 == list.size()) + return builder + " and " + el.toString(); + else + builder.add(el.toString()); + } + return builder.toString(); + } + + public static String plural(Number count, String thing) { + if (count.intValue() == 1) return thing; + if (thing.endsWith("y")) + return thing.substring(0, thing.length() - 1) + "ies"; + return thing + "s"; + } }