diff --git a/src/main/java/net/earthcomputer/clientcommands/ClientCommands.java b/src/main/java/net/earthcomputer/clientcommands/ClientCommands.java index 44f707e3..7e2f896f 100644 --- a/src/main/java/net/earthcomputer/clientcommands/ClientCommands.java +++ b/src/main/java/net/earthcomputer/clientcommands/ClientCommands.java @@ -158,6 +158,7 @@ public static void registerCommands(CommandDispatcher PosCommand.register(dispatcher); RelogCommand.register(dispatcher); RenderCommand.register(dispatcher); + ReplyCommand.register(dispatcher); ShrugCommand.register(dispatcher); SignSearchCommand.register(dispatcher); SnakeCommand.register(dispatcher); diff --git a/src/main/java/net/earthcomputer/clientcommands/Configs.java b/src/main/java/net/earthcomputer/clientcommands/Configs.java index 63f3e13e..c28734a8 100644 --- a/src/main/java/net/earthcomputer/clientcommands/Configs.java +++ b/src/main/java/net/earthcomputer/clientcommands/Configs.java @@ -1,6 +1,7 @@ package net.earthcomputer.clientcommands; import dev.xpple.betterconfig.api.Config; +import net.earthcomputer.clientcommands.command.ReplyCommand; import net.earthcomputer.clientcommands.features.ChorusManipulation; import net.earthcomputer.clientcommands.features.EnchantmentCracker; import net.earthcomputer.clientcommands.features.FishingCracker; @@ -176,4 +177,10 @@ public enum PacketDumpMethod { @Config public static int maximumPacketFieldDepth = 10; + + @Config(temporary = true, setter = @Config.Setter("setMinimumReplyDelaySeconds")) + public static float minimumReplyDelaySeconds = 0.5f; + public static void setMinimumReplyDelaySeconds(float minimumReplyDelaySeconds) { + Configs.minimumReplyDelaySeconds = Math.clamp(minimumReplyDelaySeconds, 0.0f, ReplyCommand.MAXIMUM_REPLY_DELAY_SECONDS); + } } diff --git a/src/main/java/net/earthcomputer/clientcommands/command/ReplyCommand.java b/src/main/java/net/earthcomputer/clientcommands/command/ReplyCommand.java new file mode 100644 index 00000000..c55d0d03 --- /dev/null +++ b/src/main/java/net/earthcomputer/clientcommands/command/ReplyCommand.java @@ -0,0 +1,83 @@ +package net.earthcomputer.clientcommands.command; + +import com.mojang.brigadier.Command; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.Dynamic2CommandExceptionType; +import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; +import net.earthcomputer.clientcommands.Configs; +import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; +import net.minecraft.SharedConstants; +import net.minecraft.network.chat.Component; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +import static dev.xpple.clientarguments.arguments.CMessageArgument.*; +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.*; + +public class ReplyCommand { + public static final float MAXIMUM_REPLY_DELAY_SECONDS = 300.0f; + + private static final SimpleCommandExceptionType NO_TARGET_FOUND_EXCEPTION = new SimpleCommandExceptionType(Component.translatable("commands.creply.noTargetFound")); + private static final Dynamic2CommandExceptionType MESSAGE_TOO_LONG_EXCEPTION = new Dynamic2CommandExceptionType((a, b) -> Component.translatable("commands.creply.messageTooLong", a, b)); + + private static final List replyCandidates = new ArrayList<>(); + + @Nullable + public static String getCurrentTarget() { + long now = System.currentTimeMillis(); + + for (int i = 0; i < replyCandidates.size(); i++) { + ReplyCandidate candidate = replyCandidates.get(i); + if (now - candidate.timestampMs > MAXIMUM_REPLY_DELAY_SECONDS * 1_000.0f) { + replyCandidates.remove(i--); + } else { + // list is ordered and `now - candidate.timestampMs` will only get smaller and smaller, so the cmp above will never change + break; + } + } + + for (int i = replyCandidates.size() - 1; i >= 0; i--) { + ReplyCandidate candidate = replyCandidates.get(i); + if (now - candidate.timestampMs >= Configs.minimumReplyDelaySeconds * 1_000.0f) { + return candidate.username; + } + } + + return null; + } + + public static void addReplyCandidate(String username, long timestamp) { + replyCandidates.add(new ReplyCandidate(username, timestamp)); + } + + public static void register(CommandDispatcher dispatcher) { + var command = dispatcher.register(literal("creply") + .then(argument("message", message()) + .executes(ctx -> reply(ctx.getSource(), getMessage(ctx, "message"))))); + dispatcher.register(literal("cr").redirect(command)); + } + + public static int reply(FabricClientCommandSource source, Component message) throws CommandSyntaxException { + String target = ReplyCommand.getCurrentTarget(); + if (target == null) { + throw NO_TARGET_FOUND_EXCEPTION.create(); + } + + String text = message.getString(); + String command = String.format("w %s %s", target, text); + + if (command.length() > SharedConstants.MAX_CHAT_LENGTH) { + throw MESSAGE_TOO_LONG_EXCEPTION.create(SharedConstants.MAX_CHAT_LENGTH - (command.length() - text.length()), text.length()); + } + + source.getClient().getConnection().sendCommand(command); + + return Command.SINGLE_SUCCESS; + } + + private record ReplyCandidate(String username, long timestampMs) { + } +} diff --git a/src/main/java/net/earthcomputer/clientcommands/mixin/commands/reply/ClientPacketListenerMixin.java b/src/main/java/net/earthcomputer/clientcommands/mixin/commands/reply/ClientPacketListenerMixin.java new file mode 100644 index 00000000..000a1a6f --- /dev/null +++ b/src/main/java/net/earthcomputer/clientcommands/mixin/commands/reply/ClientPacketListenerMixin.java @@ -0,0 +1,30 @@ +package net.earthcomputer.clientcommands.mixin.commands.reply; + +import net.earthcomputer.clientcommands.command.ReplyCommand; +import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.client.multiplayer.PlayerInfo; +import net.minecraft.network.chat.ChatType; +import net.minecraft.network.protocol.game.ClientboundPlayerChatPacket; +import org.jetbrains.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.UUID; + +@Mixin(ClientPacketListener.class) +public abstract class ClientPacketListenerMixin { + @Shadow public abstract @Nullable PlayerInfo getPlayerInfo(UUID uniqueId); + + @Inject(method = "handlePlayerChat", at = @At(value = "INVOKE", target = "Lnet/minecraft/network/protocol/PacketUtils;ensureRunningOnSameThread(Lnet/minecraft/network/protocol/Packet;Lnet/minecraft/network/PacketListener;Lnet/minecraft/util/thread/BlockableEventLoop;)V", shift = At.Shift.AFTER)) + private void onHandlePlayerChat(ClientboundPlayerChatPacket packet, CallbackInfo ci) { + if (packet.chatType().chatType().is(ChatType.MSG_COMMAND_INCOMING) || packet.chatType().chatType().is(ChatType.MSG_COMMAND_OUTGOING)) { + PlayerInfo info = getPlayerInfo(packet.sender()); + if (info != null) { + ReplyCommand.addReplyCandidate(info.getProfile().getName(), System.currentTimeMillis()); + } + } + } +} diff --git a/src/main/resources/assets/clientcommands/lang/en_us.json b/src/main/resources/assets/clientcommands/lang/en_us.json index c35da620..a96f474f 100644 --- a/src/main/resources/assets/clientcommands/lang/en_us.json +++ b/src/main/resources/assets/clientcommands/lang/en_us.json @@ -182,6 +182,9 @@ "commands.crender.entities.success": "Entity rendering rules have been updated", + "commands.creply.noTargetFound": "Could not find a target to reply to", + "commands.creply.messageTooLong": "Your reply was too long (maximum: %d, given: %d)", + "commands.csignsearch.starting": "Searching signs", "commands.csnap.airborne": "You cannot snap while airborne", diff --git a/src/main/resources/mixins.clientcommands.json b/src/main/resources/mixins.clientcommands.json index dc8329ce..409b38d7 100644 --- a/src/main/resources/mixins.clientcommands.json +++ b/src/main/resources/mixins.clientcommands.json @@ -66,6 +66,7 @@ "commands.alias.ClientSuggestionProviderMixin", "commands.enchant.MultiPlayerGameModeMixin", "commands.findblock.ClientLevelMixin", + "commands.reply.ClientPacketListenerMixin", "commands.generic.CommandSuggestionsMixin", "commands.glow.LivingEntityRenderStateMixin", "commands.snap.MinecraftMixin",