From d6d9be036b38a83c8cd5f9c21d4d4d15017a60ec Mon Sep 17 00:00:00 2001 From: BriceFab Date: Mon, 12 Oct 2026 20:49:27 +0200 Subject: [PATCH] Add opt-in classic combat physics and off-hand eligibility --- .../java/org/bukkit/entity/AbstractArrow.java | 23 + .../main/java/org/bukkit/entity/FishHook.java | 11 + .../main/java/org/bukkit/entity/Player.java | 40 ++ .../java/org/bukkit/entity/Projectile.java | 11 + .../bukkit/entity/ThrowableProjectile.java | 11 + .../0001-Moonrise-optimisation-patches.patch | 14 +- .../0003-Entity-Activation-Range-2.0.patch | 10 +- ...er-desync-when-new-players-are-added.patch | 4 +- .../server/level/ServerEntity.java.patch | 13 +- .../server/level/ServerPlayer.java.patch | 9 +- .../minecraft/world/entity/Entity.java.patch | 9 + .../world/entity/LivingEntity.java.patch | 169 ++++++- .../world/entity/ai/goal/TemptGoal.java.patch | 17 +- .../ai/sensing/TemptingSensor.java.patch | 18 +- .../world/entity/player/Inventory.java.patch | 9 + .../world/entity/player/Player.java.patch | 40 +- .../entity/projectile/FishingHook.java.patch | 109 +++- .../entity/projectile/Projectile.java.patch | 61 ++- .../projectile/ThrowableProjectile.java.patch | 81 ++- .../projectile/arrow/AbstractArrow.java.patch | 182 ++++++- .../AbstractThrownPotion.java.patch | 9 + .../ThrownExperienceBottle.java.patch | 10 +- .../ThrownSplashPotion.java.patch | 14 +- .../world/inventory/InventoryMenu.java.patch | 12 + .../minecraft/world/item/AxeItem.java.patch | 16 + .../item/ProjectileWeaponItem.java.patch | 17 + .../enchantment/EnchantmentHelper.java.patch | 8 + .../ExplosionDamageCalculator.java.patch | 53 ++ .../world/level/ServerExplosion.java.patch | 15 +- .../level/block/BaseFireBlock.java.patch | 12 +- .../entity/AbstractProjectile.java | 9 + .../entity/CraftAbstractArrow.java | 10 + .../craftbukkit/entity/CraftFishHook.java | 10 + .../craftbukkit/entity/CraftPlayer.java | 20 + .../entity/CraftThrowableProjectile.java | 10 + .../entity/LegacyArrowKnockbackTest.java | 151 ++++++ .../entity/LegacyExplosionPhysicsTest.java | 117 +++++ .../world/entity/LegacyOffHandMenuTest.java | 351 +++++++++++++ .../entity/LegacyPlayerKnockbackTest.java | 471 ++++++++++++++++++ .../entity/LegacyPlayerMovementTest.java | 304 +++++++++++ .../entity/LegacyVelocityTrackerTest.java | 106 ++++ .../entity/ProjectileInitialPositionTest.java | 55 ++ .../projectile/LegacyFishingHookTest.java | 215 ++++++++ .../projectile/LegacyThrowableFlightTest.java | 153 ++++++ .../arrow/LegacyArrowFlightTest.java | 310 ++++++++++++ .../LegacySplashOriginTest.java | 88 ++++ .../support/suite/LegacyCombatTestSuite.java | 13 + 47 files changed, 3330 insertions(+), 70 deletions(-) create mode 100644 paper-server/patches/sources/net/minecraft/world/level/ExplosionDamageCalculator.java.patch create mode 100644 paper-server/src/test/java/net/minecraft/world/entity/LegacyArrowKnockbackTest.java create mode 100644 paper-server/src/test/java/net/minecraft/world/entity/LegacyExplosionPhysicsTest.java create mode 100644 paper-server/src/test/java/net/minecraft/world/entity/LegacyOffHandMenuTest.java create mode 100644 paper-server/src/test/java/net/minecraft/world/entity/LegacyPlayerKnockbackTest.java create mode 100644 paper-server/src/test/java/net/minecraft/world/entity/LegacyPlayerMovementTest.java create mode 100644 paper-server/src/test/java/net/minecraft/world/entity/LegacyVelocityTrackerTest.java create mode 100644 paper-server/src/test/java/net/minecraft/world/entity/ProjectileInitialPositionTest.java create mode 100644 paper-server/src/test/java/net/minecraft/world/entity/projectile/LegacyFishingHookTest.java create mode 100644 paper-server/src/test/java/net/minecraft/world/entity/projectile/LegacyThrowableFlightTest.java create mode 100644 paper-server/src/test/java/net/minecraft/world/entity/projectile/arrow/LegacyArrowFlightTest.java create mode 100644 paper-server/src/test/java/net/minecraft/world/entity/projectile/throwableitemprojectile/LegacySplashOriginTest.java create mode 100644 paper-server/src/test/java/org/bukkit/support/suite/LegacyCombatTestSuite.java diff --git a/paper-api/src/main/java/org/bukkit/entity/AbstractArrow.java b/paper-api/src/main/java/org/bukkit/entity/AbstractArrow.java index 9fb484eee66e..5ed5a4d8d57d 100644 --- a/paper-api/src/main/java/org/bukkit/entity/AbstractArrow.java +++ b/paper-api/src/main/java/org/bukkit/entity/AbstractArrow.java @@ -12,6 +12,29 @@ */ public interface AbstractArrow extends Projectile { + /** + * Enables Minecraft 1.7-style flight for this arrow only. Disabled by default. + * This selects the nearest ray intersection against a constant 0.3-block + * entity margin, permits owner collision from the fifth airborne tick, and + * applies water drag after movement. Native collision, damage, visibility, + * protection and knockback events remain authoritative. + *

+ * Launch position, initial velocity and combat balance remain the caller's + * responsibility. The setting survives chunk unloads; airborne owner-immunity + * age resets on load, as in the legacy implementation. + * Apply before insertion into the world for a complete flight. + * + * @param legacyFlight whether to use legacy flight + */ + void setLegacyFlight(boolean legacyFlight); + + /** + * Returns whether this arrow uses legacy flight. + * + * @return legacy flight enabled + */ + boolean hasLegacyFlight(); + /** * Gets the knockback strength for an arrow, which is the * {@link org.bukkit.enchantments.Enchantment#KNOCKBACK KnockBack} level diff --git a/paper-api/src/main/java/org/bukkit/entity/FishHook.java b/paper-api/src/main/java/org/bukkit/entity/FishHook.java index fc280b419c8c..ec949f93fbe6 100644 --- a/paper-api/src/main/java/org/bukkit/entity/FishHook.java +++ b/paper-api/src/main/java/org/bukkit/entity/FishHook.java @@ -383,4 +383,15 @@ public enum HookState { * {@link org.bukkit.inventory.ItemType#FISHING_ROD} in the specified equipment slot. */ int retrieve(@NotNull EquipmentSlot slot); + /** + * Enables opt-in legacy PvP flight, impact acceptance and reel equations. + * Native fishing loot, bite timers, water buoyancy and protection events remain active. + * + * @param legacyFlight whether the profile is enabled (disabled by default) + */ + void setLegacyFlight(boolean legacyFlight); + + /** @return whether opt-in legacy PvP flight is enabled */ + boolean hasLegacyFlight(); + } diff --git a/paper-api/src/main/java/org/bukkit/entity/Player.java b/paper-api/src/main/java/org/bukkit/entity/Player.java index 65e83e171ab4..49d5fb47738f 100644 --- a/paper-api/src/main/java/org/bukkit/entity/Player.java +++ b/paper-api/src/main/java/org/bukkit/entity/Player.java @@ -74,6 +74,46 @@ @NullMarked public interface Player extends HumanEntity, Conversable, OfflinePlayer, PluginMessageRecipient, net.kyori.adventure.identity.Identified, net.kyori.adventure.bossbar.BossBarViewer, com.destroystokyo.paper.network.NetworkClient, ObjectContentsLike { // Paper + /** + * Selects 1.7.10-style combat physics for this player. Disabled by default. + * Classic explosion damage uses the legacy integer curve. Small velocity + * components use the legacy cutoff and jumps replace vertical velocity. Base knockback + * halves all velocity axes even in the air, attack bonuses + * add an impulse, and melee velocity is delivered by the native tracker. + * Spear, mace-smash and shield-block impulses retain native physics. + * Native damage acceptance, resistance attributes and knockback/velocity + * events remain authoritative. This runtime setting is not persisted. + * + * @param legacyCombat whether to use legacy combat physics + */ + void setLegacyCombat(boolean legacyCombat); + + /** + * Gets whether this player uses legacy combat physics. + * + * @return legacy combat physics enabled + */ + boolean hasLegacyCombat(); + + /** + * Controls off-hand equipment eligibility, player-inventory menu placement + * and automatic merging into stored off-hand items. Enabled by default. + * Disabling this runtime setting does not + * remove stored items or prevent their pickup. Other inventory mutations, + * hand-swap and creative actions remain governed by their native events. + * The setting is copied when this player respawns and is not persisted. + * + * @param enabled whether off-hand equipment is enabled + */ + void setOffHandEnabled(boolean enabled); + + /** + * Gets whether off-hand equipment is enabled for this player. + * + * @return whether off-hand placement is enabled + */ + boolean isOffHandEnabled(); + // Paper start @Override default net.kyori.adventure.identity.Identity identity() { diff --git a/paper-api/src/main/java/org/bukkit/entity/Projectile.java b/paper-api/src/main/java/org/bukkit/entity/Projectile.java index 45706770c496..17b8fed57e68 100644 --- a/paper-api/src/main/java/org/bukkit/entity/Projectile.java +++ b/paper-api/src/main/java/org/bukkit/entity/Projectile.java @@ -8,6 +8,17 @@ */ public interface Projectile extends Entity { + /** + * Changes the initial position of an projectile before its native spawn attempt. + * Does not insert, teleport, replace or respawn the entity, and leaves the + * cancellable native launch/spawn transaction in control. + * + * @param position finite coordinates in this projectile's world + * @throws IllegalArgumentException if the position is invalid or in another world + * @throws IllegalStateException if the projectile has already been inserted or ticked + */ + void setPositionBeforeSpawn(@org.jetbrains.annotations.NotNull org.bukkit.Location position); + /** * Retrieve the shooter of this projectile. * diff --git a/paper-api/src/main/java/org/bukkit/entity/ThrowableProjectile.java b/paper-api/src/main/java/org/bukkit/entity/ThrowableProjectile.java index 5075f1aba5ec..5fe1c93726e0 100644 --- a/paper-api/src/main/java/org/bukkit/entity/ThrowableProjectile.java +++ b/paper-api/src/main/java/org/bukkit/entity/ThrowableProjectile.java @@ -19,4 +19,15 @@ public interface ThrowableProjectile extends Projectile { * @param item ItemStack set to be displayed */ void setItem(ItemStack item); + /** + * Enables opt-in legacy flight order, gravity, owner grace and entity intersections. + * Native impact callbacks and protection events remain active. + * + * @param legacyFlight whether the profile is enabled (disabled by default) + */ + void setLegacyFlight(boolean legacyFlight); + + /** @return whether opt-in legacy flight is enabled */ + boolean hasLegacyFlight(); + } diff --git a/paper-server/patches/features/0001-Moonrise-optimisation-patches.patch b/paper-server/patches/features/0001-Moonrise-optimisation-patches.patch index 4dc9224fc4c6..3c6855826452 100644 --- a/paper-server/patches/features/0001-Moonrise-optimisation-patches.patch +++ b/paper-server/patches/features/0001-Moonrise-optimisation-patches.patch @@ -27387,7 +27387,7 @@ index 229035e15113419080187263c7fa5e004a12c3f3..550b915d4ed457231605247c3e21844f } } diff --git a/net/minecraft/server/level/ServerEntity.java b/net/minecraft/server/level/ServerEntity.java -index f11c5ea1ee9381e7bf65305b959bd7da67423ece..26822e1211194b411dd8ea75ce523133e5389e47 100644 +index eb4b85405e2a0d86a26a617133720c3949ed1acd..ae76db3d2fdc806f6c64a01d364ea261420850c1 100644 --- a/net/minecraft/server/level/ServerEntity.java +++ b/net/minecraft/server/level/ServerEntity.java @@ -90,6 +90,11 @@ public class ServerEntity { @@ -28191,7 +28191,7 @@ index 473feb1aa6ccf0f03ce0bc895de367b3e554e1bc..4fce291cb7cd6eb2f4e47237fe247a5d "Server weather", () -> String.format( diff --git a/net/minecraft/server/level/ServerPlayer.java b/net/minecraft/server/level/ServerPlayer.java -index b569df775fcc9885b7cfee1b8280b68f33f4cd71..220409fd6335e17445e1acdbfac8d83ae36a99b4 100644 +index f32b702bad5f5b26c484cb468218a0aa0ab1ba2c..a03d2e889b516ee6f9ace96ae1d29f18f6aebe69 100644 --- a/net/minecraft/server/level/ServerPlayer.java +++ b/net/minecraft/server/level/ServerPlayer.java @@ -206,7 +206,7 @@ import net.minecraft.world.scores.criteria.ObjectiveCriteria; @@ -29298,7 +29298,7 @@ index 09b6da207530987b483444cebeadc2f1c9a15247..3666c3efd188508153b6482db425648e + // Paper end - block counting } diff --git a/net/minecraft/world/entity/Entity.java b/net/minecraft/world/entity/Entity.java -index ec8b47eebc30a73adb3195aa91fd5b82518cac2d..4b74c91b47318d048abc53952ebeae7615f50524 100644 +index 80d57f833a4b5d26e5302708f8b7f41e40d71085..97f28bbdaab46357db6de27d17abb285d6745c9f 100644 --- a/net/minecraft/world/entity/Entity.java +++ b/net/minecraft/world/entity/Entity.java @@ -164,7 +164,7 @@ public abstract class Entity @@ -31374,7 +31374,7 @@ index 254a967292938a9ed6a0291bbc9cf6e971eeaf01..416646f15ceea04fbbb5a5b0044c2a49 @Nullable ChunkAccess getChunkIfLoadedImmediately(int x, int z); // Paper - ifLoaded api (we need this since current impl blocks if the chunk is loading) diff --git a/net/minecraft/world/level/ServerExplosion.java b/net/minecraft/world/level/ServerExplosion.java -index def5a92046a663e53b29f9863c541ddc30ee9d3a..91bb2ac15b9e9350552f4eeb9573c2f564854ce4 100644 +index d664956c4b74a84a8858939706a5b52b0765bd11..da0d74e6d5efff433b517c82bcb13c098ce54c0f 100644 --- a/net/minecraft/world/level/ServerExplosion.java +++ b/net/minecraft/world/level/ServerExplosion.java @@ -54,6 +54,249 @@ public class ServerExplosion implements Explosion { @@ -31777,7 +31777,7 @@ index def5a92046a663e53b29f9863c541ddc30ee9d3a..91bb2ac15b9e9350552f4eeb9573c2f5 } private void hurtEntities() { -@@ -341,6 +623,14 @@ public class ServerExplosion implements Explosion { +@@ -343,6 +625,14 @@ public class ServerExplosion implements Explosion { } public int explode() { @@ -31792,7 +31792,7 @@ index def5a92046a663e53b29f9863c541ddc30ee9d3a..91bb2ac15b9e9350552f4eeb9573c2f5 this.level.gameEvent(this.source, GameEvent.EXPLODE, this.center); List toBlow = this.calculateExplodedPositions(); this.hurtEntities(); -@@ -355,6 +645,13 @@ public class ServerExplosion implements Explosion { +@@ -357,6 +647,13 @@ public class ServerExplosion implements Explosion { this.createFire(toBlow); } @@ -31806,7 +31806,7 @@ index def5a92046a663e53b29f9863c541ddc30ee9d3a..91bb2ac15b9e9350552f4eeb9573c2f5 return toBlow.size(); } -@@ -437,12 +734,12 @@ public class ServerExplosion implements Explosion { +@@ -439,12 +736,12 @@ public class ServerExplosion implements Explosion { // Paper start - Optimize explosions private float getBlockDensity(Vec3 vec3d, Entity entity) { if (!this.level.paperConfig().environment.optimizeExplosions) { diff --git a/paper-server/patches/features/0003-Entity-Activation-Range-2.0.patch b/paper-server/patches/features/0003-Entity-Activation-Range-2.0.patch index 6ed4e07f3f37..b02e436f6935 100644 --- a/paper-server/patches/features/0003-Entity-Activation-Range-2.0.patch +++ b/paper-server/patches/features/0003-Entity-Activation-Range-2.0.patch @@ -355,7 +355,7 @@ index 0000000000000000000000000000000000000000..829f7685904fd741e00403647ae76d32 + } +} diff --git a/net/minecraft/server/level/ServerLevel.java b/net/minecraft/server/level/ServerLevel.java -index e5328f08d6a323aa1307ed791a88916b4588939c..1afbe7a877f8055702809bc33b110bdf2933f999 100644 +index 4fce291cb7cd6eb2f4e47237fe247a5d666da0ad..6cf92a7ae011fe4fa55cd744dce8d5f8fb49b1ec 100644 --- a/net/minecraft/server/level/ServerLevel.java +++ b/net/minecraft/server/level/ServerLevel.java @@ -880,6 +880,7 @@ public class ServerLevel extends Level implements WorldGenLevel, ServerEntityGet @@ -463,7 +463,7 @@ index 271ed1128959287f6c1b6ca59ded377b88f5afff..f03fa06c0ba56f7f5e1e45bc1568a490 public void tick() { super.tick(); diff --git a/net/minecraft/world/entity/Entity.java b/net/minecraft/world/entity/Entity.java -index a5792bc27fdc85ecf388ab44c0b1b3055fc41d21..4a18f74c7513f0ac6a3415163165d9239ee5b567 100644 +index 97f28bbdaab46357db6de27d17abb285d6745c9f..4929cc856531c07b3c7e37dc22e7de0ea44bc156 100644 --- a/net/minecraft/world/entity/Entity.java +++ b/net/minecraft/world/entity/Entity.java @@ -383,6 +383,15 @@ public abstract class Entity @@ -522,10 +522,10 @@ index a5792bc27fdc85ecf388ab44c0b1b3055fc41d21..4a18f74c7513f0ac6a3415163165d923 delta = this.maybeBackOffFromEdge(delta, moverType); Vec3 movement = this.collide(delta); diff --git a/net/minecraft/world/entity/LivingEntity.java b/net/minecraft/world/entity/LivingEntity.java -index 2837e630ae414bd37c4afc72c83a78e4d38a945a..3a4da514c163e7565fc241835db52c464a5c4734 100644 +index 416f920e3e7e46cb5ef423373112e1ffe9fb49a1..380663766e48a9d04db732f994600f50fa304226 100644 --- a/net/minecraft/world/entity/LivingEntity.java +++ b/net/minecraft/world/entity/LivingEntity.java -@@ -3385,6 +3385,14 @@ public abstract class LivingEntity extends Entity implements Attackable, Waypoin +@@ -3449,6 +3449,14 @@ public abstract class LivingEntity extends Entity implements Attackable, Waypoin protected void playAttackSound() { } @@ -541,7 +541,7 @@ index 2837e630ae414bd37c4afc72c83a78e4d38a945a..3a4da514c163e7565fc241835db52c46 public void tick() { super.tick(); diff --git a/net/minecraft/world/entity/Mob.java b/net/minecraft/world/entity/Mob.java -index fd17e62b99fd59130739b4c8c6c0c49122aec094..c2dd2da4bfb7d65ad2730ba1ce771e2b488beda4 100644 +index a87c1343e28114f513c9f52dcc4fe7ddd04bc5bb..1b89e5909d810d9da528c3e6cc312ef93d1c9d84 100644 --- a/net/minecraft/world/entity/Mob.java +++ b/net/minecraft/world/entity/Mob.java @@ -215,6 +215,20 @@ public abstract class Mob extends LivingEntity implements Targeting, EquipmentUs diff --git a/paper-server/patches/features/0023-Fix-entity-tracker-desync-when-new-players-are-added.patch b/paper-server/patches/features/0023-Fix-entity-tracker-desync-when-new-players-are-added.patch index d856851592b8..3a778836cc85 100644 --- a/paper-server/patches/features/0023-Fix-entity-tracker-desync-when-new-players-are-added.patch +++ b/paper-server/patches/features/0023-Fix-entity-tracker-desync-when-new-players-are-added.patch @@ -60,7 +60,7 @@ index 3a6410f4b061c7431ad09b23be9691160c36ef6c..29e02b01ba7eeb7767e8dad6b3be909f } else { this.removePlayer(player); diff --git a/net/minecraft/server/level/ServerEntity.java b/net/minecraft/server/level/ServerEntity.java -index 26822e1211194b411dd8ea75ce523133e5389e47..60af82925d2f7e605bf7340cf01f1a5668f4d725 100644 +index ae76db3d2fdc806f6c64a01d364ea261420850c1..8c7ee0e3122baddfde140357adf89201fa6c1a4e 100644 --- a/net/minecraft/server/level/ServerEntity.java +++ b/net/minecraft/server/level/ServerEntity.java @@ -89,6 +89,13 @@ public class ServerEntity { @@ -95,7 +95,7 @@ index 26822e1211194b411dd8ea75ce523133e5389e47..60af82925d2f7e605bf7340cf01f1a56 || deltaTooBig || this.teleportDelay > 400 || this.wasRiding -@@ -238,6 +245,7 @@ public class ServerEntity { +@@ -239,6 +246,7 @@ public class ServerEntity { } this.entity.needsSync = false; diff --git a/paper-server/patches/sources/net/minecraft/server/level/ServerEntity.java.patch b/paper-server/patches/sources/net/minecraft/server/level/ServerEntity.java.patch index e9fb785e88d0..b04dab957da3 100644 --- a/paper-server/patches/sources/net/minecraft/server/level/ServerEntity.java.patch +++ b/paper-server/patches/sources/net/minecraft/server/level/ServerEntity.java.patch @@ -61,7 +61,17 @@ Packet packet = null; boolean pos = positionChanged || this.tickCount % 60 == 0; boolean sentPosition = false; -@@ -223,6 +_,25 @@ +@@ -174,7 +_,8 @@ + sentRotation = true; + } + +- if (this.entity.needsSync || this.trackDelta || this.entity instanceof LivingEntity livingEntity && livingEntity.isFallFlying()) { ++ if (!(this.entity.hurtMarked && this.entity instanceof ServerPlayer player && player.legacyCombat) ++ && (this.entity.needsSync || this.trackDelta || this.entity instanceof LivingEntity livingEntity && livingEntity.isFallFlying())) { + Vec3 movement = this.entity.getDeltaMovement(); + double diff = movement.distanceToSqr(this.lastSentMovement); + if (diff > 1.0E-7 || diff > 0.0 && movement.lengthSqr() == 0.0) { +@@ -223,6 +_,26 @@ this.tickCount++; if (this.entity.hurtMarked) { @@ -81,6 +91,7 @@ + } + + if (cancelled) { ++ if (this.entity instanceof ServerPlayer player && player.legacyCombat) this.entity.hurtMarked = false; + return; + } + // CraftBukkit end diff --git a/paper-server/patches/sources/net/minecraft/server/level/ServerPlayer.java.patch b/paper-server/patches/sources/net/minecraft/server/level/ServerPlayer.java.patch index 2b9944e520cd..bf937f0810bd 100644 --- a/paper-server/patches/sources/net/minecraft/server/level/ServerPlayer.java.patch +++ b/paper-server/patches/sources/net/minecraft/server/level/ServerPlayer.java.patch @@ -1187,7 +1187,14 @@ } @Override -@@ -1602,18 +_,18 @@ +@@ -1596,24 +_,25 @@ + } + + public void restoreFrom(final ServerPlayer oldPlayer, final boolean restoreAll) { ++ this.offHandEnabled = oldPlayer.offHandEnabled; // Preserve runtime inventory capability across respawn. + this.wardenSpawnTracker = oldPlayer.wardenSpawnTracker; + this.chatSession = oldPlayer.chatSession; + this.gameMode.setGameModeForPlayer(oldPlayer.gameMode.getGameModeForPlayer(), oldPlayer.gameMode.getPreviousGameModeForPlayer()); this.onUpdateAbilities(); this.getAttributes().assignBaseValues(oldPlayer.getAttributes()); if (restoreAll) { diff --git a/paper-server/patches/sources/net/minecraft/world/entity/Entity.java.patch b/paper-server/patches/sources/net/minecraft/world/entity/Entity.java.patch index ba31084206e7..267a974462a6 100644 --- a/paper-server/patches/sources/net/minecraft/world/entity/Entity.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/entity/Entity.java.patch @@ -1261,6 +1261,15 @@ } protected boolean canAddPassenger(final Entity passenger) { +@@ -2585,7 +_,7 @@ + if (!(this instanceof Player player)) { + return Vec3.ZERO; + } else { +- boolean itemOnlyInOffhand = player.getOffhandItem().is(item) && !player.getMainHandItem().is(item); ++ boolean itemOnlyInOffhand = player.canUseSlot(EquipmentSlot.OFFHAND) && player.getOffhandItem().is(item) && !player.getMainHandItem().is(item); // Paper - configurable off-hand eligibility + HumanoidArm itemArm = itemOnlyInOffhand ? player.getMainArm().getOpposite() : player.getMainArm(); + return this.calculateViewVector(0.0F, this.getYRot() + (itemArm == HumanoidArm.RIGHT ? 80 : -80)).scale(0.5); + } @@ -2704,7 +_,7 @@ } diff --git a/paper-server/patches/sources/net/minecraft/world/entity/LivingEntity.java.patch b/paper-server/patches/sources/net/minecraft/world/entity/LivingEntity.java.patch index dd7bcd5582d9..93e4dfa49062 100644 --- a/paper-server/patches/sources/net/minecraft/world/entity/LivingEntity.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/entity/LivingEntity.java.patch @@ -520,7 +520,7 @@ this.hurtHelmet(source, damage); damage *= 0.75F; } -@@ -1214,19 +_,40 @@ +@@ -1214,19 +_,44 @@ damage = Float.MAX_VALUE; } @@ -542,7 +542,9 @@ + if (!this.actuallyHurt(level, source, (float)event.getFinalDamage(), event)) { // Paper - fix invulnerability reduction in EntityDamageEvent - no longer subtract lastHurt, that is part of the damage event calc now + return false; + } -+ if (this instanceof ServerPlayer && event.getDamage() == 0 && originalDamage == 0) return false; // Paper - revert to vanilla damage - players are not affected by damage that is 0 - skip damage if the vanilla damage is 0 and was not modified by plugins in the event. ++ if (this instanceof ServerPlayer && event.getDamage() == 0 && originalDamage == 0 ++ && !(source.getDirectEntity() instanceof net.minecraft.world.entity.projectile.FishingHook hook && hook.legacyFlight ++ || source.getDirectEntity() instanceof net.minecraft.world.entity.projectile.ThrowableProjectile thrown && thrown.legacyFlight)) return false; // Paper - revert to vanilla damage - players are not affected by damage that is 0 - skip damage if the vanilla damage is 0 and was not modified by plugins in the event. + // CraftBukkit end this.lastHurt = damage; tookFullDamage = false; @@ -555,7 +557,9 @@ + if (!this.actuallyHurt(level, source, (float)event.getFinalDamage(), event)) { + return false; + } -+ if (this instanceof ServerPlayer && event.getDamage() == 0 && originalDamage == 0) return false; // Paper - revert to vanilla damage - players are not affected by damage that is 0 - skip damage if the vanilla damage is 0 and was not modified by plugins in the event. ++ if (this instanceof ServerPlayer && event.getDamage() == 0 && originalDamage == 0 ++ && !(source.getDirectEntity() instanceof net.minecraft.world.entity.projectile.FishingHook hook && hook.legacyFlight ++ || source.getDirectEntity() instanceof net.minecraft.world.entity.projectile.ThrowableProjectile thrown && thrown.legacyFlight)) return false; // Paper - revert to vanilla damage - players are not affected by damage that is 0 - skip damage if the vanilla damage is 0 and was not modified by plugins in the event. this.lastHurt = damage; - this.invulnerableTime = 20; - this.actuallyHurt(level, source, damage); @@ -565,7 +569,7 @@ this.hurtDuration = 10; this.hurtTime = this.hurtDuration; } -@@ -1241,7 +_,7 @@ +@@ -1241,30 +_,28 @@ level.broadcastDamageEvent(this, source); } @@ -574,7 +578,11 @@ this.markHurt(); } -@@ -1252,19 +_,17 @@ +- if (!source.is(DamageTypeTags.NO_KNOCKBACK)) { ++ if (this.shouldApplyDefaultKnockback(source)) { + this.dealDefaultKnockback(source, damage, blocked); + } + } if (this.isDeadOrDying()) { if (!this.checkTotemDeathProtection(source)) { @@ -597,11 +605,56 @@ if (success) { this.lastDamageSource = source; this.lastDamageStamp = this.level().getGameTime(); -@@ -1300,13 +_,28 @@ +@@ -1288,7 +_,26 @@ + return success; + } + ++ protected static boolean hasModernDamageKnockback(final DamageSource source) { ++ return source.is(DamageTypes.SPEAR) || source.is(DamageTypes.MACE_SMASH) ++ || source.is(DamageTypes.TRIDENT) || source.is(DamageTypes.WIND_CHARGE) ++ || source.is(DamageTypes.FIREWORKS) || source.is(DamageTypes.SONIC_BOOM); ++ } ++ ++ private boolean shouldApplyDefaultKnockback(final DamageSource source) { ++ return !source.is(DamageTypeTags.NO_KNOCKBACK) ++ || this instanceof Player player && player.legacyCombat && source.getEntity() != null ++ && (source.is(DamageTypes.EXPLOSION) || source.is(DamageTypes.PLAYER_EXPLOSION)) ++ && (source.getDirectEntity() instanceof net.minecraft.world.entity.item.PrimedTnt ++ || source.getDirectEntity() instanceof net.minecraft.world.entity.monster.Creeper ++ || source.getDirectEntity() instanceof net.minecraft.world.entity.boss.wither.WitherBoss); ++ } ++ + public void dealDefaultKnockback(final DamageSource source, final float damage, final boolean blocked) { ++ // Legacy damage with no responsible entity had no base knockback. ++ // Native damage acceptance still precedes every base and explosion impulse. ++ if (this instanceof Player player && player.legacyCombat && !hasModernDamageKnockback(source) && source.getEntity() == null) return; ++ + double xd = 0.0; + double zd = 0.0; + if (source.getDirectEntity() instanceof Projectile projectile) { +@@ -1300,13 +_,46 @@ zd = source.getSourcePosition().z() - this.getZ(); } - this.knockback(0.4F, xd, zd, source, damage); ++ // Legacy arrows retain their shooter for damage attribution and base ++ // knockback; native damage acceptance and both event families still run. ++ if (!hasModernDamageKnockback(source) && (this instanceof Player player && player.legacyCombat ++ || source.getDirectEntity() instanceof net.minecraft.world.entity.projectile.arrow.AbstractArrow arrow && arrow.legacyFlight)) { ++ Entity owner = source.getEntity(); ++ if (owner == null && source.getDirectEntity() instanceof Projectile projectile) { ++ owner = projectile.getOwner() != null ? projectile.getOwner() : projectile; ++ } ++ if (owner != null) { ++ xd = owner.getX() - this.getX(); ++ zd = owner.getZ() - this.getZ(); ++ } ++ while (xd * xd + zd * zd < 1.0E-4) { ++ xd = (Math.random() - Math.random()) * 0.01; ++ zd = (Math.random() - Math.random()) * 0.01; ++ } ++ } ++ + // Paper start - Check distance in entity interactions; see for loop in knockback method + if (Math.abs(xd) > 200) { + xd = Math.random() - Math.random(); @@ -902,14 +955,26 @@ Vec3 deltaMovement = this.getDeltaMovement(); while (xd * xd + zd * zd < 1.0E-5F) { -@@ -1651,16 +_,32 @@ +@@ -1650,17 +_,42 @@ + zd = (this.random.nextDouble() - this.random.nextDouble()) * 0.01; } - Vec3 deltaVector = new Vec3(xd, 0.0, zd).normalize().scale(power); +- Vec3 deltaVector = new Vec3(xd, 0.0, zd).normalize().scale(power); - this.setDeltaMovement( -+ Vec3 targetMovement = new Vec3( // Paper - knockback events ++ boolean legacyBase = !comesFromEffect && !hasModernDamageKnockback(source) ++ && eventCause != io.papermc.paper.event.entity.EntityKnockbackEvent.Cause.SHIELD_BLOCK && (this instanceof Player player && player.legacyCombat ++ || source.getDirectEntity() instanceof net.minecraft.world.entity.projectile.arrow.AbstractArrow arrow && arrow.legacyFlight); ++ double length = legacyBase ? (double) (float) Math.sqrt(xd * xd + zd * zd) : 0.0; ++ Vec3 deltaVector = legacyBase ? new Vec3(xd / length * power, 0.0, zd / length * power) ++ : new Vec3(xd, 0.0, zd).normalize().scale(power); ++ boolean legacyExtra = comesFromEffect && !hasModernDamageKnockback(source) && attacker instanceof Player player && player.legacyCombat ++ && eventCause == io.papermc.paper.event.entity.EntityKnockbackEvent.Cause.ENTITY_ATTACK; ++ Vec3 targetMovement = legacyExtra ? deltaMovement.add(-xd * power, power > 0.0 ? 0.1 : 0.0, -zd * power) : new Vec3( // Paper - knockback events deltaMovement.x / 2.0 - deltaVector.x, - this.onGround() ? Math.min(0.4, deltaMovement.y / 2.0 + power) : deltaMovement.y, +- this.onGround() ? Math.min(0.4, deltaMovement.y / 2.0 + power) : deltaMovement.y, ++ legacyBase ++ ? Math.min((double) 0.4F, deltaMovement.y / 2.0 + power) ++ : this.onGround() ? Math.min(0.4, deltaMovement.y / 2.0 + power) : deltaMovement.y, deltaMovement.z / 2.0 - deltaVector.z ); + // Paper start - knockback events @@ -954,9 +1019,24 @@ } public boolean isLookingAtMe( -@@ -1805,10 +_,15 @@ +@@ -1790,7 +_,8 @@ + @Override + public boolean causeFallDamage(final double fallDistance, final float damageModifier, final DamageSource damageSource) { + double effectiveFallDistance; +- if (this.isIgnoringFallDamageFromCurrentImpulse()) { ++ boolean modernImpulse = this.isIgnoringFallDamageFromCurrentImpulse(); ++ if (modernImpulse) { + effectiveFallDistance = Math.min(fallDistance, this.currentImpulseImpactPos.y - this.getY()); + boolean hasLandedAboveCurrentImpulseImpactPosY = effectiveFallDistance <= 0.0; + if (hasLandedAboveCurrentImpulseImpactPosY) { +@@ -1803,12 +_,19 @@ + } + boolean damaged = super.causeFallDamage(effectiveFallDistance, damageModifier, damageSource); - int dmg = this.calculateFallDamage(effectiveFallDistance, damageModifier); +- int dmg = this.calculateFallDamage(effectiveFallDistance, damageModifier); ++ int dmg = modernImpulse && this instanceof Player player && player.legacyCombat ++ ? this.calculateNativeFallDamage(effectiveFallDistance, damageModifier) ++ : this.calculateFallDamage(effectiveFallDistance, damageModifier); if (dmg > 0) { + // CraftBukkit start + if (!this.hurtServer((ServerLevel)this.level(), damageSource, (float)dmg)) { @@ -971,6 +1051,22 @@ return true; } else { return damaged; +@@ -1848,6 +_,15 @@ + } + + protected int calculateFallDamage(final double fallDistance, final float damageModifier) { ++ if (this instanceof Player player && player.legacyCombat && !this.isIgnoringFallDamageFromCurrentImpulse()) { ++ if (this.is(EntityTypeTags.FALL_DAMAGE_IMMUNE)) return 0; ++ double power = (double) (float) fallDistance - this.getAttributeValue(Attributes.SAFE_FALL_DISTANCE); ++ return Mth.ceil(power * damageModifier * this.getAttributeValue(Attributes.FALL_DAMAGE_MULTIPLIER)); ++ } ++ return this.calculateNativeFallDamage(fallDistance, damageModifier); ++ } ++ ++ private int calculateNativeFallDamage(final double fallDistance, final float damageModifier) { + if (this.is(EntityTypeTags.FALL_DAMAGE_IMMUNE)) { + return 0; + } @@ -1905,7 +_,7 @@ protected float getDamageAfterArmorAbsorb(final DamageSource damageSource, float damage) { @@ -1217,6 +1313,15 @@ } protected void updateSwingTime() { +@@ -2256,7 +_,7 @@ + } + + public boolean isHolding(final Predicate itemPredicate) { +- return itemPredicate.test(this.getMainHandItem()) || itemPredicate.test(this.getOffhandItem()); ++ return itemPredicate.test(this.getMainHandItem()) || this.canUseSlot(EquipmentSlot.OFFHAND) && itemPredicate.test(this.getOffhandItem()); + } + + public ItemStack getItemInHand(final InteractionHand hand) { @@ -2294,7 +_,13 @@ } @@ -1232,7 +1337,7 @@ } public float getArmorCoverPercentage() { -@@ -2387,14 +_,27 @@ +@@ -2387,15 +_,30 @@ return this.hasEffect(MobEffects.JUMP_BOOST) ? 0.1F * (this.getEffect(MobEffects.JUMP_BOOST).getAmplifier() + 1.0F) : 0.0F; } @@ -1242,6 +1347,7 @@ float jumpPower = this.getJumpPower(); if (!(jumpPower <= 1.0E-5F)) { Vec3 movement = this.getDeltaMovement(); +- this.setDeltaMovement(movement.x, Math.max(jumpPower, movement.y), movement.z); + // Paper start - Prevent excessive velocity through repeated crits + long time = System.nanoTime(); + boolean canCrit = true; @@ -1253,13 +1359,17 @@ + } + } + // Paper end - Prevent excessive velocity through repeated crits - this.setDeltaMovement(movement.x, Math.max(jumpPower, movement.y), movement.z); ++ this.setDeltaMovement(movement.x, this instanceof net.minecraft.world.entity.player.Player player && player.legacyCombat ? jumpPower : Math.max(jumpPower, movement.y), movement.z); if (this.isSprinting()) { float angle = this.getYRot() * Mth.DEG_TO_RAD; +- this.addDeltaMovement(new Vec3(-Mth.sin(angle) * 0.2, 0.0, Mth.cos(angle) * 0.2)); + if (canCrit) // Paper - Prevent excessive velocity through repeated crits - this.addDeltaMovement(new Vec3(-Mth.sin(angle) * 0.2, 0.0, Mth.cos(angle) * 0.2)); ++ this.addDeltaMovement(this instanceof net.minecraft.world.entity.player.Player player && player.legacyCombat ++ ? new Vec3((double) (-Mth.sin(angle) * 0.2F), 0.0, (double) (Mth.cos(angle) * 0.2F)) ++ : new Vec3(-Mth.sin(angle) * 0.2, 0.0, Mth.cos(angle) * 0.2)); } + this.needsSync = true; @@ -2597,8 +_,10 @@ } @@ -1397,6 +1507,35 @@ } protected void tickHeadTurn(final float yBodyRotT) { +@@ -3056,22 +_,24 @@ + double dx = movement.x; + double dy = movement.y; + double dz = movement.z; +- if (this.is(EntityTypes.PLAYER)) { ++ boolean legacyMovement = this instanceof net.minecraft.world.entity.player.Player player && player.legacyCombat; ++ double movementThreshold = legacyMovement ? 0.005 : 0.003; ++ if (!legacyMovement && this.is(EntityTypes.PLAYER)) { + if (movement.horizontalDistanceSqr() < 9.0E-6) { + dx = 0.0; + dz = 0.0; + } + } else { +- if (Math.abs(movement.x) < 0.003) { ++ if (Math.abs(movement.x) < movementThreshold) { + dx = 0.0; + } + +- if (Math.abs(movement.z) < 0.003) { ++ if (Math.abs(movement.z) < movementThreshold) { + dz = 0.0; + } + } + +- if (Math.abs(movement.y) < 0.003) { ++ if (Math.abs(movement.y) < movementThreshold) { + dy = 0.0; + } + @@ -3104,8 +_,10 @@ if (!inWaterAndHasFluidHeight || this.onGround() && !(fluidHeight > fluidJumpThreshold)) { if (!this.isInLava() || this.onGround() && this.isInShallowFluid(FluidTags.LAVA)) { diff --git a/paper-server/patches/sources/net/minecraft/world/entity/ai/goal/TemptGoal.java.patch b/paper-server/patches/sources/net/minecraft/world/entity/ai/goal/TemptGoal.java.patch index 230976671c2e..7d2aeec06ede 100644 --- a/paper-server/patches/sources/net/minecraft/world/entity/ai/goal/TemptGoal.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/entity/ai/goal/TemptGoal.java.patch @@ -1,5 +1,13 @@ --- a/net/minecraft/world/entity/ai/goal/TemptGoal.java +++ b/net/minecraft/world/entity/ai/goal/TemptGoal.java +@@ -2,6 +_,7 @@ + + import java.util.EnumSet; + import java.util.function.Predicate; ++import net.minecraft.world.entity.EquipmentSlot; + import net.minecraft.world.entity.LivingEntity; + import net.minecraft.world.entity.Mob; + import net.minecraft.world.entity.PathfinderMob; @@ -23,7 +_,7 @@ private double pz; private double pRotX; @@ -9,7 +17,7 @@ private int calmDown; private boolean isRunning; private final Predicate items; -@@ -56,6 +_,15 @@ +@@ -56,12 +_,21 @@ } else { this.player = getServerLevel(this.mob) .getNearestPlayer(this.targetingConditions.range(this.mob.getAttributeValue(Attributes.TEMPT_RANGE)), this.mob); @@ -25,6 +33,13 @@ return this.player != null; } } + + private boolean shouldFollow(final LivingEntity player) { +- return this.items.test(player.getMainHandItem()) || this.items.test(player.getOffhandItem()); ++ return this.items.test(player.getMainHandItem()) || player.canUseSlot(EquipmentSlot.OFFHAND) && this.items.test(player.getOffhandItem()); // Paper - configurable off-hand eligibility + } + + @Override @@ -122,7 +_,7 @@ this.mob.getNavigation().stop(); } diff --git a/paper-server/patches/sources/net/minecraft/world/entity/ai/sensing/TemptingSensor.java.patch b/paper-server/patches/sources/net/minecraft/world/entity/ai/sensing/TemptingSensor.java.patch index 67840e2b1193..658eb6f88e7e 100644 --- a/paper-server/patches/sources/net/minecraft/world/entity/ai/sensing/TemptingSensor.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/entity/ai/sensing/TemptingSensor.java.patch @@ -1,6 +1,14 @@ --- a/net/minecraft/world/entity/ai/sensing/TemptingSensor.java +++ b/net/minecraft/world/entity/ai/sensing/TemptingSensor.java -@@ -48,7 +_,19 @@ +@@ -9,6 +_,7 @@ + import java.util.stream.Collectors; + import net.minecraft.server.level.ServerLevel; + import net.minecraft.world.entity.EntitySelector; ++import net.minecraft.world.entity.EquipmentSlot; + import net.minecraft.world.entity.PathfinderMob; + import net.minecraft.world.entity.ai.Brain; + import net.minecraft.world.entity.ai.attributes.Attributes; +@@ -48,14 +_,26 @@ .collect(Collectors.toList()); if (!players.isEmpty()) { Player player = players.get(0); @@ -21,3 +29,11 @@ } else { brain.eraseMemory(MemoryModuleType.TEMPTING_PLAYER); } + } + + private boolean playerHoldingTemptation(final PathfinderMob mob, final Player player) { +- return this.isTemptation(mob, player.getMainHandItem()) || this.isTemptation(mob, player.getOffhandItem()); ++ return this.isTemptation(mob, player.getMainHandItem()) || player.canUseSlot(EquipmentSlot.OFFHAND) && this.isTemptation(mob, player.getOffhandItem()); // Paper - configurable off-hand eligibility + } + + private boolean isTemptation(final PathfinderMob mob, final ItemStack itemStack) { diff --git a/paper-server/patches/sources/net/minecraft/world/entity/player/Inventory.java.patch b/paper-server/patches/sources/net/minecraft/world/entity/player/Inventory.java.patch index 22732ca3cc5c..1bfa67783b95 100644 --- a/paper-server/patches/sources/net/minecraft/world/entity/player/Inventory.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/entity/player/Inventory.java.patch @@ -150,3 +150,12 @@ ItemStack tmp = this.items.get(this.selected); this.items.set(this.selected, this.items.get(slot)); this.items.set(slot, tmp); +@@ -225,7 +_,7 @@ + return this.selected; + } + +- if (this.hasRemainingSpaceForItem(this.getItem(SLOT_OFFHAND), newItemStack)) { ++ if ((this.player == null || this.player.offHandEnabled) && this.hasRemainingSpaceForItem(this.getItem(SLOT_OFFHAND), newItemStack)) { + return SLOT_OFFHAND; + } + diff --git a/paper-server/patches/sources/net/minecraft/world/entity/player/Player.java.patch b/paper-server/patches/sources/net/minecraft/world/entity/player/Player.java.patch index fb7007df9e5c..59f350a039de 100644 --- a/paper-server/patches/sources/net/minecraft/world/entity/player/Player.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/entity/player/Player.java.patch @@ -9,10 +9,12 @@ public final InventoryMenu inventoryMenu; public AbstractContainerMenu containerMenu; protected FoodData foodData = new FoodData(); -@@ -172,6 +_,18 @@ +@@ -172,6 +_,20 @@ private Optional lastDeathLocation = Optional.empty(); public @Nullable FishingHook fishing; public float hurtDir; ++ public boolean legacyCombat = false; // Generic opt-in native combat profile ++ public boolean offHandEnabled = true; // Generic player-inventory menu capability + public boolean affectsSpawning = true; // Paper - Affects Spawning API + public net.kyori.adventure.util.TriState flyingFallDamage = net.kyori.adventure.util.TriState.NOT_SET; // Paper - flying fall damage + @@ -28,6 +30,18 @@ public Player(final Level level, final GameProfile gameProfile) { super(EntityTypes.PLAYER, level); +@@ -187,6 +_,11 @@ + return new PlayerEquipment(this); + } + ++ @Override ++ public boolean canUseSlot(final EquipmentSlot slot) { ++ return (slot != EquipmentSlot.OFFHAND || this.offHandEnabled) && super.canUseSlot(slot); ++ } ++ + public boolean blockActionRestricted(final Level level, final BlockPos pos, final GameType gameType) { + if (!gameType.isBlockPlacingRestricted()) { + return false; @@ -242,6 +_,13 @@ if (this.isSleeping()) { @@ -310,7 +324,8 @@ + // Paper end - Configurable sprint interruption on attack } - if (entity instanceof ServerPlayer serverPlayer && entity.hurtMarked) { +- if (entity instanceof ServerPlayer serverPlayer && entity.hurtMarked) { ++ if (entity instanceof ServerPlayer serverPlayer && entity.hurtMarked && (!serverPlayer.legacyCombat || hasModernDamageKnockback(damageSource))) { + // CraftBukkit start - Add Velocity Event + boolean cancelled = false; + org.bukkit.entity.Player player = (org.bukkit.entity.Player) entity.getBukkitEntity(); @@ -482,6 +497,21 @@ } } } +@@ -1808,6 +_,14 @@ + } + + public float getAttackStrengthScale(final float a) { ++ if (this.legacyCombat) { ++ ItemStack held = this.getMainHandItem(); ++ if (!held.has(DataComponents.KINETIC_WEAPON) && !held.has(DataComponents.PIERCING_WEAPON) ++ && !(held.getItem() instanceof net.minecraft.world.item.MaceItem) ++ && !(held.getItem() instanceof net.minecraft.world.item.TridentItem)) { ++ return 1.0F; // Classic combat had no attack-speed damage or critical-hit penalty. ++ } ++ } + return Mth.clamp((this.attackStrengthTicker + a) / this.getCurrentItemAttackStrengthDelay(), 0.0F, 1.0F); + } + @@ -1821,10 +_,39 @@ } @@ -526,7 +556,7 @@ public void resetOnlyAttackStrengthTicker() { this.attackStrengthTicker = 0; -@@ -1857,19 +_,34 @@ +@@ -1857,27 +_,44 @@ return ImmutableList.of(Pose.STANDING, Pose.CROUCHING, Pose.SWIMMING); } @@ -562,8 +592,10 @@ + supportedProjectiles = ((ProjectileWeaponItem)heldWeapon.getItem()).getAllSupportedProjectiles().and(item -> this.tryReadyArrow(heldWeapon, item, anyEventCancelled)); // Paper - PlayerReadyArrowEvent for (int i = 0; i < this.inventory.getContainerSize(); i++) { ++ if (i == Inventory.SLOT_OFFHAND && !this.canUseSlot(EquipmentSlot.OFFHAND)) continue; ItemStack itemStack = this.inventory.getItem(i); -@@ -1878,6 +_,7 @@ + if (supportedProjectiles.test(itemStack)) { + return itemStack; } } diff --git a/paper-server/patches/sources/net/minecraft/world/entity/projectile/FishingHook.java.patch b/paper-server/patches/sources/net/minecraft/world/entity/projectile/FishingHook.java.patch index fb0a0b48e740..59d078adb37a 100644 --- a/paper-server/patches/sources/net/minecraft/world/entity/projectile/FishingHook.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/entity/projectile/FishingHook.java.patch @@ -1,9 +1,20 @@ --- a/net/minecraft/world/entity/projectile/FishingHook.java +++ b/net/minecraft/world/entity/projectile/FishingHook.java -@@ -70,10 +_,26 @@ +@@ -25,6 +_,7 @@ + import net.minecraft.world.entity.EntityEvent; + import net.minecraft.world.entity.EntityType; + import net.minecraft.world.entity.EntityTypes; ++import net.minecraft.world.entity.EquipmentSlot; + import net.minecraft.world.entity.ExperienceOrb; + import net.minecraft.world.entity.InterpolationHandler; + import net.minecraft.world.entity.MoverType; +@@ -70,10 +_,29 @@ private final int lureSpeed; private final InterpolationHandler interpolationHandler = new InterpolationHandler(this); ++ public boolean legacyFlight = false; // Generic opt-in PvP flight/impact/reel profile ++ private int legacyAirTicks; ++ + // CraftBukkit start - Extra variables to enable modification of fishing wait time, values are minecraft defaults + public int minWaitTime = 100; + public int maxWaitTime = 600; @@ -42,7 +53,11 @@ return; } } else { -@@ -176,12 +_,14 @@ +@@ -173,20 +_,24 @@ + } + + boolean isInWater = liquidHeight > 0.0F; ++ if (this.legacyFlight && !this.onGround()) this.legacyAirTicks++; if (this.currentState == FishingHook.FishHookState.FLYING) { if (this.hookedIn != null) { this.setDeltaMovement(Vec3.ZERO); @@ -51,12 +66,20 @@ return; } - if (isInWater) { +- if (isInWater) { ++ if (this.legacyFlight && isInWater) this.checkCollision(); ++ if (isInWater && (!this.legacyFlight || this.hookedIn == null)) { this.setDeltaMovement(this.getDeltaMovement().multiply(0.3, 0.2, 0.3)); + new io.papermc.paper.event.entity.FishHookStateChangeEvent((org.bukkit.entity.FishHook) getBukkitEntity(), org.bukkit.entity.FishHook.HookState.BOBBING).callEvent(); // Paper - Add FishHookStateChangeEvent. #BOBBING this.currentState = FishingHook.FishHookState.BOBBING; return; } + +- this.checkCollision(); ++ if (!this.legacyFlight || !isInWater) this.checkCollision(); + } else { + if (this.currentState == FishingHook.FishHookState.HOOKED_IN_ENTITY) { + if (this.hookedIn != null) { @@ -196,6 +_,7 @@ this.setPos(this.hookedIn.getX(), this.hookedIn.getY(0.8), this.hookedIn.getZ()); } else { @@ -65,7 +88,41 @@ this.currentState = FishingHook.FishHookState.FLYING; } } -@@ -262,13 +_,13 @@ +@@ -204,6 +_,14 @@ + } + + if (this.currentState == FishingHook.FishHookState.BOBBING) { ++ if (this.legacyFlight) { ++ this.checkCollision(); ++ if (this.hookedIn != null) { ++ new io.papermc.paper.event.entity.FishHookStateChangeEvent((org.bukkit.entity.FishHook) getBukkitEntity(), org.bukkit.entity.FishHook.HookState.HOOKED_ENTITY).callEvent(); ++ this.currentState = FishingHook.FishHookState.HOOKED_IN_ENTITY; ++ return; ++ } ++ } + Vec3 movement = this.getDeltaMovement(); + double force = this.getY() + movement.y - blockPos.getY() - liquidHeight; + if (Math.abs(force) < 0.01) { +@@ -234,6 +_,11 @@ + } + } + ++ if (this.legacyFlight && this.currentState == FishingHook.FishHookState.FLYING && !fluidState.is(FluidTags.WATER)) { ++ this.moveLegacyAirborne(); ++ return; ++ } ++ + if (!fluidState.is(FluidTags.WATER) && !this.onGround() && this.hookedIn == null) { + this.setDeltaMovement(this.getDeltaMovement().add(0.0, -0.03, 0.0)); + } +@@ -256,19 +_,19 @@ + ItemStack selectedItem = owner.getMainHandItem(); + ItemStack selectedItemOffHand = owner.getOffhandItem(); + boolean mainHandIsFishing = selectedItem.is(Items.FISHING_ROD); +- boolean offHandIsFishing = selectedItemOffHand.is(Items.FISHING_ROD); ++ boolean offHandIsFishing = owner.canUseSlot(EquipmentSlot.OFFHAND) && selectedItemOffHand.is(Items.FISHING_ROD); + if ((mainHandIsFishing || offHandIsFishing) && this.distanceToSqr(owner) <= 1024.0) { + return false; } } @@ -75,12 +132,40 @@ } private void checkCollision() { - HitResult hitResult = ProjectileUtil.getHitResultOnMoveVector(this, this::canHitEntity); +- HitResult hitResult = ProjectileUtil.getHitResultOnMoveVector(this, this::canHitEntity); - this.hitTargetOrDeflectSelf(hitResult); ++ HitResult hitResult = this.legacyFlight ? this.findLegacyHit() : ProjectileUtil.getHitResultOnMoveVector(this, this::canHitEntity); + this.preHitTargetOrDeflectSelf(hitResult); } @Override +@@ -280,11 +_,26 @@ + protected void onHitEntity(final EntityHitResult hitResult) { + super.onHitEntity(hitResult); + if (!this.level().isClientSide()) { ++ if (this.legacyFlight && this.level() instanceof ServerLevel level ++ && !hitResult.getEntity().hurtServer(level, this.damageSources().thrown(this, this.getOwner()), 0.0F)) return; + this.setHookedEntity(hitResult.getEntity()); + } + } + + @Override ++ protected boolean canCollideWithOwner(final Entity owner, final Entity entity) { ++ return this.legacyFlight ? entity != owner || this.legacyAirTicks >= 5 : super.canCollideWithOwner(owner, entity); ++ } ++ ++ private void moveLegacyAirborne() { ++ this.move(MoverType.SELF, this.getDeltaMovement()); ++ this.applyEffectsFromBlocks(); ++ this.updateRotation(); ++ float drag = this.onGround() || this.horizontalCollision ? 0.5F : 0.92F; ++ this.setDeltaMovement(this.getDeltaMovement().add(0.0, -(double) 0.04F, 0.0).scale((double) drag)); ++ } ++ ++ @Override + protected void onHitBlock(final BlockHitResult hitResult) { + super.onHitBlock(hitResult); + this.setDeltaMovement(this.getDeltaMovement().normalize().scale(hitResult.distanceTo(this))); @@ -299,11 +_,11 @@ ServerLevel serverLevel = (ServerLevel)this.level(); int fishingSpeed = 1; @@ -242,6 +327,20 @@ return dmg; } else { return 0; +@@ -507,6 +_,13 @@ + Entity owner = this.getOwner(); + if (owner != null) { + Vec3 delta = new Vec3(owner.getX() - this.getX(), owner.getY() - this.getY(), owner.getZ() - this.getZ()).scale(0.1); ++ if (this.legacyFlight) { ++ double dx = owner.getX() - this.getX(); ++ double dy = owner.getY() - this.getY(); ++ double dz = owner.getZ() - this.getZ(); ++ double distance = (double) (float) Math.sqrt(dx * dx + dy * dy + dz * dz); ++ delta = delta.add(0.0, (double) (float) Math.sqrt(distance) * 0.08, 0.0); ++ } + entity.setDeltaMovement(entity.getDeltaMovement().add(delta)); + } + } @@ -517,9 +_,9 @@ } diff --git a/paper-server/patches/sources/net/minecraft/world/entity/projectile/Projectile.java.patch b/paper-server/patches/sources/net/minecraft/world/entity/projectile/Projectile.java.patch index 7aab1ebaa180..c2c46c35f546 100644 --- a/paper-server/patches/sources/net/minecraft/world/entity/projectile/Projectile.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/entity/projectile/Projectile.java.patch @@ -147,7 +147,7 @@ protected ProjectileDeflection hitTargetOrDeflectSelf(final HitResult hitResult) { if (hitResult.getType() == HitResult.Type.ENTITY) { EntityHitResult entityHitResult = (EntityHitResult)hitResult; -@@ -313,16 +_,36 @@ +@@ -313,17 +_,92 @@ } protected void onHitBlock(final BlockHitResult hitResult) { @@ -172,6 +172,7 @@ } Entity owner = this.getOwner(); +- return owner == null || this.leftOwner || !owner.isPassengerOfSameVehicle(entity); + // Paper start - Cancel hit for vanished entities + if (owner instanceof net.minecraft.server.level.ServerPlayer) { + org.bukkit.entity.Entity collided = entity.getBukkitEntity(); @@ -181,9 +182,65 @@ + } + } + // Paper end - Cancel hit for vanished entities - return owner == null || this.leftOwner || !owner.isPassengerOfSameVehicle(entity); ++ return owner == null || this.canCollideWithOwner(owner, entity); ++ } ++ ++ // Opt-in legacy projectile flight can replace only the owner-immunity rule, after all ++ // native eligibility and Paper visibility checks have passed. ++ protected boolean canCollideWithOwner(final Entity owner, final Entity entity) { ++ return this.leftOwner || !owner.isPassengerOfSameVehicle(entity); ++ } ++ ++ protected final HitResult findLegacyHit() { ++ Vec3 from = this.position(); ++ Vec3 to = from.add(this.getDeltaMovement()); ++ HitResult result = this.level().clipIncludingBorder(new net.minecraft.world.level.ClipContext(from, to, ++ net.minecraft.world.level.ClipContext.Block.COLLIDER, net.minecraft.world.level.ClipContext.Fluid.NONE, this)); ++ Vec3 endpoint = result.getType() == HitResult.Type.MISS ? to : result.getLocation(); ++ double nearestDistance = Double.MAX_VALUE; ++ for (Entity target : this.level().getEntities(this, this.getBoundingBox().expandTowards(this.getDeltaMovement()).inflate(1.0), this::canHitEntity)) { ++ Vec3 intersection = legacyClip(target.getBoundingBox().inflate((double) 0.3F), from, endpoint); ++ if (intersection != null) { ++ double distance = from.distanceToSqr(intersection); ++ if (distance < nearestDistance) { ++ nearestDistance = distance; ++ result = new EntityHitResult(target, intersection); ++ } ++ } ++ } ++ return result; ++ } ++ ++ protected static @Nullable Vec3 legacyClip(final AABB bounds, final Vec3 from, final Vec3 to) { ++ Vec3 delta = to.subtract(from); ++ Vec3 nearest = null; ++ double nearestDistance = Double.MAX_VALUE; ++ for (int face = 0; face < 6; face++) { ++ int axis = face / 2; ++ double component = axis == 0 ? delta.x : axis == 1 ? delta.y : delta.z; ++ if (component * component < (double) 1.0E-7F) continue; ++ double origin = axis == 0 ? from.x : axis == 1 ? from.y : from.z; ++ double plane = axis == 0 ? (face % 2 == 0 ? bounds.minX : bounds.maxX) ++ : axis == 1 ? (face % 2 == 0 ? bounds.minY : bounds.maxY) ++ : (face % 2 == 0 ? bounds.minZ : bounds.maxZ); ++ double t = (plane - origin) / component; ++ if (t < 0.0 || t > 1.0) continue; ++ Vec3 candidate = from.add(delta.scale(t)); ++ boolean within = axis == 0 ? candidate.y >= bounds.minY && candidate.y <= bounds.maxY && candidate.z >= bounds.minZ && candidate.z <= bounds.maxZ ++ : axis == 1 ? candidate.x >= bounds.minX && candidate.x <= bounds.maxX && candidate.z >= bounds.minZ && candidate.z <= bounds.maxZ ++ : candidate.x >= bounds.minX && candidate.x <= bounds.maxX && candidate.y >= bounds.minY && candidate.y <= bounds.maxY; ++ if (within) { ++ double distance = from.distanceToSqr(candidate); ++ if (distance < nearestDistance) { ++ nearestDistance = distance; ++ nearest = candidate; ++ } ++ } ++ } ++ return nearest; } + protected void updateRotation() { @@ -334,13 +_,7 @@ } diff --git a/paper-server/patches/sources/net/minecraft/world/entity/projectile/ThrowableProjectile.java.patch b/paper-server/patches/sources/net/minecraft/world/entity/projectile/ThrowableProjectile.java.patch index afa84e5943d2..e914838d7d21 100644 --- a/paper-server/patches/sources/net/minecraft/world/entity/projectile/ThrowableProjectile.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/entity/projectile/ThrowableProjectile.java.patch @@ -1,11 +1,84 @@ --- a/net/minecraft/world/entity/projectile/ThrowableProjectile.java +++ b/net/minecraft/world/entity/projectile/ThrowableProjectile.java -@@ -60,7 +_,7 @@ +@@ -12,6 +_,9 @@ + + public abstract class ThrowableProjectile extends Projectile { + private static final float MIN_CAMERA_DISTANCE_SQUARED = 12.25F; ++ public boolean legacyFlight = false; ++ private int legacyAirTicks; ++ private @org.jspecify.annotations.Nullable Vec3 legacyImpactOrigin; + + protected ThrowableProjectile(final EntityType type, final Level level) { + super(type, level); +@@ -45,9 +_,15 @@ + @Override + public void tick() { + this.handleFirstTickBubbleColumn(); +- this.applyGravity(); +- this.applyInertia(); +- HitResult result = ProjectileUtil.getHitResultOnMoveVector(this, this::canHitEntity); ++ Vec3 legacyOrigin = this.legacyFlight ? this.position() : null; ++ if (this.legacyFlight) { ++ super.tick(); ++ this.legacyAirTicks++; ++ } else { ++ this.applyGravity(); ++ this.applyInertia(); ++ } ++ HitResult result = this.legacyFlight ? this.findLegacyHit() : ProjectileUtil.getHitResultOnMoveVector(this, this::canHitEntity); + Vec3 newPosition; + if (result.getType() != HitResult.Type.MISS) { + newPosition = result.getLocation(); +@@ -58,10 +_,41 @@ + this.setPos(newPosition); + this.updateRotation(); this.applyEffectsFromBlocks(); - super.tick(); +- super.tick(); ++ if (!this.legacyFlight) super.tick(); if (result.getType() != HitResult.Type.MISS && this.isAlive()) { - this.hitTargetOrDeflectSelf(result); -+ this.preHitTargetOrDeflectSelf(result); // CraftBukkit - projectile hit event - } +- } ++ this.legacyImpactOrigin = legacyOrigin; ++ try { ++ this.preHitTargetOrDeflectSelf(result); // CraftBukkit - projectile hit event ++ } finally { ++ this.legacyImpactOrigin = null; ++ } ++ } ++ if (this.legacyFlight) { ++ this.applyInertia(); ++ this.applyGravity(); ++ } ++ } ++ ++ public @org.jspecify.annotations.Nullable Vec3 getLegacyImpactOrigin() { ++ return this.legacyImpactOrigin; ++ } ++ ++ @Override ++ protected boolean canCollideWithOwner(final net.minecraft.world.entity.Entity owner, final net.minecraft.world.entity.Entity entity) { ++ return this.legacyFlight ? entity != owner || this.legacyAirTicks >= 5 : super.canCollideWithOwner(owner, entity); ++ } ++ ++ @Override ++ protected void addAdditionalSaveData(final net.minecraft.world.level.storage.ValueOutput output) { ++ super.addAdditionalSaveData(output); ++ if (this.legacyFlight) output.putBoolean("LegacyFlight", true); ++ } ++ ++ @Override ++ protected void readAdditionalSaveData(final net.minecraft.world.level.storage.ValueInput input) { ++ super.readAdditionalSaveData(input); ++ this.legacyFlight = input.getBooleanOr("LegacyFlight", false); ++ this.legacyAirTicks = 0; } + private void applyInertia() { +@@ -109,6 +_,6 @@ + + @Override + protected double getDefaultGravity() { +- return 0.03; ++ return this.legacyFlight ? (double) 0.03F : 0.03; + } + } diff --git a/paper-server/patches/sources/net/minecraft/world/entity/projectile/arrow/AbstractArrow.java.patch b/paper-server/patches/sources/net/minecraft/world/entity/projectile/arrow/AbstractArrow.java.patch index e32966517d3e..d3e1e49fedd5 100644 --- a/paper-server/patches/sources/net/minecraft/world/entity/projectile/arrow/AbstractArrow.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/entity/projectile/arrow/AbstractArrow.java.patch @@ -26,6 +26,18 @@ } public void setSoundEvent(final SoundEvent soundEvent) { +@@ -148,6 +_,11 @@ + entityData.define(IN_GROUND, false); + } + ++ // Generic opt-in; item identity, launch and damage policy belong to plugins. ++ public boolean legacyFlight = false; ++ private int legacyAirTicks; ++ private @Nullable Vec3 legacyBlockHitOrigin; ++ + @Override + public void shoot(final double xd, final double yd, final double zd, final float pow, final float uncertainty) { + super.shoot(xd, yd, zd, pow, uncertainty); @@ -157,7 +_,11 @@ @Override public void lerpMotion(final Vec3 movement) { @@ -39,7 +51,7 @@ if (this.isInGround() && movement.lengthSqr() > 0.0) { this.setInGround(false); } -@@ -218,6 +_,10 @@ +@@ -218,9 +_,14 @@ this.setSharedFlagOnFire(this.getRemainingFireTicks() > 0); } } else { @@ -48,8 +60,51 @@ + if (maxArrowDespawnInvulnerability.enabled() && this.tickCount > maxArrowDespawnInvulnerability.intValue()) this.tickDespawn(); + // Paper end - tick life regardless after X seconds this.inGroundTime = 0; ++ if (this.legacyFlight && physicsEnabled) this.legacyAirTicks++; Vec3 originalPosition = this.position(); - if (this.isInWater()) { +- if (this.isInWater()) { ++ if (this.isInWater() && !this.legacyFlight) { + this.applyInertia(this.getWaterInertia()); + this.addBubbleParticles(originalPosition); + } +@@ -262,9 +_,7 @@ + this.applyEffectsFromBlocks(); + } + +- if (!this.isInWater()) { +- this.applyInertia(this.getAirDrag()); +- } ++ this.applyFlightDrag(originalPosition); + + if (physicsEnabled && !this.isInGround()) { + this.applyGravity(); +@@ -279,6 +_,18 @@ + return 0.99F; + } + ++ private void applyFlightDrag(final Vec3 originalPosition) { ++ if (this.legacyFlight && this.isInWater()) { ++ this.applyInertia(0.8F); ++ this.addBubbleParticles(originalPosition); ++ } else if (!this.isInWater()) { ++ this.applyInertia(this.getAirDrag()); ++ } ++ // The legacy impact tick applied gravity even after becoming embedded. ++ // Ground ticks never enter this flight helper. Keep the retained release motion. ++ if (this.legacyFlight && this.isInGround() && !this.noPhysics) this.applyGravity(); ++ } ++ + private void stepMoveAndHit(final BlockHitResult blockHitResult) { + while (this.isAlive()) { + Vec3 initialPosition = this.position(); +@@ -286,6 +_,7 @@ + entitiesHit.sort(Comparator.comparingDouble(c -> initialPosition.distanceToSqr(c.getEntity().position()))); + EntityHitResult firstEntityHit = entitiesHit.isEmpty() ? null : entitiesHit.getFirst(); + Vec3 nextLocation = Objects.requireNonNullElse(firstEntityHit, blockHitResult).getLocation(); ++ if (this.legacyFlight) this.legacyBlockHitOrigin = initialPosition; + this.setPos(nextLocation); + this.applyEffectsFromBlocks(initialPosition, nextLocation); + if (this.portalProcess != null && this.portalProcess.isInsidePortalThisTick()) { @@ -294,7 +_,7 @@ if (entitiesHit.isEmpty()) { @@ -68,7 +123,7 @@ if (!this.isAlive() || deflection != ProjectileDeflection.NONE) { return deflection; } -@@ -343,20 +_,37 @@ +@@ -343,20 +_,38 @@ } } @@ -87,7 +142,8 @@ + @Override protected double getDefaultGravity() { - return 0.05; +- return 0.05; ++ return this.legacyFlight ? (double) 0.05F : 0.05; } private boolean shouldFall() { @@ -97,6 +153,7 @@ private void startFalling() { this.setInGround(false); ++ this.legacyAirTicks = 0; Vec3 deltaMovement = this.getDeltaMovement(); this.setDeltaMovement(deltaMovement.multiply(this.random.nextFloat() * 0.2F, this.random.nextFloat() * 0.2F, this.random.nextFloat() * 0.2F)); - this.life = 0; @@ -158,7 +215,7 @@ } if (entity.hurtOrSimulate(damageSource, damage)) { -@@ -503,7 +_,7 @@ +@@ -503,18 +_,26 @@ this.playSound(this.soundEvent, 1.0F, 1.2F / (this.random.nextFloat() * 0.2F + 0.9F)); if (this.getPierceLevel() <= 0) { @@ -167,7 +224,20 @@ } } else { entity.setRemainingFireTicks(remainingFireTicks); -@@ -514,7 +_,7 @@ +- this.deflect(ProjectileDeflection.REVERSE, entity, this.owner, false); +- this.setDeltaMovement(this.getDeltaMovement().scale(0.2)); ++ if (this.legacyFlight) { ++ this.setDeltaMovement(this.getDeltaMovement().scale((double) -0.1F)); ++ this.setYRot(this.getYRot() + 180.0F); ++ this.yRotO += 180.0F; ++ this.legacyAirTicks = 0; ++ this.needsSync = true; ++ } else { ++ this.deflect(ProjectileDeflection.REVERSE, entity, this.owner, false); ++ this.setDeltaMovement(this.getDeltaMovement().scale(0.2)); ++ } + if (this.level() instanceof ServerLevel level && this.getDeltaMovement().lengthSqr() < 1.0E-7) { + if (this.pickup == AbstractArrow.Pickup.ALLOWED) { this.spawnAtLocation(level, this.getPickupItem(), 0.1F); } @@ -176,16 +246,110 @@ } } } -@@ -527,7 +_,7 @@ +@@ -525,9 +_,18 @@ + : 0.0F; + if (knockback > 0.0) { double knockbackResistance = Math.max(0.0, 1.0 - mob.getAttributeValue(Attributes.KNOCKBACK_RESISTANCE)); - Vec3 movement = this.getDeltaMovement().multiply(1.0, 0.0, 1.0).normalize().scale(knockback * 0.6 * knockbackResistance); +- Vec3 movement = this.getDeltaMovement().multiply(1.0, 0.0, 1.0).normalize().scale(knockback * 0.6 * knockbackResistance); ++ Vec3 flight = this.getDeltaMovement(); ++ Vec3 movement; ++ if (this.legacyFlight) { ++ float length = (float) Math.sqrt(flight.x * flight.x + flight.z * flight.z); ++ movement = length > 0.0F ? new Vec3( ++ flight.x * knockback * (double) 0.6F / (double) length * knockbackResistance, 0.0, ++ flight.z * knockback * (double) 0.6F / (double) length * knockbackResistance) : Vec3.ZERO; ++ } else { ++ movement = flight.multiply(1.0, 0.0, 1.0).normalize().scale(knockback * 0.6 * knockbackResistance); ++ } if (movement.lengthSqr() > 0.0) { - mob.push(movement.x, 0.1, movement.z); + mob.push(movement.x, 0.1, movement.z, this); // Paper - Add EntityKnockbackByEntityEvent and EntityPushedByEntityAttackEvent } } } -@@ -627,7 +_,7 @@ +@@ -542,10 +_,22 @@ + } + + Vec3 movement = this.getDeltaMovement(); +- Vec3 offsetDirection = new Vec3(Math.signum(movement.x), Math.signum(movement.y), Math.signum(movement.z)); +- Vec3 scaledMovement = offsetDirection.scale(0.05F); +- this.setPos(this.position().subtract(scaledMovement)); +- this.setDeltaMovement(Vec3.ZERO); ++ if (this.legacyFlight && this.legacyBlockHitOrigin != null) { ++ Vec3 origin = this.legacyBlockHitOrigin; ++ Vec3 hit = hitResult.getLocation(); ++ Vec3 segment = new Vec3((float) (hit.x - origin.x), (float) (hit.y - origin.y), (float) (hit.z - origin.z)); ++ float length = (float) Math.sqrt(segment.lengthSqr()); ++ Vec3 backoff = length > 0.0F ? new Vec3(segment.x / length * (double) 0.05F, ++ segment.y / length * (double) 0.05F, segment.z / length * (double) 0.05F) : Vec3.ZERO; ++ this.setPos(origin.subtract(backoff).add(segment)); ++ this.setDeltaMovement(segment); ++ this.legacyBlockHitOrigin = null; ++ } else { ++ Vec3 offsetDirection = new Vec3(Math.signum(movement.x), Math.signum(movement.y), Math.signum(movement.z)); ++ Vec3 scaledMovement = offsetDirection.scale(0.05F); ++ this.setPos(this.position().subtract(scaledMovement)); ++ this.setDeltaMovement(Vec3.ZERO); ++ } + this.playSound(this.getHitGroundSoundEvent(), 1.0F, 1.2F / (this.random.nextFloat() * 0.2F + 0.9F)); + this.setInGround(true); + this.shakeTime = 7; +@@ -592,12 +_,40 @@ + } + + protected Collection findHitEntities(final Vec3 from, final Vec3 to) { ++ if (this.legacyFlight) { ++ // The caller clips `to` at the first native block intersection. ++ // Use the nearest ray entry, rather than modern center-distance ++ // ordering or a margin which grows with projectile age. ++ EntityHitResult nearest = null; ++ double nearestDistance = Double.MAX_VALUE; ++ AABB search = this.getBoundingBox().expandTowards(this.getDeltaMovement()).inflate(1.0); ++ for (Entity target : this.level().getEntities(this, search, this::canHitEntity)) { ++ Vec3 intersection = legacyClip(target.getBoundingBox().inflate((double) 0.3F), from, to); ++ if (intersection != null) { ++ double distance = from.distanceToSqr(intersection); ++ if (distance < nearestDistance) { ++ nearestDistance = distance; ++ nearest = new EntityHitResult(target, intersection); ++ } ++ } ++ } ++ // Return one native result. stepMoveAndHit still dispatches the ++ // cancellable projectile-hit event before native damage and Punch. ++ return nearest == null ? java.util.List.of() : java.util.List.of(nearest); ++ } + return ProjectileUtil.getManyEntityHitResult( + this.level(), this, from, to, this.getBoundingBox().expandTowards(this.getDeltaMovement()).inflate(1.0), this::canHitEntity, false + ); + } + + @Override ++ protected boolean canCollideWithOwner(final Entity owner, final Entity entity) { ++ return this.legacyFlight ++ ? entity != owner || this.legacyAirTicks >= 5 ++ : super.canCollideWithOwner(owner, entity); ++ } ++ ++ @Override + protected boolean canHitEntity(final Entity entity) { + return !(entity instanceof Player playerEntity && this.getOwner() instanceof Player player && !player.canHarmPlayer(playerEntity)) + && super.canHitEntity(entity) +@@ -607,6 +_,7 @@ + @Override + protected void addAdditionalSaveData(final ValueOutput output) { + super.addAdditionalSaveData(output); ++ if (this.legacyFlight) output.putBoolean("LegacyFlight", true); + output.putShort("life", (short)this.life); + output.storeNullable("inBlockState", BlockState.CODEC, this.lastState); + output.putByte("shake", (byte)this.shakeTime); +@@ -623,11 +_,13 @@ + @Override + protected void readAdditionalSaveData(final ValueInput input) { + super.readAdditionalSaveData(input); ++ this.legacyFlight = input.getBooleanOr("LegacyFlight", false); ++ this.legacyAirTicks = 0; // Air age was not saved by the legacy arrow either. + this.life = input.getShortOr("life", (short)0); this.lastState = input.read("inBlockState", BlockState.CODEC).orElse(null); this.shakeTime = input.getByteOr("shake", (byte)0) & 255; this.setInGround(input.getBooleanOr("inGround", false)); diff --git a/paper-server/patches/sources/net/minecraft/world/entity/projectile/throwableitemprojectile/AbstractThrownPotion.java.patch b/paper-server/patches/sources/net/minecraft/world/entity/projectile/throwableitemprojectile/AbstractThrownPotion.java.patch index 15a837052f80..73283d2e18e2 100644 --- a/paper-server/patches/sources/net/minecraft/world/entity/projectile/throwableitemprojectile/AbstractThrownPotion.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/entity/projectile/throwableitemprojectile/AbstractThrownPotion.java.patch @@ -1,5 +1,14 @@ --- a/net/minecraft/world/entity/projectile/throwableitemprojectile/AbstractThrownPotion.java +++ b/net/minecraft/world/entity/projectile/throwableitemprojectile/AbstractThrownPotion.java +@@ -44,7 +_,7 @@ + + @Override + protected double getDefaultGravity() { +- return 0.05; ++ return this.legacyFlight ? (double) 0.05F : 0.05; + } + + @Override @@ -70,56 +_,97 @@ @Override protected void onHit(final HitResult hitResult) { diff --git a/paper-server/patches/sources/net/minecraft/world/entity/projectile/throwableitemprojectile/ThrownExperienceBottle.java.patch b/paper-server/patches/sources/net/minecraft/world/entity/projectile/throwableitemprojectile/ThrownExperienceBottle.java.patch index 2a4ae8a8a08e..5636b36d80bf 100644 --- a/paper-server/patches/sources/net/minecraft/world/entity/projectile/throwableitemprojectile/ThrownExperienceBottle.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/entity/projectile/throwableitemprojectile/ThrownExperienceBottle.java.patch @@ -1,6 +1,14 @@ --- a/net/minecraft/world/entity/projectile/throwableitemprojectile/ThrownExperienceBottle.java +++ b/net/minecraft/world/entity/projectile/throwableitemprojectile/ThrownExperienceBottle.java -@@ -41,16 +_,25 @@ +@@ -34,23 +_,32 @@ + + @Override + protected double getDefaultGravity() { +- return 0.07; ++ return this.legacyFlight ? (double) 0.07F : 0.07; + } + + @Override protected void onHit(final HitResult hitResult) { super.onHit(hitResult); if (this.level() instanceof ServerLevel level) { diff --git a/paper-server/patches/sources/net/minecraft/world/entity/projectile/throwableitemprojectile/ThrownSplashPotion.java.patch b/paper-server/patches/sources/net/minecraft/world/entity/projectile/throwableitemprojectile/ThrownSplashPotion.java.patch index b32cc39ff02d..a8b635039e05 100644 --- a/paper-server/patches/sources/net/minecraft/world/entity/projectile/throwableitemprojectile/ThrownSplashPotion.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/entity/projectile/throwableitemprojectile/ThrownSplashPotion.java.patch @@ -1,6 +1,6 @@ --- a/net/minecraft/world/entity/projectile/throwableitemprojectile/ThrownSplashPotion.java +++ b/net/minecraft/world/entity/projectile/throwableitemprojectile/ThrownSplashPotion.java -@@ -38,13 +_,14 @@ +@@ -38,23 +_,45 @@ } @Override @@ -10,21 +10,27 @@ float durationScale = potionItem.getOrDefault(DataComponents.POTION_DURATION_SCALE, 1.0F); Iterable mobEffects = contents.getAllEffects(); - AABB potionAabb = this.getBoundingBox().move(hitResult.getLocation().subtract(this.position())); -+ AABB potionAabb = hitResult == null ? this.getBoundingBox() : this.getBoundingBox().move(hitResult.getLocation().subtract(this.position())); // Paper - More projectile API ++ net.minecraft.world.phys.Vec3 legacyOrigin = this.legacyFlight ? this.getLegacyImpactOrigin() : null; ++ AABB potionAabb = legacyOrigin != null ? this.getBoundingBox().move(legacyOrigin.subtract(this.position())) ++ : hitResult == null ? this.getBoundingBox() : this.getBoundingBox().move(hitResult.getLocation().subtract(this.position())); // Paper - More projectile API AABB effectAabb = potionAabb.inflate(4.0, 2.0, 4.0); List entities = this.level().getEntitiesOfClass(LivingEntity.class, effectAabb); + java.util.Map affected = new java.util.HashMap<>(); // CraftBukkit float margin = ProjectileUtil.computeMargin(this); if (!entities.isEmpty()) { Entity effectSource = this.getEffectSource(); -@@ -53,8 +_,25 @@ + + for (LivingEntity entity : entities) { if (entity.isAffectedByPotions()) { - double dist = potionAabb.distanceToSqr(entity.getBoundingBox().inflate(margin)); +- double dist = potionAabb.distanceToSqr(entity.getBoundingBox().inflate(margin)); ++ double dist = legacyOrigin != null ? legacyOrigin.distanceToSqr(entity.position()) ++ : potionAabb.distanceToSqr(entity.getBoundingBox().inflate(margin)); if (dist < 16.0) { - double scale = 1.0 - Math.sqrt(dist) / 4.0; - + double scale = 1.0 - Math.sqrt(dist) / 4.0; // Paper - diff on change, used when calling the splash event for water splash potions + // CraftBukkit start ++ if (legacyOrigin != null && hitResult instanceof net.minecraft.world.phys.EntityHitResult directHit && directHit.getEntity() == entity) scale = 1.0; + affected.put(entity.getBukkitLivingEntity(), scale); + } + } diff --git a/paper-server/patches/sources/net/minecraft/world/inventory/InventoryMenu.java.patch b/paper-server/patches/sources/net/minecraft/world/inventory/InventoryMenu.java.patch index 59b69ac604cb..5928ed93080b 100644 --- a/paper-server/patches/sources/net/minecraft/world/inventory/InventoryMenu.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/inventory/InventoryMenu.java.patch @@ -15,6 +15,18 @@ this.active = active; this.owner = owner; this.addResultSlot(owner, 154, 28); +@@ -61,6 +_,11 @@ + this.addStandardInventorySlots(inventory, 8, 84); + this.addSlot(new Slot(inventory, 40, 77, 62) { + @Override ++ public boolean mayPlace(final ItemStack itemStack) { ++ return owner.offHandEnabled && super.mayPlace(itemStack); ++ } ++ ++ @Override + public void setByPlayer(final ItemStack itemStack, final ItemStack previous) { + owner.onEquipItem(EquipmentSlot.OFFHAND, previous, itemStack); + super.setByPlayer(itemStack, previous); @@ -188,4 +_,54 @@ protected Player owner() { return this.owner; diff --git a/paper-server/patches/sources/net/minecraft/world/item/AxeItem.java.patch b/paper-server/patches/sources/net/minecraft/world/item/AxeItem.java.patch index 160a7cbc7f00..41247a8385da 100644 --- a/paper-server/patches/sources/net/minecraft/world/item/AxeItem.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/item/AxeItem.java.patch @@ -1,5 +1,13 @@ --- a/net/minecraft/world/item/AxeItem.java +++ b/net/minecraft/world/item/AxeItem.java +@@ -12,6 +_,7 @@ + import net.minecraft.sounds.SoundSource; + import net.minecraft.world.InteractionHand; + import net.minecraft.world.InteractionResult; ++import net.minecraft.world.entity.EquipmentSlot; + import net.minecraft.world.entity.player.Player; + import net.minecraft.world.item.context.UseOnContext; + import net.minecraft.world.level.Level; @@ -72,6 +_,11 @@ } @@ -12,3 +20,11 @@ if (player instanceof ServerPlayer serverPlayer) { CriteriaTriggers.ITEM_USED_ON_BLOCK.trigger(serverPlayer, pos, itemInHand); } +@@ -88,6 +_,7 @@ + private static boolean playerHasBlockingItemUseIntent(final UseOnContext context) { + Player player = context.getPlayer(); + return context.getHand().equals(InteractionHand.MAIN_HAND) ++ && player.canUseSlot(EquipmentSlot.OFFHAND) // Paper - configurable off-hand eligibility + && player.getOffhandItem().has(DataComponents.BLOCKS_ATTACKS) + && !player.isSecondaryUseActive(); + } diff --git a/paper-server/patches/sources/net/minecraft/world/item/ProjectileWeaponItem.java.patch b/paper-server/patches/sources/net/minecraft/world/item/ProjectileWeaponItem.java.patch index 92137878f706..f18b7da682e2 100644 --- a/paper-server/patches/sources/net/minecraft/world/item/ProjectileWeaponItem.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/item/ProjectileWeaponItem.java.patch @@ -1,5 +1,22 @@ --- a/net/minecraft/world/item/ProjectileWeaponItem.java +++ b/net/minecraft/world/item/ProjectileWeaponItem.java +@@ -8,6 +_,7 @@ + import net.minecraft.tags.ItemTags; + import net.minecraft.util.Unit; + import net.minecraft.world.InteractionHand; ++import net.minecraft.world.entity.EquipmentSlot; + import net.minecraft.world.entity.LivingEntity; + import net.minecraft.world.entity.player.Player; + import net.minecraft.world.entity.projectile.Projectile; +@@ -31,7 +_,7 @@ + public abstract Predicate getAllSupportedProjectiles(); + + public static ItemStack getHeldProjectile(final LivingEntity entity, final Predicate valid) { +- if (valid.test(entity.getItemInHand(InteractionHand.OFF_HAND))) { ++ if (entity.canUseSlot(EquipmentSlot.OFFHAND) && valid.test(entity.getItemInHand(InteractionHand.OFF_HAND))) { // Paper - configurable off-hand eligibility + return entity.getItemInHand(InteractionHand.OFF_HAND); + } else { + return valid.test(entity.getItemInHand(InteractionHand.MAIN_HAND)) ? entity.getItemInHand(InteractionHand.MAIN_HAND) : ItemStack.EMPTY; @@ -50,6 +_,7 @@ final float uncertainty, final boolean isCrit, diff --git a/paper-server/patches/sources/net/minecraft/world/item/enchantment/EnchantmentHelper.java.patch b/paper-server/patches/sources/net/minecraft/world/item/enchantment/EnchantmentHelper.java.patch index 6613a6b8b455..e00dc2d0f939 100644 --- a/paper-server/patches/sources/net/minecraft/world/item/enchantment/EnchantmentHelper.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/item/enchantment/EnchantmentHelper.java.patch @@ -16,3 +16,11 @@ if (oldEnchantments == null) { return ItemEnchantments.EMPTY; } +@@ -490,6 +_,7 @@ + List items = new ArrayList<>(); + + for (EquipmentSlot slot : EquipmentSlot.VALUES) { ++ if (slot == EquipmentSlot.OFFHAND && !source.canUseSlot(slot)) continue; + ItemStack item = source.getItemBySlot(slot); + if (predicate.test(item)) { + ItemEnchantments enchantments = item.getOrDefault(DataComponents.ENCHANTMENTS, ItemEnchantments.EMPTY); diff --git a/paper-server/patches/sources/net/minecraft/world/level/ExplosionDamageCalculator.java.patch b/paper-server/patches/sources/net/minecraft/world/level/ExplosionDamageCalculator.java.patch new file mode 100644 index 000000000000..7e64a9db818f --- /dev/null +++ b/paper-server/patches/sources/net/minecraft/world/level/ExplosionDamageCalculator.java.patch @@ -0,0 +1,53 @@ +--- a/net/minecraft/world/level/ExplosionDamageCalculator.java ++++ b/net/minecraft/world/level/ExplosionDamageCalculator.java +@@ -30,9 +_,47 @@ + + public float getEntityDamageAmount(final Explosion explosion, final Entity entity, final float exposure) { + float doubleRadius = explosion.radius() * 2.0F; +- Vec3 center = explosion.center(); +- double dist = Math.sqrt(entity.distanceToSqr(center)) / doubleRadius; ++ boolean legacy = this.usesLegacyPhysics(explosion, entity); ++ double dist = this.getEntityDistance(explosion, entity) / doubleRadius; + double pow = (1.0 - dist) * exposure; +- return (float)((pow * pow + pow) / 2.0 * 7.0 * doubleRadius + 1.0); ++ return legacy ++ ? (float) ((int) ((pow * pow + pow) / 2.0 * 8.0 * doubleRadius + 1.0)) ++ : (float) ((pow * pow + pow) / 2.0 * 7.0 * doubleRadius + 1.0); ++ } ++ ++ // Generic player opt-in, limited to vanilla's classic explosion sources. ++ // Simple/custom calculators, wind charges and modern explosions ++ // retain their own damage and impulse policy. ++ public boolean usesLegacyPhysics(final Explosion explosion, final Entity entity) { ++ if (!(entity instanceof net.minecraft.world.entity.player.Player player && player.legacyCombat) ++ || this.getClass() != ExplosionDamageCalculator.class && this.getClass() != EntityBasedExplosionDamageCalculator.class) return false; ++ Entity source = explosion.getDirectSourceEntity(); ++ if (source == null) { ++ return this.getClass() == ExplosionDamageCalculator.class ++ && explosion instanceof ServerExplosion serverExplosion ++ && serverExplosion.getDamageSource().is(net.minecraft.world.damagesource.DamageTypes.BAD_RESPAWN_POINT); ++ } ++ return source instanceof net.minecraft.world.entity.item.PrimedTnt ++ || source instanceof net.minecraft.world.entity.monster.Creeper ++ || source instanceof net.minecraft.world.entity.vehicle.minecart.MinecartTNT ++ || source instanceof net.minecraft.world.entity.projectile.hurtingprojectile.LargeFireball ++ || source instanceof net.minecraft.world.entity.projectile.hurtingprojectile.WitherSkull ++ || source instanceof net.minecraft.world.entity.boss.wither.WitherBoss ++ || source instanceof net.minecraft.world.entity.boss.enderdragon.EndCrystal; ++ } ++ ++ public double getEntityDistance(final Explosion explosion, final Entity entity) { ++ double distance = Math.sqrt(entity.distanceToSqr(explosion.center())); ++ return this.usesLegacyPhysics(explosion, entity) ? (double) (float) distance : distance; ++ } ++ ++ public Vec3 getEntityKnockbackDirection(final Explosion explosion, final Entity entity) { ++ if (this.usesLegacyPhysics(explosion, entity)) { ++ Vec3 delta = new Vec3(entity.getX(), entity.getY() + (double) 1.62F, entity.getZ()).subtract(explosion.center()); ++ double length = (double) (float) Math.sqrt(delta.lengthSqr()); ++ return length == 0.0 ? Vec3.ZERO : new Vec3(delta.x / length, delta.y / length, delta.z / length); ++ } ++ Vec3 origin = entity instanceof net.minecraft.world.entity.item.PrimedTnt ? entity.position() : entity.getEyePosition(); ++ return origin.subtract(explosion.center()).normalize(); + } + } diff --git a/paper-server/patches/sources/net/minecraft/world/level/ServerExplosion.java.patch b/paper-server/patches/sources/net/minecraft/world/level/ServerExplosion.java.patch index 24052a5a5595..a801ad147172 100644 --- a/paper-server/patches/sources/net/minecraft/world/level/ServerExplosion.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/level/ServerExplosion.java.patch @@ -51,7 +51,7 @@ } xp += xd * 0.3F; -@@ -178,8 +_,9 @@ +@@ -178,29 +_,68 @@ int y1 = Mth.floor(this.center.y + doubleRadius + 1.0); int z0 = Mth.floor(this.center.z - doubleRadius - 1.0); int z1 = Mth.floor(this.center.z + doubleRadius + 1.0); @@ -60,10 +60,14 @@ - for (Entity entity : this.level.getEntities(this.source, new AABB(x0, y0, z0, x1, y1, z1))) { + for (Entity entity : list) { // Paper - used in loop if (!entity.ignoreExplosion(this)) { - double dist = Math.sqrt(entity.distanceToSqr(this.center)) / doubleRadius; +- double dist = Math.sqrt(entity.distanceToSqr(this.center)) / doubleRadius; ++ boolean legacyPhysics = this.damageCalculator.usesLegacyPhysics(this, entity); ++ double dist = this.damageCalculator.getEntityDistance(this, entity) / doubleRadius; if (!(dist > 1.0)) { -@@ -187,20 +_,56 @@ - Vec3 direction = entityOrigin.subtract(this.center).normalize(); +- Vec3 entityOrigin = entity instanceof PrimedTnt ? entity.position() : entity.getEyePosition(); +- Vec3 direction = entityOrigin.subtract(this.center).normalize(); ++ Vec3 direction = this.damageCalculator.getEntityKnockbackDirection(this, entity); ++ if (legacyPhysics && direction.equals(Vec3.ZERO)) continue; boolean shouldDamageEntity = this.damageCalculator.shouldDamageEntity(this, entity); float knockbackMultiplier = this.damageCalculator.getKnockbackMultiplier(entity); - float exposure = !shouldDamageEntity && knockbackMultiplier == 0.0F ? 0.0F : getSeenPercent(this.center, entity); @@ -92,7 +96,8 @@ + } + } + } else { -+ entity.hurtServer(this.level, this.damageSource, this.damageCalculator.getEntityDamageAmount(this, entity, exposure)); ++ boolean accepted = entity.hurtServer(this.level, this.damageSource, this.damageCalculator.getEntityDamageAmount(this, entity, exposure)); ++ if (legacyPhysics && !accepted) continue; + } + + if (entity.lastDamageCancelled) { // SPIGOT-5339, SPIGOT-6252, SPIGOT-6777: Skip entity if damage event was cancelled diff --git a/paper-server/patches/sources/net/minecraft/world/level/block/BaseFireBlock.java.patch b/paper-server/patches/sources/net/minecraft/world/level/block/BaseFireBlock.java.patch index 7586d87f1eff..cf2e542bf795 100644 --- a/paper-server/patches/sources/net/minecraft/world/level/block/BaseFireBlock.java.patch +++ b/paper-server/patches/sources/net/minecraft/world/level/block/BaseFireBlock.java.patch @@ -1,6 +1,6 @@ --- a/net/minecraft/world/level/block/BaseFireBlock.java +++ b/net/minecraft/world/level/block/BaseFireBlock.java -@@ -136,12 +_,13 @@ +@@ -136,45 +_,59 @@ final InsideBlockEffectApplier effectApplier, final boolean isPrecise ) { @@ -13,9 +13,15 @@ - public static void fireIgnite(final Entity entity) { + public static void fireIgnite(final Entity entity, BlockPos pos) { // Paper - track position inside effect was triggered on if (!entity.fireImmune()) { ++ boolean legacyFire = entity instanceof ServerPlayer player && player.legacyCombat ++ && entity.level().getBlockState(pos).is(Blocks.FIRE); if (entity.getRemainingFireTicks() < 0) { - entity.setRemainingFireTicks(entity.getRemainingFireTicks() + 1); -@@ -151,30 +_,41 @@ +- entity.setRemainingFireTicks(entity.getRemainingFireTicks() + 1); ++ entity.setRemainingFireTicks((legacyFire ? Math.max(-1, entity.getRemainingFireTicks()) : entity.getRemainingFireTicks()) + 1); + } else if (entity instanceof ServerPlayer) { +- int addedFireTicks = entity.level().getRandom().nextInt(1, 3); ++ int addedFireTicks = legacyFire ? 1 : entity.level().getRandom().nextInt(1, 3); + entity.setRemainingFireTicks(entity.getRemainingFireTicks() + addedFireTicks); } if (entity.getRemainingFireTicks() >= 0) { diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/entity/AbstractProjectile.java b/paper-server/src/main/java/org/bukkit/craftbukkit/entity/AbstractProjectile.java index 72863de5d5a1..43b84f0ca836 100644 --- a/paper-server/src/main/java/org/bukkit/craftbukkit/entity/AbstractProjectile.java +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/entity/AbstractProjectile.java @@ -1,5 +1,6 @@ package org.bukkit.craftbukkit.entity; +import com.google.common.base.Preconditions; import net.minecraft.Optionull; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityReference; @@ -17,6 +18,14 @@ public net.minecraft.world.entity.projectile.Projectile getHandle() { return (net.minecraft.world.entity.projectile.Projectile) this.entity; } + @Override + public void setPositionBeforeSpawn(org.bukkit.Location position) { + Preconditions.checkArgument(position != null && this.getWorld().equals(position.getWorld()), "Initial position must be in the projectile's world"); + position.checkFinite(); + Preconditions.checkState(!this.getHandle().valid && this.getHandle().tickCount == 0 && this.getHandle().isAlive(), "Projectile has already been inserted or ticked"); + this.getHandle().setPos(position.getX(), position.getY(), position.getZ()); + } + @Override public boolean doesBounce() { return false; diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftAbstractArrow.java b/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftAbstractArrow.java index cbcb8a805173..dc7bf3841113 100644 --- a/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftAbstractArrow.java +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftAbstractArrow.java @@ -27,6 +27,16 @@ public net.minecraft.world.entity.projectile.arrow.AbstractArrow getHandle() { return (net.minecraft.world.entity.projectile.arrow.AbstractArrow) this.entity; } + @Override + public void setLegacyFlight(boolean legacyFlight) { + this.getHandle().legacyFlight = legacyFlight; + } + + @Override + public boolean hasLegacyFlight() { + return this.getHandle().legacyFlight; + } + @Override public void setKnockbackStrength(int knockbackStrength) { } diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftFishHook.java b/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftFishHook.java index 82c742161a2c..10f1aeb902e6 100644 --- a/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftFishHook.java +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftFishHook.java @@ -246,4 +246,14 @@ public int retrieve(EquipmentSlot slot) { return fishingHook.retrieve(itemInHand, hand); } + @Override + public void setLegacyFlight(boolean legacyFlight) { + this.getHandle().legacyFlight = legacyFlight; + } + + @Override + public boolean hasLegacyFlight() { + return this.getHandle().legacyFlight; + } + } diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java b/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java index ad0f3e3e7b01..78d56f753e88 100644 --- a/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java @@ -254,6 +254,26 @@ public CraftPlayer(CraftServer server, ServerPlayer entity) { this.firstPlayed = System.currentTimeMillis(); } + @Override + public void setLegacyCombat(boolean legacyCombat) { + this.getHandle().legacyCombat = legacyCombat; + } + + @Override + public boolean hasLegacyCombat() { + return this.getHandle().legacyCombat; + } + + @Override + public void setOffHandEnabled(boolean enabled) { + this.getHandle().offHandEnabled = enabled; + } + + @Override + public boolean isOffHandEnabled() { + return this.getHandle().offHandEnabled; + } + @Override public ServerPlayer getHandle() { return (ServerPlayer) this.entity; diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftThrowableProjectile.java b/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftThrowableProjectile.java index 8e02e2da86f7..05cfd8849cd9 100644 --- a/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftThrowableProjectile.java +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftThrowableProjectile.java @@ -26,4 +26,14 @@ public ItemStack getItem() { public void setItem(ItemStack item) { this.getHandle().setItem(CraftItemStack.asNMSCopy(item)); } + @Override + public void setLegacyFlight(boolean legacyFlight) { + this.getHandle().legacyFlight = legacyFlight; + } + + @Override + public boolean hasLegacyFlight() { + return this.getHandle().legacyFlight; + } + } diff --git a/paper-server/src/test/java/net/minecraft/world/entity/LegacyArrowKnockbackTest.java b/paper-server/src/test/java/net/minecraft/world/entity/LegacyArrowKnockbackTest.java new file mode 100644 index 000000000000..84574dcce31d --- /dev/null +++ b/paper-server/src/test/java/net/minecraft/world/entity/LegacyArrowKnockbackTest.java @@ -0,0 +1,151 @@ +package net.minecraft.world.entity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.papermc.paper.event.entity.EntityKnockbackEvent; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.projectile.arrow.Arrow; +import net.minecraft.world.entity.projectile.arrow.AbstractArrow; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.enchantment.EnchantmentHelper; +import net.minecraft.world.phys.Vec3; +import org.bukkit.craftbukkit.entity.CraftLivingEntity; +import org.bukkit.craftbukkit.event.CraftEventFactory; +import org.bukkit.support.environment.VanillaFeature; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +@VanillaFeature +class LegacyArrowKnockbackTest { + + @Test + void selfArrowUsesLegacyRandomBaseAndAirborneVerticalHalving() { + LivingEntity victim = mock(LivingEntity.class, CALLS_REAL_METHODS); + doReturn(0.0).when(victim).getAttributeValue(any()); + doReturn(0.0).when(victim).getX(); + doReturn(0.0).when(victim).getZ(); + Arrow arrow = mock(Arrow.class); + arrow.legacyFlight = true; + when(arrow.getOwner()).thenReturn(victim); + when(arrow.calculateHorizontalHurtKnockbackDirection(any(), any())).thenReturn( + it.unimi.dsi.fastutil.doubles.DoubleDoubleImmutablePair.of(2, 0)); + DamageSource source = mock(DamageSource.class); + when(source.getDirectEntity()).thenReturn(arrow); + doReturn(new Vec3(1, -0.6, 0)).when(victim).getDeltaMovement(); + doReturn(mock(CraftLivingEntity.class)).when(victim).getBukkitEntity(); + doNothing().when(victim).setDeltaMovement(any(Vec3.class)); + doNothing().when(victim).indicateDamage(anyDouble(), anyDouble()); + try (MockedStatic factory = mockStatic(CraftEventFactory.class)) { + factory.when(() -> CraftEventFactory.callEntityKnockbackEvent(any(), any(), any(), any(), anyDouble(), any())) + .thenAnswer(call -> { + Vec3 outgoing = call.getArgument(5); + // Base strength stays 0.4, but direction is randomized for a self-hit. + assertEquals((double) 0.4F, Math.hypot(outgoing.x + 0.5, outgoing.z), 1.0E-7); + EntityKnockbackEvent event = mock(EntityKnockbackEvent.class); + when(event.getKnockback()).thenReturn(new Vector(outgoing.x, outgoing.y, outgoing.z)); + return event; + }); + victim.dealDefaultKnockback(source, 1, false); + } + ArgumentCaptor output = ArgumentCaptor.forClass(Vec3.class); + verify(victim).setDeltaMovement(output.capture()); + assertEquals(-0.6 / 2 + (double) 0.4F, output.getValue().y, 1.0E-12); + assertTrue(victim.needsSync); + } + + @Test + void cancelledNativeKnockbackProducesNoVelocityMutation() { + LivingEntity victim = mock(LivingEntity.class, CALLS_REAL_METHODS); + doReturn(0.0).when(victim).getAttributeValue(any()); + doReturn(0.0).when(victim).getX(); + doReturn(0.0).when(victim).getZ(); + Arrow arrow = mock(Arrow.class); + arrow.legacyFlight = true; + DamageSource source = mock(DamageSource.class); + when(source.getDirectEntity()).thenReturn(arrow); + doReturn(new Vec3(1, 0.6, 0)).when(victim).getDeltaMovement(); + doReturn(mock(CraftLivingEntity.class)).when(victim).getBukkitEntity(); + try (MockedStatic factory = mockStatic(CraftEventFactory.class)) { + EntityKnockbackEvent cancelled = mock(EntityKnockbackEvent.class); + when(cancelled.isCancelled()).thenReturn(true); + factory.when(() -> CraftEventFactory.callEntityKnockbackEvent(any(), any(), any(), any(), anyDouble(), any())) + .thenReturn(cancelled); + victim.knockback(0.4F, 1, 0, source, 1, false, arrow, EntityKnockbackEvent.Cause.ENTITY_ATTACK); + } + verify(victim, never()).setDeltaMovement(any(Vec3.class)); + } + @Test + void markedPunchUsesFloatNormAndFloatFactorForNonAxialFlight() throws Exception { + Vec3 flight = new Vec3(0.7, 0.4, 0.23); + float length = (float) Math.sqrt(flight.x * flight.x + flight.z * flight.z); + assertPunch(true, flight, 0.25, + new Vec3(flight.x * 2.0 * (double) 0.6F / length * 0.75, 0.1, + flight.z * 2.0 * (double) 0.6F / length * 0.75)); + } + + @Test + void markedNearVerticalPunchKeepsPositiveHorizontalDirection() throws Exception { + assertPunch(true, new Vec3(1.0E-6, 1, 0), 0, + new Vec3(1.0E-6 * 2.0 * (double) 0.6F / (double) (float) 1.0E-6, 0.1, 0)); + } + + @Test + void unmarkedPunchRetainsModernNormAndEpsilon() throws Exception { + Vec3 flight = new Vec3(0.7, 0.4, 0.23); + Vec3 nativeHorizontal = flight.multiply(1, 0, 1).normalize().scale(2.0 * 0.6 * 0.75); + assertPunch(false, flight, 0.25, new Vec3(nativeHorizontal.x, 0.1, nativeHorizontal.z)); + assertPunch(false, new Vec3(1.0E-6, 1, 0), 0, null); + } + + @Test + void zeroDirectionAndFullArmourResistanceDoNotPush() throws Exception { + assertPunch(true, new Vec3(0, 1, 0), 0, null); + assertPunch(true, new Vec3(1, 1, 0), 1, null); + assertPunch(false, new Vec3(0, 1, 0), 0, null); + } + + private static void assertPunch(boolean legacy, Vec3 flight, double resistance, Vec3 expected) throws Exception { + Arrow arrow = mock(Arrow.class, CALLS_REAL_METHODS); + arrow.legacyFlight = legacy; + arrow.firedFromWeapon = mock(ItemStack.class); + ServerLevel level = mock(ServerLevel.class); + doReturn(level).when(arrow).level(); + doReturn(flight).when(arrow).getDeltaMovement(); + LivingEntity victim = mock(LivingEntity.class); + when(victim.getAttributeValue(any())).thenReturn(resistance); + DamageSource source = mock(DamageSource.class); + try (MockedStatic enchantments = mockStatic(EnchantmentHelper.class)) { + enchantments.when(() -> EnchantmentHelper.modifyKnockback(level, arrow.firedFromWeapon, victim, source, 0.0F)) + .thenReturn(2.0F); + var method = AbstractArrow.class.getDeclaredMethod("doKnockback", LivingEntity.class, DamageSource.class); + method.setAccessible(true); + method.invoke(arrow, victim, source); + } + if (expected == null) { + verify(victim, never()).push(anyDouble(), anyDouble(), anyDouble(), any(Entity.class)); + } else { + ArgumentCaptor x = ArgumentCaptor.forClass(Double.class); + ArgumentCaptor y = ArgumentCaptor.forClass(Double.class); + ArgumentCaptor z = ArgumentCaptor.forClass(Double.class); + verify(victim).push(x.capture(), y.capture(), z.capture(), org.mockito.ArgumentMatchers.same(arrow)); + assertEquals(expected.x, x.getValue(), 0.0); + assertEquals(expected.y, y.getValue(), 0.0); + assertEquals(expected.z, z.getValue(), 0.0); + } + } + +} diff --git a/paper-server/src/test/java/net/minecraft/world/entity/LegacyExplosionPhysicsTest.java b/paper-server/src/test/java/net/minecraft/world/entity/LegacyExplosionPhysicsTest.java new file mode 100644 index 000000000000..6b193bba67d9 --- /dev/null +++ b/paper-server/src/test/java/net/minecraft/world/entity/LegacyExplosionPhysicsTest.java @@ -0,0 +1,117 @@ +package net.minecraft.world.entity; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.damagesource.DamageTypes; +import net.minecraft.world.entity.boss.wither.WitherBoss; +import net.minecraft.world.entity.item.PrimedTnt; +import net.minecraft.world.level.EntityBasedExplosionDamageCalculator; +import net.minecraft.world.level.ExplosionDamageCalculator; +import net.minecraft.world.level.ServerExplosion; +import net.minecraft.world.level.SimpleExplosionDamageCalculator; +import net.minecraft.world.phys.Vec3; +import org.bukkit.support.environment.VanillaFeature; +import org.junit.jupiter.api.Test; + +@VanillaFeature +class LegacyExplosionPhysicsTest { + private ServerPlayer player(double distanceSqr) { + ServerPlayer player = mock(ServerPlayer.class); + player.legacyCombat = true; + when(player.distanceToSqr(Vec3.ZERO)).thenReturn(distanceSqr); + when(player.getEyePosition()).thenReturn(new Vec3(0, 1.27, 0)); + return player; + } + + private ServerExplosion explosion(Entity source) { + ServerExplosion explosion = mock(ServerExplosion.class); + when(explosion.radius()).thenReturn(4.0F); + when(explosion.center()).thenReturn(Vec3.ZERO); + when(explosion.getDirectSourceEntity()).thenReturn(source); + when(explosion.getDamageSource()).thenReturn(mock(DamageSource.class)); + return explosion; + } + + @Test + void classicDamageUsesIntegerFactorEightWithFloatDistance() { + ExplosionDamageCalculator calculator = new EntityBasedExplosionDamageCalculator(mock(PrimedTnt.class)); + ServerExplosion explosion = explosion(mock(PrimedTnt.class)); + ServerPlayer player = player(0); + assertEquals(65.0F, calculator.getEntityDamageAmount(explosion, player, 1)); + when(player.distanceToSqr(Vec3.ZERO)).thenReturn(16.0); + assertEquals(25.0F, calculator.getEntityDamageAmount(explosion, player, 1)); + assertEquals(11.0F, calculator.getEntityDamageAmount(explosion, player, 0.5F)); + when(player.distanceToSqr(Vec3.ZERO)).thenReturn(2.0); + double distance = (double) (float) Math.sqrt(2.0); + assertEquals(distance, calculator.getEntityDistance(explosion, player)); + double power = (1 - distance / 8) * 0.37F; + assertEquals((float) (int) ((power * power + power) / 2 * 8 * 8 + 1), calculator.getEntityDamageAmount(explosion, player, 0.37F)); + } + + @Test + void witherSpawnBlastIsClassicAndOptOutRetainsNativeCurve() { + Entity wither = mock(WitherBoss.class); + ExplosionDamageCalculator calculator = new EntityBasedExplosionDamageCalculator(wither); + ServerExplosion explosion = explosion(wither); + when(explosion.radius()).thenReturn(7.0F); + ServerPlayer player = player(0); + assertTrue(calculator.usesLegacyPhysics(explosion, player)); + assertEquals(113.0F, calculator.getEntityDamageAmount(explosion, player, 1)); + player.legacyCombat = false; + assertFalse(calculator.usesLegacyPhysics(explosion, player)); + assertEquals(99.0F, calculator.getEntityDamageAmount(explosion, player, 1)); + } + + @Test + void optOutAndCustomCalculatorsKeepModernDamageAndDirection() { + ServerExplosion explosion = explosion(mock(PrimedTnt.class)); + ServerPlayer player = player(0); + player.legacyCombat = false; + ExplosionDamageCalculator calculator = new ExplosionDamageCalculator(); + assertEquals(57.0F, calculator.getEntityDamageAmount(explosion, player, 1)); + assertEquals(new Vec3(0, 1, 0), calculator.getEntityKnockbackDirection(explosion, player)); + player.legacyCombat = true; + ExplosionDamageCalculator custom = new ExplosionDamageCalculator() {}; + assertFalse(custom.usesLegacyPhysics(explosion, player)); + assertEquals(57.0F, custom.getEntityDamageAmount(explosion, player, 1)); + ExplosionDamageCalculator wind = new SimpleExplosionDamageCalculator(false, false, Optional.empty(), Optional.empty()); + assertFalse(wind.usesLegacyPhysics(explosion, player)); + assertEquals(57.0F, wind.getEntityDamageAmount(explosion, player, 1)); + when(explosion.getDirectSourceEntity()).thenReturn(mock(Entity.class)); + assertFalse(calculator.usesLegacyPhysics(explosion, player)); + } + + @Test + void nativeBedIsClassicButAnonymousAnchorCalculatorRemainsModern() { + ServerExplosion explosion = explosion(null); + when(explosion.getDamageSource().is(DamageTypes.BAD_RESPAWN_POINT)).thenReturn(true); + ServerPlayer player = player(0); + ExplosionDamageCalculator bed = new ExplosionDamageCalculator(); + assertTrue(bed.usesLegacyPhysics(explosion, player)); + assertEquals(65.0F, bed.getEntityDamageAmount(explosion, player, 1)); + ExplosionDamageCalculator anchor = new ExplosionDamageCalculator() {}; + assertFalse(anchor.usesLegacyPhysics(explosion, player)); + assertEquals(57.0F, anchor.getEntityDamageAmount(explosion, player, 1)); + when(explosion.getDamageSource().is(DamageTypes.BAD_RESPAWN_POINT)).thenReturn(false); + assertFalse(bed.usesLegacyPhysics(explosion, player)); + } + + @Test + void classicDirectionUsesStandingEyeAndFloatSquareRootEvenWhenCrouching() { + ServerExplosion explosion = explosion(mock(PrimedTnt.class)); + ServerPlayer player = player(1); + when(player.getX()).thenReturn(1.0); + ExplosionDamageCalculator calculator = new ExplosionDamageCalculator(); + Vec3 expected = new Vec3(1, (double) 1.62F, 0); + double length = (double) (float) Math.sqrt(expected.lengthSqr()); + expected = new Vec3(expected.x / length, expected.y / length, expected.z / length); + assertEquals(expected, calculator.getEntityKnockbackDirection(explosion, player)); + when(player.getX()).thenReturn(0.0); + when(player.getY()).thenReturn(-(double) 1.62F); + assertEquals(Vec3.ZERO, calculator.getEntityKnockbackDirection(explosion, player)); + } +} diff --git a/paper-server/src/test/java/net/minecraft/world/entity/LegacyOffHandMenuTest.java b/paper-server/src/test/java/net/minecraft/world/entity/LegacyOffHandMenuTest.java new file mode 100644 index 000000000000..3027555ff917 --- /dev/null +++ b/paper-server/src/test/java/net/minecraft/world/entity/LegacyOffHandMenuTest.java @@ -0,0 +1,351 @@ +package net.minecraft.world.entity; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.mojang.authlib.GameProfile; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.registries.Registries; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.level.ServerPlayerGameMode; +import net.minecraft.util.RandomSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.ai.goal.TemptGoal; +import net.minecraft.world.entity.ai.sensing.TemptingSensor; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.entity.player.PlayerEquipment; +import net.minecraft.world.inventory.InventoryMenu; +import net.minecraft.world.item.AxeItem; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.ProjectileWeaponItem; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.item.enchantment.EnchantmentEffectComponents; +import net.minecraft.world.item.enchantment.EnchantmentHelper; +import net.minecraft.world.item.enchantment.Enchantments; +import net.minecraft.world.item.equipment.Equippable; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import org.apache.commons.lang3.mutable.MutableBoolean; +import org.bukkit.support.RegistryHelper; +import org.bukkit.support.environment.VanillaFeature; +import org.junit.jupiter.api.Test; + +@VanillaFeature +class LegacyOffHandMenuTest { + private abstract static class NativePlayer extends Player { + NativePlayer(Level level, GameProfile profile) { super(level, profile); } + @Override + public boolean tryReadyArrow(ItemStack bow, ItemStack arrow, MutableBoolean cancelled) { + return super.tryReadyArrow(bow, arrow, cancelled); + } + } + + private static class NativeGameMode extends ServerPlayerGameMode { + NativeGameMode() { super(null); } + @Override + public void setGameModeForPlayer(GameType current, GameType previous) { + super.setGameModeForPlayer(current, previous); + } + } + + private record Fixture(Player player, Inventory inventory, InventoryMenu menu) {} + + private Fixture fixture(boolean enabled) { + Player player = mock(NativePlayer.class); + player.offHandEnabled = enabled; + when(player.canUseSlot(any())).thenCallRealMethod(); + when(player.getEquipmentSlotForItem(any())).thenCallRealMethod(); + Inventory inventory = new Inventory(player, new PlayerEquipment(player)); + when(player.getInventory()).thenReturn(inventory); + return new Fixture(player, inventory, new InventoryMenu(inventory, true, player)); + } + + private ItemStack blockingSword() { + ItemStack sword = new ItemStack(Items.DIAMOND_SWORD); + ItemStack shield = new ItemStack(Items.SHIELD); + sword.set(DataComponents.BLOCKS_ATTACKS, shield.get(DataComponents.BLOCKS_ATTACKS)); + sword.set(DataComponents.EQUIPPABLE, shield.get(DataComponents.EQUIPPABLE)); + return sword; + } + + @Test + void closedOffHandShiftClickUsesNormalInventoryAndHotbarForShieldAndCustomEquipment() { + for (ItemStack source : new ItemStack[]{new ItemStack(Items.SHIELD), blockingSword()}) { + Fixture f = fixture(false); + f.inventory.setItem(9, source.copy()); + assertEquals(source.getItem(), f.menu.quickMoveStack(f.player, 9).getItem()); + assertTrue(f.inventory.getItem(40).isEmpty()); + assertTrue(f.inventory.getItem(9).isEmpty()); + assertEquals(source.getItem(), f.inventory.getItem(0).getItem()); + f.menu.quickMoveStack(f.player, 36); + assertTrue(f.inventory.getItem(0).isEmpty()); + assertEquals(source.getItem(), f.inventory.getItem(9).getItem()); + assertTrue(f.inventory.getItem(40).isEmpty()); + } + } + + @Test + void enabledNativeAutoEquipmentAndDisabledResidualPickupStayAvailable() { + Fixture f = fixture(true); + f.inventory.setItem(9, new ItemStack(Items.SHIELD)); + f.menu.quickMoveStack(f.player, 9); + assertTrue(f.inventory.getItem(9).isEmpty()); + assertEquals(Items.SHIELD, f.inventory.getItem(40).getItem()); + assertTrue(f.menu.getSlot(45).mayPlace(new ItemStack(Items.SHIELD))); + f.player.offHandEnabled = false; + assertFalse(f.menu.getSlot(45).mayPlace(new ItemStack(Items.SHIELD))); + assertFalse(f.menu.getSlot(45).mayPlace(new ItemStack(Items.STONE))); + assertTrue(f.menu.getSlot(45).mayPickup(f.player)); + f.menu.quickMoveStack(f.player, 45); + assertTrue(f.inventory.getItem(40).isEmpty()); + assertEquals(Items.SHIELD, f.inventory.getItem(9).getItem()); + } + + @Test + void fullDestinationKeepsSourceAndNeverConsumesOrCreatesItems() { + Fixture f = fixture(false); + for (int i = 0; i < 36; i++) f.inventory.setItem(i, new ItemStack(Items.STONE, 64)); + f.inventory.setItem(9, new ItemStack(Items.SHIELD)); + assertTrue(f.menu.quickMoveStack(f.player, 9).isEmpty()); + assertEquals(Items.SHIELD, f.inventory.getItem(9).getItem()); + assertTrue(f.inventory.getItem(40).isEmpty()); + int stones = 0; + for (int i = 0; i < 36; i++) if (f.inventory.getItem(i).is(Items.STONE)) stones += f.inventory.getItem(i).getCount(); + assertEquals(35 * 64, stones); + } + + @Test + void closedOffHandDoesNotChangeArmorAutoEquipment() { + Fixture f = fixture(false); + when(f.player.isEquippableInSlot(any(), eq(EquipmentSlot.HEAD))).thenReturn(true); + f.inventory.setItem(9, new ItemStack(Items.DIAMOND_HELMET)); + f.menu.quickMoveStack(f.player, 9); + assertTrue(f.inventory.getItem(9).isEmpty()); + assertEquals(Items.DIAMOND_HELMET, f.menu.getSlot(5).getItem().getItem()); + assertTrue(f.inventory.getItem(40).isEmpty()); + } + + @Test + void automaticMergesSkipClosedResidualOffHandAndPreserveFullInventoryItems() { + for (boolean enabled : new boolean[]{false, true}) { + Fixture f = fixture(enabled); + f.inventory.setItem(40, new ItemStack(Items.STONE, 32)); + f.inventory.setItem(9, new ItemStack(Items.STONE, 16)); + assertEquals(enabled ? 40 : 9, f.inventory.getSlotWithRemainingSpace(new ItemStack(Items.STONE, 4))); + assertTrue(f.inventory.add(new ItemStack(Items.STONE, 4))); + assertEquals(enabled ? 36 : 32, f.inventory.getItem(40).getCount()); + assertEquals(enabled ? 16 : 20, f.inventory.getItem(9).getCount()); + } + Fixture full = fixture(false); + for (int i = 0; i < 36; i++) full.inventory.setItem(i, new ItemStack(Items.STONE, 64)); + full.inventory.setItem(40, new ItemStack(Items.STONE, 32)); + ItemStack incoming = new ItemStack(Items.STONE, 4); + assertFalse(full.inventory.add(incoming)); + assertEquals(4, incoming.getCount()); + assertEquals(32, full.inventory.getItem(40).getCount()); + for (int i = 0; i < 36; i++) assertEquals(64, full.inventory.getItem(i).getCount()); + } + + @Test + void equipmentEligibilityClosesOnlyOffHandAndPreservesNativeMainAndArmorSlots() { + for (boolean enabled : new boolean[]{false, true}) { + Fixture f = fixture(enabled); + assertEquals(enabled, f.player.canUseSlot(EquipmentSlot.OFFHAND)); + assertTrue(f.player.canUseSlot(EquipmentSlot.MAINHAND)); + assertTrue(f.player.canUseSlot(EquipmentSlot.HEAD)); + assertTrue(f.player.canUseSlot(EquipmentSlot.CHEST)); + when(f.player.isAlive()).thenReturn(true); + when(f.player.getItemBySlot(any())).thenReturn(ItemStack.EMPTY); + doCallRealMethod().when((LivingEntity)f.player).canDispenserEquipIntoSlot(any()); + when(f.player.canEquipWithDispenser(any())).thenCallRealMethod(); + ItemStack dispensable = new ItemStack(Items.STONE); + dispensable.set(DataComponents.EQUIPPABLE, + Equippable.builder(EquipmentSlot.OFFHAND).build()); + assertEquals(enabled, f.player.canEquipWithDispenser(dispensable)); + assertEquals(enabled ? EquipmentSlot.OFFHAND : EquipmentSlot.MAINHAND, + f.player.getEquipmentSlotForItem(new ItemStack(Items.SHIELD))); + } + } + + @Test + void nativeEquippableTargetAndMainHandSwapCannotInsertIntoClosedOffHand() { + for (boolean enabled : new boolean[]{false, true}) { + Fixture f = fixture(enabled); + when(f.player.isEquippableInSlot(any(), any())).thenCallRealMethod(); + when(f.player.isAlive()).thenReturn(true); + ItemStack source = new ItemStack(Items.SHIELD); + var equippable = source.get(DataComponents.EQUIPPABLE); + assertNotNull(equippable); + Player actor = mock(Player.class); + when(actor.level()).thenReturn(mock(Level.class)); + assertEquals(enabled ? InteractionResult.SUCCESS : InteractionResult.PASS, + equippable.equipOnTarget(actor, f.player, source)); + assertEquals(enabled ? 0 : 1, source.getCount()); + if (enabled) verify(f.player).setItemSlot(eq(EquipmentSlot.OFFHAND), any()); + else { + verify(f.player, never()).setItemSlot(eq(EquipmentSlot.OFFHAND), any()); + assertEquals(InteractionResult.PASS, + equippable.swapWithEquipmentSlot(new ItemStack(Items.SHIELD), f.player)); + } + } + } + + @Test + void nativeHeldAmmoSkipsLockedResidualButRetainsMainHandAndEnabledSelection() { + for (boolean enabled : new boolean[]{false, true}) { + Fixture f = fixture(enabled); + ItemStack offhand = new ItemStack(Items.ARROW, 5); + ItemStack main = new ItemStack(Items.ARROW, 3); + when(f.player.getItemInHand(InteractionHand.OFF_HAND)).thenReturn(offhand); + when(f.player.getItemInHand(InteractionHand.MAIN_HAND)).thenReturn(main); + var visits = new ArrayList(); + Predicate predicate = stack -> { visits.add(stack); return stack.is(Items.ARROW); }; + assertSame(enabled ? offhand : main, + ProjectileWeaponItem.getHeldProjectile(f.player, predicate)); + assertEquals(List.of(enabled ? offhand : main), visits); + when(f.player.getItemInHand(InteractionHand.MAIN_HAND)).thenReturn(new ItemStack(Items.BOW)); + assertSame(enabled ? offhand : ItemStack.EMPTY, + ProjectileWeaponItem.getHeldProjectile(f.player, stack -> stack.is(Items.ARROW))); + assertEquals(5, offhand.getCount()); + assertEquals(3, main.getCount()); + } + } + + @Test + void nativePlayerAmmoFallbackSkipsInventoryFortyButPreservesStorageAndMainSelection() throws Exception { + for (boolean enabled : new boolean[]{false, true}) { + Fixture f = fixture(enabled); + Field inventory = Player.class.getDeclaredField("inventory"); + inventory.setAccessible(true); + inventory.set(f.player, f.inventory); + when(f.player.getItemInHand(any())).thenAnswer(call -> + call.getArgument(0) == InteractionHand.OFF_HAND ? ItemStack.EMPTY : f.inventory.getItem(0)); + when(((NativePlayer)f.player).tryReadyArrow(any(), any(), any())).thenCallRealMethod(); + when(f.player.getProjectile(any())).thenCallRealMethod(); + ItemStack locked = new ItemStack(Items.ARROW, 5); + f.inventory.setItem(40, locked); + ItemStack bow = new ItemStack(Items.BOW); + assertSame(enabled ? locked : ItemStack.EMPTY, f.player.getProjectile(bow)); + ItemStack storage = new ItemStack(Items.ARROW, 3); + f.inventory.setItem(9, storage); + assertSame(storage, f.player.getProjectile(bow)); + f.inventory.setItem(40, ItemStack.EMPTY); + f.inventory.setItem(0, storage); + assertSame(storage, f.player.getProjectile(bow)); + assertEquals(5, locked.getCount()); + assertEquals(3, storage.getCount()); + } + } + + @Test + void nativeHeldIntentAndBothTemptationSelectorsIgnoreClosedResidualItems() throws Exception { + var axe = AxeItem.class.getDeclaredMethod("playerHasBlockingItemUseIntent", UseOnContext.class); + axe.setAccessible(true); + var follow = TemptGoal.class.getDeclaredMethod("shouldFollow", LivingEntity.class); + follow.setAccessible(true); + var tempting = TemptingSensor.class.getDeclaredMethod("playerHoldingTemptation", PathfinderMob.class, Player.class); + tempting.setAccessible(true); + for (boolean enabled : new boolean[]{false, true}) { + Fixture f = fixture(enabled); + when(f.player.getMainHandItem()).thenReturn(ItemStack.EMPTY); + when(f.player.getOffhandItem()).thenReturn(new ItemStack(Items.SHIELD)); + var context = mock(UseOnContext.class); + when(context.getPlayer()).thenReturn(f.player); + when(context.getHand()).thenReturn(InteractionHand.MAIN_HAND); + Object axeIntent = axe.invoke(null, context); + ItemStack food = new ItemStack(Items.CARROT); + when(f.player.getOffhandItem()).thenReturn(food); + Predicate predicate = stack -> stack.is(Items.CARROT); + doCallRealMethod().when(f.player).isHolding(any(Predicate.class)); + var mob = mock(PathfinderMob.class); + var goal = new TemptGoal(mob, 1.0, predicate, false); + var sensor = new TemptingSensor(predicate); + assertAll( + () -> assertEquals(enabled, axeIntent, "axe intent"), + () -> assertEquals(enabled, f.player.isHolding(predicate), "isHolding"), + () -> assertEquals(enabled, follow.invoke(goal, f.player), "tempt goal"), + () -> assertEquals(enabled, tempting.invoke(sensor, mob, f.player), "tempt sensor")); + when(f.player.getMainHandItem()).thenReturn(food); + assertTrue(f.player.isHolding(predicate)); + assertEquals(true, follow.invoke(goal, f.player)); + assertEquals(true, tempting.invoke(sensor, mob, f.player)); + } + } + + @Test + void nativeMendingCandidateFilterPreservesEligibleMainAndArmorRepairs() { + var mending = RegistryHelper.registryAccess().lookupOrThrow(Registries.ENCHANTMENT) + .getOrThrow(Enchantments.MENDING); + for (EquipmentSlot primarySlot : new EquipmentSlot[]{EquipmentSlot.MAINHAND, EquipmentSlot.HEAD}) { + for (boolean enabled : new boolean[]{false, true}) { + Fixture f = fixture(enabled); + ItemStack primary = new ItemStack(primarySlot == EquipmentSlot.MAINHAND ? Items.DIAMOND_AXE : Items.DIAMOND_HELMET); + ItemStack offhand = new ItemStack(Items.SHIELD); + for (ItemStack stack : new ItemStack[]{primary, offhand}) { stack.enchant(mending, 1); stack.setDamageValue(50); } + when(f.player.getItemBySlot(any())).thenAnswer(call -> call.getArgument(0) == EquipmentSlot.OFFHAND ? offhand + : call.getArgument(0) == primarySlot ? primary : ItemStack.EMPTY); + var random = mock(RandomSource.class); + when(f.player.getRandom()).thenReturn(random); + when(random.nextInt(anyInt())).thenAnswer(call -> primarySlot == EquipmentSlot.MAINHAND ? (int)call.getArgument(0) - 1 : 0); + var selected = EnchantmentHelper.getRandomItemWith( + EnchantmentEffectComponents.REPAIR_WITH_XP, f.player, ItemStack::isDamaged); + assertSame(enabled ? offhand : primary, selected.orElseThrow().itemStack()); + when(f.player.getItemBySlot(primarySlot)).thenReturn(ItemStack.EMPTY); + assertEquals(enabled, EnchantmentHelper.getRandomItemWith( + EnchantmentEffectComponents.REPAIR_WITH_XP, f.player, ItemStack::isDamaged).isPresent()); + assertEquals(50, offhand.getDamageValue()); + } + } + } + + @Test + void nativeFireworkAttachmentAngleIgnoresClosedResidualHandWithoutChangingMainGeometry() { + for (boolean enabled : new boolean[]{false, true}) { + Fixture f = fixture(enabled); + when(f.player.getMainArm()).thenReturn(HumanoidArm.RIGHT); + when(f.player.getMainHandItem()).thenReturn(new ItemStack(Items.DIAMOND_SWORD)); + when(f.player.getOffhandItem()).thenReturn(new ItemStack(Items.FIREWORK_ROCKET)); + when(f.player.calculateViewVector(anyFloat(), anyFloat())).thenCallRealMethod(); + when(f.player.getHandHoldingItemAngle(any())).thenCallRealMethod(); + Vec3 angle = f.player.getHandHoldingItemAngle(Items.FIREWORK_ROCKET); + assertTrue(enabled ? angle.x > 0 : angle.x < 0); + assertEquals(0.0, angle.y, 0.0); + assertTrue(angle.z > 0); + assertEquals(0.5, angle.length(), 1.0E-4); + when(f.player.getMainHandItem()).thenReturn(new ItemStack(Items.FIREWORK_ROCKET)); + assertTrue(f.player.getHandHoldingItemAngle(Items.FIREWORK_ROCKET).x < 0); + } + LivingEntity npc = mock(Mob.class); + when(npc.getHandHoldingItemAngle(any())).thenCallRealMethod(); + assertEquals(Vec3.ZERO, npc.getHandHoldingItemAngle(Items.FIREWORK_ROCKET)); + } + + @Test + void nativeRespawnCopiesBothEnabledAndDisabledCapabilitiesBeforeOtherRestoration() throws Exception { + for (boolean enabled : new boolean[]{false, true}) { + ServerPlayer old = mock(ServerPlayer.class); + old.offHandEnabled = enabled; + ServerPlayer next = mock(ServerPlayer.class); + next.offHandEnabled = !enabled; + var oldMode = mock(ServerPlayerGameMode.class); + var nextMode = mock(NativeGameMode.class); + Field mode = ServerPlayer.class.getDeclaredField("gameMode"); + mode.setAccessible(true); + mode.set(old, oldMode); + mode.set(next, nextMode); + class StopAfterCapabilityCopy extends RuntimeException {} + doThrow(new StopAfterCapabilityCopy()).when(nextMode).setGameModeForPlayer(any(), any()); + doCallRealMethod().when(next).restoreFrom(old, false); + assertThrows(StopAfterCapabilityCopy.class, () -> next.restoreFrom(old, false)); + assertEquals(enabled, next.offHandEnabled); + } + } +} diff --git a/paper-server/src/test/java/net/minecraft/world/entity/LegacyPlayerKnockbackTest.java b/paper-server/src/test/java/net/minecraft/world/entity/LegacyPlayerKnockbackTest.java new file mode 100644 index 000000000000..c1dc9371cf89 --- /dev/null +++ b/paper-server/src/test/java/net/minecraft/world/entity/LegacyPlayerKnockbackTest.java @@ -0,0 +1,471 @@ +package net.minecraft.world.entity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Answers.RETURNS_DEFAULTS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyFloat; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.papermc.paper.configuration.WorldConfiguration; +import io.papermc.paper.event.entity.EntityKnockbackEvent; +import it.unimi.dsi.fastutil.doubles.DoubleDoubleImmutablePair; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; +import net.minecraft.network.protocol.game.ClientboundSetEntityMotionPacket; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.network.ServerGamePacketListenerImpl; +import net.minecraft.tags.DamageTypeTags; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.damagesource.DamageTypes; +import net.minecraft.world.entity.boss.enderdragon.EndCrystal; +import net.minecraft.world.entity.boss.wither.WitherBoss; +import net.minecraft.world.entity.item.PrimedTnt; +import net.minecraft.world.entity.monster.Creeper; +import net.minecraft.world.entity.player.Abilities; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.entity.projectile.arrow.Arrow; +import net.minecraft.world.entity.projectile.hurtingprojectile.LargeFireball; +import net.minecraft.world.entity.projectile.hurtingprojectile.WitherSkull; +import net.minecraft.world.entity.vehicle.minecart.MinecartTNT; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; +import org.bukkit.craftbukkit.CraftServer; +import org.bukkit.craftbukkit.entity.CraftPlayer; +import org.bukkit.craftbukkit.event.CraftEventFactory; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.plugin.PluginManager; +import org.bukkit.support.environment.VanillaFeature; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +@VanillaFeature +class LegacyPlayerKnockbackTest { + + private ServerPlayer victim(Vec3 velocity, boolean grounded) { + ServerPlayer victim = mock(ServerPlayer.class, CALLS_REAL_METHODS); + victim.legacyCombat = true; + victim.hurtMarked = true; + doReturn(velocity).when(victim).getDeltaMovement(); + doReturn(grounded).when(victim).onGround(); + doReturn(0.0).when(victim).getAttributeValue(any()); + doReturn(mock(CraftPlayer.class)).when(victim).getBukkitEntity(); + doNothing().when(victim).setDeltaMovement(any(Vec3.class)); + return victim; + } + + private MockedStatic nativeEvents(boolean cancel) { + MockedStatic factory = mockStatic(CraftEventFactory.class); + factory.when(() -> CraftEventFactory.callEntityKnockbackEvent(any(), any(), any(), any(), anyDouble(), any())) + .thenAnswer(call -> { + Vec3 delta = call.getArgument(5); + EntityKnockbackEvent event = mock(EntityKnockbackEvent.class); + when(event.isCancelled()).thenReturn(cancel); + when(event.getKnockback()).thenReturn(new Vector(delta.x, delta.y, delta.z)); + return event; + }); + return factory; + } + + @Test + void unattributedEnvironmentalDamageDoesNotInventLegacyBaseKnockback() { + ServerPlayer victim = victim(new Vec3(0.8, 0.6, 0.2), false); + try (MockedStatic events = nativeEvents(false)) { + victim.dealDefaultKnockback(mock(DamageSource.class), 1, false); + events.verifyNoInteractions(); + } + verify(victim, never()).setDeltaMovement(any(Vec3.class)); + } + + @Test + void onlyOwnedClassicExplosionsOverrideNativeNoKnockbackTag() throws Exception { + ServerPlayer victim = victim(new Vec3(0.8, 0.6, 0.2), false); + DamageSource source = mock(DamageSource.class); + when(source.is(DamageTypeTags.NO_KNOCKBACK)).thenReturn(true); + Method applies = LivingEntity.class.getDeclaredMethod("shouldApplyDefaultKnockback", DamageSource.class); + applies.setAccessible(true); + assertEquals(false, applies.invoke(victim, source)); + when(source.getEntity()).thenReturn(mock(Entity.class)); + when(source.is(DamageTypes.PLAYER_EXPLOSION)).thenReturn(true); + for (Entity direct : new Entity[]{mock(PrimedTnt.class), + mock(Creeper.class), + mock(WitherBoss.class)}) { + when(source.getDirectEntity()).thenReturn(direct); + assertEquals(true, applies.invoke(victim, source)); + when(source.is(DamageTypes.PLAYER_EXPLOSION)).thenReturn(false); + when(source.is(DamageTypes.EXPLOSION)).thenReturn(true); + assertEquals(true, applies.invoke(victim, source)); + when(source.is(DamageTypes.EXPLOSION)).thenReturn(false); + when(source.is(DamageTypes.PLAYER_EXPLOSION)).thenReturn(true); + } + victim.legacyCombat = false; + assertEquals(false, applies.invoke(victim, source)); + victim.legacyCombat = true; + when(source.getEntity()).thenReturn(null); + assertEquals(false, applies.invoke(victim, source)); + when(source.getEntity()).thenReturn(mock(Entity.class)); + when(source.is(DamageTypes.PLAYER_EXPLOSION)).thenReturn(false); + // An unrelated modern NO_KNOCKBACK source still retains its guard. + assertEquals(false, applies.invoke(victim, source)); + } + + @Test + void modernProjectileAndCrystalOwnersDoNotInventClassicExplosionBase() throws Exception { + ServerPlayer victim = victim(Vec3.ZERO, false); + DamageSource source = mock(DamageSource.class); + Entity owner = mock(Player.class); + when(source.getEntity()).thenReturn(owner); + when(source.is(DamageTypeTags.NO_KNOCKBACK)).thenReturn(true); + when(source.is(DamageTypes.PLAYER_EXPLOSION)).thenReturn(true); + Method applies = LivingEntity.class.getDeclaredMethod("shouldApplyDefaultKnockback", DamageSource.class); + applies.setAccessible(true); + for (Entity direct : new Entity[]{mock(EndCrystal.class), + mock(LargeFireball.class), + mock(WitherSkull.class), + mock(MinecartTNT.class)}) { + when(source.getDirectEntity()).thenReturn(direct); + assertEquals(false, applies.invoke(victim, source)); + // The native damage source and owner remain intact for protection/event attribution. + assertEquals(owner, source.getEntity()); + assertEquals(direct, source.getDirectEntity()); + } + when(source.is(DamageTypeTags.NO_KNOCKBACK)).thenReturn(false); + assertEquals(true, applies.invoke(victim, source)); // Native non-tagged behavior is unchanged. + } + + @Test + void ownedExplosionBaseDirectionUsesResponsibleOwnerInsteadOfTntPosition() { + ServerPlayer victim = victim(Vec3.ZERO, false); + doReturn(0.0).when(victim).getX(); + doReturn(0.0).when(victim).getZ(); + Entity owner = mock(Entity.class); + when(owner.getX()).thenReturn(-5.0); + DamageSource source = mock(DamageSource.class); + when(source.getEntity()).thenReturn(owner); + when(source.getSourcePosition()).thenReturn(new Vec3(10, 0, 0)); + when(source.getDirectEntity()).thenReturn(mock(PrimedTnt.class)); + try (MockedStatic events = nativeEvents(false)) { + victim.dealDefaultKnockback(source, 1, true); + } + ArgumentCaptor result = ArgumentCaptor.forClass(Vec3.class); + verify(victim).setDeltaMovement(result.capture()); + assertEquals((double) 0.4F, result.getValue().x, 1.0E-12); + } + + @Test + void cancelledOwnedExplosionDamageNeverReachesBaseOrVelocityMutation() throws Exception { + // Use the real ServerPlayer -> Player -> LivingEntity damage chain and + // its actual cancelled EntityDamageEvent boundary, without world mutation. + ServerPlayer victim = mock(ServerPlayer.class); + victim.legacyCombat = true; + ServerLevel level = mock(ServerLevel.class); + DamageSource source = mock(DamageSource.class); + Player owner = mock(Player.class); + when(source.getEntity()).thenReturn(owner); + when(source.is(DamageTypes.PLAYER_EXPLOSION)).thenReturn(true); + when(source.is(DamageTypeTags.NO_KNOCKBACK)).thenReturn(true); + when(source.getDirectEntity()).thenReturn(mock(PrimedTnt.class)); + when(victim.canHarmPlayer(owner)).thenReturn(true); + when(victim.getHealth()).thenReturn(20.0F); + when(victim.getUseItem()).thenReturn(ItemStack.EMPTY); + Field abilities = Player.class.getDeclaredField("abilities"); + abilities.setAccessible(true); + abilities.set(victim, new Abilities()); + doCallRealMethod().when(victim).hurtServer(level, source, 1.0F); + doCallRealMethod().when((LivingEntity) victim).actuallyHurt(any(), any(), anyFloat(), any(EntityDamageEvent.class)); + EntityDamageEvent cancelled = mock(EntityDamageEvent.class); + when(cancelled.isCancelled()).thenReturn(true); + try (MockedStatic events = mockStatic(CraftEventFactory.class, call -> { + if (call.getMethod().getName().equals("handleLivingEntityDamageEvent")) return cancelled; + return RETURNS_DEFAULTS.answer(call); + })) { + assertEquals(false, victim.hurtServer(level, source, 1.0F)); + } + verify(victim, never()).dealDefaultKnockback(any(), anyFloat(), anyBoolean()); + verify(victim, never()).setDeltaMovement(any(Vec3.class)); + } + + @Test + void baseKnockbackHalvesEveryAxisInGroundAndAir() { + DamageSource source = mock(DamageSource.class); + for (boolean ground : new boolean[]{false, true}) { + ServerPlayer victim = victim(new Vec3(0.8, 0.6, 0.2), ground); + try (MockedStatic events = nativeEvents(false)) { + victim.knockback(0.4F, 1, 0, source, 1, false, null, EntityKnockbackEvent.Cause.ENTITY_ATTACK); + } + ArgumentCaptor result = ArgumentCaptor.forClass(Vec3.class); + verify(victim).setDeltaMovement(result.capture()); + assertEquals(0.4 - (double) 0.4F, result.getValue().x, 1.0E-12); + assertEquals((double) 0.4F, result.getValue().y, 1.0E-12); + assertEquals(0.1, result.getValue().z, 1.0E-12); + } + } + + @Test + void nonAxialBaseUsesLegacyFloatLengthAndComponentDivision() { + for (boolean legacy : new boolean[]{false, true}) { + ServerPlayer victim = victim(new Vec3(0.8, 0.6, 0.2), false); + victim.legacyCombat = legacy; + try (MockedStatic events = nativeEvents(false)) { + victim.knockback(0.4F, 1, 1, mock(DamageSource.class), 1, false, null, EntityKnockbackEvent.Cause.ENTITY_ATTACK); + } + ArgumentCaptor result = ArgumentCaptor.forClass(Vec3.class); + verify(victim).setDeltaMovement(result.capture()); + double length = legacy ? (double) (float) Math.sqrt(2) : Math.sqrt(2); + assertEquals(0.4 - 1 / length * (double) 0.4F, result.getValue().x, 1.0E-15); + assertEquals(0.1 - 1 / length * (double) 0.4F, result.getValue().z, 1.0E-15); + } + } + + @Test + void unmarkedPlayerKeepsNativeAirborneVerticalMotion() { + ServerPlayer victim = victim(new Vec3(0.8, 0.6, 0.2), false); + victim.legacyCombat = false; + try (MockedStatic events = nativeEvents(false)) { + victim.knockback(0.4F, 1, 0, mock(DamageSource.class), 1, false, null, EntityKnockbackEvent.Cause.ENTITY_ATTACK); + } + ArgumentCaptor result = ArgumentCaptor.forClass(Vec3.class); + verify(victim).setDeltaMovement(result.capture()); + assertEquals(0.6, result.getValue().y); + } + + @Test + void shieldBlockKeepsNativeAirborneKnockbackWithOrWithoutProfile() { + for (boolean legacy : new boolean[]{false, true}) { + ServerPlayer victim = victim(new Vec3(0.8, 0.6, 0.2), false); + victim.legacyCombat = legacy; + try (MockedStatic events = nativeEvents(false)) { + victim.knockback(0.5, 1, 0, mock(DamageSource.class), 1, false, mock(Player.class), EntityKnockbackEvent.Cause.SHIELD_BLOCK); + } + ArgumentCaptor result = ArgumentCaptor.forClass(Vec3.class); + verify(victim).setDeltaMovement(result.capture()); + assertEquals(0.4 - 0.5, result.getValue().x, 1.0E-12); + assertEquals(0.6, result.getValue().y, 1.0E-12); + assertEquals(0.1, result.getValue().z, 1.0E-12); + } + } + + @Test + void modernDamageSourcesRetainNativeBaseBonusAndImmediateDelivery() { + for (var type : List.of(DamageTypes.SPEAR, DamageTypes.MACE_SMASH, DamageTypes.TRIDENT, + DamageTypes.WIND_CHARGE, DamageTypes.FIREWORKS, DamageTypes.SONIC_BOOM)) { + DamageSource source = mock(DamageSource.class); + when(source.is(type)).thenReturn(true); + Player attacker = mock(Player.class); + attacker.legacyCombat = true; + for (boolean legacy : new boolean[]{false, true}) { + for (boolean effect : new boolean[]{false, true}) { + ServerPlayer victim = victim(new Vec3(0.8, 0.6, 0.2), false); + victim.legacyCombat = legacy; + try (MockedStatic events = nativeEvents(false)) { + victim.knockback(0.5, 1, 0, source, 1, effect, attacker, EntityKnockbackEvent.Cause.ENTITY_ATTACK); + } + ArgumentCaptor result = ArgumentCaptor.forClass(Vec3.class); + verify(victim).setDeltaMovement(result.capture()); + assertEquals(0.4 - 0.5, result.getValue().x, 1.0E-12); + assertEquals(0.6, result.getValue().y, 1.0E-12); + assertEquals(0.1, result.getValue().z, 1.0E-12); + } + ServerPlayer delivered = victim(new Vec3(0.8, 0.6, 0.2), false); + delivered.legacyCombat = legacy; + doReturn(42).when(delivered).getId(); + delivered.connection = mock(ServerGamePacketListenerImpl.class); + ServerLevel level = mock(ServerLevel.class); + WorldConfiguration config = mock(WorldConfiguration.class); + config.misc = config.new Misc(); + when(level.paperConfig()).thenReturn(config); + CraftServer server = mock(CraftServer.class); + when(server.getPluginManager()).thenReturn(mock(PluginManager.class)); + when(level.getCraftServer()).thenReturn(server); + when(attacker.level()).thenReturn(level); + when(attacker.getDeltaMovement()).thenReturn(Vec3.ZERO); + Vec3 old = new Vec3(0.1, 0.2, 0.3); + doCallRealMethod().when(attacker).causeExtraKnockback(delivered, 0.5F, old, source, 1, true); + try (MockedStatic events = nativeEvents(false)) { + attacker.causeExtraKnockback(delivered, 0.5F, old, source, 1, true); + } + verify(delivered.connection).send(any(ClientboundSetEntityMotionPacket.class)); + verify(delivered).setDeltaMovement(old); + assertEquals(false, delivered.hurtMarked); + } + } + } + + @Test + void modernDamageSourcesKeepNativeProjectileDirectionWithProfileEnabled() { + for (var type : List.of(DamageTypes.SPEAR, DamageTypes.MACE_SMASH, DamageTypes.TRIDENT, + DamageTypes.WIND_CHARGE, DamageTypes.FIREWORKS, DamageTypes.SONIC_BOOM)) { + for (boolean legacy : new boolean[]{false, true}) { + ServerPlayer victim = victim(Vec3.ZERO, false); + victim.legacyCombat = legacy; + doReturn(0.0).when(victim).getX(); + doReturn(0.0).when(victim).getZ(); + doNothing().when(victim).indicateDamage(anyDouble(), anyDouble()); + Entity owner = mock(Entity.class); + when(owner.getX()).thenReturn(5.0); + Arrow direct = mock(Arrow.class); + when(direct.calculateHorizontalHurtKnockbackDirection(any(), any())).thenReturn( + DoubleDoubleImmutablePair.of(2, 0)); + DamageSource source = mock(DamageSource.class); + when(source.is(type)).thenReturn(true); + when(source.getDirectEntity()).thenReturn(direct); + when(source.getEntity()).thenReturn(owner); + try (MockedStatic events = nativeEvents(false)) { + victim.dealDefaultKnockback(source, 1, true); + } + ArgumentCaptor result = ArgumentCaptor.forClass(Vec3.class); + verify(victim).setDeltaMovement(result.capture()); + assertEquals((double) 0.4F, result.getValue().x, 1.0E-12); + assertEquals(0.0, result.getValue().y, 1.0E-12); + } + } + } + + @Test + void sprintAndEnchantmentBonusesAddOnceWithoutSecondFrictionOrImmediateReset() { + // 0.5 = sprint or Knockback I; 1.0 = Knockback II; 1.5 = sprint + Knockback II. + for (float bonus : new float[]{0.5F, 1.0F, 1.5F}) { + ServerPlayer victim = victim(new Vec3(0.3, 0.4, -0.2), false); + Player attacker = mock(Player.class, CALLS_REAL_METHODS); + attacker.legacyCombat = true; + doReturn(0.0F).when(attacker).getYRot(); + doReturn(new Vec3(1, 0.2, 0.3)).when(attacker).getDeltaMovement(); + doNothing().when(attacker).setDeltaMovement(any(Vec3.class)); + doNothing().when(attacker).setSprinting(false); + ServerLevel level = mock(ServerLevel.class); + WorldConfiguration config = mock(WorldConfiguration.class); + config.misc = config.new Misc(); + when(level.paperConfig()).thenReturn(config); + doReturn(level).when(attacker).level(); + Vec3 preDamage = new Vec3(0.8, 0.6, 0.2); + try (MockedStatic events = nativeEvents(false)) { + attacker.causeExtraKnockback(victim, bonus, preDamage, mock(DamageSource.class), 1, true); + } + ArgumentCaptor result = ArgumentCaptor.forClass(Vec3.class); + verify(victim).setDeltaMovement(result.capture()); + assertEquals(0.3, result.getValue().x, 1.0E-12); + assertEquals(0.5, result.getValue().y, 1.0E-12); + assertEquals(-0.2 + bonus, result.getValue().z, 1.0E-12); + verify(victim, never()).setDeltaMovement(preDamage); + assertTrue(victim.hurtMarked); // The tracker still owns delivery. + } + } + + @Test + void acceptedImmuneWindowAttackCanAddBonusWithoutReapplyingBase() { + ServerPlayer victim = victim(new Vec3(0.8, 0.6, 0.2), false); + Player attacker = mock(Player.class); + attacker.legacyCombat = true; + try (MockedStatic events = nativeEvents(false)) { + // Native hurtOrSimulate controls whether the base stage runs. A stronger + // immune-window hit reaches the bonus stage with its existing motion. + victim.knockback(0.5F, 0, -1, mock(DamageSource.class), 1, true, attacker, EntityKnockbackEvent.Cause.ENTITY_ATTACK); + } + ArgumentCaptor result = ArgumentCaptor.forClass(Vec3.class); + verify(victim).setDeltaMovement(result.capture()); + assertEquals(new Vec3(0.8, 0.7, 0.7), result.getValue()); + } + + @Test + void cancelledBonusAndNativeResistanceAttributeRemainEffective() { + ServerPlayer victim = victim(new Vec3(0.8, 0.6, 0.2), false); + doReturn(0.25).when(victim).getAttributeValue(any()); + Player attacker = mock(Player.class); + attacker.legacyCombat = true; + try (MockedStatic events = nativeEvents(true)) { + victim.knockback(0.5F, 0, -1, mock(DamageSource.class), 1, true, attacker, EntityKnockbackEvent.Cause.ENTITY_ATTACK); + events.verify(() -> CraftEventFactory.callEntityKnockbackEvent(any(), any(), any(), any(), org.mockito.ArgumentMatchers.eq(0.375), any())); + } + verify(victim, never()).setDeltaMovement(any(Vec3.class)); + } + + @Test + void completeResistanceDoesNotGainAnUnscaledVerticalBonus() { + Vec3 before = new Vec3(0.8, 0.6, 0.2); + ServerPlayer victim = victim(before, false); + doReturn(1.0).when(victim).getAttributeValue(any()); + Player attacker = mock(Player.class); + attacker.legacyCombat = true; + try (MockedStatic events = nativeEvents(false)) { + victim.knockback(0.5F, 0, -1, mock(DamageSource.class), 1, true, attacker, EntityKnockbackEvent.Cause.ENTITY_ATTACK); + } + verify(victim).setDeltaMovement(before); + } + + @Test + void noBonusAttackLeavesVelocityForTrackerInsteadOfRestoringPreDamageMotion() { + ServerPlayer victim = victim(new Vec3(0.3, 0.4, -0.2), false); + Player attacker = mock(Player.class, CALLS_REAL_METHODS); + attacker.causeExtraKnockback(victim, 0, new Vec3(0.8, 0.6, 0.2), mock(DamageSource.class), 1, true); + verify(victim, never()).setDeltaMovement(any(Vec3.class)); + assertTrue(victim.hurtMarked); + } + @Test + void nativeZeroImpactsAcceptOnlyOptedInFamiliesWithoutChipDamageAndRespectDenials() throws Exception { + for (boolean rod : new boolean[]{false, true}) { + for (boolean flagged : new boolean[]{false, true}) { + for (boolean cancelled : new boolean[]{false, true}) { + for (boolean immune : new boolean[]{false, true}) { + ServerPlayer victim = mock(ServerPlayer.class); + victim.legacyCombat = true; + victim.invulnerableDuration = 20; + when(victim.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class)); + victim.invulnerableTime = immune ? 11 : 0; + ServerLevel level = mock(ServerLevel.class); + when(victim.level()).thenReturn(level); + when(victim.getHealth()).thenReturn(20.0F); + when(victim.getUseItem()).thenReturn(ItemStack.EMPTY); + Field abilities = Player.class.getDeclaredField("abilities"); + abilities.setAccessible(true); + abilities.set(victim, new Abilities()); + Player owner = mock(Player.class); + when(victim.canHarmPlayer(owner)).thenReturn(true); + net.minecraft.world.entity.projectile.Projectile direct; + if (rod) { + var hook = mock(net.minecraft.world.entity.projectile.FishingHook.class); + hook.legacyFlight = flagged; + direct = hook; + } else { + var thrown = mock(net.minecraft.world.entity.projectile.throwableitemprojectile.Snowball.class); + thrown.legacyFlight = flagged; + direct = thrown; + } + DamageSource source = mock(DamageSource.class); + when(source.getEntity()).thenReturn(owner); + when(source.getDirectEntity()).thenReturn(direct); + doCallRealMethod().when(victim).hurtServer(level, source, 0F); + doCallRealMethod().when((LivingEntity)victim).actuallyHurt(any(), any(), anyFloat(), any(EntityDamageEvent.class)); + EntityDamageEvent event = mock(EntityDamageEvent.class); + when(event.isCancelled()).thenReturn(cancelled); + try (var events = mockStatic(CraftEventFactory.class, call -> { + if (call.getMethod().getName().equals("handleLivingEntityDamageEvent")) return event; + return RETURNS_DEFAULTS.answer(call); + })) { + assertEquals(flagged && !cancelled && !immune, victim.hurtServer(level, source, 0F)); + } + verify(victim, never()).setHealth(anyFloat()); + if (flagged && !cancelled && !immune) { + verify(victim).dealDefaultKnockback(source, 0F, false); + assertEquals(20, victim.invulnerableTime); + } else verify(victim, never()).dealDefaultKnockback(any(), anyFloat(), anyBoolean()); + } + } + } + } + } + +} diff --git a/paper-server/src/test/java/net/minecraft/world/entity/LegacyPlayerMovementTest.java b/paper-server/src/test/java/net/minecraft/world/entity/LegacyPlayerMovementTest.java new file mode 100644 index 000000000000..a4d26db8593d --- /dev/null +++ b/paper-server/src/test/java/net/minecraft/world/entity/LegacyPlayerMovementTest.java @@ -0,0 +1,304 @@ +package net.minecraft.world.entity; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Abilities; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.Vec3; +import org.bukkit.support.environment.VanillaFeature; +import org.junit.jupiter.api.Test; + +@VanillaFeature +class LegacyPlayerMovementTest { + private ServerPlayer player(boolean legacy, Vec3 velocity) { + ServerPlayer player = mock(ServerPlayer.class); + player.legacyCombat = legacy; + when(player.getDeltaMovement()).thenReturn(velocity); + return player; + } + + @Test + void nativeJumpReplacesVerticalMotionOnlyForOptedInPlayer() { + Player legacy = mock(Player.class); + legacy.legacyCombat = true; + when(legacy.getDeltaMovement()).thenReturn(new Vec3(0.2, 0.8, 0.3)); + when(legacy.getJumpPower()).thenReturn(0.42F); + // Invoke LivingEntity's actual implementation without ServerPlayer's event wrapper. + LivingEntity living = legacy; + doCallRealMethod().when(living).jumpFromGround(); + living.jumpFromGround(); + verify(legacy).setDeltaMovement(0.2, (double) 0.42F, 0.3); + Player modern = mock(Player.class); + when(modern.getDeltaMovement()).thenReturn(new Vec3(0.2, 0.8, 0.3)); + when(modern.getJumpPower()).thenReturn(0.42F); + doCallRealMethod().when((LivingEntity) modern).jumpFromGround(); + modern.jumpFromGround(); + verify(modern).setDeltaMovement(0.2, 0.8, 0.3); + } + + @Test + void nonAxialSprintJumpUsesFloatProductOnlyWhenOptedIn() { + for (boolean legacy : new boolean[]{true, false}) { + Player player = mock(Player.class); + player.legacyCombat = legacy; + when(player.getDeltaMovement()).thenReturn(Vec3.ZERO); + when(player.getJumpPower()).thenReturn(0.42F); + when(player.isSprinting()).thenReturn(true); + when(player.getYRot()).thenReturn(37.0F); + doCallRealMethod().when((LivingEntity) player).jumpFromGround(); + player.jumpFromGround(); + float angle = 37.0F * net.minecraft.util.Mth.DEG_TO_RAD; + double x = legacy ? (double)(-net.minecraft.util.Mth.sin(angle) * 0.2F) : -net.minecraft.util.Mth.sin(angle) * 0.2; + double z = legacy ? (double)(net.minecraft.util.Mth.cos(angle) * 0.2F) : net.minecraft.util.Mth.cos(angle) * 0.2; + verify(player).addDeltaMovement(new Vec3(x, 0, z)); + } + } + + @Test + void classicCadenceIgnoresFatigueSpeedButKeepsModernWeapons() { + for (net.minecraft.world.item.Item item : new net.minecraft.world.item.Item[]{ + net.minecraft.world.item.Items.AIR, net.minecraft.world.item.Items.DIAMOND_SWORD, + net.minecraft.world.item.Items.DIAMOND_AXE, net.minecraft.world.item.Items.BOW, + net.minecraft.world.item.Items.MACE, net.minecraft.world.item.Items.TRIDENT, + net.minecraft.world.item.Items.WOODEN_SPEAR, net.minecraft.world.item.Items.DIAMOND_SPEAR + }) { + for (boolean legacy : new boolean[]{true, false}) { + Player player = mock(Player.class); + player.legacyCombat = legacy; + when(player.getMainHandItem()).thenReturn(new net.minecraft.world.item.ItemStack(item)); + // High attack-speed fixture with native Mining Fatigue I (-10%). + when(player.getAttributeValue(net.minecraft.world.entity.ai.attributes.Attributes.ATTACK_SPEED)).thenReturn(39.6); + doCallRealMethod().when(player).getCurrentItemAttackStrengthDelay(); + doCallRealMethod().when(player).getAttackStrengthScale(anyFloat()); + boolean modern = item == net.minecraft.world.item.Items.MACE || item == net.minecraft.world.item.Items.TRIDENT + || item == net.minecraft.world.item.Items.WOODEN_SPEAR || item == net.minecraft.world.item.Items.DIAMOND_SPEAR; + float nativeScale = net.minecraft.util.Mth.clamp(0.5F / (float)(20.0 / 39.6), 0, 1); + assertEquals(legacy && !modern ? 1.0F : nativeScale, player.getAttackStrengthScale(0.5F), item + " legacy=" + legacy); + verify(player, never()).resetAttackStrengthTicker(); + } + } + } + + @Test + void componentDefinedModernWeaponKeepsNativeCadence() { + for (boolean kinetic : new boolean[]{true, false}) { + net.minecraft.world.item.ItemStack stack = new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.DIAMOND_SWORD); + net.minecraft.world.item.ItemStack spear = new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.WOODEN_SPEAR); + if (kinetic) stack.set(net.minecraft.core.component.DataComponents.KINETIC_WEAPON, spear.get(net.minecraft.core.component.DataComponents.KINETIC_WEAPON)); + else stack.set(net.minecraft.core.component.DataComponents.PIERCING_WEAPON, spear.get(net.minecraft.core.component.DataComponents.PIERCING_WEAPON)); + Player player = mock(Player.class); + player.legacyCombat = true; + when(player.getMainHandItem()).thenReturn(stack); + when(player.getCurrentItemAttackStrengthDelay()).thenReturn(5.0F); + doCallRealMethod().when(player).getAttackStrengthScale(anyFloat()); + assertEquals(0.1F, player.getAttackStrengthScale(0.5F)); + } + } + + private void aiStepClip(ServerPlayer player) throws Exception { + Field inventory = Player.class.getDeclaredField("inventory"); + inventory.setAccessible(true); + inventory.set(player, mock(Inventory.class)); + Field abilities = Player.class.getDeclaredField("abilities"); + abilities.setAccessible(true); + abilities.set(player, new Abilities()); + Field equipment = LivingEntity.class.getDeclaredField("equipment"); + equipment.setAccessible(true); + equipment.set(player, mock(EntityEquipment.class)); + when(player.canSimulateMovement()).thenReturn(true); + class StopAfterClip extends RuntimeException {} + doThrow(new StopAfterClip()).when((LivingEntity) player).applyInput(); + doCallRealMethod().when(player).aiStep(); + assertThrows(StopAfterClip.class, player::aiStep); + } + + @Test + void actualAiStepClipsEachLegacyAxisAtPointZeroZeroFive() throws Exception { + ServerPlayer legacy = player(true, new Vec3(0.0049, -0.0049, 0.005)); + aiStepClip(legacy); + verify(legacy).setDeltaMovement(0, 0, 0.005); + ServerPlayer modern = player(false, new Vec3(0.0049, -0.0049, 0.005)); + aiStepClip(modern); + verify(modern).setDeltaMovement(0.0049, -0.0049, 0.005); + } + + @Test + void actualAiStepRetainsModernCombinedHorizontalThresholdWhenDisabled() throws Exception { + ServerPlayer modern = player(false, new Vec3(0.0025, 0.003, 0.0025)); + when(modern.is(net.minecraft.world.entity.EntityTypes.PLAYER)).thenReturn(true); + aiStepClip(modern); + verify(modern).setDeltaMovement(0.0025, 0.003, 0.0025); + ServerPlayer legacy = player(true, new Vec3(0.0025, 0.003, 0.0025)); + aiStepClip(legacy); + verify(legacy).setDeltaMovement(0, 0, 0); + } + private Player fallPlayer(boolean legacy) { + Player player = mock(Player.class); + player.legacyCombat = legacy; + when(player.getAttributeValue(net.minecraft.world.entity.ai.attributes.Attributes.SAFE_FALL_DISTANCE)).thenReturn(3.0); + when(player.getAttributeValue(net.minecraft.world.entity.ai.attributes.Attributes.FALL_DAMAGE_MULTIPLIER)).thenReturn(1.0); + doCallRealMethod().when((LivingEntity)player).calculateFallDamage(anyDouble(), anyFloat()); + return player; + } + + @Test + void ordinaryLegacyFallsUseFloatInputAndCeilingWithNativeAttributes() { + Player legacy = fallPlayer(true); + assertEquals(0, legacy.calculateFallDamage(3, 1)); + assertEquals(0, legacy.calculateFallDamage(3.00000001, 1)); + assertEquals(1, legacy.calculateFallDamage(3.5, 1)); + assertEquals(2, legacy.calculateFallDamage(4.5, 1)); + when(legacy.getAttributeValue(net.minecraft.world.entity.ai.attributes.Attributes.SAFE_FALL_DISTANCE)).thenReturn(4.0); + assertEquals(1, legacy.calculateFallDamage(4.5, 1)); // Native Jump Boost safe-distance attribute. + when(legacy.getAttributeValue(net.minecraft.world.entity.ai.attributes.Attributes.FALL_DAMAGE_MULTIPLIER)).thenReturn(0.5); + assertEquals(1, legacy.calculateFallDamage(5.5, 1)); + Player modern = fallPlayer(false); + assertEquals(0, modern.calculateFallDamage(3.5, 1)); + assertEquals(1, modern.calculateFallDamage(4.5, 1)); + when(legacy.isIgnoringFallDamageFromCurrentImpulse()).thenReturn(true); + assertEquals(0, legacy.calculateFallDamage(4.5, 1)); + } + + @Test + void cappedModernImpulseLandingKeepsFloorEvenWhenContextExpiresDuringLanding() throws Exception { + Player player = fallPlayer(true); + Field abilities = Player.class.getDeclaredField("abilities"); + abilities.setAccessible(true); + abilities.set(player, new Abilities()); + player.currentImpulseImpactPos = new Vec3(0, 3.5, 0); + doCallRealMethod().when(player).isIgnoringFallDamageFromCurrentImpulse(); + doCallRealMethod().when(player).tryResetCurrentImpulseContext(); + doCallRealMethod().when(player).resetCurrentImpulseContext(); + doCallRealMethod().when(player).causeFallDamage(anyDouble(), anyFloat(), any()); + assertFalse(player.causeFallDamage(10, 1, mock(net.minecraft.world.damagesource.DamageSource.class))); + assertNull(player.currentImpulseImpactPos); + verify(player, never()).hurtServer(any(), any(), anyFloat()); + } + + @Test + void modernLungeKeepsDirectForwardSelfImpulseAndNativeGrace() { + var impulse = new net.minecraft.world.item.enchantment.effects.ApplyEntityImpulse( + new Vec3(0, 0, 1), new Vec3(1, 0, 1), new net.minecraft.world.item.enchantment.LevelBasedValue.Constant(0.458F)); + for (boolean legacy : new boolean[]{false, true}) { + Player player = mock(Player.class); + player.legacyCombat = legacy; + when(player.getLookAngle()).thenReturn(new Vec3(0, 0, 1)); + impulse.apply(mock(net.minecraft.server.level.ServerLevel.class), 1, null, player, Vec3.ZERO); + var delivered = org.mockito.ArgumentCaptor.forClass(Vec3.class); + verify(player).addDeltaMovement(delivered.capture()); + assertEquals(0, delivered.getValue().x, 1.0E-7); + assertEquals(0, delivered.getValue().y, 0.0); + assertEquals((double)0.458F, delivered.getValue().z, 1.0E-7); + assertTrue(player.hurtMarked); + assertTrue(player.needsSync); + verify(player).applyPostImpulseGraceTime(10); + } + } + + @Test + void classicFireOnsetKeepsSoulFireNativeResistanceAndCombustionArmourControls() throws Exception { + for (boolean legacy : new boolean[]{false, true}) { + for (boolean cancel : new boolean[]{false, true}) { + for (double burningTime : new double[]{1.0, 0.5}) { + for (net.minecraft.world.level.block.Block block : new net.minecraft.world.level.block.Block[]{ + net.minecraft.world.level.block.Blocks.FIRE, net.minecraft.world.level.block.Blocks.SOUL_FIRE + }) { + var player = mock(ServerPlayer.class, CALLS_REAL_METHODS); + player.legacyCombat = legacy; + Field fireAbilities = Player.class.getDeclaredField("abilities"); + fireAbilities.setAccessible(true); + fireAbilities.set(player, new Abilities()); + var level = mock(net.minecraft.server.level.ServerLevel.class); + doReturn(level).when(player).level(); + when(level.getBlockState(net.minecraft.core.BlockPos.ZERO)).thenReturn(block.defaultBlockState()); + doReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class)).when(player).getBukkitEntity(); + doReturn(false).when(player).fireImmune(); + doReturn(burningTime).when(player).getAttributeValue(net.minecraft.world.entity.ai.attributes.Attributes.BURNING_TIME); + doNothing().when(player).clearFreeze(); + var server = mock(org.bukkit.craftbukkit.CraftServer.class); + var manager = mock(org.bukkit.plugin.PluginManager.class); + when(level.getCraftServer()).thenReturn(server); + when(server.getPluginManager()).thenReturn(manager); + doAnswer(call -> { + var event = (org.bukkit.event.entity.EntityCombustByBlockEvent)call.getArgument(0); + assertEquals(8.0F, event.getDuration()); + event.setCancelled(cancel); + return null; + }).when(manager).callEvent(any(org.bukkit.event.entity.EntityCombustByBlockEvent.class)); + boolean classic = legacy && block == net.minecraft.world.level.block.Blocks.FIRE; + player.setRemainingFireTicks(-player.getFireImmuneTicks()); + net.minecraft.world.level.block.BaseFireBlock.fireIgnite(player, net.minecraft.core.BlockPos.ZERO); + if (!classic) { + assertEquals(-19, player.getRemainingFireTicks()); + verify(manager, never()).callEvent(any()); + } else if (cancel) { + assertEquals(-1, player.getRemainingFireTicks()); + } else { + assertEquals((int)(160 * burningTime), player.getRemainingFireTicks()); + } + } + } + } + } + } + + @Test + void repeatedClassicFireContactBalancesActualBaseTickAndExitWhileModernFireRetainsRandomGrowth() throws Throwable { + var tick = java.lang.invoke.MethodHandles.privateLookupIn(Entity.class, java.lang.invoke.MethodHandles.lookup()) + .findSpecial(Entity.class, "baseTick", java.lang.invoke.MethodType.methodType(void.class), Entity.class); + for (boolean legacy : new boolean[]{true, false}) { + for (net.minecraft.world.level.block.Block block : new net.minecraft.world.level.block.Block[]{ + net.minecraft.world.level.block.Blocks.FIRE, net.minecraft.world.level.block.Blocks.SOUL_FIRE + }) { + var player = mock(ServerPlayer.class, CALLS_REAL_METHODS); + player.legacyCombat = legacy; + Field abilities = Player.class.getDeclaredField("abilities"); + abilities.setAccessible(true); + abilities.set(player, new Abilities()); + var level = mock(net.minecraft.server.level.ServerLevel.class); + doReturn(level).when(player).level(); + doReturn(false).when(player).fireImmune(); + doReturn(false).when(player).isInLava(); + doReturn(false).when((Entity)player).canSpawnSprintParticle(); + doReturn(false).when(player).isEyeInFluid(any()); + doReturn(false).when((Entity)player).updateFluidInteraction(); + doNothing().when((Entity)player).computeSpeed(); + doNothing().when((Entity)player).handlePortal(); + doNothing().when(player).updateSwimming(); + doNothing().when(player).checkBelowWorld(); + doNothing().when(player).setSharedFlagOnFire(anyBoolean()); + doNothing().when(player).clearFreeze(); + doReturn(1.0).when(player).getAttributeValue(net.minecraft.world.entity.ai.attributes.Attributes.BURNING_TIME); + doReturn(false).when(player).hurtServer(any(), any(), anyFloat()); + var sources = mock(net.minecraft.world.damagesource.DamageSources.class); + when(level.damageSources()).thenReturn(sources); + var random = mock(net.minecraft.util.RandomSource.class); + when(level.getRandom()).thenReturn(random); + when(random.nextInt(1, 3)).thenReturn(2); + when(level.getBlockState(net.minecraft.core.BlockPos.ZERO)).thenReturn(block.defaultBlockState()); + var manager = mock(org.bukkit.plugin.PluginManager.class); + var server = mock(org.bukkit.craftbukkit.CraftServer.class); + when(level.getCraftServer()).thenReturn(server); + when(server.getPluginManager()).thenReturn(manager); + doReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class)).when(player).getBukkitEntity(); + player.setRemainingFireTicks(160); + boolean classic = legacy && block == net.minecraft.world.level.block.Blocks.FIRE; + for (int i = 0; i < 5; i++) { + tick.invoke(player); // Actual native Entity.baseTick fire decrement, excluding unrelated LivingEntity tick work. + net.minecraft.world.level.block.BaseFireBlock.fireIgnite(player, net.minecraft.core.BlockPos.ZERO); + assertEquals(160 + (classic ? 0 : i + 1), player.getRemainingFireTicks()); + } + tick.invoke(player); // Exit: no contact callback, countdown resumes. + assertEquals(classic ? 159 : 164, player.getRemainingFireTicks()); + if (classic) verify(random, never()).nextInt(1, 3); + else verify(random, times(5)).nextInt(1, 3); + verify(manager, times(5)).callEvent(any(org.bukkit.event.entity.EntityCombustByBlockEvent.class)); + } + } + } + +} diff --git a/paper-server/src/test/java/net/minecraft/world/entity/LegacyVelocityTrackerTest.java b/paper-server/src/test/java/net/minecraft/world/entity/LegacyVelocityTrackerTest.java new file mode 100644 index 000000000000..69cb94133590 --- /dev/null +++ b/paper-server/src/test/java/net/minecraft/world/entity/LegacyVelocityTrackerTest.java @@ -0,0 +1,106 @@ +package net.minecraft.world.entity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import net.minecraft.network.protocol.game.ClientboundSetEntityMotionPacket; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerEntity; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.entity.ai.attributes.AttributeMap; +import org.bukkit.Bukkit; +import org.bukkit.craftbukkit.entity.CraftPlayer; +import org.bukkit.event.player.PlayerVelocityEvent; +import org.bukkit.plugin.PluginManager; +import org.bukkit.support.environment.VanillaFeature; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +@VanillaFeature +class LegacyVelocityTrackerTest { + + @Test + void trackerPublishesFinalVelocityAndHonorsLateEventChanges() throws Exception { + checkTracker(false, true, true); + } + + @Test + void cancelledLegacyTrackerVelocityIsConsumedWithoutSendingOrRetrying() throws Exception { + checkTracker(true, true, false); + } + + @Test + void unmarkedPlayerRetainsNativeCancellationRetryBehavior() throws Exception { + checkTracker(true, false, false); + } + + private void checkTracker(boolean cancelled, boolean legacy, boolean modify) throws Exception { + ServerPlayer victim = mock(ServerPlayer.class); + victim.legacyCombat = legacy; + victim.hurtMarked = true; + AtomicReference movement = new AtomicReference<>(Vec3.ZERO); + when(victim.getDeltaMovement()).thenAnswer(call -> movement.get()); + when(victim.trackingPosition()).thenReturn(Vec3.ZERO); + when(victim.getPassengers()).thenReturn(List.of()); + when(victim.getEntityData()).thenReturn(mock(SynchedEntityData.class)); + AttributeMap attributes = mock(AttributeMap.class); + when(attributes.getAttributesToSync()).thenReturn(new java.util.HashSet<>()); + when(victim.getAttributes()).thenReturn(attributes); + CraftPlayer bukkit = mock(CraftPlayer.class); + when(victim.getBukkitEntity()).thenReturn(bukkit); + when(bukkit.getVelocity()).thenAnswer(call -> new Vector(movement.get().x, movement.get().y, movement.get().z)); + doAnswer(call -> { + Vector velocity = call.getArgument(0); + movement.set(new Vec3(velocity.getX(), velocity.getY(), velocity.getZ())); + return null; + }).when(bukkit).setVelocity(any(Vector.class)); + ServerEntity.Synchronizer network = mock(ServerEntity.Synchronizer.class); + ServerEntity tracker = new ServerEntity(mock(ServerLevel.class), victim, 20, false, network, Set.of()); + // Make the normal delta path eligible too: it must not publish a + // tracking-only motion packet before the final cancellable event. + movement.set(new Vec3(1, 0.2, 0)); + victim.needsSync = legacy; + Field ticks = ServerEntity.class.getDeclaredField("tickCount"); + ticks.setAccessible(true); + ticks.setInt(tracker, 1); // Isolate the native hurtMarked delivery boundary. + PluginManager plugins = mock(PluginManager.class); + doAnswer(call -> { + PlayerVelocityEvent event = call.getArgument(0); + assertEquals(new Vector(1, 0.2, 0), event.getVelocity()); + if (modify) event.setVelocity(new Vector(2, 0.3, 0)); + event.setCancelled(cancelled); + return null; + }).when(plugins).callEvent(any(PlayerVelocityEvent.class)); + try (MockedStatic api = mockStatic(Bukkit.class)) { + api.when(Bukkit::getPluginManager).thenReturn(plugins); + tracker.sendChanges(); + } + verify(plugins).callEvent(any(PlayerVelocityEvent.class)); + verify(network, never()).sendToTrackingPlayers(any(ClientboundSetEntityMotionPacket.class)); + if (cancelled) { + verify(network, never()).sendToTrackingPlayersAndSelf(any(ClientboundSetEntityMotionPacket.class)); + if (legacy) assertFalse(victim.hurtMarked); else assertTrue(victim.hurtMarked); + } else { + ArgumentCaptor packet = ArgumentCaptor.forClass(ClientboundSetEntityMotionPacket.class); + verify(network).sendToTrackingPlayersAndSelf(packet.capture()); + assertEquals(new Vec3(2, 0.3, 0), packet.getValue().movement()); + assertFalse(victim.hurtMarked); + } + } +} diff --git a/paper-server/src/test/java/net/minecraft/world/entity/ProjectileInitialPositionTest.java b/paper-server/src/test/java/net/minecraft/world/entity/ProjectileInitialPositionTest.java new file mode 100644 index 000000000000..eac329952fe3 --- /dev/null +++ b/paper-server/src/test/java/net/minecraft/world/entity/ProjectileInitialPositionTest.java @@ -0,0 +1,55 @@ +package net.minecraft.world.entity; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import net.minecraft.world.entity.projectile.arrow.AbstractArrow; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.craftbukkit.entity.CraftAbstractArrow; +import org.bukkit.support.environment.VanillaFeature; +import org.junit.jupiter.api.Test; + +@VanillaFeature +class ProjectileInitialPositionTest { + private final World world = mock(World.class); + private final AbstractArrow arrow = mock(AbstractArrow.class); + private final CraftAbstractArrow projectile = mock(CraftAbstractArrow.class, CALLS_REAL_METHODS); + + private void prepare() { + doReturn(world).when(projectile).getWorld(); + doReturn(arrow).when(projectile).getHandle(); + when(arrow.isAlive()).thenReturn(true); + } + + @Test + void initialPositionChangesOnlyNativeCoordinatesBeforeInsertion() { + prepare(); + projectile.setPositionBeforeSpawn(new Location(world, 2, 3, 4)); + verify(arrow).setPos(2, 3, 4); + } + + @Test + void spawnedOrTickedOrDeadProjectileCannotUseInitialPositionApi() { + prepare(); + Location position = new Location(world, 2, 3, 4); + arrow.valid = true; + assertThrows(IllegalStateException.class, () -> projectile.setPositionBeforeSpawn(position)); + arrow.valid = false; + arrow.tickCount = 1; + assertThrows(IllegalStateException.class, () -> projectile.setPositionBeforeSpawn(position)); + arrow.tickCount = 0; + when(arrow.isAlive()).thenReturn(false); + assertThrows(IllegalStateException.class, () -> projectile.setPositionBeforeSpawn(position)); + verify(arrow, never()).setPos(anyDouble(), anyDouble(), anyDouble()); + } + + @Test + void crossWorldNullAndNonFiniteCoordinatesAreRejected() { + prepare(); + assertThrows(IllegalArgumentException.class, () -> projectile.setPositionBeforeSpawn(null)); + assertThrows(IllegalArgumentException.class, () -> projectile.setPositionBeforeSpawn(new Location(mock(World.class), 2, 3, 4))); + assertThrows(IllegalArgumentException.class, () -> projectile.setPositionBeforeSpawn(new Location(world, Double.NaN, 3, 4))); + verify(arrow, never()).setPos(anyDouble(), anyDouble(), anyDouble()); + } +} diff --git a/paper-server/src/test/java/net/minecraft/world/entity/projectile/LegacyFishingHookTest.java b/paper-server/src/test/java/net/minecraft/world/entity/projectile/LegacyFishingHookTest.java new file mode 100644 index 000000000000..98dcfc8b0be6 --- /dev/null +++ b/paper-server/src/test/java/net/minecraft/world/entity/projectile/LegacyFishingHookTest.java @@ -0,0 +1,215 @@ +package net.minecraft.world.entity.projectile; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.tags.FluidTags; +import net.minecraft.util.RandomSource; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.damagesource.DamageSources; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.EntityHitResult; +import net.minecraft.world.phys.Vec3; +import org.bukkit.event.entity.EntityRemoveEvent; +import org.bukkit.support.environment.VanillaFeature; +import org.junit.jupiter.api.Test; + +@VanillaFeature +class LegacyFishingHookTest { + private static final class NativeHook extends FishingHook { + private NativeHook() { super(net.minecraft.world.entity.EntityTypes.FISHING_BOBBER, null); } + @Override public void applyEffectsFromBlocks() {} + @Override public void reapplyPosition() {} + } + + private record Flight(FishingHook hook, ServerLevel level, Player owner, AtomicReference position, AtomicReference motion) {} + + private Flight flight(boolean legacy, boolean water, boolean server) throws Exception { + ServerLevel level = mock(ServerLevel.class); + when(level.isClientSide()).thenReturn(!server); + NativeHook hook = mock(NativeHook.class, CALLS_REAL_METHODS); + hook.legacyFlight = legacy; + hook.hasBeenShot = true; + Field despawn = Entity.class.getDeclaredField("despawnTime"); + despawn.setAccessible(true); + despawn.setInt(hook, -1); + hook.leftOwner = true; + doReturn(level).when(hook).level(); + doReturn(mock(org.bukkit.craftbukkit.entity.CraftFishHook.class)).when(hook).getBukkitEntity(); + doReturn(new UUID(0, 0)).when(hook).getUUID(); + doReturn(mock(InterpolationHandler.class)).when(hook).getInterpolation(); + doNothing().when(hook).baseTick(); + doReturn(false).when(hook).isDescending(); + doNothing().when(hook).checkLeftOwner(); + doNothing().when(hook).applyEffectsFromBlocks(); + doNothing().when(hook).updateRotation(); + doReturn(mock(SynchedEntityData.class)).when(hook).getEntityData(); + Field random = FishingHook.class.getDeclaredField("syncronizedRandom"); + random.setAccessible(true); + random.set(hook, mock(RandomSource.class)); + Field state = FishingHook.class.getDeclaredField("currentState"); + state.setAccessible(true); + state.set(hook, FishingHook.FishHookState.FLYING); + Player owner = mock(Player.class); + when(owner.isAlive()).thenReturn(true); + when(owner.position()).thenReturn(Vec3.ZERO); + when(owner.canInteractWithLevel()).thenReturn(true); + ItemStack rod = mock(ItemStack.class); + when(rod.is(Items.FISHING_ROD)).thenReturn(true); + when(owner.getMainHandItem()).thenReturn(rod); + when(owner.getOffhandItem()).thenReturn(mock(ItemStack.class)); + doReturn(owner).when(hook).getPlayerOwner(); + doReturn(owner).when(hook).getOwner(); + AtomicReference position = new AtomicReference<>(Vec3.ZERO); + AtomicReference motion = new AtomicReference<>(new Vec3(1, 0.5, 0.2)); + doAnswer(call -> position.get()).when(hook).position(); + doAnswer(call -> position.get().x).when(hook).getX(); + doAnswer(call -> position.get().y).when(hook).getY(); + doAnswer(call -> position.get().z).when(hook).getZ(); + doReturn(BlockPos.ZERO).when(hook).blockPosition(); + doReturn(new AABB(-0.25, 0, -0.25, 0.25, 0.5, 0.25)).when(hook).getBoundingBox(); + doAnswer(call -> motion.get()).when(hook).getDeltaMovement(); + doAnswer(call -> { motion.set(call.getArgument(0)); return null; }).when(hook).setDeltaMovement(any(Vec3.class)); + doAnswer(call -> { position.set(position.get().add((Vec3)call.getArgument(1))); return null; }).when(hook).move(eq(MoverType.SELF), any()); + when(level.getEntities(any(Entity.class), any(AABB.class), any())).thenReturn(List.of()); + when(level.clipIncludingBorder(any())).thenAnswer(call -> BlockHitResult.miss(position.get().add(motion.get()), Direction.UP, BlockPos.ZERO)); + FluidState fluid = mock(FluidState.class); + when(fluid.is(FluidTags.WATER)).thenReturn(water); + when(fluid.getHeight(level, BlockPos.ZERO)).thenReturn(water ? 1F : 0F); + when(level.getFluidState(BlockPos.ZERO)).thenReturn(fluid); + doReturn(ProjectileDeflection.NONE).when(hook).preHitTargetOrDeflectSelf(any()); + return new Flight(hook, level, owner, position, motion); + } + + private void tick(Flight flight) { + try (var api = mockStatic(org.bukkit.Bukkit.class)) { + api.when(org.bukkit.Bukkit::getPluginManager).thenReturn(mock(org.bukkit.plugin.PluginManager.class)); + flight.hook.tick(); + } + } + + @Test + void actualTickMovesBeforeFloatGravityAndDragOnlyForMarkedAirborneHook() throws Exception { + Flight legacy = flight(true, false, false); + tick(legacy); + assertEquals(new Vec3(1, 0.5, 0.2), legacy.position.get()); + assertEquals(new Vec3(1, 0.5 - (double)0.04F, 0.2).scale((double)0.92F), legacy.motion.get()); + Flight modern = flight(false, false, false); + tick(modern); + assertEquals(new Vec3(1, 0.47, 0.2), modern.position.get()); + assertEquals(new Vec3(1, 0.47, 0.2).scale(0.92), modern.motion.get()); + } + + @Test + void markedGroundCollisionUsesHalfDragWhereUnmarkedNativeStops() throws Exception { + for (boolean legacy : new boolean[]{false, true}) { + Flight flight = flight(legacy, false, false); + flight.hook.horizontalCollision = true; + tick(flight); + assertEquals(legacy ? new Vec3(1, 0.5 - (double)0.04F, 0.2).scale((double)0.5F) : Vec3.ZERO, flight.motion.get()); + } + } + + @Test + void ownerGraceIsFiveAirborneTicksAndNativePredicateRemainsTheGate() throws Exception { + Flight flight = flight(true, false, false); + Field ticks = FishingHook.class.getDeclaredField("legacyAirTicks"); + ticks.setAccessible(true); + when(flight.owner.canBeHitByProjectile()).thenReturn(true); + ticks.setInt(flight.hook, 4); + assertFalse(flight.hook.canHitEntity(flight.owner)); + ticks.setInt(flight.hook, 5); + assertTrue(flight.hook.canHitEntity(flight.owner)); + when(flight.owner.canBeHitByProjectile()).thenReturn(false); + assertFalse(flight.hook.canHitEntity(flight.owner)); + } + + @Test + void onlyAcceptedNativeZeroDamageLatchesMarkedHook() throws Exception { + for (boolean accepted : new boolean[]{false, true}) { + Flight flight = flight(true, false, true); + Entity target = mock(Entity.class); + DamageSources sources = mock(DamageSources.class); + DamageSource source = mock(DamageSource.class); + doReturn(sources).when(flight.hook).damageSources(); + when(sources.thrown(flight.hook, flight.owner)).thenReturn(source); + when(target.hurtServer(flight.level, source, 0F)).thenReturn(accepted); + flight.hook.onHitEntity(new EntityHitResult(target)); + verify(target).hurtServer(flight.level, source, 0F); + if (accepted) verify(flight.hook).setHookedEntity(target); else verify(flight.hook, never()).setHookedEntity(any()); + } + Flight modern = flight(false, false, true); + Entity target = mock(Entity.class); + modern.hook.onHitEntity(new EntityHitResult(target)); + verify(target, never()).hurtServer(any(), any(), anyFloat()); + verify(modern.hook).setHookedEntity(target); + } + + @Test + void underwaterBobbingStillChecksTargetsBeforeBuoyancy() throws Exception { + Flight flight = flight(true, true, true); + Field state = FishingHook.class.getDeclaredField("currentState"); + state.setAccessible(true); + state.set(flight.hook, FishingHook.FishHookState.BOBBING); + Entity target = mock(Entity.class); + when(target.getBoundingBox()).thenReturn(new AABB(0.5, -1, -1, 0.8, 1, 1)); + when(flight.level.getEntities(any(Entity.class), any(AABB.class), any())).thenReturn(List.of(target)); + doAnswer(call -> { flight.hook.setHookedEntity(target); return ProjectileDeflection.NONE; }) + .when(flight.hook).preHitTargetOrDeflectSelf(any(EntityHitResult.class)); + tick(flight); + verify(flight.hook).preHitTargetOrDeflectSelf(any(EntityHitResult.class)); + assertEquals(FishingHook.FishHookState.HOOKED_IN_ENTITY, state.get(flight.hook)); + } + + @Test + void nativeReelAddsLegacyFourthRootLiftOnlyWhenMarked() throws Exception { + for (boolean legacy : new boolean[]{false, true}) { + Flight flight = flight(legacy, false, false); + when(flight.owner.getX()).thenReturn(2.0); + when(flight.owner.getY()).thenReturn(3.0); + when(flight.owner.getZ()).thenReturn(4.0); + Entity target = mock(Entity.class); + when(target.getDeltaMovement()).thenReturn(new Vec3(0.1, 0.2, 0.3)); + flight.hook.pullEntity(target); + double distance = (double)(float)Math.sqrt(29.0); + double lift = legacy ? (double)(float)Math.sqrt(distance) * 0.08 : 0.0; + verify(target).setDeltaMovement(new Vec3(0.1 + 2.0 * 0.1, 0.2 + (3.0 * 0.1 + lift), 0.3 + 4.0 * 0.1)); + } + } + @Test + void lockedResidualRodCannotKeepNativeHookAliveAfterMainHandSwitch() throws Exception { + var stop = FishingHook.class.getDeclaredMethod("shouldStopFishing", Player.class); + stop.setAccessible(true); + for (boolean enabled : new boolean[]{false, true}) { + Flight f = flight(false, false, true); + f.owner.offHandEnabled = enabled; + when(f.owner.canUseSlot(any())).thenCallRealMethod(); + when(f.owner.getMainHandItem()).thenReturn(ItemStack.EMPTY); + when(f.owner.getOffhandItem()).thenReturn(new ItemStack(Items.FISHING_ROD)); + doNothing().when(f.hook).discard(any(EntityRemoveEvent.Cause.class)); + assertEquals(!enabled, stop.invoke(f.hook, f.owner)); + if (enabled) verify(f.hook, never()).discard(any(EntityRemoveEvent.Cause.class)); + else verify(f.hook).discard(EntityRemoveEvent.Cause.DESPAWN); + when(f.owner.getMainHandItem()).thenReturn(new ItemStack(Items.FISHING_ROD)); + assertEquals(false, stop.invoke(f.hook, f.owner)); + } + } + +} diff --git a/paper-server/src/test/java/net/minecraft/world/entity/projectile/LegacyThrowableFlightTest.java b/paper-server/src/test/java/net/minecraft/world/entity/projectile/LegacyThrowableFlightTest.java new file mode 100644 index 000000000000..3ebc4ffde4d6 --- /dev/null +++ b/paper-server/src/test/java/net/minecraft/world/entity/projectile/LegacyThrowableFlightTest.java @@ -0,0 +1,153 @@ +package net.minecraft.world.entity.projectile; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.entity.projectile.throwableitemprojectile.Snowball; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.Vec3; +import org.bukkit.support.environment.VanillaFeature; +import org.junit.jupiter.api.Test; + +@VanillaFeature +class LegacyThrowableFlightTest { + private static final class NativeSnowball extends Snowball { + private NativeSnowball() { super(EntityTypes.SNOWBALL, null); } + @Override public void applyEffectsFromBlocks() {} + } + private record Flight(NativeSnowball projectile, ServerLevel level, AtomicReference position, AtomicReference velocity) {} + + private Flight flight(boolean legacy, boolean startWater, boolean endWater, boolean cachedWater) throws Exception { + NativeSnowball projectile = mock(NativeSnowball.class, CALLS_REAL_METHODS); + projectile.legacyFlight = legacy; + projectile.hasBeenShot = true; + projectile.leftOwner = true; + Field despawn = Entity.class.getDeclaredField("despawnTime"); + despawn.setAccessible(true); + despawn.setInt(projectile, -1); + ServerLevel level = mock(ServerLevel.class); + doReturn(level).when(projectile).level(); + doReturn(false).when(projectile).isNoGravity(); + doReturn(false).when(projectile).isDescending(); + doReturn(true).when(projectile).isAlive(); + doNothing().when(projectile).checkLeftOwner(); + doNothing().when(projectile).updateRotation(); + AtomicReference position = new AtomicReference<>(Vec3.ZERO); + AtomicReference velocity = new AtomicReference<>(new Vec3(1, 0.5, 0.2)); + AtomicBoolean water = new AtomicBoolean(cachedWater); + doAnswer(call -> { water.set(position.get().x < 0.5 ? startWater : endWater); return null; }).when(projectile).baseTick(); + doAnswer(call -> water.get()).when(projectile).isInWater(); + doAnswer(call -> position.get()).when(projectile).position(); + doAnswer(call -> position.get().y).when(projectile).getY(); + doAnswer(call -> { position.set(call.getArgument(0)); return null; }).when(projectile).setPos(any(Vec3.class)); + doAnswer(call -> velocity.get()).when(projectile).getDeltaMovement(); + doAnswer(call -> { velocity.set(call.getArgument(0)); return null; }).when(projectile).setDeltaMovement(any(Vec3.class)); + doReturn(new AABB(-0.125, 0, -0.125, 0.125, 0.25, 0.125)).when(projectile).getBoundingBox(); + when(level.getEntities(any(Entity.class), any(AABB.class), any())).thenReturn(List.of()); + when(level.clipIncludingBorder(any())).thenAnswer(call -> BlockHitResult.miss(position.get().add(velocity.get()), Direction.UP, BlockPos.ZERO)); + doReturn(ProjectileDeflection.NONE).when(projectile).preHitTargetOrDeflectSelf(any()); + return new Flight(projectile, level, position, velocity); + } + + @Test + void markedDryTickMovesBeforeFloatDragAndGravityAndUnmarkedStaysNative() throws Exception { + Flight legacy = flight(true, false, false, false); + legacy.projectile.tick(); + assertEquals(new Vec3(1, 0.5, 0.2), legacy.position.get()); + assertEquals(new Vec3(1, 0.5, 0.2).scale((double)0.99F).add(0, -(double)0.03F, 0), legacy.velocity.get()); + verify(legacy.projectile).baseTick(); + Flight modern = flight(false, false, false, false); + modern.projectile.tick(); + assertEquals(new Vec3(1, 0.5 - 0.03, 0.2).scale((double)0.99F), modern.position.get()); + assertEquals(modern.position.get(), modern.velocity.get()); + verify(modern.projectile).baseTick(); + } + + @Test + void startingWaterControlsDragForEntryExitAndFirstSpawn() throws Exception { + for (boolean starting : new boolean[]{false, true}) { + for (boolean ending : new boolean[]{false, true}) { + Flight flight = flight(true, starting, ending, false); + flight.projectile.tick(); + assertEquals(new Vec3(1, 0.5, 0.2), flight.position.get()); + float drag = starting ? 0.8F : 0.99F; + assertEquals(new Vec3(1, 0.5, 0.2).scale((double)drag).add(0, -(double)0.03F, 0), flight.velocity.get()); + verify(flight.projectile).baseTick(); + } + } + Flight modernFirstSpawn = flight(false, true, true, false); + modernFirstSpawn.projectile.tick(); + assertEquals(new Vec3(1, 0.47, 0.2).scale((double)0.99F), modernFirstSpawn.velocity.get()); + } + + @Test + void impactOriginExistsOnlyInsideNativeCallbackEvenIfItThrows() throws Exception { + Flight flight = flight(true, false, false, false); + BlockHitResult hit = new BlockHitResult(new Vec3(0.25, 0.125, 0.05), Direction.WEST, BlockPos.ZERO, false); + when(flight.level.clipIncludingBorder(any())).thenReturn(hit); + RuntimeException cancelled = new RuntimeException("fixture callback failure"); + doAnswer(call -> { + assertEquals(Vec3.ZERO, flight.projectile.getLegacyImpactOrigin()); + assertEquals(hit.getLocation(), flight.position.get()); + throw cancelled; + }).when(flight.projectile).preHitTargetOrDeflectSelf(hit); + assertSame(cancelled, assertThrows(RuntimeException.class, flight.projectile::tick)); + assertNull(flight.projectile.getLegacyImpactOrigin()); + } + + @Test + void ownerGraceAndSavedProfileDoNotReplaceNativeEligibility() throws Exception { + Flight flight = flight(true, false, false, false); + Entity owner = mock(Entity.class); + doReturn(owner).when(flight.projectile).getOwner(); + when(owner.canBeHitByProjectile()).thenReturn(true); + Field age = ThrowableProjectile.class.getDeclaredField("legacyAirTicks"); + age.setAccessible(true); + age.setInt(flight.projectile, 4); + assertFalse(flight.projectile.canHitEntity(owner)); + age.setInt(flight.projectile, 5); + assertTrue(flight.projectile.canHitEntity(owner)); + when(owner.canBeHitByProjectile()).thenReturn(false); + assertFalse(flight.projectile.canHitEntity(owner)); + doReturn(mock(net.minecraft.world.item.ItemStack.class)).when(flight.projectile).getItem(); + ValueOutput output = mock(ValueOutput.class); + ((ThrowableProjectile)flight.projectile).addAdditionalSaveData(output); + verify(output).putBoolean("LegacyFlight", true); + ValueInput input = mock(ValueInput.class); + when(input.read(anyString(), any())).thenReturn(java.util.Optional.empty()); + when(input.getBooleanOr("LegacyFlight", false)).thenReturn(true); + doNothing().when(flight.projectile).setItem(any()); + ((ThrowableProjectile)flight.projectile).readAdditionalSaveData(input); + assertTrue(flight.projectile.legacyFlight); + assertEquals(0, age.getInt(flight.projectile)); + when(input.getBooleanOr("LegacyFlight", false)).thenReturn(false); + ((ThrowableProjectile)flight.projectile).readAdditionalSaveData(input); + assertFalse(flight.projectile.legacyFlight); + } + @Test + void eachClassicFamilyUsesItsFloatGravityOnlyWhenFlagged() { + var snow = mock(Snowball.class, CALLS_REAL_METHODS); + var potion = mock(net.minecraft.world.entity.projectile.throwableitemprojectile.ThrownSplashPotion.class, CALLS_REAL_METHODS); + var xp = mock(net.minecraft.world.entity.projectile.throwableitemprojectile.ThrownExperienceBottle.class, CALLS_REAL_METHODS); + for (ThrowableProjectile projectile : new ThrowableProjectile[]{snow, potion, xp}) { + double modern = projectile == snow ? 0.03 : projectile == potion ? 0.05 : 0.07; + assertEquals(modern, projectile.getDefaultGravity()); + projectile.legacyFlight = true; + assertEquals((double)(float)modern, projectile.getDefaultGravity()); + } + } + +} diff --git a/paper-server/src/test/java/net/minecraft/world/entity/projectile/arrow/LegacyArrowFlightTest.java b/paper-server/src/test/java/net/minecraft/world/entity/projectile/arrow/LegacyArrowFlightTest.java new file mode 100644 index 000000000000..e49c84c1fe80 --- /dev/null +++ b/paper-server/src/test/java/net/minecraft/world/entity/projectile/arrow/LegacyArrowFlightTest.java @@ -0,0 +1,310 @@ +package net.minecraft.world.entity.projectile.arrow; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.List; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.projectile.ProjectileDeflection; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.EntityHitResult; +import net.minecraft.world.phys.Vec3; +import org.bukkit.support.environment.VanillaFeature; +import org.junit.jupiter.api.Test; + +@VanillaFeature +class LegacyArrowFlightTest { + + private Arrow arrow(Level level) { + Arrow arrow = mock(Arrow.class, CALLS_REAL_METHODS); + arrow.legacyFlight = true; + doReturn(level).when(arrow).level(); + doReturn(Vec3.ZERO).when(arrow).getDeltaMovement(); + doReturn(false).when(arrow).isInGround(); + doReturn((byte) 0).when(arrow).getPierceLevel(); + doReturn(new AABB(-0.25, 0, -0.25, 0.25, 0.5, 0.25)).when(arrow).getBoundingBox(); + return arrow; + } + + private Entity target(AABB bounds) { + Entity target = mock(Entity.class); + when(target.getBoundingBox()).thenReturn(bounds); + when(target.canBeHitByProjectile()).thenReturn(true); + when(target.position()).thenReturn(bounds.getCenter()); + return target; + } + + @Test + void ownerImmunityUsesAirborneTicksInsteadOfLeavingTheBoundingBox() throws Exception { + Arrow arrow = arrow(mock(Level.class)); + Entity owner = mock(Entity.class); + when(owner.isPassengerOfSameVehicle(owner)).thenReturn(true); + Field airTicks = AbstractArrow.class.getDeclaredField("legacyAirTicks"); + airTicks.setAccessible(true); + for (int tick = 0; tick < 5; tick++) { + airTicks.setInt(arrow, tick); + assertFalse(arrow.canCollideWithOwner(owner, owner)); + } + airTicks.setInt(arrow, 5); + assertTrue(arrow.canCollideWithOwner(owner, owner)); + assertTrue(arrow.canCollideWithOwner(owner, mock(Entity.class))); + arrow.legacyFlight = false; + assertFalse(arrow.canCollideWithOwner(owner, owner)); + } + + @Test + void selectsNearestRayIntersectionRatherThanNearestEntityCenter() { + Level level = mock(Level.class); + Arrow arrow = arrow(level); + // The long box has a farther center, but its ray entry is closest. + Entity nearerEntry = target(new AABB(2, -1, -1, 12, 1, 1)); + Entity nearerCenter = target(new AABB(4, -1, -1, 5, 1, 1)); + when(level.getEntities(any(Entity.class), any(AABB.class), any())).thenReturn(List.of(nearerCenter, nearerEntry)); + Collection hits = arrow.findHitEntities(Vec3.ZERO, new Vec3(20, 0, 0)); + assertEquals(1, hits.size()); + assertEquals(nearerEntry, hits.iterator().next().getEntity()); + } + + @Test + void youngArrowHasConstantMarginAndCannotHitBeyondClippedBlockEndpoint() { + Level level = mock(Level.class); + Arrow arrow = arrow(level); + Entity withinMargin = target(new AABB(1, -1, 0.25, 2, 1, 1)); + Entity beyondWall = target(new AABB(4, -1, -1, 5, 1, 1)); + when(level.getEntities(any(Entity.class), any(AABB.class), any())).thenReturn(List.of(beyondWall, withinMargin)); + Collection hits = arrow.findHitEntities(Vec3.ZERO, new Vec3(3, 0, 0)); + assertEquals(withinMargin, hits.iterator().next().getEntity()); + when(level.getEntities(any(Entity.class), any(AABB.class), any())).thenReturn(List.of(beyondWall)); + assertTrue(arrow.findHitEntities(Vec3.ZERO, new Vec3(3, 0, 0)).isEmpty()); + } + + @Test + void legacyOwnerRuleDoesNotBypassNativeEntityEligibility() throws Exception { + Arrow arrow = arrow(mock(Level.class)); + Entity owner = mock(Entity.class); + doReturn(owner).when(arrow).getOwner(); + Field airTicks = AbstractArrow.class.getDeclaredField("legacyAirTicks"); + airTicks.setAccessible(true); + airTicks.setInt(arrow, 5); + assertFalse(arrow.canHitEntity(owner)); + when(owner.canBeHitByProjectile()).thenReturn(true); + assertTrue(arrow.canHitEntity(owner)); + } + + @Test + void selectedCollisionPassesThroughCancellableNativeHitBoundary() throws Exception { + Level level = mock(Level.class); + Arrow arrow = arrow(level); + Entity target = target(new AABB(1, -1, -1, 2, 1, 1)); + when(level.getEntities(any(Entity.class), any(AABB.class), any())).thenReturn(List.of(target)); + doReturn(Vec3.ZERO).when(arrow).position(); + doReturn(true).when(arrow).isAlive(); + doNothing().when(arrow).setPos(any(Vec3.class)); + doNothing().when(arrow).applyEffectsFromBlocks(any(Vec3.class), any(Vec3.class)); + // A cancelled native projectile-hit callback returns NONE without damage. + doReturn(ProjectileDeflection.NONE).when(arrow).preHitTargetOrDeflectSelf(any()); + BlockHitResult endpoint = mock(BlockHitResult.class); + when(endpoint.getLocation()).thenReturn(new Vec3(3, 0, 0)); + Method step = AbstractArrow.class.getDeclaredMethod("stepMoveAndHit", BlockHitResult.class); + step.setAccessible(true); + step.invoke(arrow, endpoint); + verify(arrow).preHitTargetOrDeflectSelf(any(EntityHitResult.class)); + verify(arrow, never()).onHitEntity(any()); + } + + @Test + void profileSurvivesSaveLoadAndOldArrowsRemainNativeByDefault() throws Exception { + Arrow arrow = arrow(mock(Level.class)); + doReturn(false).when(arrow).isInGround(); + doReturn(false).when(arrow).isCritArrow(); + ValueOutput output = mock(ValueOutput.class); + arrow.addAdditionalSaveData(output); + verify(output).putBoolean("LegacyFlight", true); + + ValueInput input = mock(ValueInput.class); + when(input.read(anyString(), any())).thenReturn(java.util.Optional.empty()); + when(input.getBooleanOr("LegacyFlight", false)).thenReturn(true); + doNothing().when(arrow).setInGround(false); + doNothing().when(arrow).setCritArrow(false); + doNothing().when(arrow).setPierceLevel((byte) 0); + doNothing().when(arrow).setPickupItemStack(any()); + Field age = AbstractArrow.class.getDeclaredField("legacyAirTicks"); + age.setAccessible(true); + age.setInt(arrow, 9); + arrow.legacyFlight = false; + arrow.readAdditionalSaveData(input); + assertTrue(arrow.legacyFlight); + assertEquals(0, age.getInt(arrow)); + + when(input.getBooleanOr("LegacyFlight", false)).thenReturn(false); + arrow.readAdditionalSaveData(input); + assertFalse(arrow.legacyFlight); + } + + @Test + void nativeDragAndGravityFollowLegacyAirAndWaterTrajectories() throws Exception { + Method drag = AbstractArrow.class.getDeclaredMethod("applyFlightDrag", Vec3.class); + drag.setAccessible(true); + Method gravity = Entity.class.getDeclaredMethod("applyGravity"); + gravity.setAccessible(true); + for (boolean water : new boolean[]{false, true}) { + Arrow arrow = arrow(mock(Level.class)); + doReturn(water).when(arrow).isInWater(); + doReturn(false).when(arrow).isNoGravity(); + java.util.concurrent.atomic.AtomicReference movement = + new java.util.concurrent.atomic.AtomicReference<>(new Vec3(2, 0.4, 0)); + doAnswer(call -> movement.get()).when(arrow).getDeltaMovement(); + doAnswer(call -> { movement.set(call.getArgument(0)); return null; }).when(arrow).setDeltaMovement(any(Vec3.class)); + Vec3 position = Vec3.ZERO; + for (int tick = 0; tick < 3; tick++) { + position = position.add(movement.get()); + drag.invoke(arrow, position); + gravity.invoke(arrow); + } + assertEquals(water ? 4.88 : 5.9402, position.x, 1.0E-6); + assertEquals(water ? 0.836 : 1.03854, position.y, 1.0E-6); + } + } + + @Test + void gravityPreservesLegacyFloatValueOnlyWhenEnabled() { + Arrow arrow = arrow(mock(Level.class)); + assertEquals((double) 0.05F, arrow.getDefaultGravity()); + arrow.legacyFlight = false; + assertEquals(0.05, arrow.getDefaultGravity()); + } + @Test + void markedRayIncludesInsideExitAndBothBoundaryEndpoints() { + assertLegacyRay(new Vec3(0.5, 0.5, 0.5), new Vec3(2, 0.5, 0.5), new Vec3(1, 0.5, 0.5)); + assertLegacyRay(new Vec3(0, 0.5, 0.5), new Vec3(2, 0.5, 0.5), new Vec3(0, 0.5, 0.5)); + assertLegacyRay(new Vec3(-1, 0.5, 0.5), new Vec3(0, 0.5, 0.5), new Vec3(0, 0.5, 0.5)); + assertLegacyRay(new Vec3(-1, -1, 0.5), new Vec3(1, 1, 0.5), new Vec3(0, 0, 0.5)); + } + + @Test + void markedRayUsesLegacySquaredAxisInterpolationCutoff() { + assertLegacyRay(new Vec3(-0.00005, 0.5, 0.5), new Vec3(0.00005, 0.5, 0.5), null); + assertLegacyRay(new Vec3(-0.0002, 0.5, 0.5), new Vec3(0.0002, 0.5, 0.5), new Vec3(0, 0.5, 0.5)); + } + + private void assertLegacyRay(Vec3 from, Vec3 to, Vec3 expected) { + Level level = mock(Level.class); + Arrow arrow = arrow(level); + double margin = (double)0.3F; + Entity target = target(new AABB(margin, margin, margin, 1 - margin, 1 - margin, 1 - margin)); + when(level.getEntities(any(Entity.class), any(AABB.class), any())).thenReturn(List.of(target)); + var hits = arrow.findHitEntities(from, to); + if (expected == null) assertTrue(hits.isEmpty()); + else { + assertEquals(1, hits.size()); + assertEquals(expected, hits.iterator().next().getLocation()); + } + } + + @Test + void markedObliqueBlockImpactKeepsFloatSegmentAndReleasedMotion() throws Exception { + blockImpact(true, false); + } + + @Test + void unmarkedObliqueBlockImpactRetainsNativeEmbeddingAndVerticalRelease() throws Exception { + blockImpact(false, false); + } + + @Test + void cancelledBlockHitRetainsNativeEmbeddingButSuppressesBlockCallbacks() throws Exception { + blockImpact(true, true); + blockImpact(false, true); + } + + private void blockImpact(boolean legacy, boolean cancelled) throws Exception { + Level level = mock(Level.class); + io.papermc.paper.configuration.WorldConfiguration config = mock(io.papermc.paper.configuration.WorldConfiguration.class); + config.entities = mock(io.papermc.paper.configuration.WorldConfiguration.Entities.class); + config.entities.spawning = mock(io.papermc.paper.configuration.WorldConfiguration.Entities.Spawning.class); + config.entities.spawning.maxArrowDespawnInvulnerability = new io.papermc.paper.configuration.type.number.IntOr.Disabled(java.util.OptionalInt.of(200)); + when(level.paperConfig()).thenReturn(config); + var block = mock(net.minecraft.world.level.block.state.BlockState.class); + when(level.getBlockState(any())).thenReturn(block); + Arrow arrow = arrow(level); + arrow.legacyFlight = legacy; + Field cancelledField = net.minecraft.world.entity.projectile.Projectile.class.getDeclaredField("hitCancelled"); + cancelledField.setAccessible(true); + cancelledField.setBoolean(arrow, cancelled); + Field randomField = Entity.class.getDeclaredField("random"); + randomField.setAccessible(true); + var random = mock(net.minecraft.util.RandomSource.class); + when(random.nextFloat()).thenReturn(0.5F); + randomField.set(arrow, random); + Vec3 initial = new Vec3(0.2, 0.3, 0.4); + Vec3 hit = new Vec3(1.1, 0.9, 2.0); + var position = new java.util.concurrent.atomic.AtomicReference<>(initial); + var movement = new java.util.concurrent.atomic.AtomicReference<>(new Vec3(2, 1, 3)); + var ground = new java.util.concurrent.atomic.AtomicBoolean(); + doAnswer(call -> position.get()).when(arrow).position(); + doAnswer(call -> { position.set(call.getArgument(0)); return null; }).when(arrow).setPos(any(Vec3.class)); + doAnswer(call -> movement.get()).when(arrow).getDeltaMovement(); + doAnswer(call -> { movement.set(call.getArgument(0)); return null; }).when(arrow).setDeltaMovement(any(Vec3.class)); + doAnswer(call -> ground.get()).when(arrow).isInGround(); + doAnswer(call -> { ground.set(call.getArgument(0)); return null; }).when(arrow).setInGround(org.mockito.ArgumentMatchers.anyBoolean()); + doNothing().when(arrow).setCritArrow(org.mockito.ArgumentMatchers.anyBoolean()); + doNothing().when(arrow).setPierceLevel(org.mockito.ArgumentMatchers.anyByte()); + doNothing().when(arrow).playSound(any(), org.mockito.ArgumentMatchers.anyFloat(), org.mockito.ArgumentMatchers.anyFloat()); + doReturn(List.of()).when(arrow).findHitEntities(any(), any()); + doReturn(true).when(arrow).isAlive(); + doNothing().when(arrow).applyEffectsFromBlocks(any(), any()); + BlockHitResult result = new BlockHitResult(hit, net.minecraft.core.Direction.WEST, net.minecraft.core.BlockPos.ZERO, false); + doAnswer(call -> { arrow.onHitBlock(call.getArgument(0)); return ProjectileDeflection.NONE; }) + .when(arrow).preHitTargetOrDeflectSelf(any(BlockHitResult.class)); + Method step = AbstractArrow.class.getDeclaredMethod("stepMoveAndHit", BlockHitResult.class); + step.setAccessible(true); + step.invoke(arrow, result); + Vec3 segment = new Vec3((float)(hit.x - initial.x), (float)(hit.y - initial.y), (float)(hit.z - initial.z)); + float length = (float)Math.sqrt(segment.lengthSqr()); + Vec3 embedded = legacy ? initial.subtract(new Vec3(segment.x / length * (double)0.05F, segment.y / length * (double)0.05F, segment.z / length * (double)0.05F)).add(segment) + : hit.subtract(new Vec3(0.05F, 0.05F, 0.05F)); + assertEquals(embedded.x, position.get().x, 1.0E-15); + assertEquals(embedded.y, position.get().y, 1.0E-15); + assertEquals(embedded.z, position.get().z, 1.0E-15); + assertEquals(legacy ? segment : Vec3.ZERO, movement.get()); + assertTrue(ground.get()); + if (cancelled) verify(block, never()).onProjectileHit(level, block, result, arrow); + else verify(block).onProjectileHit(level, block, result, arrow); + Method drag = AbstractArrow.class.getDeclaredMethod("applyFlightDrag", Vec3.class); + drag.setAccessible(true); + doReturn(false).when(arrow).isInWater(); + doReturn(false).when(arrow).isNoGravity(); + drag.invoke(arrow, initial); + Vec3 beforeRelease = legacy ? segment.scale((double)0.99F).add(0, -(double)0.05F, 0) : Vec3.ZERO; + assertEquals(beforeRelease, movement.get()); + Method shouldFall = AbstractArrow.class.getDeclaredMethod("shouldFall"); + shouldFall.setAccessible(true); + assertFalse((boolean) shouldFall.invoke(arrow)); + when(level.noCollision(any(AABB.class))).thenReturn(true); // Supporting collision removed. + assertTrue((boolean) shouldFall.invoke(arrow)); + Method falling = AbstractArrow.class.getDeclaredMethod("startFalling"); + falling.setAccessible(true); + falling.invoke(arrow); + assertFalse(ground.get()); + assertEquals(beforeRelease.multiply((double)0.1F, (double)0.1F, (double)0.1F), movement.get()); + } + +} diff --git a/paper-server/src/test/java/net/minecraft/world/entity/projectile/throwableitemprojectile/LegacySplashOriginTest.java b/paper-server/src/test/java/net/minecraft/world/entity/projectile/throwableitemprojectile/LegacySplashOriginTest.java new file mode 100644 index 000000000000..002efb624d24 --- /dev/null +++ b/paper-server/src/test/java/net/minecraft/world/entity/projectile/throwableitemprojectile/LegacySplashOriginTest.java @@ -0,0 +1,88 @@ +package net.minecraft.world.entity.projectile.throwableitemprojectile; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import java.util.List; +import java.util.Map; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.Direction; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.alchemy.PotionContents; +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; +import org.bukkit.craftbukkit.entity.CraftLivingEntity; +import org.bukkit.craftbukkit.event.CraftEventFactory; +import org.bukkit.event.entity.EntityPotionEffectEvent; +import org.bukkit.event.entity.PotionSplashEvent; +import org.bukkit.support.environment.VanillaFeature; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +@VanillaFeature +class LegacySplashOriginTest { + @Test void markedSplashUsesPreStepFeetDistanceWhileUnmarkedRetainsBoxDistance() { + splash(true, false, false, 0.25, 50); + splash(false, false, false, 0.8125, 163); + } + @Test void directHitHasFullLegacyIntensityAndCancelledSplashAppliesNothing() { + splash(true, true, false, 1.0, 200); + splash(true, false, true, 0.25, 0); + } + private void splash(boolean legacy, boolean direct, boolean cancelled, double intensity, int duration) { + ServerLevel level = mock(ServerLevel.class); + ThrownSplashPotion potion = mock(ThrownSplashPotion.class, CALLS_REAL_METHODS); + potion.legacyFlight = legacy; + doReturn(level).when(potion).level(); + Vec3 actualHit = new Vec3(2, 0, 0); + doReturn(actualHit).when(potion).position(); + doReturn(Vec3.ZERO).when(potion).getLegacyImpactOrigin(); + doReturn(null).when(potion).getOwner(); + AABB impactBox = new AABB(1.875, 0, -0.125, 2.125, 0.25, 0.125); + doReturn(impactBox).when(potion).getBoundingBox(); + LivingEntity target = mock(LivingEntity.class); + when(target.position()).thenReturn(new Vec3(direct ? 2 : 3, 0, 0)); + when(target.getBoundingBox()).thenReturn(impactBox.move(direct ? 0 : 1, 0, 0)); + when(target.isAffectedByPotions()).thenReturn(true); + CraftLivingEntity bukkit = mock(CraftLivingEntity.class); + when(bukkit.getHandle()).thenReturn(target); + when(target.getBukkitLivingEntity()).thenReturn(bukkit); + when(level.getEntitiesOfClass(eq(LivingEntity.class), any(AABB.class))).thenReturn(List.of(target)); + ItemStack item = mock(ItemStack.class); + PotionContents contents = mock(PotionContents.class); + when(contents.getAllEffects()).thenReturn(List.of(new MobEffectInstance(MobEffects.SPEED, 200))); + when(item.getOrDefault(DataComponents.POTION_CONTENTS, PotionContents.EMPTY)).thenReturn(contents); + when(item.getOrDefault(DataComponents.POTION_DURATION_SCALE, 1.0F)).thenReturn(1.0F); + HitResult hit = direct ? new EntityHitResult(target, actualHit) : new BlockHitResult(actualHit, Direction.WEST, BlockPos.ZERO, false); + try (var events = mockStatic(CraftEventFactory.class)) { + events.when(() -> CraftEventFactory.callPotionSplashEvent(same(potion), same(hit), anyMap())).thenAnswer(call -> { + Map affected = call.getArgument(2); + assertEquals(intensity, affected.get(bukkit), 0.0); + PotionSplashEvent event = mock(PotionSplashEvent.class); + when(event.isCancelled()).thenReturn(cancelled); + when(event.getAffectedEntities()).thenReturn(affected.keySet()); + when(event.getIntensity(bukkit)).thenReturn(affected.get(bukkit)); + return event; + }); + assertEquals(!cancelled, potion.onHitAsPotion(level, item, hit)); + } + ArgumentCaptor search = ArgumentCaptor.forClass(AABB.class); + verify(level).getEntitiesOfClass(eq(LivingEntity.class), search.capture()); + assertEquals(legacy ? -4.125 : -2.125, search.getValue().minX, 0.0); + assertEquals(actualHit, potion.position()); + if (cancelled) verify(target, never()).addEffect(any(), any(), any(EntityPotionEffectEvent.Cause.class)); + else { + var effect = ArgumentCaptor.forClass(MobEffectInstance.class); + verify(target).addEffect(effect.capture(), same(potion), eq(EntityPotionEffectEvent.Cause.POTION_SPLASH)); + assertEquals(duration, effect.getValue().getDuration()); + } + } +} diff --git a/paper-server/src/test/java/org/bukkit/support/suite/LegacyCombatTestSuite.java b/paper-server/src/test/java/org/bukkit/support/suite/LegacyCombatTestSuite.java new file mode 100644 index 000000000000..912c62c90d39 --- /dev/null +++ b/paper-server/src/test/java/org/bukkit/support/suite/LegacyCombatTestSuite.java @@ -0,0 +1,13 @@ +package org.bukkit.support.suite; + +import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.IncludeTags; +import org.junit.platform.suite.api.SelectPackages; +import org.junit.platform.suite.api.Suite; + +@Suite +@IncludeTags("VanillaFeature") +@SelectPackages("net.minecraft.world.entity") +@ConfigurationParameter(key = "TestSuite", value = "VanillaFeature") +public class LegacyCombatTestSuite { +}