diff --git a/README.md b/README.md index 858d7689..6fcf929b 100644 --- a/README.md +++ b/README.md @@ -1,139 +1 @@ -# Meteor Addon Template - -A template to allow easy usage of the Meteor Addon API. - -### How to use - -#### Use GitHub Template (Recommended) - -- Click the green `Use this template` button in the top right corner of this page. - This will create a new repository with this template and a clean history. - -#### Clone Manually - -- Alternatively, clone this repository using these commands for a clean history: - ```bash - git clone --depth 1 https://github.com/MeteorDevelopment/meteor-addon-template your-addon-name - cd your-addon-name - rm -rf .git - git init - git add . - git commit -m "Initial commit from template" - ``` - -#### Development - -- Use this template to add custom modules, commands, HUDs, and other features to Meteor Client. -- To test, run the `Minecraft Client` configuration in your IDE. - This will start a Minecraft client with the Meteor Client mod and your addon loaded. -- To build, run the gradle `build` task. This will create a JAR file in the `build/libs` folder. - - Move the JAR file to the `mods` folder of your Minecraft installation, alongside the Meteor Client mod and run the - game. - -### Updating to newer Minecraft versions - -To update this template to a newer Minecraft version, follow these steps: - -1. Ensure a Meteor Client snapshot is available for the new Minecraft version. -2. Update `gradle/libs.versions.toml` (the versions catalog): - - Set the version entries to the new versions. Common keys to update are: - - `versions.minecraft` - Minecraft version - - `versions.fabric-loader` - Fabric loader version - - `versions.meteor` - Meteor Client snapshot version - - If your addon depends on other libraries listed under the `[libraries]` section, update their versions there as - needed. - - After editing, refresh Gradle dependencies and rebuild your project in the IDE. -3. Update Loom: - - Change the `loom` version in `gradle/libs.versions.toml` (the `versions.loom` entry) to the latest version - compatible with the new Minecraft version. -4. Update the Gradle wrapper: - - Run the wrapper update command for your platform. Examples: - - Unix / macOS / Windows (Powershell): `./gradlew wrapper --gradle-version && ./gradlew wrapper` - - Windows (cmd.exe): `gradlew.bat wrapper --gradle-version && gradlew.bat wrapper` - - This updates and regenerates the Gradle Wrapper scripts (`gradlew`, `gradlew.bat`, etc.) for the specified version. -5. Update your source code: - - Adjust for Minecraft source changes: method names, imports, mixins, etc. - - Check for Meteor Client API changes that may affect your addon by comparing against the - [master branch](https://github.com/MeteorDevelopment/meteor-client/tree/master). -6. Build and test: - - Run the gradle `build` task. - - Confirm the build succeeds and your addon works with the new Minecraft version. - -### Project structure - -```text -. -│── .github -│ ╰── workflows -│ │── dev_build.yml -│ ╰── pull_request.yml -│── gradle -│ │── libs.versions.toml -│ ╰── wrapper -│ │── gradle-wrapper.jar -│ ╰── gradle-wrapper.properties -│── src -│ ╰── main -│ │── java -│ │ ╰── com -│ │ ╰── example -│ │ ╰── addon -│ │ │── commands -│ │ │ ╰── CommandExample -│ │ │── hud -│ │ │ ╰── HudExample -│ │ │── modules -│ │ │ ╰── ModuleExample -│ │ ╰── AddonTemplate -│ ╰── resources -│ │── assets -│ │ ╰── template -│ │ ╰── icon.png -│ │── addon-template.mixins.json -│ ╰── fabric.mod.json -│── .editorconfig -│── .gitignore -│── build.gradle.kts -│── gradle.properties -│── gradlew -│── gradlew.bat -│── LICENSE -│── README.md -╰── settings.gradle.kts -``` - -This is the default project structure. Each folder/file has a specific purpose. -Here is a brief explanation of the ones you might need to modify: - -- `.github/workflows`: Contains the GitHub Actions configuration files. -- `gradle`: Contains the Gradle wrapper files and the versions catalog. - - `libs.versions.toml`: Defines version numbers for Minecraft, Loom, Meteor, and other dependencies. - - `wrapper`: Contains the Gradle wrapper executable files. - To update the Gradle wrapper executable itself, run the wrapper update command (examples are shown above). -- `src/main/java/com/example/addon`: Contains the main class of the addon. - Here you can register your custom commands, modules, and HUDs. - Edit the `getPackage` method to reflect the package of your addon. -- `src/main/resources`: Contains the resources of the addon. - - `assets`: Contains the assets of the addon. - You can add your own assets here, separated in subfolders. - - `template`: Contains the assets of the template. - You can replace the `icon.png` file with your own addon icon. - Also, rename this folder to reflect the name of your addon. - - `addon-template.mixins.json`: Contains the Mixin configuration for the addon. - You can add your own mixins in the `client` array. - - `fabric.mod.json`: Contains the metadata of the addon. - Edit the various fields to reflect the metadata of your addon. -- `build.gradle.kts`: Contains the Gradle build script. - You can manage the dependencies of the addon here. - Remember to keep the `fabric-loom` version up-to-date. -- `gradle.properties`: Contains additional build properties used by the build script - (for example `maven_group` and `archives_base_name`). - Dependency and platform version numbers are stored in `gradle/libs.versions.toml`. -- `LICENSE`: Contains the license of the addon. - You can edit this file to change the license of your addon. -- `README.md`: Contains the documentation of the addon. - You can edit this file to reflect the documentation of your addon, and showcase its features. - -## License - -This template is available under the CC0 license. Feel free to use it for your own projects. +a kitbot specificaly designed for the server 6b6t.org also it uses meteor friends list as a whitelist i was too lazy to add a option to disable the whitelist and all possible commands are im too lazy to write here just try !help in game and it identifies wich to take by reading a sign put the type of chest it is on the sign wich has to be on a side of the chest it is meant here for only for 1 clan so there arent that many kits for now u can change if u want i think diff --git a/gradle.properties b/gradle.properties index 982e5183..d478dfdc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,4 +5,4 @@ org.gradle.configuration-cache=false # Mod Properties maven_group=com.example -archives_base_name=addon-template +archives_base_name=kitbot diff --git a/settings.gradle.kts b/settings.gradle.kts index 4f8c5612..af9690b6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,4 +13,4 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } -rootProject.name = "addon-template" +rootProject.name = "kitbot" diff --git a/src/main/java/com/example/addon/AddonTemplate.java b/src/main/java/com/example/addon/AddonTemplate.java index 3bd07afc..fa80a7a4 100644 --- a/src/main/java/com/example/addon/AddonTemplate.java +++ b/src/main/java/com/example/addon/AddonTemplate.java @@ -2,7 +2,9 @@ import com.example.addon.commands.CommandExample; import com.example.addon.hud.HudExample; -import com.example.addon.modules.ModuleExample; +import com.example.addon.modules.ChestTakerModule; +import com.example.addon.modules.CoordinateChangeKillModule; +import com.example.addon.modules.KitBotModule; import com.mojang.logging.LogUtils; import meteordevelopment.meteorclient.addons.GithubRepo; import meteordevelopment.meteorclient.addons.MeteorAddon; @@ -15,15 +17,17 @@ public class AddonTemplate extends MeteorAddon { public static final Logger LOG = LogUtils.getLogger(); - public static final Category CATEGORY = new Category("Example"); + public static final Category CATEGORY = new Category("6b6t"); public static final HudGroup HUD_GROUP = new HudGroup("Example"); @Override public void onInitialize() { - LOG.info("Initializing Meteor Addon Template"); + LOG.info("Initializing 6b6t addon"); // Modules - Modules.get().add(new ModuleExample()); + Modules.get().add(new KitBotModule()); + Modules.get().add(new ChestTakerModule()); + Modules.get().add(new CoordinateChangeKillModule()); // Commands Commands.add(new CommandExample()); diff --git a/src/main/java/com/example/addon/modules/ChestTakerModule.java b/src/main/java/com/example/addon/modules/ChestTakerModule.java new file mode 100644 index 00000000..5f267bab --- /dev/null +++ b/src/main/java/com/example/addon/modules/ChestTakerModule.java @@ -0,0 +1,285 @@ +package com.example.addon.modules; + +import com.example.addon.AddonTemplate; +import meteordevelopment.meteorclient.events.world.TickEvent; +import meteordevelopment.meteorclient.pathing.BaritoneUtils; +import meteordevelopment.meteorclient.pathing.PathManagers; +import meteordevelopment.meteorclient.systems.modules.Module; +import meteordevelopment.meteorclient.systems.modules.Modules; +import meteordevelopment.orbit.EventHandler; +import net.minecraft.client.gui.screens.inventory.ContainerScreen; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.inventory.ChestMenu; +import net.minecraft.world.inventory.ContainerInput; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.level.block.WallSignBlock; +import net.minecraft.world.level.block.entity.SignBlockEntity; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.Vec3; + +import java.util.Locale; +import java.util.OptionalInt; + +public class ChestTakerModule extends Module { + private static final int ACTION_INTERVAL_TICKS = 4; + private static final int CHEST_TIMEOUT_TICKS = 1200; + private static final int EMPTY_CHEST_WAIT_TICKS = 40; + private static final double INTERACTION_DISTANCE_SQUARED = 25.0; + + private BlockPos targetChest; + private ChestMenu targetMenu; + private String requester; + private String kitType; + private int requestedAmount; + private int initialShulkerCount; + private int ticks; + private int emptyChestTicks; + private int actionCooldown; + private int interactionCooldown; + private boolean pathing; + private boolean[] initialShulkerSlots; + + public ChestTakerModule() { + super(AddonTemplate.CATEGORY, "chest-taker", "Collects requested shulker boxes from the chest behind the matching kit sign."); + } + + @Override + public void onDeactivate() { + stopPathing(); + boolean hadRequest = requester != null; + clearRequest(); + if (hadRequest) Modules.get().get(KitBotModule.class).cancelActiveRequest(); + targetMenu = null; + } + + public boolean requestKit(String playerName, String requestedKit, int amount) { + if (requester != null || mc.player == null || mc.level == null || mc.gameMode == null) return false; + + if (mc.gui.screen() instanceof ContainerScreen && mc.player.containerMenu != null) { + mc.player.closeContainer(); + } + + requester = playerName; + kitType = requestedKit; + requestedAmount = amount; + initialShulkerSlots = snapshotShulkerSlots(); + initialShulkerCount = countInventoryShulkers(); + ticks = 0; + emptyChestTicks = 0; + actionCooldown = 0; + interactionCooldown = 0; + targetMenu = null; + + if (!findAndPathToKitChest()) { + clearRequest(); + warning("Could not find a loaded sign and chest for the %s kit within 64 blocks.", requestedKit); + return false; + } + return true; + } + + public void cancelKitPickup() { + if (requester == null) return; + stopPathing(); + clearRequest(); + targetMenu = null; + } + + @EventHandler + private void onTick(TickEvent.Post event) { + if (requester == null) return; + + if (mc.player == null || mc.level == null || mc.gameMode == null) { + cancelKitPickup(); + return; + } + + if (++ticks >= CHEST_TIMEOUT_TICKS) { + warning("Timed out while collecting %s's %s kit.", requester, kitType); + ChestMenu openMenu = mc.gui.screen() instanceof ContainerScreen screen + && screen.getMenu() instanceof ChestMenu menu ? menu : null; + failOutOfStock(openMenu); + return; + } + + if (!(mc.gui.screen() instanceof ContainerScreen screen)) { + targetMenu = null; + if (targetChest == null) return; + + if (interactionCooldown > 0) { + interactionCooldown--; + } else if (mc.player.distanceToSqr(Vec3.atCenterOf(targetChest)) <= INTERACTION_DISTANCE_SQUARED) { + BlockHitResult hit = new BlockHitResult(Vec3.atCenterOf(targetChest), Direction.UP, targetChest, false); + mc.gameMode.useItemOn(mc.player, InteractionHand.MAIN_HAND, hit); + interactionCooldown = 20; + } + return; + } + + if (!(screen.getMenu() instanceof ChestMenu menu)) return; + if (menu != targetMenu) { + if (targetChest == null) return; + targetMenu = menu; + targetChest = null; + stopPathing(); + actionCooldown = ACTION_INTERVAL_TICKS; + } + + if (actionCooldown > 0) { + actionCooldown--; + return; + } + + int collected = countInventoryShulkers() - initialShulkerCount; + if (collected >= requestedAmount) { + finishPickup(collected); + return; + } + + int chestSlotCount = Math.max(0, menu.slots.size() - 36); + for (int slot = 0; slot < chestSlotCount; slot++) { + ItemStack stack = menu.getSlot(slot).getItem(); + if (!ShulkerItemUtils.isShulker(stack)) continue; + + emptyChestTicks = 0; + mc.gameMode.handleContainerInput(menu.containerId, slot, 0, ContainerInput.QUICK_MOVE, mc.player); + actionCooldown = ACTION_INTERVAL_TICKS; + return; + } + + if (++emptyChestTicks >= EMPTY_CHEST_WAIT_TICKS) { + warning("The %s chest had %d of %d requested shulker(s).", kitType, collected, requestedAmount); + failOutOfStock(menu); + } + } + + private boolean findAndPathToKitChest() { + if (!BaritoneUtils.IS_AVAILABLE || PathManagers.get() == null) { + warning("Baritone is not available; cannot reach the kit chest."); + return false; + } + + BlockPos playerPos = mc.player.blockPosition(); + int radius = 64; + for (int x = -radius; x <= radius; x++) { + for (int y = -16; y <= 16; y++) { + for (int z = -radius; z <= radius; z++) { + BlockPos signPos = playerPos.offset(x, y, z); + if (mc.player.distanceToSqr(Vec3.atCenterOf(signPos)) > (double) radius * radius) continue; + + var signState = mc.level.getBlockState(signPos); + if (!(signState.getBlock() instanceof WallSignBlock)) continue; + if (!(mc.level.getBlockEntity(signPos) instanceof SignBlockEntity sign) + || !signMatchesKit(sign, kitType)) continue; + + BlockPos chestPos = signPos.relative(signState.getValue(WallSignBlock.FACING).getOpposite()); + if (!(mc.level.getBlockState(chestPos).getBlock() instanceof ChestBlock)) continue; + + targetChest = chestPos.immutable(); + PathManagers.get().moveTo(targetChest, false); + pathing = true; + info("Pathing to the %s kit chest behind its sign.", kitType); + return true; + } + } + } + return false; + } + + private boolean signMatchesKit(SignBlockEntity sign, String requestedKit) { + String token = switch (requestedKit) { + case "pvp" -> "pvp"; + case "obsidian" -> "obsidian"; + case "totems" -> "totem"; + case "random" -> "random"; + case "build" -> "build"; + case "terracotta" -> "terracotta"; + case "random_concrete" -> "random_concrete"; + case "refill" -> "refill"; + default -> ""; + }; + if (token.isEmpty()) return false; + + for (boolean front : new boolean[] { true, false }) { + for (var line : sign.getText(front).getMessages(false)) { + if (line.getString().toLowerCase(Locale.ROOT).contains(token)) return true; + } + } + return false; + } + + private int countInventoryShulkers() { + int count = 0; + for (int slot = 0; slot < 36; slot++) { + ItemStack stack = mc.player.getInventory().getItem(slot); + if (ShulkerItemUtils.isShulker(stack)) count += stack.getCount(); + } + return count; + } + + private boolean[] snapshotShulkerSlots() { + boolean[] slots = new boolean[36]; + for (int slot = 0; slot < slots.length; slot++) { + slots[slot] = ShulkerItemUtils.isShulker(mc.player.getInventory().getItem(slot)); + } + return slots; + } + + private void finishPickup(int collected) { + String playerName = requester; + if (mc.player != null) mc.player.closeContainer(); + stopPathing(); + clearRequest(); + targetMenu = null; + + KitBotModule kitBot = Modules.get().get(KitBotModule.class); + if (!kitBot.beginKitDelivery(playerName)) { + warning("Collected the requested kit, but could not start the teleport for %s.", playerName); + kitBot.cancelActiveRequest(); + } + } + + private void failOutOfStock(ChestMenu menu) { + String failedRequester = requester; + if (menu != null && initialShulkerSlots != null) { + for (int inventorySlot = 0; inventorySlot < initialShulkerSlots.length; inventorySlot++) { + if (initialShulkerSlots[inventorySlot] + || !ShulkerItemUtils.isShulker(mc.player.getInventory().getItem(inventorySlot))) continue; + + OptionalInt menuSlot = menu.findSlot(mc.player.getInventory(), inventorySlot); + menuSlot.ifPresent(slot -> mc.gameMode.handleContainerInput( + menu.containerId, slot, 0, ContainerInput.QUICK_MOVE, mc.player)); + } + } + if (mc.player != null && mc.gui.screen() instanceof ContainerScreen) { + mc.player.closeContainer(); + } + stopPathing(); + clearRequest(); + targetMenu = null; + Modules.get().get(KitBotModule.class).reportOutOfStock(failedRequester); + } + + private void clearRequest() { + targetChest = null; + requester = null; + kitType = null; + requestedAmount = 0; + initialShulkerCount = 0; + initialShulkerSlots = null; + ticks = 0; + emptyChestTicks = 0; + actionCooldown = 0; + interactionCooldown = 0; + } + + private void stopPathing() { + if (pathing && BaritoneUtils.IS_AVAILABLE && PathManagers.get() != null) { + PathManagers.get().stop(); + } + pathing = false; + } +} diff --git a/src/main/java/com/example/addon/modules/CoordinateChangeKillModule.java b/src/main/java/com/example/addon/modules/CoordinateChangeKillModule.java new file mode 100644 index 00000000..b6861023 --- /dev/null +++ b/src/main/java/com/example/addon/modules/CoordinateChangeKillModule.java @@ -0,0 +1,83 @@ +package com.example.addon.modules; + +import com.example.addon.AddonTemplate; +import meteordevelopment.meteorclient.events.world.TickEvent; +import meteordevelopment.meteorclient.pathing.PathManagers; +import meteordevelopment.meteorclient.settings.BoolSetting; +import meteordevelopment.meteorclient.settings.Setting; +import meteordevelopment.meteorclient.settings.SettingGroup; +import meteordevelopment.meteorclient.systems.modules.Module; +import meteordevelopment.meteorclient.systems.modules.Modules; +import meteordevelopment.orbit.EventHandler; +import net.minecraft.world.phys.Vec3; + +public class CoordinateChangeKillModule extends Module { + private static final long POST_BARITONE_GRACE_NANOS = 5_000_000_000L; + private static final double KILL_MOVEMENT_DISTANCE_SQUARED = 100.0; + + private final SettingGroup sgGeneral = settings.getDefaultGroup(); + private final Setting ignoreBaritone = sgGeneral.add(new BoolSetting.Builder() + .name("ignore-baritone") + .description("Ignore coordinate changes while Baritone is pathing.") + .defaultValue(true) + .build() + ); + + private Vec3 lastPosition; + private boolean wasBaritonePathing; + private long ignoreMovementUntilNanos; + + public CoordinateChangeKillModule() { + super(AddonTemplate.CATEGORY, "coordinate-change-kill", "Sends /kill when your position changes by more than 10 blocks at once."); + } + + @Override + public void onActivate() { + lastPosition = mc.player == null ? null : mc.player.position(); + wasBaritonePathing = false; + ignoreMovementUntilNanos = 0; + } + + @Override + public void onDeactivate() { + lastPosition = null; + wasBaritonePathing = false; + ignoreMovementUntilNanos = 0; + } + + @EventHandler + private void onTick(TickEvent.Post event) { + if (mc.player == null || mc.player.connection == null) { + lastPosition = null; + wasBaritonePathing = false; + ignoreMovementUntilNanos = 0; + return; + } + Vec3 currentPosition = mc.player.position(); + long now = System.nanoTime(); + boolean baritonePathing = ignoreBaritone.get() + && PathManagers.get() != null && PathManagers.get().isPathing(); + if (baritonePathing) { + wasBaritonePathing = true; + lastPosition = currentPosition; + return; + } + if (wasBaritonePathing) { + wasBaritonePathing = false; + ignoreMovementUntilNanos = now + POST_BARITONE_GRACE_NANOS; + } + if (ignoreBaritone.get() && now < ignoreMovementUntilNanos) { + lastPosition = currentPosition; + return; + } + + boolean movedMoreThanThreshold = lastPosition != null + && currentPosition.distanceToSqr(lastPosition) > KILL_MOVEMENT_DISTANCE_SQUARED; + if (movedMoreThanThreshold) { + mc.player.connection.sendCommand("kill"); + info("Moved more than 10 blocks at once; sent /kill."); + Modules.get().get(KitBotModule.class).onCoordinateChangeKillSent(); + } + lastPosition = currentPosition; + } +} diff --git a/src/main/java/com/example/addon/modules/KitBotModule.java b/src/main/java/com/example/addon/modules/KitBotModule.java new file mode 100644 index 00000000..e62b8b20 --- /dev/null +++ b/src/main/java/com/example/addon/modules/KitBotModule.java @@ -0,0 +1,472 @@ +package com.example.addon.modules; + +import com.example.addon.AddonTemplate; +import meteordevelopment.meteorclient.events.game.ReceiveMessageEvent; +import meteordevelopment.meteorclient.events.packets.PacketEvent; +import meteordevelopment.meteorclient.events.world.TickEvent; +import meteordevelopment.meteorclient.pathing.PathManagers; +import meteordevelopment.meteorclient.settings.BoolSetting; +import meteordevelopment.meteorclient.settings.IntSetting; +import meteordevelopment.meteorclient.settings.Setting; +import meteordevelopment.meteorclient.settings.SettingGroup; +import meteordevelopment.meteorclient.systems.friends.Friend; +import meteordevelopment.meteorclient.systems.friends.Friends; +import meteordevelopment.meteorclient.systems.modules.Module; +import meteordevelopment.meteorclient.systems.modules.Modules; +import meteordevelopment.orbit.EventHandler; +import net.minecraft.network.protocol.game.ClientboundRespawnPacket; +import net.minecraft.world.phys.Vec3; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Queue; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class KitBotModule extends Module { + private static final int TELEPORT_TIMEOUT_TICKS = 2400; + private static final int POST_CHEST_TPA_DELAY_TICKS = 20; + private static final long TELEPORT_MOVEMENT_WINDOW_NANOS = 2_000_000_000L; + private static final double TELEPORT_DISTANCE_SQUARED = 10_000.0; + private static final int RESPAWN_TIMEOUT_TICKS = 200; + private static final int MAX_KIT_AMOUNT = 6; + private static final long REQUEST_COOLDOWN_NANOS = 5L * 60 * 1_000_000_000; + private static final long POST_TELEPORT_DELAY_NANOS = 2L * 1_000_000_000; + private static final Pattern SERVER_TELEPORT_COOLDOWN_PATTERN = Pattern.compile( + "(?i)you have to wait\\s+([0-9]+\\s*[dhms](?:\\s*[0-9]+\\s*[dhms])*)\\s+to teleport again" + ); + + private final SettingGroup sgGeneral = settings.getDefaultGroup(); + private final SettingGroup sgTesting = settings.createGroup("Testing"); + private final Queue requests = new ArrayDeque<>(); + private final Deque teleportPositionSamples = new ArrayDeque<>(); + private final Map lastRequestNanos = new HashMap<>(); + private final Map lastNotifiedQueuePositions = new IdentityHashMap<>(); + + private final Setting defaultKitAmount = sgGeneral.add(new IntSetting.Builder() + .name("default-kit-amount") + .description("Shulker boxes to collect when a friend does not specify an amount (maximum 6).") + .defaultValue(MAX_KIT_AMOUNT) + .range(1, MAX_KIT_AMOUNT) + .sliderRange(1, MAX_KIT_AMOUNT) + .build() + ); + + private final Setting resetCooldownButton = sgTesting.add(new BoolSetting.Builder() + .name("reset-all-cooldowns") + .description("Press to clear the request cooldown for every friend.") + .defaultValue(false) + .onChanged(this::onResetCooldownButtonChanged) + .build() + ); + + private volatile State state = State.IDLE; + private volatile boolean respawnPacketReceived; + private KitRequest activeRequest; + private int stateTicks; + private boolean sawDeath; + private long killDeadlineNanos; + private int lastAnnouncedKillSecond; + + public KitBotModule() { + super(AddonTemplate.CATEGORY, "kit-bot", "Fulfills kit requests from Meteor friends using matching signs, then drops the kit with /kill."); + } + + @Override + public void onDeactivate() { + requests.clear(); + lastNotifiedQueuePositions.clear(); + activeRequest = null; + state = State.IDLE; + teleportPositionSamples.clear(); + respawnPacketReceived = false; + killDeadlineNanos = 0; + lastAnnouncedKillSecond = 0; + stateTicks = 0; + Modules.get().get(ChestTakerModule.class).cancelKitPickup(); + } + + @EventHandler + private void onReceiveMessage(ReceiveMessageEvent event) { + if (mc.player == null || mc.player.connection == null) return; + + String message = event.getMessage().getString(); + String serverCooldown = getServerTeleportCooldown(message); + if (serverCooldown != null && activeRequest != null + && (state == State.SENDING_TPA || state == State.WAITING_FOR_TELEPORT)) { + String requester = activeRequest.requester(); + lastRequestNanos.remove(requester.toLowerCase(Locale.ROOT)); + mc.player.connection.sendCommand("msg " + requester + + " server cooldown try again in " + serverCooldown); + warning("Server teleport cooldown blocked %s's kit delivery; it was not counted as delivered.", requester); + finishRequest(); + return; + } + + KitRequest request = parseFriendRequest(message); + if (request == null) { + handleWhisperCommand(message); + return; + } + + String requesterKey = request.requester().toLowerCase(Locale.ROOT); + long secondsRemaining = cooldownSecondsRemaining(requesterKey); + if (secondsRemaining > 0) { + mc.player.connection.sendCommand("msg " + request.requester() + + " kit request cooldown active; try again in " + secondsRemaining + " seconds"); + return; + } + + requests.add(request); + notifyQueuePositions(); + info("Queued %s's %s request for %d shulker(s), position %d.", + request.requester(), request.kitType(), request.amount(), requests.size() + (activeRequest == null ? 0 : 1)); + startNextRequest(); + } + + @EventHandler + private void onTick(TickEvent.Post event) { + if (mc.player == null || mc.player.connection == null) { + requests.clear(); + lastNotifiedQueuePositions.clear(); + activeRequest = null; + state = State.IDLE; + teleportPositionSamples.clear(); + respawnPacketReceived = false; + killDeadlineNanos = 0; + lastAnnouncedKillSecond = 0; + return; + } + + if (state == State.IDLE) { + startNextRequest(); + return; + } + + if (state == State.SENDING_TPA) { + if (activeRequest == null) { + finishRequest(); + return; + } + + if (++stateTicks < POST_CHEST_TPA_DELAY_TICKS) return; + + stateTicks = 0; + teleportPositionSamples.clear(); + teleportPositionSamples.addLast(new PositionSample(System.nanoTime(), mc.player.position())); + state = State.WAITING_FOR_TELEPORT; + mc.player.connection.sendCommand("tpa " + activeRequest.requester()); + info("Sent /tpa request to %s.", activeRequest.requester()); + return; + } + + if (state == State.WAITING_FOR_TELEPORT) { + long now = System.nanoTime(); + Vec3 currentPosition = mc.player.position(); + teleportPositionSamples.addLast(new PositionSample(now, currentPosition)); + while (teleportPositionSamples.size() > 1 + && now - teleportPositionSamples.peekFirst().timestampNanos() > TELEPORT_MOVEMENT_WINDOW_NANOS) { + teleportPositionSamples.removeFirst(); + } + PositionSample oldestSample = teleportPositionSamples.peekFirst(); + boolean fastCoordinateChange = oldestSample != null + && now - oldestSample.timestampNanos() <= TELEPORT_MOVEMENT_WINDOW_NANOS + && currentPosition.distanceToSqr(oldestSample.position()) >= TELEPORT_DISTANCE_SQUARED; + if (fastCoordinateChange) { + teleportPositionSamples.clear(); + startPostTeleportDelay(); + return; + } + + if (++stateTicks >= TELEPORT_TIMEOUT_TICKS) { + warning("No accepted teleport message was detected; cancelling %s's request.", activeRequest.requester()); + finishRequest(); + } + return; + } + + if (state == State.POST_TELEPORT_DELAY) { + long remainingNanos = killDeadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + state = State.WAITING_FOR_RESPAWN; + stateTicks = 0; + sawDeath = false; + respawnPacketReceived = false; + info("Sending /kill command packet now for %s's kit request.", activeRequest.requester()); + mc.player.connection.sendCommand("kill"); + } else { + int remainingSeconds = (int) ((remainingNanos + 999_999_999L) / 1_000_000_000L); + if (remainingSeconds < lastAnnouncedKillSecond) { + lastAnnouncedKillSecond = remainingSeconds; + info("/kill in %d second(s).", remainingSeconds); + } + } + return; + } + + if (state == State.WAITING_FOR_RESPAWN) { + if (mc.player.getHealth() <= 0) sawDeath = true; + if ((respawnPacketReceived || sawDeath) && mc.player.getHealth() > 0) { + info("Respawn detected; moving to the next queued request."); + finishRequest(); + } else if (++stateTicks >= RESPAWN_TIMEOUT_TICKS) { + warning("Respawn was not detected after /kill; moving to the next queued request."); + finishRequest(); + } + } + } + + @EventHandler + private void onPacketReceive(PacketEvent.Receive event) { + if (state == State.WAITING_FOR_RESPAWN && event.packet instanceof ClientboundRespawnPacket) { + respawnPacketReceived = true; + } + } + + private void startPostTeleportDelay() { + state = State.POST_TELEPORT_DELAY; + killDeadlineNanos = System.nanoTime() + POST_TELEPORT_DELAY_NANOS; + lastAnnouncedKillSecond = 2; + info("Teleport detected; /kill in 2 seconds."); + } + + public boolean beginKitDelivery(String requester) { + if (activeRequest == null || !activeRequest.requester().equalsIgnoreCase(requester) + || mc.player == null || mc.player.connection == null || state != State.WAITING_FOR_CHEST) return false; + + if (PathManagers.get() != null) PathManagers.get().stop(); + lastRequestNanos.put(activeRequest.requester().toLowerCase(Locale.ROOT), System.nanoTime()); + stateTicks = 0; + state = State.SENDING_TPA; + info("Collected %d %s shulker(s); sending /tpa to %s in 1 second.", + activeRequest.amount(), activeRequest.kitType(), activeRequest.requester()); + return true; + } + + public void onCoordinateChangeKillSent() { + if (activeRequest == null || (state != State.SENDING_TPA + && state != State.WAITING_FOR_TELEPORT && state != State.POST_TELEPORT_DELAY)) return; + + state = State.WAITING_FOR_RESPAWN; + stateTicks = 0; + sawDeath = false; + respawnPacketReceived = false; + killDeadlineNanos = 0; + info("Coordinate Change Kill sent /kill; counting %s's kit as delivered.", activeRequest.requester()); + } + + public void cancelActiveRequest() { + if (activeRequest == null) return; + warning("Cancelled %s's %s kit request.", activeRequest.requester(), activeRequest.kitType()); + finishRequest(); + } + + public void reportOutOfStock(String requester) { + if (activeRequest == null || !activeRequest.requester().equalsIgnoreCase(requester)) return; + whisperOutOfStock(activeRequest); + info("Not sending a teleport request to %s because the requested kit is out of stock.", activeRequest.requester()); + finishRequest(); + } + + public boolean hasPendingKitRequests() { + return activeRequest != null || !requests.isEmpty() || state != State.IDLE; + } + + private void startNextRequest() { + if (activeRequest != null || state != State.IDLE || mc.player == null || mc.player.connection == null) return; + + while (!requests.isEmpty()) { + activeRequest = requests.remove(); + long secondsRemaining = cooldownSecondsRemaining(activeRequest.requester().toLowerCase(Locale.ROOT)); + if (secondsRemaining > 0) { + mc.player.connection.sendCommand("msg " + activeRequest.requester() + + " kit request cooldown active; try again in " + secondsRemaining + " seconds"); + activeRequest = null; + continue; + } + + state = State.WAITING_FOR_CHEST; + + ChestTakerModule chestTaker = Modules.get().get(ChestTakerModule.class); + if (!chestTaker.isActive()) chestTaker.enable(); + if (chestTaker.requestKit(activeRequest.requester(), activeRequest.kitType(), activeRequest.amount())) { + return; + } + + warning("Could not start %s's %s kit pickup.", activeRequest.requester(), activeRequest.kitType()); + whisperOutOfStock(activeRequest); + activeRequest = null; + state = State.IDLE; + } + } + + private long cooldownSecondsRemaining(String requesterKey) { + Long lastRequest = lastRequestNanos.get(requesterKey); + if (lastRequest == null) return 0; + long remainingNanos = REQUEST_COOLDOWN_NANOS - (System.nanoTime() - lastRequest); + return remainingNanos <= 0 ? 0 : (remainingNanos + 999_999_999) / 1_000_000_000; + } + + private void onResetCooldownButtonChanged(Boolean pressed) { + if (!pressed) return; + + lastRequestNanos.clear(); + info("All friend request cooldowns have been reset."); + resetCooldownButton.set(false); + } + + private void whisperOutOfStock(KitRequest request) { + if (mc.player != null && mc.player.connection != null) { + mc.player.connection.sendCommand("msg " + request.requester() + " out of stock"); + } + } + + private void handleWhisperCommand(String message) { + Whisper whisper = parseWhisper(message); + if (whisper == null) return; + + boolean friend = false; + for (Friend entry : Friends.get()) { + if (entry.getName().equalsIgnoreCase(whisper.sender())) { + friend = true; + break; + } + } + if (!friend) { + mc.player.connection.sendCommand("msg " + whisper.sender() + " not whitelisted"); + return; + } + + if (whisper.content().equalsIgnoreCase("!help")) { + mc.player.connection.sendCommand("msg " + whisper.sender() + + " Kits: pvp, obsidian, totems, random, build, terracotta, random concrete, refill."); + mc.player.connection.sendCommand("msg " + whisper.sender() + + " Example: !kit obsidian [amount] (amount 1-6)."); + } else { + mc.player.connection.sendCommand("msg " + whisper.sender() + " invallid command !help for commands"); + } + } + + private String getServerTeleportCooldown(String message) { + Matcher matcher = SERVER_TELEPORT_COOLDOWN_PATTERN.matcher(message); + return matcher.find() ? matcher.group(1).trim() : null; + } + + private Whisper parseWhisper(String message) { + String name = "([A-Za-z0-9_]{1,16})"; + String prefix = "(?i)^\\s*(?:\\[[^\\]]+\\]\\s*)*"; + String[] patterns = { + prefix + name + "\\s+(?:whispers(?:\\s+to\\s+you)?|tells\\s+you)(?:\\s*:\\s*|\\s+)(.*)$", + prefix + name + "\\s*->\\s*(?:you|me)\\s*:\\s*(.*)$", + prefix + "(?:from|whisper\\s+from)\\s+" + name + "\\s*:\\s*(.*)$" + }; + + for (String pattern : patterns) { + Matcher matcher = Pattern.compile(pattern).matcher(message); + if (!matcher.matches()) continue; + + return new Whisper(matcher.group(1), matcher.group(2)); + } + return null; + } + + private void finishRequest() { + activeRequest = null; + teleportPositionSamples.clear(); + stateTicks = 0; + sawDeath = false; + respawnPacketReceived = false; + killDeadlineNanos = 0; + lastAnnouncedKillSecond = 0; + state = State.IDLE; + startNextRequest(); + notifyQueuePositions(); + } + + private void notifyQueuePositions() { + if (mc.player == null || mc.player.connection == null) return; + + Map currentPositions = new IdentityHashMap<>(); + int position = 1; + if (activeRequest != null) currentPositions.put(activeRequest, position++); + for (KitRequest request : requests) currentPositions.put(request, position++); + + for (Map.Entry entry : currentPositions.entrySet()) { + KitRequest request = entry.getKey(); + int currentPosition = entry.getValue(); + Integer previousPosition = lastNotifiedQueuePositions.get(request); + if (previousPosition == null || previousPosition != currentPosition) { + mc.player.connection.sendCommand("msg " + request.requester() + + " position #" + currentPosition + " in *queue*"); + } + } + + lastNotifiedQueuePositions.keySet().retainAll(currentPositions.keySet()); + lastNotifiedQueuePositions.putAll(currentPositions); + } + + private KitRequest parseFriendRequest(String message) { + String commandPattern = "(!kit\\s+(?:random\\s+concrete|pvp|obsidian|totems?|random|build|terracotta|refill)(?:\\s+\\d+)?)"; + for (Friend friend : Friends.get()) { + String name = Pattern.quote(friend.getName()); + String prefix = "(?i)^\\s*(?:\\[[^\\]]+\\]\\s*)*"; + String[] formats = { + "<" + name + ">\\s*" + commandPattern, + name + "\\s*:\\s*" + commandPattern, + name + "\\s+(?:whispers(?:\\s+to\\s+you)?|tells\\s+you)(?:\\s*:\\s*|\\s+)" + commandPattern, + name + "\\s*->\\s*(?:you|me)\\s*:\\s*" + commandPattern, + "(?:from|whisper\\s+from)\\s+" + name + "\\s*:\\s*" + commandPattern + }; + + for (String format : formats) { + Matcher messageMatcher = Pattern.compile(prefix + format + "\\s*$").matcher(message); + if (!messageMatcher.matches()) continue; + + Matcher commandMatcher = Pattern.compile( + "(?i)!kit\\s+(random\\s+concrete|pvp|obsidian|totems?|random|build|terracotta|refill)(?:\\s+(\\d+))?") + .matcher(messageMatcher.group(1)); + if (!commandMatcher.matches()) continue; + + String kitType = commandMatcher.group(1).toLowerCase(Locale.ROOT).replace(' ', '_'); + if (kitType.equals("totem")) kitType = "totems"; + String amountText = commandMatcher.group(2); + if (amountText != null && amountText.length() > 1) { + warning("Kit amount must be between 1 and %d.", MAX_KIT_AMOUNT); + return null; + } + + int amount = amountText == null ? defaultKitAmount.get() : Integer.parseInt(amountText); + if (amount < 1 || amount > MAX_KIT_AMOUNT) { + warning("Kit amount must be between 1 and %d.", MAX_KIT_AMOUNT); + return null; + } + + return new KitRequest(friend.getName(), kitType, amount); + } + } + + return null; + } + + private enum State { + IDLE, + WAITING_FOR_CHEST, + SENDING_TPA, + WAITING_FOR_TELEPORT, + POST_TELEPORT_DELAY, + WAITING_FOR_RESPAWN + } + + private record KitRequest(String requester, String kitType, int amount) { + } + + private record PositionSample(long timestampNanos, Vec3 position) { + } + + private record Whisper(String sender, String content) { + } +} diff --git a/src/main/java/com/example/addon/modules/ShulkerItemUtils.java b/src/main/java/com/example/addon/modules/ShulkerItemUtils.java new file mode 100644 index 00000000..1bc63ffc --- /dev/null +++ b/src/main/java/com/example/addon/modules/ShulkerItemUtils.java @@ -0,0 +1,32 @@ +package com.example.addon.modules; + +import net.minecraft.core.component.DataComponents; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.component.ItemContainerContents; +import net.minecraft.world.level.block.ShulkerBoxBlock; + +final class ShulkerItemUtils { + private ShulkerItemUtils() { + } + + static boolean isShulker(ItemStack stack) { + return !stack.isEmpty() + && stack.getItem() instanceof BlockItem blockItem + && blockItem.getBlock() instanceof ShulkerBoxBlock; + } + + static boolean isNamedShulker(ItemStack stack, String name) { + return stack.getCustomName() != null + && isShulker(stack) + && stack.getCustomName().getString().equalsIgnoreCase(name.trim()); + } + + static boolean containsItem(ItemStack stack, Item item) { + if (!isShulker(stack)) return false; + + ItemContainerContents contents = stack.get(DataComponents.CONTAINER); + return contents != null && contents.nonEmptyItemCopyStream().anyMatch(itemStack -> itemStack.is(item)); + } +} diff --git a/src/main/resources/assets/kitbot/icon.png b/src/main/resources/assets/kitbot/icon.png new file mode 100644 index 00000000..daefd59e Binary files /dev/null and b/src/main/resources/assets/kitbot/icon.png differ diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 58192695..7884c860 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -1,16 +1,16 @@ { "schemaVersion": 1, - "id": "addon-template", + "id": "kitbot", "version": "${version}", - "name": "Addon Template", - "description": "An addon template for Meteor Client addons.", + "name": "KitBot", + "description": "Kitbot made by hamburgermain_ on Discord :)", "authors": [ - "seasnail" + "hamburgermain_" ], "contact": { "sources": "https://github.com/MeteorDevelopment/meteor-addon-template" }, - "icon": "assets/template/icon.png", + "icon": "assets/kitbot/icon.png", "environment": "client", "entrypoints": { "meteor": [