Resource Adding a cancellable VillagerTradeEvent

Discussion in 'Spigot Plugin Development' started by _Ross__, Jan 7, 2020.

  1. I was surprised to find no existing villager trade event or a solution to add one. So I'm sharing what I came up with but please feel free to let me know if the Spigot API has some obscure native event for this ;)

    The event that is fired can be cancelled, accounts for variable pricing, and is -IMO- overall rather awesome (but dang complex and literally took me 3-4 days to put together).

    To use my solution add a class and paste in the code below then simply create an instance and add a listener for the VillagerTradeListener.VillagerTradeEvent - Enjoy!

    About this solution
    This event is made cancellable by tapping into the InventoryClickEvent, which is a point where the action can be canceled. Obviously at the point this event is triggered the trade has not happened yet so I had to basically simulate what would happen when the trade/click action completed. This was rather complicated to say the least and the 1.14+ variable pricing system is not exposed through the bukkit/spigot API - so getting at that data involved a fair bit of research and the use of reflection.

    As a bonus, I've also included the functionality which can be used to exert control over the variable pricing by setting the "specialPriceDiff" (which is directly added to the base cost of the first input item) and the ability to set the "demand" value. These values should be set in an InventoryOpenEvent handler.

    NOTICE: This custom event solution was built for 1.15.1 - behavior of older versions of minecraft may (and likely do) differ slightly (e.g. double clicking to stack to cursor is disabled in trade windows in 1.15 but may not be in older versions) so be sure to sanity check things if planning to use this on an older version. AND - please post below with your findings, even if you can't solve the problem you find yourself.
     
    #1 _Ross__, Jan 7, 2020
    Last edited: Jan 8, 2020
    • Useful Useful x 1
  2. The Code (part 1 of 2)
    Code (Java):

    package me.ross.spigot.listeners;

    import org.bukkit.Bukkit;
    import org.bukkit.ChatColor;
    import org.bukkit.Material;
    import org.bukkit.entity.AbstractVillager;
    import org.bukkit.entity.HumanEntity;
    import org.bukkit.entity.Villager;
    import org.bukkit.entity.WanderingTrader;
    import org.bukkit.event.Event;
    import org.bukkit.event.EventHandler;
    import org.bukkit.event.HandlerList;
    import org.bukkit.event.Listener;
    import org.bukkit.event.inventory.InventoryAction;
    import org.bukkit.event.inventory.InventoryClickEvent;
    import org.bukkit.event.inventory.InventoryOpenEvent;
    import org.bukkit.event.inventory.InventoryType;
    import org.bukkit.inventory.*;
    import org.bukkit.inventory.meta.BookMeta;
    import org.bukkit.inventory.meta.ItemMeta;
    import org.bukkit.plugin.java.JavaPlugin;
    import org.jetbrains.annotations.Contract;
    import org.jetbrains.annotations.NotNull;
    import org.jetbrains.annotations.Nullable;

    import java.lang.reflect.Field;
    import java.lang.reflect.InvocationTargetException;
    import java.lang.reflect.Method;
    import java.text.Normalizer;
    import java.util.*;
    import java.util.logging.Logger;

    /**
     * Performs all of the logic needed to trigger the, cancelable, custom VillagerTradeListener.VillagerTradeEvent.
     */

    public class VillagerTradeListener implements Listener {
        private final Logger logger = Logger.getLogger("VillagerTradeListener");

        private static final Set<InventoryAction> purchaseSingleItemActions;
        static {
            // Each of these action types are single order purchases that do not require free inventory space to satisfy.
            // I.e. they stack up on the cursor (hover under the mouse).
            purchaseSingleItemActions = new HashSet<>();
            purchaseSingleItemActions.add(InventoryAction.PICKUP_ONE);
            purchaseSingleItemActions.add(InventoryAction.PICKUP_ALL);
            purchaseSingleItemActions.add(InventoryAction.PICKUP_HALF);
            purchaseSingleItemActions.add(InventoryAction.PICKUP_SOME);
            purchaseSingleItemActions.add(InventoryAction.DROP_ONE_SLOT);
            purchaseSingleItemActions.add(InventoryAction.DROP_ALL_SLOT);  // strangely in trades this is a single item event
            purchaseSingleItemActions.add(InventoryAction.HOTBAR_SWAP);
        }

        /** Because there is no Inventory:clone method */
        public static class InventorySnapshot implements InventoryHolder {
            Inventory inventory;

            public InventorySnapshot(Inventory inv) {
                ItemStack[] source = inv.getStorageContents();
                inventory = Bukkit.createInventory(this, source.length, "Snapshot");
                for (int i = 0; i < source.length; i++) {
                    inventory.setItem(i, source[i] != null ? source[i].clone() : null);
                }
            }
            public InventorySnapshot(int size) {
                inventory = Bukkit.createInventory(this, size, "Snapshot");
            }
            @Override
            public @NotNull Inventory getInventory() {
                return inventory;
            }
        }

        public static class VillagerTradeEvent extends Event {
            private static final HandlerList handlers = new HandlerList();
            final HumanEntity player;
            final AbstractVillager villager;
            final MerchantRecipe recipe;
            final int offerIndex;
            final int orders;
            final int ingredientOneDiscountedPrice;
            final int ingredientOneTotalAmount;
            final int ingredientTwoTotalAmount;
            final int amountPurchased;
            final int amountLost;
            boolean cancelled = false;

            public VillagerTradeEvent(HumanEntity player, AbstractVillager villager, MerchantRecipe recipe, int offerIndex,
                                      int orders, int ingredientOneDiscountedPrice,
                                      int amountPurchased, int amountLost) {
                this.player = player;
                this.villager = villager;
                this.recipe = recipe;
                this.offerIndex = offerIndex;
                this.orders = orders;
                this.ingredientOneDiscountedPrice = ingredientOneDiscountedPrice;
                this.amountPurchased = amountPurchased;
                this.amountLost = amountLost;

                ingredientOneTotalAmount = ingredientOneDiscountedPrice * orders;
                if (recipe.getIngredients().size() > 1) {
                    ItemStack bb = recipe.getIngredients().get(1);
                    ingredientTwoTotalAmount = bb.getType() != Material.AIR ? bb.getAmount() * orders : 0;
                } else {
                    ingredientTwoTotalAmount = 0;
                }
            }

            public boolean isCancelled() {
                return cancelled;
            }

            /** Cancels the trade. Note the client will need to close and reopen the trade window to, for example, see that
             * a canceled trade is not sold out.
             */

            public void setCancelled(boolean toCancel) {
                cancelled = toCancel;
            }

            public HumanEntity getPlayer() {
                return player;
            }

            public AbstractVillager getVillager() {
                return villager;
            }

            public MerchantRecipe getRecipe() {
                return recipe;
            }

            /** For the total count of the item purchased use {@code getAmountPurchased}.*/
            public int getOrders() {
                return orders;
            }

            public int getOfferIndex() {
                return offerIndex;
            }

            /**
             * The actual amount of ingredient one charged for a single 'order'; e.g. the price after all
             * gossip/player-reputation and hero of the village effects have been applied.
             * Note that only the first ingredient is discounted by the villager.
             * @return amount of item 1 each order actually cost.
             */

            public int getIngredientOneDiscountedPrice() {
                return ingredientOneDiscountedPrice;
            }

            /** The total amount of {@code recipe.getIngredients().get(0)} spent */
            public int getIngredientOneTotalAmount() {
                return ingredientOneTotalAmount;
            }
            /** The total amount of {@code recipe.getIngredients().get(1)} spent, or zero if no ingredient 2*/
            public int getIngredientTwoTotalAmount() {
                return ingredientTwoTotalAmount;
            }

            @NotNull
            public String getBestNameForIngredientOne() {
                return bestNameFor(recipe.getIngredients().get(0));
            }

            @Nullable
            public String getBestNameForIngredientTwo() {
                if (recipe.getIngredients().size() > 1) {
                    ItemStack stack = recipe.getIngredients().get(1);
                    if (stack != null)
                        return bestNameFor(stack);
                }
                return null;
            }

            @NotNull
            public String getBestNameForResultItem() {
                return bestNameFor(recipe.getResult());
            }

            /** Total amount of {@code recipe.getResult()} purchased. This value is the total count the player received. */
            public int getAmountPurchased() {
                return amountPurchased;
            }

            /** When the player does not have inventory space for all of the items purchased they may drop or simply
             * be lost. I've seen both happen.*/

            public int getAmountLost() {
                return amountLost;
            }

            @NotNull
            @Override
            public HandlerList getHandlers() {
                return handlers;
            }

            @NotNull
            public static HandlerList getHandlerList() {
                return handlers;
            }

            @NotNull
            static private String bestNameFor(ItemStack stack) {
                if (stack == null) return "null";
                if (stack.getType() == Material.WRITTEN_BOOK) {
                    BookMeta meta = (BookMeta)stack.getItemMeta();
                    if (meta != null && meta.hasTitle() && meta.getTitle() != null)
                        return ChatColor.stripColor(meta.getTitle());
                    // TODO: fallback to finding enchants
                }
                if (stack.getItemMeta() != null) {
                    ItemMeta meta = stack.getItemMeta();
                    if (meta.hasDisplayName())
                        return ChatColor.stripColor(meta.getDisplayName());
                }
                return stack.getType().name();
            }

            public String bestNameForVillager() {
                if (villager.getCustomName() != null)
                    return villager.getCustomName();
                return villager.getName();
            }

            public boolean isWanderingTraider() {
                return villager instanceof WanderingTrader;
            }

            @NotNull
            public Villager.Profession getVillagerProfession() {
                if (!(villager instanceof Villager)) return Villager.Profession.NONE;
                return ((Villager)villager).getProfession();
            }

            @Nullable
            public Villager.Type getVillagerType() {
                if (!(villager instanceof Villager)) return null;
                return ((Villager)villager).getVillagerType();
            }
        }

        public VillagerTradeListener(JavaPlugin owner) {
            owner.getServer().getPluginManager().registerEvents(this, owner);
        }

        /**
         * Calculates if the given stacks are of the exact same item thus could be stacked.
         * @return true - stacks can be combined (assuming a max stack size > 1).
         */

        @Contract("null,null->true")
        public static boolean areStackable(@Nullable ItemStack a, @Nullable ItemStack b) {
            if (a == null && b == null || (a == null && b.getType() == Material.AIR)
                    || (b == null && a.getType() == Material.AIR)) return true;
            if (a == null || b == null || a.getType() != b.getType()) return false;
            if (a.getItemMeta() == null && b.getItemMeta() == null) return true;
            if (a.getItemMeta() == null || b.getItemMeta() == null) return false;
            return a.getItemMeta().equals(b.getItemMeta());
        }

        @EventHandler
        public void onInventoryClickEvent(final InventoryClickEvent event) {
            if (event.getAction() == InventoryAction.NOTHING) return;
            if (event.getInventory().getType() != InventoryType.MERCHANT) return;
            if (event.getSlotType() != InventoryType.SlotType.RESULT) return;
            // Currently (1.15.x) there are no non-AbstractVillager Merchants
            if (!(event.getInventory().getHolder() instanceof AbstractVillager)) return;

            final HumanEntity player = event.getWhoClicked();
            final AbstractVillager villager = (AbstractVillager)event.getInventory().getHolder();
            final MerchantInventory merchantInventory = (MerchantInventory)event.getInventory();
            final MerchantRecipe recipe = merchantInventory.getSelectedRecipe();

            if (recipe == null) return;
            final ItemStack discountedA = NmsOperations.getPriceAdjustedIngredient1(villager, merchantInventory.getSelectedRecipeIndex());
            final int discountedPriceA = discountedA.getAmount();
            final int maxUses = recipe.getMaxUses() - recipe.getUses();

            VillagerTradeEvent vtEvent = null;
            if (purchaseSingleItemActions.contains(event.getAction())) {
                vtEvent = new VillagerTradeEvent(
                        player, villager,recipe, merchantInventory.getSelectedRecipeIndex(),
                        1, discountedPriceA,
                        recipe.getResult().getAmount(), 0
                );
            } else if (event.getAction() == InventoryAction.MOVE_TO_OTHER_INVENTORY) {
                // This situation is where the player SHIFT+CLICKS the output item to buy multiple times at once.
                // Because this event is fired before any inventories have changed - we need to simulate what will happen
                // when the inventories update.
                InventorySnapshot playerSnap = new InventorySnapshot(player.getInventory());
                InventorySnapshot merchantSnap = new InventorySnapshot(9);
                for (int i = 0; i < 3; i++) {
                    if (merchantInventory.getItem(i) != null)
                        merchantSnap.getInventory().setItem(i, merchantInventory.getItem(i).clone());
                }
                List<ItemStack> ingredients = recipe.getIngredients();
                ItemStack ma = merchantSnap.getInventory().getItem(0);
                ItemStack mb = merchantSnap.getInventory().getItem(1);
                ItemStack ra = ingredients.get(0);
                ItemStack rb = ingredients.size() > 1 ? ingredients.get(1) : null;
                if (rb != null && rb.getType() == Material.AIR) rb = null;

                if (areStackable(ra, mb)) {
                    ItemStack tmp = ma;
                    ma = mb;
                    mb = tmp;
                }

                int amount = ma.getAmount() / discountedPriceA;
                if (rb != null && mb != null && rb.getType() != Material.AIR && mb.getType() != Material.AIR) {
                    amount = Math.min(amount, mb.getAmount() / rb.getAmount());
                }
                amount = clamp(amount, 0, maxUses);

                // In order for "failed" below to be populated we need to compute each stack here
                int maxStackSize = recipe.getResult().getMaxStackSize();
                List<ItemStack> stacks = new ArrayList<>();
                int unaccounted = amount;
                while (unaccounted != 0) {
                    ItemStack stack = recipe.getResult().clone();
                    stack.setAmount(Math.min(maxStackSize, unaccounted));
                    stacks.add(stack);
                    unaccounted -= stack.getAmount();
                }
                HashMap<Integer, ItemStack> failed = playerSnap.getInventory().addItem(stacks.toArray(new ItemStack[0]));
                int loss = 0;
                if (!failed.isEmpty()) {
                    // int requested = amount;
                    for (ItemStack stack : failed.values()) {
                        amount -= stack.getAmount();
                    }
                    // If a partial result is delivered, the rest of it is dropped... or just lost... I've seen both happen
                    int rem = amount % recipe.getResult().getAmount();
                    if (rem != 0) {
                        loss = recipe.getResult().getAmount() - rem;
                        amount += loss;
                    }
                }
                int orders = amount / recipe.getResult().getAmount();
                vtEvent = new VillagerTradeEvent(
                        player, villager, recipe, merchantInventory.getSelectedRecipeIndex(),
                        orders, discountedPriceA,
                        amount, loss
                );
            }
            if (vtEvent != null) {
                vtEvent.setCancelled(event.isCancelled());
                Bukkit.getPluginManager().callEvent(vtEvent);
                event.setCancelled(vtEvent.isCancelled());
            }
        }


        public static int clamp(int value, int min, int max) {
            if (value < min) return min;
            if (value > max) return max;
            return value;
        }
    }
     
     
    #2 _Ross__, Jan 7, 2020
    Last edited: Jan 8, 2020
  3. The Code (part 2 of 2)
    You can either add these classes as their own .java files or just add them as an inner class to the VillagerTradeListener (which is where they originally were, but i had to split this post due to length)
    Code (Java):

    //
    // NMS Reflection stuff
    //
    public static class InvalidNmsOperationsState extends RuntimeException {
        public InvalidNmsOperationsState(final String message) {
            super(message);
        }
        public InvalidNmsOperationsState(final String message, Throwable cause) {
            super(message, cause);
        }
    }

    public static class NmsOperations {
        private static final Logger logger = Logger.getLogger("VillagerTradeListener.NmsOperations");
        private static final boolean allClassesAndMethodsOK;
        private static final String nmsVersionString;
        private static Method obcCraftAbstractVillager_getHandle;
        private static Method nmsEntityVillagerAbstract_getOffers;
        private static Method nmsMerchantRecipe_getSpecialPrice;
        private static Method nmsMerchantRecipe_getDemand;
        private static Method nmsMerchantRecipe_setSpecialPrice;
        private static Field nmsMerchantRecipe_demandField;
        private static Method nmsMerchantRecipe_getBuyItem1;
        private static Method obcCraftItemStack_asBukkitCopy;
        static {
            String nmsPackageName = Bukkit.getServer().getClass().getPackage().getName();
            nmsVersionString = nmsPackageName.substring(nmsPackageName.lastIndexOf('.') + 1) + '.';
            boolean ok;
            try {
                // returns nms.EntityVillagerAbstract
                obcCraftAbstractVillager_getHandle = getMethod(getOBCClass("entity.CraftAbstractVillager"), "getHandle");
                // returns nms.MerchantRecipeList (extends ArrayList<MerchantRecipe>)
                nmsEntityVillagerAbstract_getOffers = getMethod(getNMSClass("EntityVillagerAbstract"), "getOffers");

                Class<?> merchantRecipeClazz = getNMSClass("MerchantRecipe");
                nmsMerchantRecipe_getSpecialPrice = getMethod(merchantRecipeClazz, "getSpecialPrice");  // -> int
                nmsMerchantRecipe_getDemand = getMethod(merchantRecipeClazz, "getDemand");  // -> int
                nmsMerchantRecipe_setSpecialPrice = getMethod(merchantRecipeClazz, "setSpecialPrice", int.class);
                nmsMerchantRecipe_demandField = getField(merchantRecipeClazz, "demand");
                nmsMerchantRecipe_getBuyItem1 = getMethod(merchantRecipeClazz, "getBuyItem1");  // -> nms.ItemStack
                obcCraftItemStack_asBukkitCopy = getMethod(getOBCClass("inventory.CraftItemStack"), "asBukkitCopy", getNMSClass("ItemStack"));  // STATIC -> ItemStack
                ok = true;
            } catch (ClassNotFoundException | NoSuchMethodException | NoSuchFieldException ex) {
                ok = false;
                logger.severe("Failure while reflecting: " + ex.getMessage());
                ex.printStackTrace();
            }
            allClassesAndMethodsOK = ok;
        }

        public static void checkAllClassesAndMethodsOK() throws InvalidNmsOperationsState {
            if (!allClassesAndMethodsOK) throw new InvalidNmsOperationsState(
                    "NmsOperations: Some classes or methods were not successfully loaded!");
        }

        public static Class<?> getNMSClass(final String nmsClassName) throws ClassNotFoundException {
            String clazzName = "net.minecraft.server." + nmsVersionString + nmsClassName;
            return Class.forName(clazzName);
        }

        public static Class<?> getOBCClass(final String obcClassName) throws ClassNotFoundException {
            String clazzName = "org.bukkit.craftbukkit." + nmsVersionString + obcClassName;
            return Class.forName(clazzName);
        }

        public static Method getMethod(final Class<?> clazz, final String methodName, final Class<?>... params) throws NoSuchMethodException {
            Method method = clazz.getDeclaredMethod(methodName, params);
            if (!method.isAccessible()) method.setAccessible(true);
            return method;
        }

        public static Field getField(final Class<?> clazz, final String fieldName) throws NoSuchFieldException {
            Field field = clazz.getDeclaredField(fieldName);
            if (!field.isAccessible()) field.setAccessible(true);
            return field;
        }

        /**
         * Produces a price adjusted ItemStack of the first input item after player-villager reputation, HotV,
         * and demand are calculated. Simply put, the result reflects the price actually being asked by the villager
         * for the first input item.
         *
         * Notes:
         *   Price adjustments only occur when the villager is actively engaged in a trade with a player!
         *   Only the first ingredient is ever discounted - that's why there is no getPriceAdjustedIngredient2.
         * @param villager owning villager
         * @param offerIndex index of the offer/recipe;
         * @return Price adjusted ItemStack
         */

        public static ItemStack getPriceAdjustedIngredient1(final AbstractVillager villager, final int offerIndex) throws InvalidNmsOperationsState {
            checkAllClassesAndMethodsOK();
            try {
                final Object nmsAbstractVillager = obcCraftAbstractVillager_getHandle.invoke(villager);
                final Object nmsOffer = ((ArrayList)nmsEntityVillagerAbstract_getOffers.invoke(nmsAbstractVillager)).get(offerIndex);
                final Object nmsItemStack1 = nmsMerchantRecipe_getBuyItem1.invoke(nmsOffer);
                return (ItemStack)obcCraftItemStack_asBukkitCopy.invoke(null, nmsItemStack1);
            } catch (IllegalAccessException | InvocationTargetException ex) {
                ex.printStackTrace();
                return null;
            }
        }

        /** This is the amount the price is adjusted by - it accounts for player-villager reputation scores and HotV effect.
         * NOTICE: this value will always be zero, unless the villager is engaged in a trade.*/

        public static int getOfferSpecialPriceDiff(final AbstractVillager villager, final int offerIndex) throws InvalidNmsOperationsState{
            checkAllClassesAndMethodsOK();
            try {
                final Object nmsAbstractVillager = obcCraftAbstractVillager_getHandle.invoke(villager);
                final Object nmsOffer = ((ArrayList)nmsEntityVillagerAbstract_getOffers.invoke(nmsAbstractVillager)).get(offerIndex);
                return (Integer)nmsMerchantRecipe_getSpecialPrice.invoke(nmsOffer);
            } catch (IllegalAccessException | InvocationTargetException ex) {
                throw new InvalidNmsOperationsState("Reflection failure while attempting to fetch offer specialPriceDiff", ex);
            }
        }

        /** The demand value affects pricing when it is GT zero. */
        public static int getOfferDemand(final AbstractVillager villager, final int offerIndex) throws InvalidNmsOperationsState {
            checkAllClassesAndMethodsOK();
            try {
                final Object nmsAbstractVillager = obcCraftAbstractVillager_getHandle.invoke(villager);
                final Object nmsOffer = ((ArrayList) nmsEntityVillagerAbstract_getOffers.invoke(nmsAbstractVillager)).get(offerIndex);
                return (Integer) nmsMerchantRecipe_getDemand.invoke(nmsOffer);
            } catch (IllegalAccessException | InvocationTargetException ex) {
                throw new InvalidNmsOperationsState("Reflection failure while attempting to fetch offer demand", ex);
            }
        }

        //
        // Bonus functions...
        // These functions are NOT useful for VillagerTradeEvent handling, but can be used in InventoryOpenEvent
        // handling to affect the price the player sees and pays.
        //

        /**
         * Setting the special price diff to a negative value will result in discounted pricing, setting it positive
         * will result in inflated pricing and setting it to zero will result in default pricing.
         */

        public static boolean setOfferSpecialPriceDiff(final AbstractVillager villager, final int offerIndex, final int specialPriceDiff) {
            checkAllClassesAndMethodsOK();
            try {
                final Object nmsAbstractVillager = obcCraftAbstractVillager_getHandle.invoke(villager);
                final Object nmsOffer = ((ArrayList)nmsEntityVillagerAbstract_getOffers.invoke(nmsAbstractVillager)).get(offerIndex);
                nmsMerchantRecipe_setSpecialPrice.invoke(nmsOffer, specialPriceDiff);
                return true;
            } catch (IllegalAccessException | InvocationTargetException ex) {
                ex.printStackTrace();
                return false;
            }
        }
        public static boolean setOfferDemand(final AbstractVillager villager, final int offerIndex, final int demand) {
            checkAllClassesAndMethodsOK();
            try {
                final Object nmsAbstractVillager = obcCraftAbstractVillager_getHandle.invoke(villager);
                final Object nmsOffer = ((ArrayList)nmsEntityVillagerAbstract_getOffers.invoke(nmsAbstractVillager)).get(offerIndex);
                nmsMerchantRecipe_demandField.set(nmsOffer, demand);
                return true;
            } catch (IllegalAccessException | InvocationTargetException ex) {
                ex.printStackTrace();
                return false;
            }
        }
    }
     
     
    #3 _Ross__, Jan 7, 2020
    Last edited: Jan 8, 2020
  4. Sample event handlers

    Showcasing the VillagerTradeListener.VillagerTradeEvent
    Code (Java):
    //
    // Showcase event handler
    //
    @EventHandler
    void onVillagerTradeEvent(VillagerTradeListener.VillagerTradeEvent event) {
        StringBuilder sb = new StringBuilder("VillagerTradeEvent ");
        sb.append(" ;Player: ").append(event.getPlayer().getName());
        sb.append(" ;Item: ").append(event.getBestNameForResultItem());
        sb.append(" ;Orders: ").append(event.getOrders());
        sb.append(" ;Total Purchased: ").append(event.getAmountPurchased());
        sb.append(" ;Total Lost or Dropped: ").append(event.getAmountLost());
        sb.append(" ;Cost: ").append(event.getIngredientOneTotalAmount())
                .append('x').append(event.getBestNameForIngredientOne());
        if (event.getIngredientTwoTotalAmount() > 0)
            sb.append(" ;Cost: ").append(event.getIngredientTwoTotalAmount())
                    .append('x').append(event.getBestNameForIngredientTwo());
        sb.append(" ;Villager{");
        sb.append(" ;Name: ").append(event.getBestNameForVillager());
        sb.append(" ;Type: ").append(event.getVillagerType());
        sb.append(" ;Profession: ").append(event.isWanderingTraider() ? "Wandering Traider" : event.getVillagerProfession());
        sb.append(" } ");
        logger.info(Normalizer.normalize(sb.toString(), Normalizer.Form.NFD));
        logger.info(String.format("Discounted item 1 cost: %d -> %d",
                event.getRecipe().getIngredients().get(0).getAmount(),
                event.getIngredientOneDiscountedPrice()
        ));
        // Yes, you can cancel the trade!
        // event.setCancelled(true);
    }

    Example of how to disable variable pricing.
    Code (Java):

    /**
    * Example of how to disable variable pricing.
    *
    * Sample in game commands to query and write demand value
    *   /data get entity @e[type=villager,distance=..6,sort=nearest,limit=1] Offers.Recipes[0].demand
    *   /data modify entity @e[type=villager,distance=..6,sort=nearest,limit=1] Offers.Recipes[0].demand set value 1000
    */

    @EventHandler
    void onInventoryOpenEvent(InventoryOpenEvent event) {
        InventoryHolder invHolder = event.getInventory().getHolder();
        if (invHolder instanceof AbstractVillager) {
            AbstractVillager villager = (AbstractVillager)invHolder;
            for (int i = 0; i < villager.getRecipeCount(); i++) {
                // This change REVERTS when the player closes the trade window.
                NmsOperations.setOfferSpecialPriceDiff(villager, i, 0);

                // This change DOES NOT revert when the player closes the trade window!
                // If you wish to revert it after a trade you will need to track the
                //   initial value and re-set it in an InventoryCloseEvent
                NmsOperations.setOfferDemand(villager, i, -1000);
            }
        }
    }
     
    #4 _Ross__, Jan 7, 2020
    Last edited: Jan 8, 2020
  5. instead of asking people to scroll through several posts and copy and paste them, upload it to github..
     
    • Agree Agree x 5
  6. MiniDigger

    Supporter

    Shoving code at ppl is not a tutorial, just fyi.
    Good resource anyways.
     
  7. If you feel like the event should be in the API, feel free to do a code a pull request and add it yourself or submit a feature request for other developers to do it. That way anyone can use the event without having to go the extra mile
     
  8. I implemented something similar to this in the past and wanted to checkout how you tackled some of the difficulties I ran into. I noted a few things while quickly glancing over your code:

    There are custom merchants in Bukkit (Server#createMerchant) which are non-AbstractVillager merchants if I remember correctly.

    Players can also trade by double clicking a matching item in their inventory (inventory action COLLECT_TO_CURSOR). [Side note: This click action has some weird effects in some cases. And there is currently a bug in minecraft that involves some of these trades, depending on the traded items (https://bugs.mojang.com/browse/MC-129515).]

    Not sure if PICKUP_ONE and PICKUP_SOME can even occur in the context of trading. When I implemented something similar to this in the past, and if I remember it correctly, I did actually only observe PICKUP_ALL and PICKUP_HALF. Might have to test again if something changed there in newer minecraft versions.
    Also, not all of those clicks will actually trigger trades. It will only trigger a trade if the cursor can hold the traded items (cursor empty, or item on cursor matches and has enough space).

    On the other hand, it is also possible for DROP_ONE_SLOT and DROP_ALL_SLOT to trigger trades. The traded items are then dropped.
    And one can trade with inventory action HOTBAR_SWAP, which moves/combines the item into the clicked hotbar slot and only triggers if the slot can hold the item.

    If I remember correctly, (at least in the past) one could have a recipe selected and still not trigger a trade with the click. One would then need to compare the traded and provided items manually to figure out whether the click triggers a trade (which is rather tricky due to the different item comparison rules Minecraft uses for determining whether a trade accepts the items provided by the player (traded items do not need to perfectly match)). Maybe this has changed in newer minecraft versions with their revamp of the trading interface, not sure.

    The different item comparison rules mentioned above also apply when you try to figure out whether the player has provided the required items in reverse order. You seem to use the strict item comparison implemented in Bukkit. For instance, minecraft considers items to match here even if the provided item contains additional properties (such as a display name even though the item required by the trading recipe does not contain a display name, etc.).

    Regarding handling of shift-clicking:
    In the past (I think this is still possible), if the player has the first recipe selected in the trading view, shift clicking may actually trigger trades of different recipes. The recipe returned by Bukkit's MerchantInventory#getSelectedRecipe() does not necessarily return the recipe actually selected by the player. Bukkit doc: "This does not necessarily match the recipe selected by the player: If the player has selected the first recipe, the merchant will search all of its offers for a matching recipe to activate.". This recipe may change in the course of handling the individual trades triggered by the shift click. If the last recipe no longer matches the remaining items provided by the player (eg. because there are not enough items remaining to apply the selected trading recipe another time), it may switch to a later recipe that still accepts the remaining offered items and returns the same result item.

    There are also some differences in the order in which minecraft consumes and adds items to the player's inventory.

    Most of these things are probably edge cases that only affect a small collection of actual trades.

    I agree that it would probably be niced to have something like this in Bukkit directly. However, implementing this in a way that it also works for custom plugin-created Merchants, supports villager trading experience rewards, still allows to be cancelled, and does not re-implement minecraft's trading logic is rather tricky.
     
    • Useful Useful x 2
  9. Thanks for the review and call outs!

    Double clicking inventory while in a trade window does nothing in 1.15.x - kind of annoying IMO, maybe that was their quick hack to "fix" the issue.

    in 1.15.1 left click, shift+left click, right click, & middle click trigger these actions when clicking on the "result" slot.

    Yup - however, these cases are filtered out by
    if (event.getAction() == InventoryAction.NOTHING) return;

    Humm, I'll admit in the years I've played minecraft I've never tried to drop items from a trade result slot - but you're right they do execute the trade. I've updated the code to support these 3 actions - all of which appear to only perform a single purchase.


    I believe event.getAction() == InventoryAction.NOTHING handles these cases too.

    I swap the inputs around to match the recipe, if necessary, to simplify this case.

    This warrants more research - but I'm using custom heads as currency and the trade appeared to only accept very strictly matched inputs. Then again, once I got the pesky trades working (by setting up the villagers via the Spigot API vs trying to do it with in game commands) I didn't investigate further so there might be some wiggle room in the comparisons.

    Wow, that sounds like a bug... i mean the player is being sold items they didn't want to buy. If anyone has a repro for this in 1.15.x I'm very interested in it.

    Humm, I'll poke around the edges of inventory getting freed by the purchase and see if anything shakes out. But I suspect this will be handled by my solution - though it may wrongly allocate items to the "lost" count - none the less I'll look at things from this angle to see if anything breaks.

    Update: I don't believe this is relevant - there is no way to perform a trade without putting the items in the input slot so by the time this listener is triggered those items are already out of the players inventory.

    Ya, I'm not sure if there would be a solid way to add roughly the same event at the spigot level - but I would expect (hope really) that there is a sane point to tap into - frankly making my solution support cancelling the trade was a non-objective when I started working on this but once I settled on putting all of the logic in the InventoryClickEvent it was a no brainer to support it.

    I was thrilled when I found that the "specialPriceDiff" was populated when actively engaged in a trade; how that value gets calculated is complex enough I about expect it to evolve over versions. The only logic i chose to duplicate (well, apart from simulating the whole inventory transaction) was bringing in the "demand" bias logic which may change over time but is otherwise very simple (the demand biasing that is - the rest of this solution is quite complex :p). I could have, and probably should just do it, use reflection to access net.minecraft.world.item.trading.MerchantOffer.getCostA() - but I didn't want to have to deal with re-wrapping or reflecting out the result from the nms item stack that returned.

    Update: I've changed my solution to NOT duplicate any of the price calculation logic and to fully reflect out the adjusted price.
     
    #9 _Ross__, Jan 8, 2020
    Last edited: Jan 11, 2020
    • Friendly Friendly x 1
  10. For reference, here is my implementation: https://github.com/Shopkeepers/Shop...pkeepers/ui/defaults/TradingHandler.java#L251
    It's for a shop plugin and only reacts to clicks when trading with specific, custom merchants. I had some requirements which lead me to fully re-implement minecraft's trading logic as closely as possible. But a few special types of clicks are not supported. It think it's based on the trading logic present in MC 1.12.x and might therefore likely not be fully up-to-date anymore.
    Not sure if explicitly checking the cursor space was required for some reason, since CraftBukkit already does some checks for that. Will have to test this again at some point, and also check for new click types and changes to the inventory actions.

    Edit: Regarding the item matching used for trading (and a few other things in minecraft): Minecraft uses the logic implemented in GameProfileSerializer.a(NBTBase, NBTBase, boolean) for that. I created a PR to expose that matching logic in Bukkit back then, but it never (so far) made it into it.

    Edit: There is an old PR for Paper for an event like this, but it doesn't support custom plugin-created merchants.
     
    #10 blablubbabc, Jan 8, 2020
    Last edited: Jan 8, 2020
    • Useful Useful x 1
  11. This is super cool, you obviously put a ton of effort into it and worked very hard on it. I can't think of a use for this at the moment for me but I am sure a lot of people will find this very useful. Thanks for sharing!
     
    • Friendly Friendly x 1
  12. Looks great! Looking through the code it looks like you can only access the discount price for ingredient one, but not ingredient two.
     
  13. Correct, this is because ingredient two is never granted a discount by the game.
     
  14. I'm trying to use this custom Event but nothing appear on my @EventHandler code :/

    EDIT: Found how to ! But not working with custom merchants like player.openMerchant(merchant, true);
     
    #14 tutur1004, Apr 26, 2020
    Last edited: Apr 26, 2020
  15. Does this event work in 1.15.2? It claims to have a missing method called "clamp"?

    Also the getPurchaseAmount method doesn't account for shift click trades
     
    #15 Swiftlicious, May 29, 2020
    Last edited: May 29, 2020
  16. Work on 1.15.2 but only with vanilla villagers, custom trade inv doesn't work
     
  17. @_Ross__ if the result item is over 1 then the total amount purchased is incorrect. Idk if you still update this resource or not but it would be great. An example is the 1 emerald for 4 glass trade from a librarian will result in 12 glass purchased when I get an actual total of 48 when shift purchasing.