diff --git a/build.gradle.kts b/build.gradle.kts index 6833f0d8..b64613ce 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -94,6 +94,7 @@ dependencies { } modApi(fabricApi.module("fabric-api-base", libs.versions.fabricApi.get())) modImplementation(fabricApi.module("fabric-networking-api-v1", libs.versions.fabricApi.get())) + modImplementation(fabricApi.module("fabric-command-api-v2", libs.versions.fabricApi.get())) // Only used for prod test modCompileOnly(fabricApi.module("fabric-lifecycle-events-v1", libs.versions.fabricApi.get())) @@ -150,6 +151,13 @@ loom { source("testmod") server() } + + configureEach { + vmArgs( + "-Dmixin.debug.countInjections=true", + "-Dmixin.debug.strict=true" + ) + } } mods { diff --git a/src/main/java/net/kyori/adventure/platform/fabric/impl/AdventureCommon.java b/src/main/java/net/kyori/adventure/platform/fabric/impl/AdventureCommon.java index 2110a35b..b15e8a61 100644 --- a/src/main/java/net/kyori/adventure/platform/fabric/impl/AdventureCommon.java +++ b/src/main/java/net/kyori/adventure/platform/fabric/impl/AdventureCommon.java @@ -1,7 +1,7 @@ /* * This file is part of adventure-platform-fabric, licensed under the MIT License. * - * Copyright (c) 2020-2022 KyoriPowered + * Copyright (c) 2020-2023 KyoriPowered * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -23,6 +23,7 @@ */ package net.kyori.adventure.platform.fabric.impl; +import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.logging.LogUtils; import java.io.IOException; @@ -30,11 +31,15 @@ import java.nio.file.Path; import java.util.List; import java.util.Locale; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; import net.fabricmc.api.EnvType; import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; import net.fabricmc.loader.api.FabricLoader; import net.kyori.adventure.Adventure; @@ -68,6 +73,7 @@ public class AdventureCommon implements ModInitializer { private static final Logger LOGGER = LogUtils.getLogger(); + public static final ScheduledExecutorService SCHEDULER; public static final SidedProxy SIDE_PROXY; public static final ComponentFlattener FLATTENER; @@ -75,6 +81,13 @@ public class AdventureCommon implements ModInitializer { public static final String MOD_FAPI_NETWORKING = "fabric-networking-api-v1"; static { + // Daemon thread executor for scheduled tasks + SCHEDULER = Executors.newSingleThreadScheduledExecutor(new ThreadFactoryBuilder() + .setNameFormat("adventure-platform-fabric-scheduler-%d") + .setDaemon(true) + .setUncaughtExceptionHandler((thread, ex) -> LOGGER.error("An uncaught exception occurred in scheduler thread '{}':", thread.getName(), ex)) + .build()); + final var sidedProxy = chooseSidedProxy(); SIDE_PROXY = sidedProxy; FLATTENER = createFlattener(sidedProxy); @@ -168,6 +181,18 @@ public void onInitialize() { }); }); + CommandRegistrationCallback.EVENT.register((dispatcher, registries, env) -> { + ClickCallbackRegistry.INSTANCE.registerToDispatcher(dispatcher); + }); + + // Perform scheduled cleanup + SCHEDULER.scheduleWithFixedDelay( + ClickCallbackRegistry.INSTANCE::cleanUp, + ClickCallbackRegistry.CLEAN_UP_RATE, + ClickCallbackRegistry.CLEAN_UP_RATE, + TimeUnit.SECONDS + ); + // If we are in development mode, shut down immediately if (Boolean.getBoolean("adventure.testMode")) { if (FabricLoader.getInstance().getEnvironmentType() == EnvType.SERVER) { diff --git a/src/main/java/net/kyori/adventure/platform/fabric/impl/ClickCallbackRegistry.java b/src/main/java/net/kyori/adventure/platform/fabric/impl/ClickCallbackRegistry.java new file mode 100644 index 00000000..e3aa1b11 --- /dev/null +++ b/src/main/java/net/kyori/adventure/platform/fabric/impl/ClickCallbackRegistry.java @@ -0,0 +1,158 @@ +/* + * This file is part of adventure-platform-fabric, licensed under the MIT License. + * + * Copyright (c) 2023 KyoriPowered + * + * 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 net.kyori.adventure.platform.fabric.impl; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.RemovalListener; +import com.mojang.brigadier.Command; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.logging.LogUtils; +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.UuidArgument; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; + +public final class ClickCallbackRegistry { + private static final Logger LOGGER = LogUtils.getLogger(); + + public static final ClickCallbackRegistry INSTANCE = new ClickCallbackRegistry(); + + public static final int CLEAN_UP_RATE = 30 /* seconds */; + + private static final String UUID_ARG = "uuid"; + private static final String COMMAND_NAME = "adventure_callback"; + private static final Component FAILURE_MESSAGE = Component.text("No callback with that ID could be found! You may have used it too many times, or it may have expired.", NamedTextColor.RED); + + private final Cache registrations = CacheBuilder.newBuilder() + .expireAfterWrite(24, TimeUnit.HOURS) + .maximumSize(1024) // to avoid unbounded growth + .removalListener((RemovalListener) notification -> LOGGER.debug("Removing callback {} from cache for reason {}", notification.getKey(), notification.getCause())) + .build(); + + private ClickCallbackRegistry() { + } + + /** + * Register a callback, returning the command to be attached to a click event. + * + * @param callback the callback to register + * @param options options + * @return a new callback handler command + * @since 5.7.0 + */ + public String register(final ClickCallback callback, final ClickCallback.Options options) { + final UUID id = UUID.randomUUID(); + final CallbackRegistration reg = new CallbackRegistration( + options, + callback, + Instant.now().plus(options.lifetime()), + new AtomicInteger() + ); + + this.registrations.put(id, reg); + + return "/" + COMMAND_NAME + " " + id; + } + + public void registerToDispatcher(final CommandDispatcher dispatcher) { + dispatcher.register(Commands.literal(COMMAND_NAME) + .requires(HiddenRequirement.alwaysAllowed()) // hide from client tab completion + .then(Commands.argument(UUID_ARG, UuidArgument.uuid()) + .executes(ctx -> { + final UUID callbackId = UuidArgument.getUuid(ctx, UUID_ARG); + final @Nullable CallbackRegistration reg = this.registrations.getIfPresent(callbackId); + + if (reg == null) { + ctx.getSource().sendFailure(FAILURE_MESSAGE); + return 0; // unsuccessful + } + + // check use count + boolean expire = false; + boolean allowUse = true; + final int allowedUses = reg.options.uses(); + if (allowedUses != ClickCallback.UNLIMITED_USES) { + final int useCount = reg.useCount().incrementAndGet(); + if (useCount >= allowedUses) { + expire = true; + allowUse = !(useCount > allowedUses); + // allowUse: determine + } + } + + // check duration expiry + final Instant now = Instant.now(); + if (now.isAfter(reg.expiryTime())) { + expire = true; + allowUse = false; + } + + if (expire) { + this.registrations.invalidate(callbackId); + } + if (allowUse) { + reg.callback().accept(ctx.getSource()); // pass the CommandSourceStack to the callback action + } else { + ctx.getSource().sendFailure(FAILURE_MESSAGE); + } + return Command.SINGLE_SUCCESS; + }))); + } + + /** + * Perform a periodic cleanup of callbacks. + */ + public void cleanUp() { + final Instant now = Instant.now(); + + final Set uuidsToClean = new HashSet<>(); + for (final var entry : this.registrations.asMap().entrySet()) { + final var reg = entry.getValue(); + final int allowedUses = reg.options().uses(); + if (allowedUses != ClickCallback.UNLIMITED_USES && reg.useCount().get() >= allowedUses) { + uuidsToClean.add(entry.getKey()); + } else if (now.isAfter(reg.expiryTime())) { + uuidsToClean.add(entry.getKey()); + } + } + + for (final UUID id : uuidsToClean) { + this.registrations.invalidate(id); + } + } + + private record CallbackRegistration(ClickCallback.Options options, ClickCallback callback, Instant expiryTime, AtomicInteger useCount) {} +} diff --git a/src/main/java/net/kyori/adventure/platform/fabric/impl/HiddenRequirement.java b/src/main/java/net/kyori/adventure/platform/fabric/impl/HiddenRequirement.java new file mode 100644 index 00000000..c5e90071 --- /dev/null +++ b/src/main/java/net/kyori/adventure/platform/fabric/impl/HiddenRequirement.java @@ -0,0 +1,66 @@ +/* + * This file is part of adventure-platform-fabric, licensed under the MIT License. + * + * Copyright (c) 2023 KyoriPowered + * + * 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 net.kyori.adventure.platform.fabric.impl; + +import java.util.Objects; +import java.util.function.Predicate; +import org.jetbrains.annotations.NotNull; + +/** + * A requirement for commands that prevents them from being synced to the client. + * + *

Not yet implemented for client commands.

+ * + * @param base the base predicate + * @param value type + */ +public record HiddenRequirement(Predicate base) implements Predicate { + public static HiddenRequirement alwaysAllowed() { + return new HiddenRequirement<>(t -> true); + } + + @Override + public boolean test(final V v) { + return this.base.test(v); + } + + @Override + public @NotNull Predicate and(final @NotNull Predicate other) { + return new HiddenRequirement<>(this.base.and(unwrap(Objects.requireNonNull(other, "other")))); + } + + @Override + public @NotNull Predicate negate() { + return new HiddenRequirement<>(this.base.negate()); + } + + @Override + public @NotNull Predicate or(final @NotNull Predicate other) { + return new HiddenRequirement<>(this.base.or(unwrap(Objects.requireNonNull(other, "other")))); + } + + private static @NotNull Predicate unwrap(final @NotNull Predicate pred) { + return pred instanceof HiddenRequirement req ? req.base : pred; + } +} diff --git a/src/main/java/net/kyori/adventure/platform/fabric/impl/service/FabricClickCallbackProviderImpl.java b/src/main/java/net/kyori/adventure/platform/fabric/impl/service/FabricClickCallbackProviderImpl.java new file mode 100644 index 00000000..3eeb7f78 --- /dev/null +++ b/src/main/java/net/kyori/adventure/platform/fabric/impl/service/FabricClickCallbackProviderImpl.java @@ -0,0 +1,39 @@ +/* + * This file is part of adventure-platform-fabric, licensed under the MIT License. + * + * Copyright (c) 2023 KyoriPowered + * + * 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 net.kyori.adventure.platform.fabric.impl.service; + +import com.google.auto.service.AutoService; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.platform.fabric.impl.ClickCallbackRegistry; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.event.ClickEvent; +import org.jetbrains.annotations.NotNull; + +@AutoService(ClickCallback.Provider.class) +public final class FabricClickCallbackProviderImpl implements ClickCallback.Provider { + @Override + public @NotNull ClickEvent create(final @NotNull ClickCallback callback, final ClickCallback.@NotNull Options options) { + return ClickEvent.runCommand(ClickCallbackRegistry.INSTANCE.register(callback, options)); + } +} diff --git a/src/main/resource-templates/fabric.mod.yaml b/src/main/resource-templates/fabric.mod.yaml index 00877f62..f6e3d900 100644 --- a/src/main/resource-templates/fabric.mod.yaml +++ b/src/main/resource-templates/fabric.mod.yaml @@ -47,6 +47,7 @@ custom: depends: fabricloader: ">=0.14.0" fabric-api-base: "*" + fabric-command-api-v2: "*" recommends: fabric-networking-api-v1: "*" diff --git a/src/mixin/java/net/kyori/adventure/platform/fabric/impl/mixin/minecraft/commands/CommandsMixin.java b/src/mixin/java/net/kyori/adventure/platform/fabric/impl/mixin/minecraft/commands/CommandsMixin.java index 8edbb1ea..ec3fd345 100644 --- a/src/mixin/java/net/kyori/adventure/platform/fabric/impl/mixin/minecraft/commands/CommandsMixin.java +++ b/src/mixin/java/net/kyori/adventure/platform/fabric/impl/mixin/minecraft/commands/CommandsMixin.java @@ -1,7 +1,7 @@ /* * This file is part of adventure-platform-fabric, licensed under the MIT License. * - * Copyright (c) 2022 KyoriPowered + * Copyright (c) 2022-2023 KyoriPowered * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -23,6 +23,7 @@ */ package net.kyori.adventure.platform.fabric.impl.mixin.minecraft.commands; +import com.google.common.collect.Iterators; import com.mojang.brigadier.arguments.ArgumentType; import com.mojang.brigadier.builder.ArgumentBuilder; import com.mojang.brigadier.builder.RequiredArgumentBuilder; @@ -32,6 +33,7 @@ import java.util.Iterator; import java.util.Map; import java.util.Set; +import net.kyori.adventure.platform.fabric.impl.HiddenRequirement; import net.kyori.adventure.platform.fabric.impl.ServerArgumentType; import net.kyori.adventure.platform.fabric.impl.ServerArgumentTypes; import net.kyori.adventure.platform.fabric.impl.accessor.brigadier.builder.RequiredArgumentBuilderAccess; @@ -42,6 +44,7 @@ import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.ModifyVariable; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.LocalCapture; @@ -80,4 +83,17 @@ public abstract class CommandsMixin { } } + + /** + * Hide hidden commands from the client upon sync. + * + *

This injection is optional because its failure won't break any essential behavior.

+ * + * @param itr original rootCommandSource.getChildren() iterator + * @return the filtered iterator + */ + @ModifyVariable(method = "fillUsableCommands", at = @At("STORE"), ordinal = 0, require = 0) + private Iterator> adventure$filterHiddenCommands(final Iterator> itr) { + return Iterators.filter(itr, node -> !(node.getRequirement() instanceof HiddenRequirement)); + } } diff --git a/src/testmod/java/net/kyori/adventure/platform/test/fabric/AdventureTester.java b/src/testmod/java/net/kyori/adventure/platform/test/fabric/AdventureTester.java index 031f992d..4b9cd147 100644 --- a/src/testmod/java/net/kyori/adventure/platform/test/fabric/AdventureTester.java +++ b/src/testmod/java/net/kyori/adventure/platform/test/fabric/AdventureTester.java @@ -63,6 +63,7 @@ import net.kyori.adventure.platform.fabric.FabricServerAudiences; import net.kyori.adventure.sound.Sound; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.Style; @@ -202,13 +203,19 @@ public void onInitialize() { viewer.playSound(sound(sound, Sound.Source.MASTER, 1f, 1f)); return 1; }))) - .then(literal("book").executes(ctx -> { - ctx.getSource().openBook(Book.builder() - .title(text("My book", NamedTextColor.RED)) - .author(text("The adventure team", COLOR_RESPONSE)) - .addPage(text("Welcome to our rules page")) - .addPage(text("Let's do a thing!")) - .build()); + .then(literal("book_callback").executes(ctx -> { + final ClickEvent callback = ClickEvent.callback( + aud -> aud.openBook(Book.builder() + .title(text("My book", NamedTextColor.RED)) + .author(text("The adventure team", COLOR_RESPONSE)) + .addPage(text("Welcome to our rules page")) + .addPage(text("Let's do a thing!")) + .build()), + opts -> opts.uses(1) + ); + LOGGER.info("{}", callback); + + ctx.getSource().sendMessage(Component.textOfChildren(Component.text("Click here").clickEvent(callback), Component.text(" to see important information!"))); return 1; })) .then(literal("rename").then(argument(ARG_TARGET, EntityArgument.entity()).then(argument(ARG_TEXT, miniMessage()).executes(ctx -> {