Compare commits

...
1 Commits
Author SHA1 Message Date
dmg a637e4090c fix(training): let projectiles hit dummies
Release / release (push) Successful in 11m2s
2026-09-22 14:04:03 -04:00
2 changed files with 98 additions and 9 deletions

No files matched your search

@@ -28,6 +28,7 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
private final NamespacedKey idKey;
private final NamespacedKey typeKey;
private final java.util.Set<Entity> relocating = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
private final java.util.Set<Zombie> loaded = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
private Attempt pending;
private boolean closed;
@@ -64,14 +65,18 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
for (var entity : chunk.getEntities()) {
if (!tagged(entity)) continue;
UUID id = id(entity); var target = id == null ? null : desired.get(id);
if (!(entity instanceof Zombie zombie) || target == null || !retained.add(id)) { entity.remove(); continue; }
if (!(entity instanceof Zombie zombie) || target == null || !retained.add(id)) {
entity.remove();
if (entity instanceof Zombie zombie) loaded.remove(zombie);
continue;
}
configure(zombie, target);
var current = zombie.getLocation(); var expected = location(target);
if (current.distanceSquared(expected) > 0.000001 || Math.abs(current.getYaw() - expected.getYaw()) > 0.001
|| Math.abs(current.getPitch() - expected.getPitch()) > 0.001) {
relocating.add(zombie); boolean moved;
try { moved = zombie.teleport(expected); } finally { relocating.remove(zombie); }
if (!moved) { zombie.remove(); spawn(target); }
if (!moved) { zombie.remove(); loaded.remove(zombie); spawn(target); }
}
}
for (var value : desired.values()) if (!retained.contains(value.id())) spawn(value);
@@ -80,7 +85,10 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
public void removeLoaded(UUID id) {
for (var world : plugin.getServer().getWorlds()) {
var entities = world.getEntities(); if (entities != null) for (var entity : entities) {
if (tagged(entity) && id.equals(id(entity))) entity.remove();
if (tagged(entity) && id.equals(id(entity))) {
entity.remove();
if (entity instanceof Zombie zombie) loaded.remove(zombie);
}
}
}
}
@@ -111,8 +119,10 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
private void configure(Zombie zombie, TrainingDummy value) {
zombie.getPersistentDataContainer().set(typeKey, PersistentDataType.STRING, TYPE);
zombie.getPersistentDataContainer().set(idKey, PersistentDataType.STRING, value.id().toString());
loaded.add(zombie);
zombie.setAI(false); zombie.setAware(false); zombie.setGravity(false); zombie.setSilent(true);
zombie.setInvulnerable(false); zombie.setCollidable(false); zombie.setCanPickupItems(false);
zombie.setInvulnerable(false); zombie.setCollidable(true); refreshPushExemptions(zombie);
zombie.setCanPickupItems(false);
zombie.setLeashHolder(null); zombie.setRemoveWhenFarAway(false); zombie.setPersistent(true);
zombie.setShouldBurnInDay(false); zombie.setBaby(false); zombie.setCanBreakDoors(false);
zombie.setFireTicks(0); zombie.setFreezeTicks(0); zombie.setVelocity(new org.bukkit.util.Vector());
@@ -121,6 +131,29 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
zombie.setCustomNameVisible(true);
}
private void refreshPushExemptions(Zombie dummy) {
var exemptions = dummy.getCollidableExemptions();
exemptions.clear();
var world = dummy.getLocation().getWorld();
if (world == null) return;
var entities = world.getEntities();
if (entities == null) return;
for (var entity : entities) {
if (entity != dummy && entity instanceof LivingEntity) exemptions.add(entity.getUniqueId());
}
}
private void updatePushExemption(Entity entity, boolean present) {
if (!(entity instanceof LivingEntity)) return;
var world = entity.getWorld();
if (world == null) return;
for (var dummy : java.util.List.copyOf(loaded)) {
if (dummy == entity || !world.equals(dummy.getWorld()) || !managed(dummy)) continue;
if (present) dummy.getCollidableExemptions().add(entity.getUniqueId());
else dummy.getCollidableExemptions().remove(entity.getUniqueId());
}
}
private Location location(TrainingDummy value) {
return new Location(plugin.getServer().getWorld(value.world()), value.x(), value.y(), value.z(), value.yaw(), value.pitch());
}
@@ -180,9 +213,18 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
}
@EventHandler(priority = EventPriority.MONITOR)
public void chunk(org.bukkit.event.world.ChunkLoadEvent event) { reconcile(event.getChunk()); }
@EventHandler(priority = EventPriority.MONITOR)
public void entityAdded(com.destroystokyo.paper.event.entity.EntityAddToWorldEvent event) {
updatePushExemption(event.getEntity(), true);
}
@EventHandler(priority = EventPriority.MONITOR)
public void entityRemoved(com.destroystokyo.paper.event.entity.EntityRemoveFromWorldEvent event) {
if (event.getEntity() instanceof Zombie zombie) loaded.remove(zombie);
updatePushExemption(event.getEntity(), false);
}
@Override public void close() {
closed = true; pending = null;
closed = true; pending = null; loaded.clear();
for (var world : plugin.getServer().getWorlds()) {
var entities = world.getEntities(); if (entities != null) for (var entity : entities) if (tagged(entity)) entity.remove();
}
@@ -44,7 +44,10 @@ class TrainingDummyNativeTest {
var chunk = mock(Chunk.class); when(chunk.getWorld()).thenReturn(admin.world);
when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(z); when(chunk.getEntities()).thenReturn(new Entity[0]);
when(admin.world.getChunkAt(x, z)).thenReturn(chunk); when(admin.world.isChunkLoaded(x, z)).thenReturn(true);
when(admin.world.getEntities()).thenReturn(List.of());
var pusher = zombie(admin.core.location.clone().add(1, 0, 0));
var projectile = mock(org.bukkit.entity.Arrow.class);
when(projectile.getUniqueId()).thenReturn(UUID.randomUUID());
when(admin.world.getEntities()).thenReturn(List.of(admin.player, pusher, projectile));
var zombie = zombie(admin.core.location);
doAnswer(call -> { ((Consumer<Zombie>) call.getArgument(2)).accept(zombie); return zombie; })
.when(admin.world).spawn(any(Location.class), eq(Zombie.class), any(Consumer.class));
@@ -59,12 +62,22 @@ class TrainingDummyNativeTest {
Runnable callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
var directory = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
var saved = directory.all().getFirst(); assertEquals("Trial Target", saved.name());
verify(zombie).setAI(false); verify(zombie).setGravity(false); verify(zombie).setCollidable(false);
verify(zombie).setAI(false); verify(zombie).setGravity(false); verify(zombie).setCollidable(true);
assertTrue(zombie.getCollidableExemptions().contains(admin.core.id));
assertTrue(zombie.getCollidableExemptions().contains(pusher.getUniqueId()));
assertFalse(zombie.getCollidableExemptions().contains(projectile.getUniqueId()),
"Projectile identities must retain collision with the dummy hit surface");
verify(zombie).setCanPickupItems(false); verify(zombie).setRemoveWhenFarAway(false); verify(zombie).setPersistent(true);
verify(zombie).setShouldBurnInDay(false); verify(zombie).setBaby(false);
verify(zombie).customName(Component.text("Trial Target")); verify(zombie).setCustomNameVisible(true);
var controller = (TrainingDummyController) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummyController");
assertTrue(controller.tagged(zombie));
var latePusher = zombie(admin.core.location.clone().add(2, 0, 0));
when(admin.world.getEntities()).thenReturn(List.of(zombie, latePusher));
controller.entityAdded(new com.destroystokyo.paper.event.entity.EntityAddToWorldEvent(latePusher, admin.world));
assertTrue(zombie.getCollidableExemptions().contains(latePusher.getUniqueId()));
controller.entityRemoved(new com.destroystokyo.paper.event.entity.EntityRemoveFromWorldEvent(latePusher, admin.world));
assertFalse(zombie.getCollidableExemptions().contains(latePusher.getUniqueId()));
assertFalse(((games.dmg.fantasystick.party.PartyRegistrarController)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "partyRegistrarController")).tagged(zombie));
assertFalse(((games.dmg.fantasystick.shop.FantasyArmorerController)
@@ -222,11 +235,45 @@ class TrainingDummyNativeTest {
}
}
@Test void pinnedNativeProjectileSweepRequiresTheDummyPickableHitSurface() {
var level = mock(net.minecraft.world.level.Level.class);
var projectile = mock(net.minecraft.world.entity.Entity.class);
when(projectile.level()).thenReturn(level); when(projectile.getRootVehicle()).thenReturn(projectile);
var dummy = mock(net.minecraft.world.entity.monster.zombie.Zombie.class, CALLS_REAL_METHODS);
doReturn(false).when(dummy).isRemoved();
doReturn(new net.minecraft.world.phys.AABB(1, 0, 0, 2, 2, 1)).when(dummy).getBoundingBox();
doReturn(0f).when(dummy).getPickRadius(); doReturn(dummy).when(dummy).getRootVehicle();
when(level.getEntities(same(projectile), any(net.minecraft.world.phys.AABB.class), any())).thenAnswer(call -> {
@SuppressWarnings("unchecked")
var predicate = (java.util.function.Predicate<net.minecraft.world.entity.Entity>) call.getArgument(2);
return predicate.test(dummy) ? List.of(dummy) : List.of();
});
var start = new net.minecraft.world.phys.Vec3(0, 1, 0.5);
var end = new net.minecraft.world.phys.Vec3(3, 1, 0.5);
var search = new net.minecraft.world.phys.AABB(0, 0, 0, 3, 2, 1);
dummy.collides = false;
assertFalse(dummy.isPickable());
assertNull(net.minecraft.world.entity.projectile.ProjectileUtil.getEntityHitResult(
projectile, start, end, search, net.minecraft.world.entity.Entity::isPickable, 9));
dummy.collides = true;
assertTrue(dummy.isPickable());
assertSame(dummy, net.minecraft.world.entity.projectile.ProjectileUtil.getEntityHitResult(
projectile, start, end, search, net.minecraft.world.entity.Entity::isPickable, 9).getEntity());
var pusher = mock(net.minecraft.world.entity.Entity.class);
var pusherId = UUID.randomUUID(); when(pusher.getUUID()).thenReturn(pusherId);
doReturn(true).when(dummy).isPushable(); dummy.collidableExemptions = new java.util.HashSet<>();
assertTrue(dummy.canCollideWithBukkit(pusher));
dummy.collidableExemptions.add(pusherId);
assertFalse(dummy.canCollideWithBukkit(pusher),
"A living-entity exemption suppresses body pushing without removing projectile pickability");
}
static Zombie zombie(Location location) {
var zombie = mock(Zombie.class); when(zombie.getUniqueId()).thenReturn(UUID.randomUUID());
when(zombie.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE); when(zombie.getName()).thenReturn("Training Dummy");
when(zombie.getLocation()).thenReturn(location.clone()); when(zombie.getHealth()).thenReturn(20d);
when(zombie.getAbsorptionAmount()).thenReturn(0d);
when(zombie.getLocation()).thenReturn(location.clone()); when(zombie.getWorld()).thenReturn(location.getWorld());
when(zombie.getHealth()).thenReturn(20d); when(zombie.getAbsorptionAmount()).thenReturn(0d);
when(zombie.getCollidableExemptions()).thenReturn(new java.util.HashSet<>());
Map<String, String> values = new HashMap<>(); var pdc = mock(PersistentDataContainer.class);
when(zombie.getPersistentDataContainer()).thenReturn(pdc);
doAnswer(call -> { values.put(((NamespacedKey) call.getArgument(0)).getKey(), call.getArgument(2)); return null; })