From 9bcc0dc6fe993dfc5c07a70c6697445dc6b8ef29 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 15 May 2026 07:49:27 +0200 Subject: [PATCH] don't use quick_move for book trade --- .../autotrade/event/AutoTradeClientTick.java | 627 ++---------------- .../event/AutoTradeConfigSelectors.java | 102 +++ .../event/AutoTradeContainerFlow.java | 119 ++++ .../event/AutoTradeMerchantScreenTick.java | 170 +++++ .../autotrade/event/AutoTradeTickState.java | 88 +++ .../autotrade/event/AutoTradeVoidDelay.java | 29 + .../autotrade/event/ContainerIoHelper.java | 59 ++ .../autotrade/event/TradeFormatHelper.java | 101 +++ .../autotrade/event/TraderInteractor.java | 155 +++++ .../sebseb7/autotrade/util/TradeItemSpec.java | 45 +- 10 files changed, 896 insertions(+), 599 deletions(-) create mode 100644 src/main/java/com/github/sebseb7/autotrade/event/AutoTradeConfigSelectors.java create mode 100644 src/main/java/com/github/sebseb7/autotrade/event/AutoTradeContainerFlow.java create mode 100644 src/main/java/com/github/sebseb7/autotrade/event/AutoTradeMerchantScreenTick.java create mode 100644 src/main/java/com/github/sebseb7/autotrade/event/AutoTradeTickState.java create mode 100644 src/main/java/com/github/sebseb7/autotrade/event/AutoTradeVoidDelay.java create mode 100644 src/main/java/com/github/sebseb7/autotrade/event/TradeFormatHelper.java create mode 100644 src/main/java/com/github/sebseb7/autotrade/event/TraderInteractor.java diff --git a/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeClientTick.java b/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeClientTick.java index 7e1a194..793d5cb 100644 --- a/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeClientTick.java +++ b/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeClientTick.java @@ -1,611 +1,72 @@ package com.github.sebseb7.autotrade.event; -import com.github.sebseb7.autotrade.AutoTrade; import com.github.sebseb7.autotrade.config.Configs; -import com.github.sebseb7.autotrade.render.VillagerTradeCache; -import com.github.sebseb7.autotrade.util.TradeItemSpec; -import fi.dy.masa.malilib.gui.Message; import fi.dy.masa.malilib.util.GuiUtils; -import fi.dy.masa.malilib.util.InfoUtils; -import java.util.List; -import java.util.Vector; import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.screens.inventory.ContainerScreen; -import net.minecraft.network.chat.Component; import net.minecraft.client.gui.screens.inventory.MerchantScreen; -import net.minecraft.client.gui.screens.inventory.ShulkerBoxScreen; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.network.protocol.game.ServerboundSelectTradePacket; -import net.minecraft.world.InteractionHand; import net.minecraft.world.entity.Entity; -import net.minecraft.world.entity.decoration.ItemFrame; -//? if npcSplit { -import net.minecraft.world.entity.npc.villager.Villager; -import net.minecraft.world.entity.npc.wanderingtrader.WanderingTrader; -//?} -//? if npcFlat { -import net.minecraft.world.entity.npc.Villager; -import net.minecraft.world.entity.npc.WanderingTrader; -//?} -import net.minecraft.world.entity.player.Inventory; -import net.minecraft.world.entity.player.Player; -import net.minecraft.world.inventory.AbstractContainerMenu; -import net.minecraft.world.inventory.MerchantMenu; -import net.minecraft.world.inventory.ShulkerBoxMenu; -import net.minecraft.world.inventory.Slot; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.item.trading.MerchantOffer; -import net.minecraft.world.item.trading.MerchantOffers; -import net.minecraft.world.level.ClipContext; -import net.minecraft.world.level.block.Blocks; -import net.minecraft.world.phys.AABB; -import net.minecraft.world.phys.BlockHitResult; -import net.minecraft.world.phys.EntityHitResult; -import net.minecraft.world.phys.HitResult; -import net.minecraft.world.phys.Vec3; +/** + * Main tick handler for AutoTrade. + * Orchestrates trader location, merchant trading, and container management. + */ final class AutoTradeClientTick { - private final Vector villagersInRange = new Vector<>(); - private int villagerActive = 0; + private final AutoTradeTickState state = new AutoTradeTickState(); + private final TraderInteractor traderInteractor = new TraderInteractor(state); + private final AutoTradeContainerFlow containerFlow = new AutoTradeContainerFlow(state); + private final AutoTradeMerchantScreenTick merchantScreenTick = new AutoTradeMerchantScreenTick(state); - private boolean inputInRange = false; - private boolean inputOpened = false; - private boolean outputInRange = false; - private boolean outputOpened = false; - private int tickCount = 0; - private int voidDelay = 0; - private int containerDelay = 0; - - /** - * 1 second at 20 TPS — client wireframe highlight (see - * {@code TraderHighlightRenderer}). - */ - private static final int TRADER_HIGHLIGHT_TICKS = 20; - - private int traderGlowTicksRemaining = 0; - private int traderGlowEntityId = -1; - - private int inputContainerHighlightTicks = 0; - private int outputContainerHighlightTicks = 0; - private int postMerchantInventorySyncTicks = 0; - - /** - * Entity we have already snapped the camera onto and are waiting for line of - * sight to clear on. Prevents re-rotating to the same villager every tick - * while a block briefly obstructs the view; reset once we interact (or after - * a timeout) so the next villager can be acquired. - */ - private int rotatingTargetId = -1; - private int rotatingTargetTicks = 0; - private static final int LOS_TIMEOUT_TICKS = 60; - - /** - * Entity to draw in-world highlight for; {@code null} when inactive or unknown - * id. - */ Entity getTraderGlowEntityForRender(Minecraft mc) { - if (traderGlowTicksRemaining <= 0 || traderGlowEntityId < 0 || mc.level == null) { - return null; - } - return findEntityById(mc, traderGlowEntityId); - } - - void tick(Minecraft mc) { - tickTraderGlow(mc); - tickContainerHighlights(mc); - if (voidDelay > 0) { - if (Configs.Generic.VOID_TRADING_DELAY_AFTER_TELEPORT.getBooleanValue()) { - boolean found = false; - for (Entity entity : mc.level.entitiesForRendering()) { - if (entity.getId() == villagerActive) { - found = true; - } - } - if (!found) { - voidDelay--; - } - } else { - voidDelay--; - } - return; - } - if (containerDelay > 0) { - containerDelay--; - } - if (mc.player == null) { - return; - } - if (postMerchantInventorySyncTicks > 0) { - postMerchantInventorySyncTicks--; - ContainerIoHelper.syncPlayerInventoryAfterMerchant(mc); - } - if (!Configs.Generic.ENABLED.getBooleanValue()) { - return; - } - Inventory plInv = mc.player.getInventory(); - if (Configs.Generic.GLASS_BLOCK.getBooleanValue()) { - tickGlassBlockSelection(mc); - } - if (Configs.Generic.ITEM_FRAME.getBooleanValue()) { - tickItemFrameSelection(mc); - } - if (GuiUtils.getCurrentScreen() instanceof MerchantScreen screen) { - tickMerchantScreen(mc, screen); - inputInRange = false; - outputInRange = false; - return; - } - if (GuiUtils.getCurrentScreen() instanceof ShulkerBoxScreen sbs) { - ShulkerBoxMenu m = sbs.getMenu(); - if ((containerDelay == 0) && inputOpened) { - inputOpened = false; - ContainerIoHelper.processInput(m, plInv); - sbs.onClose(); - } - if ((containerDelay == 0) && outputOpened) { - outputOpened = false; - ContainerIoHelper.processOutput(m, plInv); - sbs.onClose(); - } - } else if (GuiUtils.getCurrentScreen() instanceof ContainerScreen cs) { - AbstractContainerMenu m = cs.getMenu(); - if ((containerDelay == 0) && inputOpened) { - inputOpened = false; - ContainerIoHelper.processInput(m, plInv); - cs.onClose(); - } - if ((containerDelay == 0) && outputOpened) { - outputOpened = false; - ContainerIoHelper.processOutput(m, plInv); - cs.onClose(); - } - } - boolean found = false; - Vector newVillagersInRange = new Vector<>(villagersInRange); - for (Entity entity : mc.level.entitiesForRendering()) { - if (entity instanceof Villager || entity instanceof WanderingTrader) { - if (entity.distanceToSqr(mc.player) < (2.5f * 2.5f)) { - if (!found) { - if (!newVillagersInRange.contains(entity)) { - found = true; - // Paper/Folia rejects entity-interact packets unless the player is - // actually facing a visible part of the entity hitbox. Aim at any - // point we have a clean line of sight to (eyes, head, chest, feet); - // if none of them are visible yet, wait without flooding the camera - // with re-rotations every tick. - Vec3 aimPoint = firstVisiblePoint(mc, entity); - if (rotatingTargetId != entity.getId()) { - rotatingTargetId = entity.getId(); - rotatingTargetTicks = 0; - Vec3 lookAt = aimPoint != null ? aimPoint : entity.getEyePosition(); - Vec3 eyePos = mc.player.getEyePosition(); - Vec3 delta = lookAt.subtract(eyePos); - double horiz = Math.sqrt(delta.x * delta.x + delta.z * delta.z); - float yaw = (float) (Math.toDegrees(Math.atan2(delta.z, delta.x)) - 90.0); - float pitch = (float) -Math.toDegrees(Math.atan2(delta.y, horiz)); - mc.player.setYRot(yaw); - mc.player.setXRot(pitch); - mc.player.yHeadRot = yaw; - } else { - rotatingTargetTicks++; - } - if (aimPoint == null) { - if (rotatingTargetTicks > LOS_TIMEOUT_TICKS) { - // Give up so the player can move past this villager. - newVillagersInRange.add(entity); - rotatingTargetId = -1; - } - break; - } - newVillagersInRange.add(entity); - rotatingTargetId = -1; - EntityHitResult ehr = new EntityHitResult(entity, aimPoint); - AutoTrade.autoInteracting = true; - try { - //? if mc26 { - mc.gameMode.interact(mc.player, entity, ehr, InteractionHand.MAIN_HAND); - //?} else { - mc.gameMode.interactAt(mc.player, entity, ehr, InteractionHand.MAIN_HAND); - mc.gameMode.interact(mc.player, entity, InteractionHand.MAIN_HAND); - //?} - } finally { - AutoTrade.autoInteracting = false; - } - mc.player.swing(InteractionHand.MAIN_HAND); - postMerchantInventorySyncTicks = 0; - voidDelay = Configs.Generic.VOID_TRADING_DELAY.getIntegerValue(); - villagerActive = entity.getId(); - break; - } - } - } - } - } - for (Entity entity : villagersInRange) { - if (entity.distanceToSqr(mc.player) >= 16.0D) { - newVillagersInRange.remove(entity); - } - } - villagersInRange.clear(); - villagersInRange.addAll(newVillagersInRange); - if (found) { - return; - } - BlockPos input = new BlockPos(Configs.Generic.INPUT_CONTAINER_X.getIntegerValue(), - Configs.Generic.INPUT_CONTAINER_Y.getIntegerValue(), - Configs.Generic.INPUT_CONTAINER_Z.getIntegerValue()); - BlockPos output = new BlockPos(Configs.Generic.OUTPUT_CONTAINER_X.getIntegerValue(), - Configs.Generic.OUTPUT_CONTAINER_Y.getIntegerValue(), - Configs.Generic.OUTPUT_CONTAINER_Z.getIntegerValue()); - Vec3 ic = input.getCenter(); - Vec3 oc = output.getCenter(); - if ((mc.player.distanceToSqr(ic) < 16.0D) && (inputInRange == false)) { - inputInRange = true; - mc.gameMode.useItemOn(mc.player, InteractionHand.MAIN_HAND, - new BlockHitResult(ic, Direction.UP, input, false)); - containerDelay = Configs.Generic.CONTAINER_CLOSE_DELAY.getIntegerValue(); - inputOpened = true; - inputContainerHighlightTicks = TRADER_HIGHLIGHT_TICKS; - return; - } - if ((mc.player.distanceToSqr(oc) < 16.0D) && (outputInRange == false)) { - outputInRange = true; - mc.gameMode.useItemOn(mc.player, InteractionHand.MAIN_HAND, - new BlockHitResult(oc, Direction.UP, output, false)); - containerDelay = Configs.Generic.CONTAINER_CLOSE_DELAY.getIntegerValue(); - outputOpened = true; - outputContainerHighlightTicks = TRADER_HIGHLIGHT_TICKS; - return; - } - if (mc.player.distanceToSqr(ic) > 25.0D) { - inputOpened = false; - inputInRange = false; - } - if (mc.player.distanceToSqr(oc) > 25.0D) { - outputOpened = false; - outputInRange = false; - } - tickCount++; - if (tickCount > 200) { - tickCount = 0; - inputInRange = false; - outputInRange = false; - var cur = GuiUtils.getCurrentScreen(); - if (cur != null) { - if (cur instanceof MerchantScreen || cur instanceof ShulkerBoxScreen - || cur instanceof ContainerScreen) { - cur.onClose(); - } - } - } - } - - private void tickGlassBlockSelection(Minecraft mc) { - int playerX = (int) mc.player.getX(); - int playerZ = (int) mc.player.getZ(); - int playerY = (int) mc.player.getY(); - int selectorOffset = Configs.Generic.SELECTOR_OFFSET.getIntegerValue(); - int absSelectorOffset = Math.abs(selectorOffset); - for (int x = playerX - (absSelectorOffset + 3); x < playerX + (absSelectorOffset + 3); x += 1) { - for (int z = playerZ - (absSelectorOffset + 3); z < playerZ + (absSelectorOffset + 3); z += 1) { - for (int y = playerY - (absSelectorOffset + 3); y < playerY + (absSelectorOffset + 3); y += 1) { - BlockPos pos = new BlockPos(x, y, z); - if (mc.level.getBlockState(pos).getBlock() == Blocks.RED_STAINED_GLASS) { - if ((x != Configs.Generic.INPUT_CONTAINER_X.getIntegerValue()) - || ((y - selectorOffset) != Configs.Generic.INPUT_CONTAINER_Y.getIntegerValue()) - || (z != Configs.Generic.INPUT_CONTAINER_Z.getIntegerValue())) { - Configs.Generic.INPUT_CONTAINER_X.setIntegerValue(x); - Configs.Generic.INPUT_CONTAINER_Y.setIntegerValue(y - selectorOffset); - Configs.Generic.INPUT_CONTAINER_Z.setIntegerValue(z); - InfoUtils.showGuiOrInGameMessage(Message.MessageType.INFO, - "autotrade.message.input_container_set", x, y - selectorOffset, z); - } - break; - } - if (mc.level.getBlockState(pos).getBlock() == Blocks.BLUE_STAINED_GLASS) { - if ((x != Configs.Generic.OUTPUT_CONTAINER_X.getIntegerValue()) - || ((y - selectorOffset) != Configs.Generic.OUTPUT_CONTAINER_Y.getIntegerValue()) - || (z != Configs.Generic.OUTPUT_CONTAINER_Z.getIntegerValue())) { - Configs.Generic.OUTPUT_CONTAINER_X.setIntegerValue(x); - Configs.Generic.OUTPUT_CONTAINER_Y.setIntegerValue(y - selectorOffset); - Configs.Generic.OUTPUT_CONTAINER_Z.setIntegerValue(z); - InfoUtils.showGuiOrInGameMessage(Message.MessageType.INFO, - "autotrade.message.output_container_set", x, y - selectorOffset, z); - } - break; - } - } - } - } - } - - private void tickItemFrameSelection(Minecraft mc) { - Vec3 pm = new Vec3(mc.player.getX(), mc.player.getY(), mc.player.getZ()); - AABB box = new AABB(pm.subtract(3, 3, 3), pm.add(3, 3, 3)); - @SuppressWarnings("unchecked") - List frames = (List) (List) mc.level.getEntities((Entity) null, box, - e -> e instanceof ItemFrame && e.isAlive()); - for (ItemFrame entity : frames) { - ItemStack stack = entity.getItem(); - String customName = stack.getHoverName().getString(); - if ("sell".equalsIgnoreCase(customName) || "\"sell\"".equals(customName)) { - String sellItem = TradeItemSpec.encodeFromStack(stack); - if (!Configs.Generic.SELL_ITEM.getStringValue().equals(sellItem)) { - InfoUtils.showGuiOrInGameMessage(Message.MessageType.INFO, "autotrade.message.sell_item_set", - sellItem); - Configs.Generic.SELL_ITEM.setValueFromString(sellItem); - break; - } - } - if ("buy".equalsIgnoreCase(customName) || "\"buy\"".equals(customName)) { - String buyItem = TradeItemSpec.encodeFromStack(stack); - if (!Configs.Generic.BUY_ITEM.getStringValue().equals(buyItem)) { - InfoUtils.showGuiOrInGameMessage(Message.MessageType.INFO, "autotrade.message.buy_item_set", - buyItem); - Configs.Generic.BUY_ITEM.setValueFromString(buyItem); - break; - } - } - } - } - - private void tickMerchantScreen(Minecraft mc, MerchantScreen screen) { - MerchantMenu menu = screen.getMenu(); - MerchantOffers offers = menu.getOffers(); - - Entity activeEntity = findEntityById(mc, villagerActive); - if (activeEntity != null && offers != null && !offers.isEmpty()) { - VillagerTradeCache.put(activeEntity.getUUID(), offers); - } - - if (tryExecuteOneMerchantTrade(mc, screen)) { - ContainerIoHelper.syncPlayerInventoryAfterMerchant(mc); - return; - } - - finishMerchantSession(mc, screen); - } - - /** - * Runs at most one trade per tick so the server can answer before the next - * packet — avoids inventory slot ghosts from batched select-trade + shift-clicks. - */ - private boolean tryExecuteOneMerchantTrade(Minecraft mc, MerchantScreen screen) { - MerchantMenu menu = screen.getMenu(); - MerchantOffers offers = menu.getOffers(); - if (offers == null || offers.isEmpty()) { - return false; - } - String sellItemStr = Configs.Generic.SELL_ITEM.getStringValue(); - String buyItemStr = Configs.Generic.BUY_ITEM.getStringValue(); - - for (int i = 0; i < offers.size(); i++) { - MerchantOffer offer = offers.get(i); - int tradesLeft = offer.getMaxUses() - offer.getUses(); - - if (TradeItemSpec.matches(offer.getResult(), buyItemStr) && Configs.Generic.ENABLE_BUY.getBooleanValue() - && offer.getResult().getCount() <= Configs.Generic.BUY_LIMIT.getIntegerValue()) { - if (tradesLeft > 0 && playerHasMerchantCosts(mc.player, offer)) { - Slot slot = menu.getSlot(2); - menu.setSelectionHint(i); - mc.player.connection.send(new ServerboundSelectTradePacket(i)); - AutoTrade.bought += offer.getMaxUses(); - showTradeNotice(mc, "autotrade.message.trade_bought", - Component.literal(formatItemCountNameForTrades(offer.getResult(), tradesLeft)), - Component.literal(formatOfferPriceForTrades(offer, tradesLeft))); - try { - ContainerIoHelper.quickMoveResultSlot(mc, menu, slot.index); - } catch (Exception e) { - System.out.println("err " + e); - } - return true; - } - } - - if (TradeItemSpec.matches(offer.getCostA(), sellItemStr) - && Configs.Generic.ENABLE_SELL.getBooleanValue() - && offer.getCostA().getCount() <= Configs.Generic.SELL_LIMIT.getIntegerValue()) { - if (tradesLeft > 0 && playerHasMerchantCosts(mc.player, offer)) { - Slot slot = menu.getSlot(2); - menu.setSelectionHint(i); - mc.player.connection.send(new ServerboundSelectTradePacket(i)); - AutoTrade.sold += offer.getMaxUses(); - showTradeNotice(mc, "autotrade.message.trade_sold", - Component.literal(formatItemCountNameForTrades(offer.getCostA(), tradesLeft) - + formatOptionalSecondCostForTrades(offer, tradesLeft)), - Component.literal(formatItemCountNameForTrades(offer.getResult(), tradesLeft))); - try { - ContainerIoHelper.quickMoveResultSlot(mc, menu, slot.index); - } catch (Exception e) { - System.out.println("err " + e); - } - return true; - } - } - } - return false; - } - - private void finishMerchantSession(Minecraft mc, MerchantScreen screen) { - ContainerIoHelper.syncPlayerInventoryAfterMerchant(mc); - screen.onClose(); - ContainerIoHelper.syncPlayerInventoryAfterMerchant(mc); - postMerchantInventorySyncTicks = 15; - startTraderGlow(mc, villagerActive); - } - - /** - * Malilib's {@code showGuiOrInGameMessage} routes to multiple HUD targets; trade spam looked like 3× duplication. - * Vanilla overlay is a single on-screen line (same idea as vanilla toast-adjacent hints). - */ - private static void showTradeNotice(Minecraft mc, String translationKey, Component arg1, Component arg2) { - if (mc.gui == null) { - return; - } - mc.gui.setOverlayMessage(Component.translatable(translationKey, arg1, arg2), false); - } - - private void tickTraderGlow(Minecraft mc) { - if (mc.level == null || traderGlowTicksRemaining <= 0) { - return; - } - traderGlowTicksRemaining--; - if (traderGlowTicksRemaining == 0) { - traderGlowEntityId = -1; - } - } - - private void tickContainerHighlights(Minecraft mc) { - if (mc.level == null) { - return; - } - if (inputContainerHighlightTicks > 0) { - inputContainerHighlightTicks--; - } - if (outputContainerHighlightTicks > 0) { - outputContainerHighlightTicks--; - } + return state.getTraderGlowEntityForRender(mc); } int getInputContainerHighlightTicks() { - return inputContainerHighlightTicks; + return state.getInputContainerHighlightTicks(); } int getOutputContainerHighlightTicks() { - return outputContainerHighlightTicks; + return state.getOutputContainerHighlightTicks(); } - private void startTraderGlow(Minecraft mc, int entityId) { - if (mc.level == null) { + void tick(Minecraft mc) { + state.tickTraderGlow(mc); + state.tickContainerHighlights(mc); + + if (AutoTradeVoidDelay.handle(mc, state)) return; + + if (state.containerDelay > 0) state.containerDelay--; + if (mc.player == null) return; + + tickPostMerchantSync(mc); + if (!Configs.Generic.ENABLED.getBooleanValue()) { + state.clearMerchantQuickMoveDefer(); return; } - if (findEntityById(mc, entityId) == null) { - traderGlowTicksRemaining = 0; - traderGlowEntityId = -1; + + AutoTradeConfigSelectors.tickGlassBlockSelection(mc); + AutoTradeConfigSelectors.tickItemFrameSelection(mc); + + merchantScreenTick.tickDeferredResultQuickMove(mc); + + if (GuiUtils.getCurrentScreen() instanceof MerchantScreen screen) { + merchantScreenTick.tick(mc, screen); + containerFlow.resetContainerFlags(); return; } - traderGlowEntityId = entityId; - traderGlowTicksRemaining = TRADER_HIGHLIGHT_TICKS; + + containerFlow.processOpenContainers(mc, mc.player.getInventory()); + + if (traderInteractor.findAndInteract(mc)) return; + + if (containerFlow.handleContainerProximity(mc)) return; + + containerFlow.tickPeriodicReset(); } - /** - * Returns the first point on {@code target}'s bounding box (eyes, head, - * chest, feet, or one of the four upper corners) that has an unobstructed - * block ray-cast from the player's eyes; {@code null} when the entire body - * is occluded. - */ - private static Vec3 firstVisiblePoint(Minecraft mc, Entity target) { - Vec3 eye = mc.player.getEyePosition(); - AABB box = target.getBoundingBox(); - double cx = (box.minX + box.maxX) * 0.5; - double cz = (box.minZ + box.maxZ) * 0.5; - Vec3[] candidates = { - target.getEyePosition(), - new Vec3(cx, (box.minY + box.maxY) * 0.5, cz), - new Vec3(cx, box.maxY - 0.05, cz), - new Vec3(cx, box.minY + 0.1, cz), - new Vec3(box.minX + 0.05, box.maxY - 0.2, box.minZ + 0.05), - new Vec3(box.maxX - 0.05, box.maxY - 0.2, box.minZ + 0.05), - new Vec3(box.minX + 0.05, box.maxY - 0.2, box.maxZ - 0.05), - new Vec3(box.maxX - 0.05, box.maxY - 0.2, box.maxZ - 0.05), - }; - for (Vec3 p : candidates) { - BlockHitResult hit = mc.level.clip(new ClipContext(eye, p, ClipContext.Block.COLLIDER, - ClipContext.Fluid.NONE, mc.player)); - if (hit.getType() == HitResult.Type.MISS - || eye.distanceToSqr(hit.getLocation()) >= eye.distanceToSqr(p) - 1.0E-4) { - return p; - } + private void tickPostMerchantSync(Minecraft mc) { + if (state.postMerchantInventorySyncTicks > 0) { + state.postMerchantInventorySyncTicks--; + ContainerIoHelper.syncPlayerInventoryAfterMerchant(mc); } - return null; - } - - private static Entity findEntityById(Minecraft mc, int entityId) { - for (Entity e : mc.level.entitiesForRendering()) { - if (e.getId() == entityId) { - return e; - } - } - return null; - } - - /** - * Same stack rules as the merchant menu: player must have enough of each - * non-empty cost before we fire packets or show a trade toast. - */ - private static boolean playerHasMerchantCosts(Player player, MerchantOffer offer) { - if (!costRequirementMet(player.getInventory(), offer.getCostA())) { - return false; - } - return costRequirementMet(player.getInventory(), offer.getCostB()); - } - - private static boolean costRequirementMet(Inventory inv, ItemStack required) { - if (required.isEmpty()) { - return true; - } - int need = required.getCount(); - int have = 0; - for (int s = 0; s < inv.getContainerSize(); s++) { - ItemStack st = inv.getItem(s); - if (ItemStack.isSameItemSameComponents(st, required)) { - have += st.getCount(); - if (have >= need) { - return true; - } - } - } - return false; - } - - /** e.g. "3× Book" (one villager use). */ - private static String formatItemCountAndName(ItemStack stack) { - return stack.getCount() + "× " + stack.getHoverName().getString(); - } - - /** - * Per-trade count × how many of this offer remain before the trade, e.g. 1 - * iron/trade × 12 runs → "12× …". - */ - private static String formatItemCountNameForTrades(ItemStack perTrade, int remainingOfferUses) { - if (remainingOfferUses <= 0) { - return formatItemCountAndName(perTrade); - } - return (perTrade.getCount() * remainingOfferUses) + "× " + perTrade.getHoverName().getString(); - } - - /** For buying: the stacks you pay, scaled to how many of this offer remain. */ - private static String formatOfferPriceForTrades(MerchantOffer offer, int t) { - if (t <= 0) { - String a = offer.getCostA().isEmpty() ? null : formatItemCountAndName(offer.getCostA()); - if (offer.getCostB().isEmpty()) { - return a != null ? a : "—"; - } - String b = formatItemCountAndName(offer.getCostB()); - return a == null ? b : a + " + " + b; - } - String a = offer.getCostA().isEmpty() - ? null - : (offer.getCostA().getCount() * t) + "× " + offer.getCostA().getHoverName().getString(); - if (offer.getCostB().isEmpty()) { - return a != null ? a : "—"; - } - String b = (offer.getCostB().getCount() * t) + "× " + offer.getCostB().getHoverName().getString(); - return a == null ? b : a + " + " + b; - } - - /** - * If the trade has a second cost item, " + 2× …" scaled to remaining offer - * uses. - */ - private static String formatOptionalSecondCostForTrades(MerchantOffer offer, int t) { - if (offer.getCostB().isEmpty()) { - return ""; - } - if (t <= 0) { - return " + " + formatItemCountAndName(offer.getCostB()); - } - return " + " + (offer.getCostB().getCount() * t) + "× " + offer.getCostB().getHoverName().getString(); } } diff --git a/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeConfigSelectors.java b/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeConfigSelectors.java new file mode 100644 index 0000000..a983817 --- /dev/null +++ b/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeConfigSelectors.java @@ -0,0 +1,102 @@ +package com.github.sebseb7.autotrade.event; + +import com.github.sebseb7.autotrade.config.Configs; +import fi.dy.masa.malilib.gui.Message.MessageType; +import fi.dy.masa.malilib.util.InfoUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; + +final class AutoTradeConfigSelectors { + + private AutoTradeConfigSelectors() {} + + static void tickGlassBlockSelection(Minecraft mc) { + if (!Configs.Generic.GLASS_BLOCK.getBooleanValue()) return; + int playerX = (int) mc.player.getX(); + int playerZ = (int) mc.player.getZ(); + int playerY = (int) mc.player.getY(); + int selectorOffset = Configs.Generic.SELECTOR_OFFSET.getIntegerValue(); + int absSelectorOffset = Math.abs(selectorOffset); + var redGlass = net.minecraft.world.level.block.Blocks.RED_STAINED_GLASS; + var blueGlass = net.minecraft.world.level.block.Blocks.BLUE_STAINED_GLASS; + + for (int x = playerX - (absSelectorOffset + 3); x < playerX + (absSelectorOffset + 3); x++) { + for (int z = playerZ - (absSelectorOffset + 3); z < playerZ + (absSelectorOffset + 3); z++) { + for (int y = playerY - (absSelectorOffset + 3); y < playerY + (absSelectorOffset + 3); y++) { + BlockPos pos = new BlockPos(x, y, z); + if (mc.level.getBlockState(pos).getBlock() == redGlass) { + updateInputContainerPos(x, y, z, selectorOffset); + break; + } + if (mc.level.getBlockState(pos).getBlock() == blueGlass) { + updateOutputContainerPos(x, y, z, selectorOffset); + break; + } + } + } + } + } + + private static void updateInputContainerPos(int x, int y, int z, int selectorOffset) { + if (x != Configs.Generic.INPUT_CONTAINER_X.getIntegerValue() + || (y - selectorOffset) != Configs.Generic.INPUT_CONTAINER_Y.getIntegerValue() + || z != Configs.Generic.INPUT_CONTAINER_Z.getIntegerValue()) { + Configs.Generic.INPUT_CONTAINER_X.setIntegerValue(x); + Configs.Generic.INPUT_CONTAINER_Y.setIntegerValue(y - selectorOffset); + Configs.Generic.INPUT_CONTAINER_Z.setIntegerValue(z); + InfoUtils.showGuiOrInGameMessage(MessageType.INFO, + "autotrade.message.input_container_set", x, y - selectorOffset, z); + } + } + + private static void updateOutputContainerPos(int x, int y, int z, int selectorOffset) { + if (x != Configs.Generic.OUTPUT_CONTAINER_X.getIntegerValue() + || (y - selectorOffset) != Configs.Generic.OUTPUT_CONTAINER_Y.getIntegerValue() + || z != Configs.Generic.OUTPUT_CONTAINER_Z.getIntegerValue()) { + Configs.Generic.OUTPUT_CONTAINER_X.setIntegerValue(x); + Configs.Generic.OUTPUT_CONTAINER_Y.setIntegerValue(y - selectorOffset); + Configs.Generic.OUTPUT_CONTAINER_Z.setIntegerValue(z); + InfoUtils.showGuiOrInGameMessage(MessageType.INFO, + "autotrade.message.output_container_set", x, y - selectorOffset, z); + } + } + + static void tickItemFrameSelection(Minecraft mc) { + if (!Configs.Generic.ITEM_FRAME.getBooleanValue()) return; + Vec3 pm = new Vec3(mc.player.getX(), mc.player.getY(), mc.player.getZ()); + var box = new net.minecraft.world.phys.AABB(pm.subtract(3, 3, 3), pm.add(3, 3, 3)); + @SuppressWarnings("unchecked") + var frames = (java.util.List) + (java.util.List) mc.level.getEntities((Entity) null, box, + e -> e instanceof net.minecraft.world.entity.decoration.ItemFrame && e.isAlive()); + + for (var entity : frames) { + var stack = entity.getItem(); + String customName = stack.getHoverName().getString(); + handleItemFrameSell(stack, customName); + handleItemFrameBuy(stack, customName); + } + } + + private static void handleItemFrameSell(net.minecraft.world.item.ItemStack stack, String customName) { + if (!("sell".equalsIgnoreCase(customName) || "\"sell\"".equals(customName))) return; + String sellItem = com.github.sebseb7.autotrade.util.TradeItemSpec.encodeFromStack(stack); + if (!Configs.Generic.SELL_ITEM.getStringValue().equals(sellItem)) { + InfoUtils.showGuiOrInGameMessage(MessageType.INFO, + "autotrade.message.sell_item_set", sellItem); + Configs.Generic.SELL_ITEM.setValueFromString(sellItem); + } + } + + private static void handleItemFrameBuy(net.minecraft.world.item.ItemStack stack, String customName) { + if (!("buy".equalsIgnoreCase(customName) || "\"buy\"".equals(customName))) return; + String buyItem = com.github.sebseb7.autotrade.util.TradeItemSpec.encodeFromStack(stack); + if (!Configs.Generic.BUY_ITEM.getStringValue().equals(buyItem)) { + InfoUtils.showGuiOrInGameMessage(MessageType.INFO, + "autotrade.message.buy_item_set", buyItem); + Configs.Generic.BUY_ITEM.setValueFromString(buyItem); + } + } +} diff --git a/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeContainerFlow.java b/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeContainerFlow.java new file mode 100644 index 0000000..d738493 --- /dev/null +++ b/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeContainerFlow.java @@ -0,0 +1,119 @@ +package com.github.sebseb7.autotrade.event; + +import com.github.sebseb7.autotrade.config.Configs; +import fi.dy.masa.malilib.util.GuiUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.inventory.ContainerScreen; +import net.minecraft.client.gui.screens.inventory.MerchantScreen; +import net.minecraft.client.gui.screens.inventory.ShulkerBoxScreen; +import net.minecraft.core.BlockPos; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.Vec3; + +final class AutoTradeContainerFlow { + private final AutoTradeTickState state; + + AutoTradeContainerFlow(AutoTradeTickState state) { + this.state = state; + } + + void processOpenContainers(Minecraft mc, Inventory plInv) { + if (GuiUtils.getCurrentScreen() instanceof ShulkerBoxScreen sbs) { + processContainerIo(sbs, sbs.getMenu(), plInv); + } else if (GuiUtils.getCurrentScreen() instanceof ContainerScreen cs) { + processContainerIo(cs, cs.getMenu(), plInv); + } + } + + void resetContainerFlags() { + state.inputInRange = false; + state.outputInRange = false; + } + + boolean handleContainerProximity(Minecraft mc) { + BlockPos input = new BlockPos( + Configs.Generic.INPUT_CONTAINER_X.getIntegerValue(), + Configs.Generic.INPUT_CONTAINER_Y.getIntegerValue(), + Configs.Generic.INPUT_CONTAINER_Z.getIntegerValue()); + BlockPos output = new BlockPos( + Configs.Generic.OUTPUT_CONTAINER_X.getIntegerValue(), + Configs.Generic.OUTPUT_CONTAINER_Y.getIntegerValue(), + Configs.Generic.OUTPUT_CONTAINER_Z.getIntegerValue()); + Vec3 ic = input.getCenter(); + Vec3 oc = output.getCenter(); + + if (mc.player.distanceToSqr(ic) < 16.0D && !state.inputInRange) { + state.inputInRange = true; + mc.gameMode.useItemOn(mc.player, InteractionHand.MAIN_HAND, + new BlockHitResult(ic, net.minecraft.core.Direction.UP, input, false)); + state.containerDelay = Configs.Generic.CONTAINER_CLOSE_DELAY.getIntegerValue(); + state.inputOpened = true; + state.inputContainerHighlightTicks = AutoTradeTickState.TRADER_HIGHLIGHT_TICKS; + return true; + } + + if (mc.player.distanceToSqr(oc) < 16.0D && !state.outputInRange) { + state.outputInRange = true; + mc.gameMode.useItemOn(mc.player, InteractionHand.MAIN_HAND, + new BlockHitResult(oc, net.minecraft.core.Direction.UP, output, false)); + state.containerDelay = Configs.Generic.CONTAINER_CLOSE_DELAY.getIntegerValue(); + state.outputOpened = true; + state.outputContainerHighlightTicks = AutoTradeTickState.TRADER_HIGHLIGHT_TICKS; + return true; + } + + if (mc.player.distanceToSqr(ic) > 25.0D) { + state.inputOpened = false; + state.inputInRange = false; + } + if (mc.player.distanceToSqr(oc) > 25.0D) { + state.outputOpened = false; + state.outputInRange = false; + } + + return false; + } + + void tickPeriodicReset() { + state.tickCount++; + if (state.tickCount > 200) { + state.tickCount = 0; + state.inputInRange = false; + state.outputInRange = false; + var cur = GuiUtils.getCurrentScreen(); + if (cur != null && isContainerScreen(cur)) { + cur.onClose(); + } + } + } + + private void processContainerIo(Object screen, AbstractContainerMenu menu, Inventory plInv) { + boolean closed = false; + if (state.containerDelay == 0 && state.inputOpened) { + state.inputOpened = false; + ContainerIoHelper.processInput(menu, plInv); + closeScreen(screen); + closed = true; + } + if (state.containerDelay == 0 && state.outputOpened) { + state.outputOpened = false; + ContainerIoHelper.processOutput(menu, plInv); + if (!closed) closeScreen(screen); + } + } + + private static void closeScreen(Object screen) { + if (screen instanceof MerchantScreen s) s.onClose(); + else if (screen instanceof ShulkerBoxScreen s) s.onClose(); + else if (screen instanceof ContainerScreen s) s.onClose(); + } + + private static boolean isContainerScreen(Object screen) { + return screen instanceof MerchantScreen + || screen instanceof ShulkerBoxScreen + || screen instanceof ContainerScreen; + } +} diff --git a/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeMerchantScreenTick.java b/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeMerchantScreenTick.java new file mode 100644 index 0000000..f82a36a --- /dev/null +++ b/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeMerchantScreenTick.java @@ -0,0 +1,170 @@ +package com.github.sebseb7.autotrade.event; + +import com.github.sebseb7.autotrade.AutoTrade; +import com.github.sebseb7.autotrade.config.Configs; +import com.github.sebseb7.autotrade.render.VillagerTradeCache; +import com.github.sebseb7.autotrade.util.TradeItemSpec; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.inventory.MerchantScreen; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.inventory.MerchantMenu; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.trading.MerchantOffers; + +/** + * Villager GUI: one trade attempt per {@link #tick}, matching {@link MerchantOffers} + * recipe stacks, then deferring a normal pickup (not shift-click) from the result slot when + * it is an enchanted book (shift-click would chain other book trades); other results still use shift-click. + */ +final class AutoTradeMerchantScreenTick { + /** First tick after a trade decrements without acting; remaining ticks wait on the server. */ + private static final int RESULT_QUICK_MOVE_DELAY_TICKS = 3; + + private final AutoTradeTickState state; + + AutoTradeMerchantScreenTick(AutoTradeTickState state) { + this.state = state; + } + + void tickDeferredResultQuickMove(Minecraft mc) { + if (state.merchantResultQuickMoveDelayTicks <= 0) { + return; + } + state.merchantResultQuickMoveDelayTicks--; + if (state.merchantResultQuickMoveDelayTicks > 0) { + return; + } + if (!(mc.screen instanceof MerchantScreen screen)) { + AutoTrade.logger.warn("[AutoTrade merchant] defer execute aborted: current screen is not MerchantScreen"); + state.clearMerchantQuickMoveDefer(); + return; + } + MerchantMenu menu = screen.getMenu(); + MerchantOffers offers = menu.getOffers(); + int idx = state.merchantResultQuickMoveOfferIndex; + if (offers == null || idx < 0 || idx >= offers.size()) { + AutoTrade.logger.warn( + "[AutoTrade merchant] defer execute aborted: bad offer index idx={} offersSize={}", + idx, + offers == null ? -1 : offers.size()); + state.clearMerchantQuickMoveDefer(); + return; + } + menu.setSelectionHint(idx); + menu.tryMoveItems(idx); + var slot = menu.getSlot(2); + ItemStack slot2 = slot.getItem(); + String buySpec = Configs.Generic.BUY_ITEM.getStringValue(); + try { + if (state.merchantResultQuickMoveIsBuy) { + boolean match = !slot2.isEmpty() && TradeItemSpec.matches(slot2, buySpec); + if (match) { + ContainerIoHelper.quickMoveResultSlot(mc, menu, slot.index); + } else { + AutoTrade.logger.warn("[AutoTrade merchant] defer quickMove skipped: slot2 did not match buy spec"); + } + } else { + if (!slot2.isEmpty()) { + ContainerIoHelper.quickMoveResultSlot(mc, menu, slot.index); + } else { + AutoTrade.logger.warn("[AutoTrade merchant] defer quickMove skipped: result slot empty"); + } + } + } catch (Exception e) { + AutoTrade.logger.warn("[AutoTrade merchant] defer quickMove exception", e); + } + state.clearMerchantQuickMoveDefer(); + ContainerIoHelper.syncPlayerInventoryAfterMerchant(mc); + } + + void tick(Minecraft mc, MerchantScreen screen) { + MerchantMenu menu = screen.getMenu(); + MerchantOffers offers = menu.getOffers(); + int villagerActive = state.getVillagerActive(); + + cacheTraderOffers(mc, villagerActive, offers); + + if (state.merchantResultQuickMoveDelayTicks > 0) { + return; + } + + if (tryExecuteOneMerchantTrade(mc, menu, offers)) { + ContainerIoHelper.syncPlayerInventoryAfterMerchant(mc); + return; + } + + finishMerchantSession(mc, screen); + } + + private static void cacheTraderOffers(Minecraft mc, int villagerActive, MerchantOffers offers) { + Entity activeEntity = TraderInteractor.findEntityById(mc, villagerActive); + if (activeEntity != null && offers != null && !offers.isEmpty()) { + VillagerTradeCache.put(activeEntity.getUUID(), offers); + } + } + + private boolean tryExecuteOneMerchantTrade(Minecraft mc, MerchantMenu menu, MerchantOffers offers) { + if (offers == null || offers.isEmpty()) { + return false; + } + String sellItemStr = Configs.Generic.SELL_ITEM.getStringValue(); + String buyItemStr = Configs.Generic.BUY_ITEM.getStringValue(); + boolean buyOn = Configs.Generic.ENABLE_BUY.getBooleanValue(); + boolean sellOn = Configs.Generic.ENABLE_SELL.getBooleanValue(); + int buyLimit = Configs.Generic.BUY_LIMIT.getIntegerValue(); + int sellLimit = Configs.Generic.SELL_LIMIT.getIntegerValue(); + + for (int i = 0; i < offers.size(); i++) { + var offer = offers.get(i); + int tradesLeft = offer.getMaxUses() - offer.getUses(); + boolean buyRecipe = buyOn && TradeItemSpec.matches(offer.getResult(), buyItemStr); + boolean buyCountOk = offer.getResult().getCount() <= buyLimit; + boolean costOk = TradeFormatHelper.playerHasMerchantCosts(mc.player, offer); + + boolean sellRecipe = sellOn && TradeItemSpec.matches(offer.getCostA(), sellItemStr); + boolean sellCountOk = offer.getCostA().getCount() <= sellLimit; + + if (buyOn && buyRecipe && buyCountOk) { + if (tradesLeft > 0 && costOk) { + menu.setSelectionHint(i); + mc.player.connection.send(new net.minecraft.network.protocol.game.ServerboundSelectTradePacket(i)); + AutoTrade.bought += offer.getMaxUses(); + TradeFormatHelper.showTradeNotice(mc, "autotrade.message.trade_bought", + Component.literal(TradeFormatHelper.formatItemCountNameForTrades(offer.getResult(), tradesLeft)), + Component.literal(TradeFormatHelper.formatOfferPriceForTrades(offer, tradesLeft))); + state.merchantResultQuickMoveDelayTicks = RESULT_QUICK_MOVE_DELAY_TICKS; + state.merchantResultQuickMoveOfferIndex = i; + state.merchantResultQuickMoveIsBuy = true; + return true; + } + } + + if (sellOn && sellRecipe && sellCountOk) { + if (tradesLeft > 0 && costOk) { + menu.setSelectionHint(i); + mc.player.connection.send(new net.minecraft.network.protocol.game.ServerboundSelectTradePacket(i)); + AutoTrade.sold += offer.getMaxUses(); + TradeFormatHelper.showTradeNotice(mc, "autotrade.message.trade_sold", + Component.literal(TradeFormatHelper.formatItemCountNameForTrades(offer.getCostA(), tradesLeft) + + TradeFormatHelper.formatOptionalSecondCostForTrades(offer, tradesLeft)), + Component.literal(TradeFormatHelper.formatItemCountNameForTrades(offer.getResult(), tradesLeft))); + state.merchantResultQuickMoveDelayTicks = RESULT_QUICK_MOVE_DELAY_TICKS; + state.merchantResultQuickMoveOfferIndex = i; + state.merchantResultQuickMoveIsBuy = false; + return true; + } + } + } + return false; + } + + private void finishMerchantSession(Minecraft mc, MerchantScreen screen) { + state.clearMerchantQuickMoveDefer(); + ContainerIoHelper.syncPlayerInventoryAfterMerchant(mc); + screen.onClose(); + ContainerIoHelper.syncPlayerInventoryAfterMerchant(mc); + state.postMerchantInventorySyncTicks = 15; + state.startTraderGlow(mc, state.getVillagerActive()); + } +} diff --git a/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeTickState.java b/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeTickState.java new file mode 100644 index 0000000..93b453f --- /dev/null +++ b/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeTickState.java @@ -0,0 +1,88 @@ +package com.github.sebseb7.autotrade.event; + +import net.minecraft.client.Minecraft; +import net.minecraft.world.entity.Entity; + +/** + * Mutable per-session tick state for {@link AutoTradeClientTick}. + */ +final class AutoTradeTickState { + static final int TRADER_HIGHLIGHT_TICKS = 20; + + int tickCount; + int voidDelay; + int containerDelay; + int postMerchantInventorySyncTicks; + + /** Last villager/wandering trader we opened trades with (entity id). */ + int villagerActive; + + boolean inputInRange; + boolean inputOpened; + boolean outputInRange; + boolean outputOpened; + + int traderGlowTicksRemaining; + int traderGlowEntityId = -1; + + int inputContainerHighlightTicks; + int outputContainerHighlightTicks; + + /** + * After selecting a trade on the server, wait this many client ticks before + * shift-moving the result so slot contents match the server. + */ + int merchantResultQuickMoveDelayTicks; + int merchantResultQuickMoveOfferIndex = -1; + boolean merchantResultQuickMoveIsBuy; + + void clearMerchantQuickMoveDefer() { + merchantResultQuickMoveDelayTicks = 0; + merchantResultQuickMoveOfferIndex = -1; + } + + int getVillagerActive() { + return villagerActive; + } + + Entity getTraderGlowEntityForRender(Minecraft mc) { + if (traderGlowTicksRemaining <= 0 || traderGlowEntityId < 0 || mc.level == null) return null; + return TraderInteractor.findEntityById(mc, traderGlowEntityId); + } + + int getInputContainerHighlightTicks() { + return inputContainerHighlightTicks; + } + + int getOutputContainerHighlightTicks() { + return outputContainerHighlightTicks; + } + + void tickTraderGlow(Minecraft mc) { + if (mc.level == null || traderGlowTicksRemaining <= 0) return; + traderGlowTicksRemaining--; + if (traderGlowTicksRemaining == 0) traderGlowEntityId = -1; + } + + void startTraderGlow(Minecraft mc, int entityId) { + if (mc.level == null) { + traderGlowTicksRemaining = 0; + traderGlowEntityId = -1; + return; + } + Entity active = TraderInteractor.findEntityById(mc, entityId); + if (active == null) { + traderGlowTicksRemaining = 0; + traderGlowEntityId = -1; + return; + } + traderGlowEntityId = entityId; + traderGlowTicksRemaining = TRADER_HIGHLIGHT_TICKS; + } + + void tickContainerHighlights(Minecraft mc) { + if (mc.level == null) return; + if (inputContainerHighlightTicks > 0) inputContainerHighlightTicks--; + if (outputContainerHighlightTicks > 0) outputContainerHighlightTicks--; + } +} diff --git a/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeVoidDelay.java b/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeVoidDelay.java new file mode 100644 index 0000000..0371b36 --- /dev/null +++ b/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeVoidDelay.java @@ -0,0 +1,29 @@ +package com.github.sebseb7.autotrade.event; + +import com.github.sebseb7.autotrade.config.Configs; +import net.minecraft.client.Minecraft; +import net.minecraft.world.entity.Entity; + +final class AutoTradeVoidDelay { + + static boolean handle(Minecraft mc, AutoTradeTickState state) { + if (state.voidDelay <= 0) return false; + + if (Configs.Generic.VOID_TRADING_DELAY_AFTER_TELEPORT.getBooleanValue()) { + if (!entityStillExists(mc, state.getVillagerActive())) { + state.voidDelay--; + } + } else { + state.voidDelay--; + } + return true; + } + + private static boolean entityStillExists(Minecraft mc, int entityId) { + if (mc.level == null) return false; + for (Entity entity : mc.level.entitiesForRendering()) { + if (entity.getId() == entityId) return true; + } + return false; + } +} diff --git a/src/main/java/com/github/sebseb7/autotrade/event/ContainerIoHelper.java b/src/main/java/com/github/sebseb7/autotrade/event/ContainerIoHelper.java index bad9c2b..ae97ca7 100644 --- a/src/main/java/com/github/sebseb7/autotrade/event/ContainerIoHelper.java +++ b/src/main/java/com/github/sebseb7/autotrade/event/ContainerIoHelper.java @@ -9,15 +9,18 @@ import java.util.HashMap; import java.util.Map; import net.minecraft.client.Minecraft; import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; import net.minecraft.world.inventory.AbstractContainerMenu; //? if mc26 { import net.minecraft.world.inventory.ContainerInput; //?} else { import net.minecraft.world.inventory.ClickType; //?} +import net.minecraft.world.inventory.MerchantMenu; import net.minecraft.world.inventory.Slot; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; final class ContainerIoHelper { private ContainerIoHelper() { @@ -25,6 +28,11 @@ final class ContainerIoHelper { static void quickMoveResultSlot(Minecraft mc, AbstractContainerMenu menu, int slotIndex) { Slot slot = menu.getSlot(slotIndex); + ItemStack stack = slot.getItem(); + if (menu instanceof MerchantMenu && stack.is(Items.ENCHANTED_BOOK)) { + moveMerchantResultWithPickup(mc, menu, slot); + return; + } //? if mc26 { mc.gameMode.handleContainerInput(menu.containerId, slot.index, 0, ContainerInput.QUICK_MOVE, mc.player); //?} else { @@ -32,6 +40,57 @@ final class ContainerIoHelper { //?} } + /** + * For {@link MerchantMenu} result stacks that are {@linkplain Items#ENCHANTED_BOOK enchanted books} only: + * vanilla shift-click can chain every book row; use two {@code PICKUP} clicks instead. + * Other merchant results (e.g. emeralds) still use {@code QUICK_MOVE}. + */ + private static void moveMerchantResultWithPickup(Minecraft mc, AbstractContainerMenu menu, Slot resultSlot) { + if (mc.player == null || mc.gameMode == null) { + return; + } + if (resultSlot.getItem().isEmpty()) { + return; + } + ((MultiPlayerGameModeInvoker) mc.gameMode).invokeEnsureHasSentCarriedItem(); + containerPickupClick(mc, menu, resultSlot.index, 0); + ItemStack carried = menu.getCarried(); + if (carried.isEmpty()) { + return; + } + int depositSlotId = findMerchantDepositSlot(menu, mc.player, carried); + if (depositSlotId < 0) { + return; + } + containerPickupClick(mc, menu, depositSlotId, 0); + } + + private static void containerPickupClick(Minecraft mc, AbstractContainerMenu menu, int slotId, int button) { + //? if mc26 { + mc.gameMode.handleContainerInput(menu.containerId, slotId, button, ContainerInput.PICKUP, mc.player); + //?} else { + mc.gameMode.handleInventoryMouseClick(menu.containerId, slotId, button, ClickType.PICKUP, mc.player); + //?} + } + + /** First player inventory slot in this menu that can accept {@code carried} (merge or empty). */ + private static int findMerchantDepositSlot(AbstractContainerMenu menu, Player player, ItemStack carried) { + for (int i = 0; i < menu.slots.size(); i++) { + Slot s = menu.getSlot(i); + if (s.container != player.getInventory()) { + continue; + } + ItemStack inSlot = s.getItem(); + if (inSlot.isEmpty()) { + return s.index; + } + if (ItemStack.isSameItemSameComponents(inSlot, carried) && inSlot.getCount() < s.getMaxStackSize()) { + return s.index; + } + } + return -1; + } + /** * After automated merchant packets, flush cursor/carried prediction so it matches the server. * Implemented via {@link MultiPlayerGameModeInvoker} because {@code ensureHasSentCarriedItem()} is private. diff --git a/src/main/java/com/github/sebseb7/autotrade/event/TradeFormatHelper.java b/src/main/java/com/github/sebseb7/autotrade/event/TradeFormatHelper.java new file mode 100644 index 0000000..9b899d7 --- /dev/null +++ b/src/main/java/com/github/sebseb7/autotrade/event/TradeFormatHelper.java @@ -0,0 +1,101 @@ +package com.github.sebseb7.autotrade.event; + +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.trading.MerchantOffer; + +/** + * Merchant trade overlay strings and cost checks (extracted from the client tick flow). + */ +final class TradeFormatHelper { + + private TradeFormatHelper() { + } + + static boolean playerHasMerchantCosts(Player player, MerchantOffer offer) { + return costRequirementMet(player.getInventory(), offer.getCostA()) + && costRequirementMet(player.getInventory(), offer.getCostB()); + } + + private static boolean costRequirementMet(Inventory inv, ItemStack required) { + if (required.isEmpty()) { + return true; + } + int need = required.getCount(); + int have = 0; + for (int s = 0; s < inv.getContainerSize(); s++) { + ItemStack st = inv.getItem(s); + if (ItemStack.isSameItemSameComponents(st, required)) { + have += st.getCount(); + if (have >= need) { + return true; + } + } + } + return false; + } + + /** + * Malilib's {@code showGuiOrInGameMessage} routes to multiple HUD targets; trade spam looked like 3× duplication. + * Vanilla overlay is a single on-screen line (same idea as vanilla toast-adjacent hints). + */ + static void showTradeNotice(Minecraft mc, String translationKey, Component arg1, Component arg2) { + if (mc.gui == null) { + return; + } + mc.gui.setOverlayMessage(Component.translatable(translationKey, arg1, arg2), false); + } + + /** e.g. "3× Book" (one villager use). */ + private static String formatItemCountAndName(ItemStack stack) { + return stack.getCount() + "× " + stack.getHoverName().getString(); + } + + /** + * Per-trade count × how many of this offer remain before the trade, e.g. 1 + * iron/trade × 12 runs → "12× …". + */ + static String formatItemCountNameForTrades(ItemStack perTrade, int remainingOfferUses) { + if (remainingOfferUses <= 0) { + return formatItemCountAndName(perTrade); + } + return (perTrade.getCount() * remainingOfferUses) + "× " + perTrade.getHoverName().getString(); + } + + /** For buying: the stacks you pay, scaled to how many of this offer remain. */ + static String formatOfferPriceForTrades(MerchantOffer offer, int t) { + if (t <= 0) { + String a = offer.getCostA().isEmpty() ? null : formatItemCountAndName(offer.getCostA()); + if (offer.getCostB().isEmpty()) { + return a != null ? a : "—"; + } + String b = formatItemCountAndName(offer.getCostB()); + return a == null ? b : a + " + " + b; + } + String a = offer.getCostA().isEmpty() + ? null + : (offer.getCostA().getCount() * t) + "× " + offer.getCostA().getHoverName().getString(); + if (offer.getCostB().isEmpty()) { + return a != null ? a : "—"; + } + String b = (offer.getCostB().getCount() * t) + "× " + offer.getCostB().getHoverName().getString(); + return a == null ? b : a + " + " + b; + } + + /** + * If the trade has a second cost item, " + 2× …" scaled to remaining offer + * uses. + */ + static String formatOptionalSecondCostForTrades(MerchantOffer offer, int t) { + if (offer.getCostB().isEmpty()) { + return ""; + } + if (t <= 0) { + return " + " + formatItemCountAndName(offer.getCostB()); + } + return " + " + (offer.getCostB().getCount() * t) + "× " + offer.getCostB().getHoverName().getString(); + } +} diff --git a/src/main/java/com/github/sebseb7/autotrade/event/TraderInteractor.java b/src/main/java/com/github/sebseb7/autotrade/event/TraderInteractor.java new file mode 100644 index 0000000..eef6864 --- /dev/null +++ b/src/main/java/com/github/sebseb7/autotrade/event/TraderInteractor.java @@ -0,0 +1,155 @@ +package com.github.sebseb7.autotrade.event; + +import com.github.sebseb7.autotrade.AutoTrade; +import com.github.sebseb7.autotrade.config.Configs; +import java.util.Vector; +import net.minecraft.client.Minecraft; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.Entity; +//? if npcSplit { +import net.minecraft.world.entity.npc.villager.Villager; +import net.minecraft.world.entity.npc.wanderingtrader.WanderingTrader; +//?} +//? if npcFlat { +import net.minecraft.world.entity.npc.Villager; +import net.minecraft.world.entity.npc.WanderingTrader; +//?} +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.EntityHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; + +/** + * Finds nearby villagers/wandering traders and performs the synthetic interact used to open trades. + */ +final class TraderInteractor { + private static final int LOS_TIMEOUT_TICKS = 60; + + private final AutoTradeTickState state; + private final Vector villagersInRange = new Vector<>(); + private int rotatingTargetId = -1; + private int rotatingTargetTicks = 0; + + TraderInteractor(AutoTradeTickState state) { + this.state = state; + } + + int getVillagerActive() { + return state.villagerActive; + } + + boolean findAndInteract(Minecraft mc) { + if (mc.level == null || mc.player == null) { + return false; + } + boolean found = false; + Vector newVillagersInRange = new Vector<>(villagersInRange); + for (Entity entity : mc.level.entitiesForRendering()) { + if (entity instanceof Villager || entity instanceof WanderingTrader) { + if (entity.distanceToSqr(mc.player) < (2.5f * 2.5f)) { + if (!found) { + if (!newVillagersInRange.contains(entity)) { + found = true; + Vec3 aimPoint = firstVisiblePoint(mc, entity); + if (rotatingTargetId != entity.getId()) { + rotatingTargetId = entity.getId(); + rotatingTargetTicks = 0; + Vec3 lookAt = aimPoint != null ? aimPoint : entity.getEyePosition(); + Vec3 eyePos = mc.player.getEyePosition(); + Vec3 delta = lookAt.subtract(eyePos); + double horiz = Math.sqrt(delta.x * delta.x + delta.z * delta.z); + float yaw = (float) (Math.toDegrees(Math.atan2(delta.z, delta.x)) - 90.0); + float pitch = (float) -Math.toDegrees(Math.atan2(delta.y, horiz)); + mc.player.setYRot(yaw); + mc.player.setXRot(pitch); + mc.player.yHeadRot = yaw; + } else { + rotatingTargetTicks++; + } + if (aimPoint == null) { + if (rotatingTargetTicks > LOS_TIMEOUT_TICKS) { + newVillagersInRange.add(entity); + rotatingTargetId = -1; + } + break; + } + newVillagersInRange.add(entity); + rotatingTargetId = -1; + EntityHitResult ehr = new EntityHitResult(entity, aimPoint); + AutoTrade.autoInteracting = true; + try { + //? if mc26 { + mc.gameMode.interact(mc.player, entity, ehr, InteractionHand.MAIN_HAND); + //?} else { + mc.gameMode.interactAt(mc.player, entity, ehr, InteractionHand.MAIN_HAND); + mc.gameMode.interact(mc.player, entity, InteractionHand.MAIN_HAND); + //?} + } finally { + AutoTrade.autoInteracting = false; + } + mc.player.swing(InteractionHand.MAIN_HAND); + state.postMerchantInventorySyncTicks = 0; + state.voidDelay = Configs.Generic.VOID_TRADING_DELAY.getIntegerValue(); + state.villagerActive = entity.getId(); + break; + } + } + } + } + } + for (Entity entity : villagersInRange) { + if (entity.distanceToSqr(mc.player) >= 16.0D) { + newVillagersInRange.remove(entity); + } + } + villagersInRange.clear(); + villagersInRange.addAll(newVillagersInRange); + return found; + } + + static Entity findEntityById(Minecraft mc, int entityId) { + if (mc.level == null) { + return null; + } + for (Entity e : mc.level.entitiesForRendering()) { + if (e.getId() == entityId) { + return e; + } + } + return null; + } + + /** + * Returns the first point on {@code target}'s bounding box (eyes, head, + * chest, feet, or one of the four upper corners) that has an unobstructed + * block ray-cast from the player's eyes; {@code null} when the entire body + * is occluded. + */ + private static Vec3 firstVisiblePoint(Minecraft mc, Entity target) { + Vec3 eye = mc.player.getEyePosition(); + AABB box = target.getBoundingBox(); + double cx = (box.minX + box.maxX) * 0.5; + double cz = (box.minZ + box.maxZ) * 0.5; + Vec3[] candidates = { + target.getEyePosition(), + new Vec3(cx, (box.minY + box.maxY) * 0.5, cz), + new Vec3(cx, box.maxY - 0.05, cz), + new Vec3(cx, box.minY + 0.1, cz), + new Vec3(box.minX + 0.05, box.maxY - 0.2, box.minZ + 0.05), + new Vec3(box.maxX - 0.05, box.maxY - 0.2, box.minZ + 0.05), + new Vec3(box.minX + 0.05, box.maxY - 0.2, box.maxZ - 0.05), + new Vec3(box.maxX - 0.05, box.maxY - 0.2, box.maxZ - 0.05), + }; + for (Vec3 p : candidates) { + BlockHitResult hit = mc.level.clip(new ClipContext(eye, p, ClipContext.Block.COLLIDER, + ClipContext.Fluid.NONE, mc.player)); + if (hit.getType() == HitResult.Type.MISS + || eye.distanceToSqr(hit.getLocation()) >= eye.distanceToSqr(p) - 1.0E-4) { + return p; + } + } + return null; + } +} diff --git a/src/main/java/com/github/sebseb7/autotrade/util/TradeItemSpec.java b/src/main/java/com/github/sebseb7/autotrade/util/TradeItemSpec.java index 75e366e..ecbc887 100644 --- a/src/main/java/com/github/sebseb7/autotrade/util/TradeItemSpec.java +++ b/src/main/java/com/github/sebseb7/autotrade/util/TradeItemSpec.java @@ -3,7 +3,6 @@ package com.github.sebseb7.autotrade.util; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; -import java.util.List; import java.util.Map; import net.minecraft.core.component.DataComponents; import net.minecraft.core.registries.BuiltInRegistries; @@ -15,7 +14,7 @@ import net.minecraft.world.item.enchantment.ItemEnchantments; * item. For enchanted books (and other enchanted items), holding the item when * binding the hotkey stores * {@code minecraft:enchanted_book#minecraft:sharpness=4&minecraft:unbreaking=3} - * so only that exact enchantment set is matched. + * so only that exact enchantment set is matched (same ids and levels as on the stack). */ public final class TradeItemSpec { private static final char SPEC_SEP = '#'; @@ -23,15 +22,26 @@ public final class TradeItemSpec { private TradeItemSpec() { } + private static String normalizeEnchantId(String id) { + String t = id.trim(); + if (t.isEmpty()) { + return t; + } + if (t.indexOf(':') < 0) { + return "minecraft:" + t; + } + return t; + } + public static String encodeFromStack(ItemStack stack) { String base = BuiltInRegistries.ITEM.getKey(stack.getItem()).toString(); ItemEnchantments enchants = enchantmentsForSpec(stack); if (enchants == null || enchants.isEmpty()) { return base; } - List parts = new ArrayList<>(); + ArrayList parts = new ArrayList<>(); for (var e : enchants.entrySet()) { - String name = e.getKey().getRegisteredName(); + String name = normalizeEnchantId(e.getKey().getRegisteredName()); parts.add(name + "=" + e.getIntValue()); } Collections.sort(parts); @@ -50,15 +60,16 @@ public final class TradeItemSpec { if (stack.isEmpty()) { return false; } - int sep = spec.indexOf(SPEC_SEP); - String itemPart = sep < 0 ? spec : spec.substring(0, sep); + String trimmed = spec.trim(); + int sep = trimmed.indexOf(SPEC_SEP); + String itemPart = (sep < 0 ? trimmed : trimmed.substring(0, sep)).trim(); if (!BuiltInRegistries.ITEM.getKey(stack.getItem()).toString().equals(itemPart)) { return false; } if (sep < 0) { return true; } - Map expected = parseEnchantSection(spec.substring(sep + 1)); + Map expected = parseEnchantSection(trimmed.substring(sep + 1)); if (expected == null) { return false; } @@ -70,7 +81,7 @@ public final class TradeItemSpec { return false; } for (var e : actual.entrySet()) { - String name = e.getKey().getRegisteredName(); + String name = normalizeEnchantId(e.getKey().getRegisteredName()); int level = e.getIntValue(); Integer want = expected.get(name); if (want == null || want != level) { @@ -89,27 +100,29 @@ public final class TradeItemSpec { } private static Map parseEnchantSection(String section) { - if (section.isEmpty()) { + String s = section.trim(); + if (s.isEmpty()) { return Map.of(); } Map out = new HashMap<>(); - for (String piece : section.split("&")) { - if (piece.isEmpty()) { + for (String piece : s.split("&")) { + String p = piece.trim(); + if (p.isEmpty()) { return null; } - int eq = piece.lastIndexOf('='); - if (eq <= 0 || eq == piece.length() - 1) { + int eq = p.lastIndexOf('='); + if (eq <= 0 || eq == p.length() - 1) { return null; } - String enchantId = piece.substring(0, eq); - String levelStr = piece.substring(eq + 1); + String enchantId = p.substring(0, eq).trim(); + String levelStr = p.substring(eq + 1).trim(); int level; try { level = Integer.parseInt(levelStr); } catch (NumberFormatException e) { return null; } - out.put(enchantId, level); + out.put(normalizeEnchantId(enchantId), level); } return out; }