diff --git a/src/main/java/xyz/nucleoid/stimuli/event/entity/EntityLeashEvent.java b/src/main/java/xyz/nucleoid/stimuli/event/entity/EntityLeashEvent.java
new file mode 100644
index 0000000..d1cb56e
--- /dev/null
+++ b/src/main/java/xyz/nucleoid/stimuli/event/entity/EntityLeashEvent.java
@@ -0,0 +1,41 @@
+package xyz.nucleoid.stimuli.event.entity;
+
+import net.minecraft.entity.Entity;
+import net.minecraft.server.network.ServerPlayerEntity;
+import net.minecraft.util.ActionResult;
+import net.minecraft.util.Hand;
+import net.minecraft.util.math.BlockPos;
+import org.jetbrains.annotations.Nullable;
+import xyz.nucleoid.stimuli.event.StimulusEvent;
+
+public class EntityLeashEvent {
+ /**
+ * Called when a leash is attached to either a holding {@link Entity} or a {@link BlockPos}.
+ *
+ *
A leash holder can be either a player or an existing leash knot.
+ * A leash position may or may not contain a leash knot yet.
+ *
+ *
Upon return:
+ *
+ * - {@link ActionResult#SUCCESS} cancels further handlers and allows the leash to be attached.
+ *
- {@link ActionResult#FAIL} cancels further handlers and prevents the leash from being attached.
+ *
- {@link ActionResult#PASS} moves on to the next listener.
+ */
+ public static final StimulusEvent ATTACH = StimulusEvent.create(Attach.class, ctx -> (entity, leashHolder, leashPos, player, hand) -> {
+ try {
+ for (var listener : ctx.getListeners()) {
+ var result = listener.onAttachLeash(entity, leashHolder, leashPos, player, hand);
+ if (result != ActionResult.PASS) {
+ return result;
+ }
+ }
+ } catch (Throwable t) {
+ ctx.handleException(t);
+ }
+ return ActionResult.PASS;
+ });
+
+ public interface Attach {
+ ActionResult onAttachLeash(Entity entity, @Nullable Entity leashHolder, @Nullable BlockPos leashPos, ServerPlayerEntity player, @Nullable Hand hand);
+ }
+}
diff --git a/src/main/java/xyz/nucleoid/stimuli/mixin/entity/EntityMixin.java b/src/main/java/xyz/nucleoid/stimuli/mixin/entity/EntityMixin.java
new file mode 100644
index 0000000..e11a8ef
--- /dev/null
+++ b/src/main/java/xyz/nucleoid/stimuli/mixin/entity/EntityMixin.java
@@ -0,0 +1,56 @@
+package xyz.nucleoid.stimuli.mixin.entity;
+
+import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
+import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
+import com.llamalad7.mixinextras.sugar.Local;
+import net.minecraft.entity.Entity;
+import net.minecraft.entity.Leashable;
+import net.minecraft.entity.player.PlayerEntity;
+import net.minecraft.network.packet.s2c.play.ScreenHandlerSlotUpdateS2CPacket;
+import net.minecraft.server.network.ServerPlayerEntity;
+import net.minecraft.util.ActionResult;
+import net.minecraft.util.Hand;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import xyz.nucleoid.stimuli.Stimuli;
+import xyz.nucleoid.stimuli.event.entity.EntityLeashEvent;
+
+@Mixin(Entity.class)
+public class EntityMixin {
+ @WrapOperation(
+ method = "interact",
+ at = @At(
+ value = "INVOKE",
+ target = "Lnet/minecraft/entity/Leashable;canLeashAttachTo()Z"
+ )
+ )
+ private static boolean onLeashAttach(Leashable leashable, Operation original, @Local(argsOnly = true) PlayerEntity player, @Local(argsOnly = true) Hand hand) {
+ if (!original.call(leashable)) {
+ return false;
+ }
+
+ // Leashable instance is the same as this entity
+ var entity = (Entity) leashable;
+
+ if (!entity.getWorld().isClient()) {
+ var serverPlayer = (ServerPlayerEntity) player;
+
+ var events = Stimuli.select();
+
+ try (var invokers = events.forEntity(serverPlayer)) {
+ var result = invokers.get(EntityLeashEvent.ATTACH).onAttachLeash(entity, serverPlayer, null, serverPlayer, hand);
+ if (result == ActionResult.FAIL) {
+ var stack = player.getStackInHand(hand);
+
+ // notify the client that this action did not go through
+ int slot = hand == Hand.MAIN_HAND ? serverPlayer.getInventory().selectedSlot : 40;
+ serverPlayer.networkHandler.sendPacket(new ScreenHandlerSlotUpdateS2CPacket(ScreenHandlerSlotUpdateS2CPacket.UPDATE_PLAYER_INVENTORY_SYNC_ID, 0, slot, stack));
+
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/main/java/xyz/nucleoid/stimuli/mixin/entity/LeadItemMixin.java b/src/main/java/xyz/nucleoid/stimuli/mixin/entity/LeadItemMixin.java
new file mode 100644
index 0000000..d6f8d35
--- /dev/null
+++ b/src/main/java/xyz/nucleoid/stimuli/mixin/entity/LeadItemMixin.java
@@ -0,0 +1,51 @@
+package xyz.nucleoid.stimuli.mixin.entity;
+
+import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
+import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
+import com.llamalad7.mixinextras.sugar.Local;
+import net.minecraft.entity.Entity;
+import net.minecraft.entity.Leashable;
+import net.minecraft.entity.player.PlayerEntity;
+import net.minecraft.item.LeadItem;
+import net.minecraft.server.network.ServerPlayerEntity;
+import net.minecraft.util.ActionResult;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.world.World;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import xyz.nucleoid.stimuli.Stimuli;
+import xyz.nucleoid.stimuli.event.entity.EntityLeashEvent;
+
+import java.util.List;
+import java.util.function.Predicate;
+
+@Mixin(LeadItem.class)
+public class LeadItemMixin {
+ @WrapOperation(
+ method = "attachHeldMobsToBlock",
+ at = @At(
+ value = "INVOKE",
+ target = "Lnet/minecraft/item/LeadItem;collectLeashablesAround(Lnet/minecraft/world/World;Lnet/minecraft/util/math/BlockPos;Ljava/util/function/Predicate;)Ljava/util/List;"
+ )
+ )
+ private static List onLeashAttachToBlock(World world, BlockPos pos, Predicate predicate, Operation> original, @Local(argsOnly = true) PlayerEntity player) {
+ return original.call(world, pos, predicate).stream()
+ .filter(leashable -> {
+ // Leashable is a filtered Entity instance
+ var entity = (Entity) leashable;
+ var serverPlayer = (ServerPlayerEntity) player;
+
+ var events = Stimuli.select();
+
+ try (var invokers = events.forEntity(serverPlayer)) {
+ var result = invokers.get(EntityLeashEvent.ATTACH).onAttachLeash(entity, null, pos, serverPlayer, null);
+ if (result == ActionResult.FAIL) {
+ return false;
+ }
+ }
+
+ return true;
+ })
+ .toList();
+ }
+}
diff --git a/src/main/java/xyz/nucleoid/stimuli/mixin/entity/LeashKnotEntityMixin.java b/src/main/java/xyz/nucleoid/stimuli/mixin/entity/LeashKnotEntityMixin.java
new file mode 100644
index 0000000..09ba126
--- /dev/null
+++ b/src/main/java/xyz/nucleoid/stimuli/mixin/entity/LeashKnotEntityMixin.java
@@ -0,0 +1,53 @@
+package xyz.nucleoid.stimuli.mixin.entity;
+
+import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
+import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
+import com.llamalad7.mixinextras.sugar.Local;
+import com.llamalad7.mixinextras.sugar.Share;
+import com.llamalad7.mixinextras.sugar.ref.LocalBooleanRef;
+import net.minecraft.entity.Entity;
+import net.minecraft.entity.Leashable;
+import net.minecraft.entity.decoration.LeashKnotEntity;
+import net.minecraft.entity.player.PlayerEntity;
+import net.minecraft.server.network.ServerPlayerEntity;
+import net.minecraft.util.ActionResult;
+import net.minecraft.util.Hand;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import xyz.nucleoid.stimuli.Stimuli;
+import xyz.nucleoid.stimuli.event.entity.EntityLeashEvent;
+
+@Mixin(LeashKnotEntity.class)
+public class LeashKnotEntityMixin {
+ @WrapOperation(
+ method = "interact",
+ at = @At(
+ value = "INVOKE",
+ target = "Lnet/minecraft/entity/Leashable;getLeashHolder()Lnet/minecraft/entity/Entity;",
+ ordinal = 0
+ )
+ )
+ private Entity onLeashAttachToKnot(Leashable leashable, Operation original, @Local(argsOnly = true) PlayerEntity player, @Local(argsOnly = true) Hand hand, @Share("attached") LocalBooleanRef attached) {
+ // Leashable is a filtered Entity instance
+ var entity = (Entity) leashable;
+
+ var leashHolder = (Entity) (Object) this;
+ var currentHolder = original.call(leashable);
+
+ // Vanilla reattaches leashes already attached to this knot, so these cases must be filtered out
+ if (leashHolder != currentHolder) {
+ var serverPlayer = (ServerPlayerEntity) player;
+
+ var events = Stimuli.select();
+
+ try (var invokers = events.forEntity(serverPlayer)) {
+ var result = invokers.get(EntityLeashEvent.ATTACH).onAttachLeash(entity, leashHolder, null, serverPlayer, hand);
+ if (result == ActionResult.FAIL) {
+ return null;
+ }
+ }
+ }
+
+ return currentHolder;
+ }
+}
diff --git a/src/main/resources/stimuli.mixins.json b/src/main/resources/stimuli.mixins.json
index d824846..6478e5c 100644
--- a/src/main/resources/stimuli.mixins.json
+++ b/src/main/resources/stimuli.mixins.json
@@ -16,6 +16,9 @@
"block.FarmlandBlockMixin",
"block.FlowerPotBlockMixin",
"block.TurtleEggBlockMixin",
+ "entity.EntityMixin",
+ "entity.LeadItemMixin",
+ "entity.LeashKnotEntityMixin",
"entity.LivingEntityMixin",
"entity.ShearableEntityMixin",
"entity.ShearsDispenserBehaviorMixin",
diff --git a/src/testmod/java/xyz/nucleoid/stimuli/test/StimuliInitializer.java b/src/testmod/java/xyz/nucleoid/stimuli/test/StimuliInitializer.java
index 0e7464d..6b1c804 100644
--- a/src/testmod/java/xyz/nucleoid/stimuli/test/StimuliInitializer.java
+++ b/src/testmod/java/xyz/nucleoid/stimuli/test/StimuliInitializer.java
@@ -10,6 +10,7 @@
import org.slf4j.Logger;
import xyz.nucleoid.stimuli.Stimuli;
import xyz.nucleoid.stimuli.event.block.FlowerPotModifyEvent;
+import xyz.nucleoid.stimuli.event.entity.EntityLeashEvent;
import xyz.nucleoid.stimuli.event.entity.EntityShearEvent;
import xyz.nucleoid.stimuli.event.projectile.ArrowFireEvent;
import xyz.nucleoid.stimuli.event.world.ExplosionDetonatedEvent;
@@ -58,5 +59,31 @@ public void onInitialize() {
Stimuli.global().listen(ExplosionDetonatedEvent.EVENT, (explosion, particles) -> {
server.sendMessage(Text.literal("ExplosionDetonatedEvent: " + explosion.getDestructionType().name()));
});
+ Stimuli.global().listen(EntityLeashEvent.ATTACH, (entity, leashHolder, leashPos, player, hand) -> {
+ var message = Text.literal("EntityLeashEvent.Attach: ")
+ .append(entity.getName());
+
+ if (leashHolder != null) {
+ message
+ .append(" to ")
+ .append(leashHolder.getName());
+ }
+
+ if (hand != null) {
+ message.append(" with " + hand);
+ }
+
+ if (leashPos != null) {
+ message.append(" at " + leashPos);
+ }
+
+ if (player == null) {
+ server.sendMessage(message);
+ } else {
+ player.sendMessage(message);
+ }
+
+ return result;
+ });
}
}