--- a/net/minecraft/world/entity/npc/AbstractVillager.java
+++ b/net/minecraft/world/entity/npc/AbstractVillager.java
@@ -2,13 +2,16 @@
 
 import com.google.common.collect.Lists;
 import com.mojang.logging.LogUtils;
+import com.mojang.serialization.DataResult;
 import java.util.ArrayList;
+import java.util.Objects;
 import javax.annotation.Nullable;
 import net.minecraft.Util;
 import net.minecraft.advancements.CriteriaTriggers;
 import net.minecraft.core.particles.ParticleOptions;
 import net.minecraft.nbt.CompoundTag;
 import net.minecraft.nbt.NbtOps;
+import net.minecraft.nbt.Tag;
 import net.minecraft.network.syncher.EntityDataAccessor;
 import net.minecraft.network.syncher.EntityDataSerializers;
 import net.minecraft.network.syncher.SynchedEntityData;
@@ -37,7 +40,23 @@
 import net.minecraft.world.phys.Vec3;
 import org.slf4j.Logger;
 
+// CraftBukkit start
+import org.bukkit.Bukkit;
+import org.bukkit.craftbukkit.inventory.CraftMerchant;
+import org.bukkit.craftbukkit.inventory.CraftMerchantRecipe;
+import org.bukkit.event.entity.VillagerAcquireTradeEvent;
+// CraftBukkit end
+
 public abstract class AbstractVillager extends AgeableMob implements InventoryCarrier, Npc, Merchant {
+
+    // CraftBukkit start
+    private CraftMerchant craftMerchant;
+
+    @Override
+    public CraftMerchant getCraftMerchant() {
+        return (this.craftMerchant == null) ? this.craftMerchant = new CraftMerchant(this) : this.craftMerchant;
+    }
+    // CraftBukkit end
     private static final EntityDataAccessor<Integer> DATA_UNHAPPY_COUNTER = SynchedEntityData.defineId(AbstractVillager.class, EntityDataSerializers.INT);
     private static final Logger LOGGER = LogUtils.getLogger();
     public static final int VILLAGER_SLOT_OFFSET = 300;
@@ -46,7 +65,7 @@
     private Player tradingPlayer;
     @Nullable
     protected MerchantOffers offers;
-    private final SimpleContainer inventory = new SimpleContainer(8);
+    private final SimpleContainer inventory = new SimpleContainer(8, (org.bukkit.craftbukkit.entity.CraftAbstractVillager) this.getBukkitEntity()); // CraftBukkit add argument
 
     public AbstractVillager(EntityType<? extends AbstractVillager> type, Level world) {
         super(type, world);
@@ -60,15 +79,15 @@
             entityData = new AgeableMob.AgeableMobGroupData(false);
         }
 
-        return super.finalizeSpawn(world, difficulty, spawnReason, entityData);
+        return super.finalizeSpawn(world, difficulty, spawnReason, (SpawnGroupData) entityData);
     }
 
     public int getUnhappyCounter() {
-        return this.entityData.get(DATA_UNHAPPY_COUNTER);
+        return (Integer) this.entityData.get(AbstractVillager.DATA_UNHAPPY_COUNTER);
     }
 
     public void setUnhappyCounter(int ticks) {
-        this.entityData.set(DATA_UNHAPPY_COUNTER, ticks);
+        this.entityData.set(AbstractVillager.DATA_UNHAPPY_COUNTER, ticks);
     }
 
     @Override
@@ -79,7 +98,7 @@
     @Override
     protected void defineSynchedData(SynchedEntityData.Builder builder) {
         super.defineSynchedData(builder);
-        builder.define(DATA_UNHAPPY_COUNTER, 0);
+        builder.define(AbstractVillager.DATA_UNHAPPY_COUNTER, 0);
     }
 
     @Override
@@ -97,6 +116,13 @@
         return this.tradingPlayer != null;
     }
 
+    // Paper start - Villager#resetOffers
+    public void resetOffers() {
+        this.offers = new MerchantOffers();
+        this.updateTrades();
+    }
+    // Paper end - Villager#resetOffers
+
     @Override
     public MerchantOffers getOffers() {
         if (this.level().isClientSide) {
@@ -112,21 +138,33 @@
     }
 
     @Override
-    public void overrideOffers(@Nullable MerchantOffers offers) {
-    }
+    public void overrideOffers(@Nullable MerchantOffers offers) {}
 
     @Override
-    public void overrideXp(int experience) {
+    public void overrideXp(int experience) {}
+
+    // Paper start - Add PlayerTradeEvent and PlayerPurchaseEvent
+    @Override
+    public void processTrade(MerchantOffer recipe, @Nullable io.papermc.paper.event.player.PlayerPurchaseEvent event) { // The MerchantRecipe passed in here is the one set by the PlayerPurchaseEvent
+        if (event == null || event.willIncreaseTradeUses()) {
+            recipe.increaseUses();
+        }
+        if (event == null || event.isRewardingExp()) {
+            this.rewardTradeXp(recipe);
+        }
+        this.notifyTrade(recipe);
     }
+    // Paper end - Add PlayerTradeEvent and PlayerPurchaseEvent
 
     @Override
     public void notifyTrade(MerchantOffer offer) {
-        offer.increaseUses();
+        // offer.increaseUses(); // Paper - Add PlayerTradeEvent and PlayerPurchaseEvent
         this.ambientSoundTime = -this.getAmbientSoundInterval();
-        this.rewardTradeXp(offer);
+        // this.rewardTradeXp(offer); // Paper - Add PlayerTradeEvent and PlayerPurchaseEvent
         if (this.tradingPlayer instanceof ServerPlayer) {
-            CriteriaTriggers.TRADE.trigger((ServerPlayer)this.tradingPlayer, this, offer.getResult());
+            CriteriaTriggers.TRADE.trigger((ServerPlayer) this.tradingPlayer, this, offer.getResult());
         }
+
     }
 
     protected abstract void rewardTradeXp(MerchantOffer offer);
@@ -142,6 +180,7 @@
             this.ambientSoundTime = -this.getAmbientSoundInterval();
             this.makeSound(this.getTradeUpdatedSound(!stack.isEmpty()));
         }
+
     }
 
     @Override
@@ -161,11 +200,10 @@
     public void addAdditionalSaveData(CompoundTag nbt) {
         super.addAdditionalSaveData(nbt);
         if (!this.level().isClientSide) {
-            MerchantOffers merchantOffers = this.getOffers();
-            if (!merchantOffers.isEmpty()) {
-                nbt.put(
-                    "Offers", MerchantOffers.CODEC.encodeStart(this.registryAccess().createSerializationContext(NbtOps.INSTANCE), merchantOffers).getOrThrow()
-                );
+            MerchantOffers merchantrecipelist = this.getOffers();
+
+            if (!merchantrecipelist.isEmpty()) {
+                nbt.put("Offers", (Tag) MerchantOffers.CODEC.encodeStart(this.registryAccess().createSerializationContext(NbtOps.INSTANCE), merchantrecipelist).getOrThrow());
             }
         }
 
@@ -176,10 +214,13 @@
     public void readAdditionalSaveData(CompoundTag nbt) {
         super.readAdditionalSaveData(nbt);
         if (nbt.contains("Offers")) {
-            MerchantOffers.CODEC
-                .parse(this.registryAccess().createSerializationContext(NbtOps.INSTANCE), nbt.get("Offers"))
-                .resultOrPartial(Util.prefix("Failed to load offers: ", LOGGER::warn))
-                .ifPresent(offers -> this.offers = offers);
+            DataResult<MerchantOffers> dataresult = MerchantOffers.CODEC.parse(this.registryAccess().createSerializationContext(NbtOps.INSTANCE), nbt.get("Offers")); // CraftBukkit - decompile error
+            Logger logger = AbstractVillager.LOGGER;
+
+            Objects.requireNonNull(logger);
+            dataresult.resultOrPartial(Util.prefix("Failed to load offers: ", logger::warn)).ifPresent((merchantrecipelist) -> {
+                this.offers = merchantrecipelist;
+            });
         }
 
         this.readInventoryFromTag(nbt, this.registryAccess());
@@ -193,7 +234,7 @@
     }
 
     protected void stopTrading() {
-        this.setTradingPlayer(null);
+        this.setTradingPlayer((Player) null);
     }
 
     @Override
@@ -203,12 +244,14 @@
     }
 
     protected void addParticlesAroundSelf(ParticleOptions parameters) {
-        for (int i = 0; i < 5; i++) {
-            double d = this.random.nextGaussian() * 0.02;
-            double e = this.random.nextGaussian() * 0.02;
-            double f = this.random.nextGaussian() * 0.02;
-            this.level().addParticle(parameters, this.getRandomX(1.0), this.getRandomY() + 1.0, this.getRandomZ(1.0), d, e, f);
+        for (int i = 0; i < 5; ++i) {
+            double d0 = this.random.nextGaussian() * 0.02D;
+            double d1 = this.random.nextGaussian() * 0.02D;
+            double d2 = this.random.nextGaussian() * 0.02D;
+
+            this.level().addParticle(parameters, this.getRandomX(1.0D), this.getRandomY() + 1.0D, this.getRandomZ(1.0D), d0, d1, d2);
         }
+
     }
 
     @Override
@@ -223,30 +266,47 @@
 
     @Override
     public SlotAccess getSlot(int mappedIndex) {
-        int i = mappedIndex - 300;
-        return i >= 0 && i < this.inventory.getContainerSize() ? SlotAccess.forContainer(this.inventory, i) : super.getSlot(mappedIndex);
+        int j = mappedIndex - 300;
+
+        return j >= 0 && j < this.inventory.getContainerSize() ? SlotAccess.forContainer(this.inventory, j) : super.getSlot(mappedIndex);
     }
 
     protected abstract void updateTrades();
 
     protected void addOffersFromItemListings(MerchantOffers recipeList, VillagerTrades.ItemListing[] pool, int count) {
-        ArrayList<VillagerTrades.ItemListing> arrayList = Lists.newArrayList(pool);
-        int i = 0;
+        ArrayList<VillagerTrades.ItemListing> arraylist = Lists.newArrayList(pool);
+        int j = 0;
 
-        while (i < count && !arrayList.isEmpty()) {
-            MerchantOffer merchantOffer = arrayList.remove(this.random.nextInt(arrayList.size())).getOffer(this, this.random);
-            if (merchantOffer != null) {
-                recipeList.add(merchantOffer);
-                i++;
+        while (j < count && !arraylist.isEmpty()) {
+            MerchantOffer merchantrecipe = ((VillagerTrades.ItemListing) arraylist.remove(this.random.nextInt(arraylist.size()))).getOffer(this, this.random);
+
+            if (merchantrecipe != null) {
+                // CraftBukkit start
+                VillagerAcquireTradeEvent event = new VillagerAcquireTradeEvent((org.bukkit.entity.AbstractVillager) this.getBukkitEntity(), merchantrecipe.asBukkit());
+                // Suppress during worldgen
+                if (this.valid) {
+                    Bukkit.getPluginManager().callEvent(event);
+                }
+                if (!event.isCancelled()) {
+                    // Paper start - Fix crash from invalid ingredient list
+                    final CraftMerchantRecipe craftMerchantRecipe = CraftMerchantRecipe.fromBukkit(event.getRecipe());
+                    if (craftMerchantRecipe.getIngredients().isEmpty()) return;
+                    recipeList.add(craftMerchantRecipe.toMinecraft());
+                    // Paper end - Fix crash from invalid ingredient list
+                }
+                // CraftBukkit end
+                ++j;
             }
         }
+
     }
 
     @Override
     public Vec3 getRopeHoldPosition(float delta) {
-        float f = Mth.lerp(delta, this.yBodyRotO, this.yBodyRot) * (float) (Math.PI / 180.0);
-        Vec3 vec3 = new Vec3(0.0, this.getBoundingBox().getYsize() - 1.0, 0.2);
-        return this.getPosition(delta).add(vec3.yRot(-f));
+        float f1 = Mth.lerp(delta, this.yBodyRotO, this.yBodyRot) * 0.017453292F;
+        Vec3 vec3d = new Vec3(0.0D, this.getBoundingBox().getYsize() - 1.0D, 0.2D);
+
+        return this.getPosition(delta).add(vec3d.yRot(-f1));
     }
 
     @Override
