don't use quick_move for book trade

This commit is contained in:
seb
2026-05-15 07:49:27 +02:00
parent 786d78e6fe
commit 9bcc0dc6fe
10 changed files with 896 additions and 599 deletions

View File

@@ -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<Entity> 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<Entity> 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<ItemFrame> frames = (List<ItemFrame>) (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 <strong>one</strong> 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();
}
}

View File

@@ -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<net.minecraft.world.entity.decoration.ItemFrame>)
(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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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--;
}
}

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -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();
}
}

View File

@@ -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<Entity> 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<Entity> 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;
}
}

View File

@@ -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<String> parts = new ArrayList<>();
ArrayList<String> 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<String, Integer> expected = parseEnchantSection(spec.substring(sep + 1));
Map<String, Integer> 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<String, Integer> parseEnchantSection(String section) {
if (section.isEmpty()) {
String s = section.trim();
if (s.isEmpty()) {
return Map.of();
}
Map<String, Integer> 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;
}