From 7bafa8e4ad66f50bd5a33efeffaab61ced98ab8f Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:37:10 +0200 Subject: [PATCH 01/17] fix: remove duplicate PlaceBlockBlocker registration PlaceBlockBlocker was subscribed twice, causing every BlockPlaceEvent to be handled twice and the "block placing blocked" notice to be sent twice. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/com/eternalcode/combat/CombatPlugin.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/CombatPlugin.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/CombatPlugin.java index 4385582c..a886c831 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/CombatPlugin.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/CombatPlugin.java @@ -208,8 +208,7 @@ public void onEnable() { new CommandsBlocker(this.fightManager, noticeService, pluginConfig), new ElytraBlocker(this.fightManager, pluginConfig), new ElytraEquipBlocker(this.fightManager, noticeService, pluginConfig, server), - new FlyingBlocker(this.fightManager, pluginConfig, server), - new PlaceBlockBlocker(this.fightManager, noticeService, pluginConfig) + new FlyingBlocker(this.fightManager, pluginConfig, server) ); new KnockbackMountController(noticeService, this.regionProvider, this.fightManager).register(this); From e7d0eecb45791cad5cd006b533f3623ee0d7b39a Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:37:27 +0200 Subject: [PATCH 02/17] fix: continue instead of return on expired combat tag FightTask exited its entire run() on the first expired tag, so with more than one player in combat the remaining players were skipped every tick: combat-timer notifications became unreliable and untagging was delayed to at most one expiry per second. Use continue to process all players. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/com/eternalcode/combat/fight/FightTask.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/FightTask.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/FightTask.java index 9f78f18b..54f1ca88 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/FightTask.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/FightTask.java @@ -38,7 +38,7 @@ public void run() { if (fightTag.isExpired()) { this.fightManager.untag(playerUniqueId, CauseOfUnTag.TIME_EXPIRED); - return; + continue; } Duration remaining = fightTag.getRemainingDuration(); From f07e5e229853e4fe16a3462317515cb9ee2d0798 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:37:50 +0200 Subject: [PATCH 03/17] fix: enforce headDropOnlyInCombat for player head drops When headDropOnlyInCombat was true and the victim was not in combat, the guard fell through to the chance roll and heads could still drop. Also the in-combat case short-circuited to true, ignoring headDropChance. Now the setting blocks out-of-combat drops and the configured chance always applies. Co-Authored-By: Claude Opus 4.8 --- .../com/eternalcode/combat/fight/drop/DropController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/drop/DropController.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/drop/DropController.java index 3614c6e5..f8a8957b 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/drop/DropController.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/drop/DropController.java @@ -85,8 +85,8 @@ private boolean shouldHeadDrop(boolean inCombat) { return false; } - if (this.dropSettings.headDropOnlyInCombat && inCombat) { - return true; + if (this.dropSettings.headDropOnlyInCombat && !inCombat) { + return false; } if (this.dropSettings.headDropChance <= 0.0) { From 80adc99e2012087f182f04f4a90caa94637c50b1 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:38:11 +0200 Subject: [PATCH 04/17] fix: guard against offline player in TridentController.onUntag FightUntagEvent fires on death, logout and admin untag, where the player may be offline and server.getPlayer returns null, causing an NPE. Remove the trident delay by UUID regardless and only reset the cooldown when the player is online. Co-Authored-By: Claude Opus 4.8 --- .../combat/fight/trident/TridentController.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/trident/TridentController.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/trident/TridentController.java index 1182819f..930243d1 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/trident/TridentController.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/trident/TridentController.java @@ -119,9 +119,15 @@ public void onRiptide(PlayerRiptideEvent event) { @EventHandler(ignoreCancelled = true) public void onUntag(FightUntagEvent event) { - Player player = server.getPlayer(event.getPlayer()); + UUID playerId = event.getPlayer(); + this.tridentService.removeDelay(playerId); + + Player player = server.getPlayer(playerId); + if (player == null) { + return; + } + player.setCooldown(Material.TRIDENT, 0); - this.tridentService.removeDelay(player.getUniqueId()); } private boolean isRiptideInteract(PlayerInteractEvent event) { From 0173594d8b0c3f8525ef26e18e84429e75c1f422 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:38:30 +0200 Subject: [PATCH 05/17] fix: do not throw on offline tagged player in knockback onTag A FightTagEvent can carry an offline player's UUID (e.g. an attacker tagged via crystal PvP who already disconnected). Throwing IllegalStateException logged a stack trace on every such tag. Return early instead, consistent with FlyingBlocker.onTag. Co-Authored-By: Claude Opus 4.8 --- .../combat/fight/knockback/KnockbackRegionController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/knockback/KnockbackRegionController.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/knockback/KnockbackRegionController.java index 589b0c41..36468062 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/knockback/KnockbackRegionController.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/knockback/KnockbackRegionController.java @@ -135,7 +135,7 @@ void onVehicleMove(VehicleMoveEvent event) { void onTag(FightTagEvent event) { Player player = this.server.getPlayer(event.getPlayer()); if (player == null) { - throw new IllegalStateException("Player cannot be null!"); + return; } Optional regionOptional = this.regionProvider.getRegion(player.getLocation()); From a69bc112e61fbeb3d0df400c8514701ce4057ad4 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:38:57 +0200 Subject: [PATCH 06/17] fix: spawn death-flare particles on sync scheduler The recurring particle task read entity state (isDead/isValid/getLocation) and called world.spawnParticle from an async scheduler thread, which is not thread-safe on Paper and throws on Folia. Use the sync scheduler instead. Co-Authored-By: Claude Opus 4.8 --- .../eternalcode/combat/fight/death/DeathFlareController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/death/DeathFlareController.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/death/DeathFlareController.java index 53e11b69..5b6278cd 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/death/DeathFlareController.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/death/DeathFlareController.java @@ -102,7 +102,7 @@ private void spawnFlare(Player player) { } private void scheduleParticles(Firework flare, World world) { - this.scheduler.runLaterAsync( + this.scheduler.runLater( () -> { if (flare.isDead() || !flare.isValid()) { return; From baff6b9aa01c497eaed9c9461bf6c6a7d046e49f Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:39:30 +0200 Subject: [PATCH 07/17] fix: drop overflow items instead of losing them on respawn Inventory#addItem returns items that did not fit, which was discarded, so kept items were silently destroyed when the respawn inventory was full. Drop any leftovers at the player's location. Co-Authored-By: Claude Opus 4.8 --- .../com/eternalcode/combat/fight/drop/DropController.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/drop/DropController.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/drop/DropController.java index f8a8957b..d344867a 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/drop/DropController.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/drop/DropController.java @@ -16,6 +16,7 @@ import org.bukkit.inventory.meta.SkullMeta; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; @@ -139,7 +140,9 @@ public void onPlayerRespawn(PlayerRespawnEvent event) { ItemStack[] itemsToGive = this.keepInventoryManager.nextItems(playerUniqueId) .toArray(new ItemStack[0]); - playerInventory.addItem(itemsToGive); + Map leftover = playerInventory.addItem(itemsToGive); + leftover.values().forEach(item -> + player.getWorld().dropItemNaturally(player.getLocation(), item)); } } } From 6b53c40b63b628163d4dfd37a8b9f164b966bead Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:40:25 +0200 Subject: [PATCH 08/17] fix: make removeRandomItems safe and progress-guaranteed Index off the working copy instead of the original list, guard the empty case, clamp the delete count to the total available amount (so a misconfig > 100% cannot spin forever), always remove at least one item per iteration to guarantee progress, and drop drained stacks so no zero-amount ItemStacks leak back into the drop list. Co-Authored-By: Claude Opus 4.8 --- .../combat/util/InventoryUtil.java | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/util/InventoryUtil.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/util/InventoryUtil.java index cc752025..13439caf 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/util/InventoryUtil.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/util/InventoryUtil.java @@ -24,23 +24,38 @@ public static RemoveItemResult removeRandomItems(List list, int items List currentItems = new ArrayList<>(list); List removedItems = new ArrayList<>(); - int currentItemsToDelete = itemsToDelete; - while (currentItemsToDelete > 0) { - int randomIndex = RANDOM.nextInt(list.size()); + int totalAvailable = 0; + for (ItemStack item : currentItems) { + totalAvailable += Math.max(0, item.getAmount()); + } + + // Never try to remove more than is actually available, otherwise the loop + // could never reach zero and would spin forever. + int currentItemsToDelete = Math.min(itemsToDelete, totalAvailable); + + while (currentItemsToDelete > 0 && !currentItems.isEmpty()) { + int randomIndex = RANDOM.nextInt(currentItems.size()); ItemStack currentItem = currentItems.get(randomIndex); int amount = currentItem.getAmount(); - int randomAmount = RANDOM.nextInt(0, Math.min(currentItemsToDelete, amount) + 1); - - if (amount <= 0 || randomAmount <= 0) { + if (amount <= 0) { + currentItems.remove(randomIndex); continue; } + int maxRemovable = Math.min(currentItemsToDelete, amount); + int randomAmount = RANDOM.nextInt(1, maxRemovable + 1); + ItemStack removedItem = currentItem.clone(); removedItem.setAmount(randomAmount); removedItems.add(removedItem); - currentItem.setAmount(amount - randomAmount); + int remaining = amount - randomAmount; + if (remaining <= 0) { + currentItems.remove(randomIndex); + } else { + currentItem.setAmount(remaining); + } currentItemsToDelete -= randomAmount; } From bedb241bf178f9350da3be07173b628f85b8f139 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:40:44 +0200 Subject: [PATCH 09/17] fix: remove TOCTOU NPE window in isInCombat isInCombat did containsKey followed by get; a concurrent untag (reachable from async PlaceholderAPI calls and the async reload command) between the two calls returned null and threw NPE. Use a single get. Co-Authored-By: Claude Opus 4.8 --- .../java/com/eternalcode/combat/fight/FightManagerImpl.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/FightManagerImpl.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/FightManagerImpl.java index a7cdb65f..35bb7cda 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/FightManagerImpl.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/FightManagerImpl.java @@ -27,13 +27,9 @@ public FightManagerImpl(EventManager eventManager) { @Override public boolean isInCombat(UUID player) { - if (!this.fights.containsKey(player)) { - return false; - } - FightTag fightTag = this.fights.get(player); - return !fightTag.isExpired(); + return fightTag != null && !fightTag.isExpired(); } @Override From 04d01d78e22cf981141a4b5da611bbe04d7836cf Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:41:27 +0200 Subject: [PATCH 10/17] fix: close namespaced/padded command bypass in CommandsBlocker Blacklisted commands could be run in combat via a namespaced form (/minecraft:tp) or with leading whitespace, since matching was a raw prefix check on the message. Normalize the label by trimming and stripping the namespace prefix before matching. Co-Authored-By: Claude Opus 4.8 --- .../combat/fight/blocker/CommandsBlocker.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/blocker/CommandsBlocker.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/blocker/CommandsBlocker.java index 8f6867ab..f56e8ed8 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/blocker/CommandsBlocker.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/blocker/CommandsBlocker.java @@ -32,7 +32,7 @@ void onPlayerCommandPreprocess(PlayerCommandPreprocessEvent event) { return; } - String command = event.getMessage().substring(1); + String command = normalizeCommand(event.getMessage().substring(1)); boolean isAnyMatch = this.config.commands.restrictedCommands.stream() .anyMatch(restrictedCommand -> StringUtil.startsWithIgnoreCase(command, restrictedCommand)); @@ -51,4 +51,21 @@ void onPlayerCommandPreprocess(PlayerCommandPreprocessEvent event) { } } + // Trim leading spaces and strip any namespace prefix (e.g. "minecraft:tp" -> "tp") + // from the command label so blacklisted commands cannot be bypassed with those forms. + private static String normalizeCommand(String rawCommand) { + String command = rawCommand.stripLeading(); + + int spaceIndex = command.indexOf(' '); + String label = spaceIndex == -1 ? command : command.substring(0, spaceIndex); + + int colonIndex = label.indexOf(':'); + if (colonIndex == -1) { + return command; + } + + String rest = spaceIndex == -1 ? "" : command.substring(spaceIndex); + return label.substring(colonIndex + 1) + rest; + } + } From 7747bfb9b55da5e7d9ca563b3794d926aed6a9cc Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:42:14 +0200 Subject: [PATCH 11/17] fix: evict expired tag-out entries via self-expiring cache tagOuts was a plain HashMap that never removed expired entries, growing unbounded over long uptime. Use a Caffeine cache with per-entry expiry tied to the tag-out end time. Co-Authored-By: Claude Opus 4.8 --- .../fight/tagout/FightTagOutServiceImpl.java | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/tagout/FightTagOutServiceImpl.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/tagout/FightTagOutServiceImpl.java index 548e6d1a..63630c5e 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/tagout/FightTagOutServiceImpl.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/tagout/FightTagOutServiceImpl.java @@ -1,14 +1,34 @@ package com.eternalcode.combat.fight.tagout; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; import java.time.Duration; import java.time.Instant; -import java.util.HashMap; -import java.util.Map; import java.util.UUID; public class FightTagOutServiceImpl implements FightTagOutService { - private final Map tagOuts = new HashMap<>(); + // Self-expiring cache so entries are evicted at their end time instead of + // accumulating for every player that has ever tagged out. + private final Cache tagOuts = Caffeine.newBuilder() + .expireAfter(new Expiry() { + @Override + public long expireAfterCreate(UUID key, Instant endTime, long currentTime) { + return Math.max(0, Duration.between(Instant.now(), endTime).toNanos()); + } + + @Override + public long expireAfterUpdate(UUID key, Instant endTime, long currentTime, long currentDuration) { + return this.expireAfterCreate(key, endTime, currentTime); + } + + @Override + public long expireAfterRead(UUID key, Instant endTime, long currentTime, long currentDuration) { + return currentDuration; + } + }) + .build(); @Override public void tagOut(UUID player, Duration duration) { @@ -19,12 +39,12 @@ public void tagOut(UUID player, Duration duration) { @Override public void unTagOut(UUID player) { - this.tagOuts.remove(player); + this.tagOuts.invalidate(player); } @Override public boolean isTaggedOut(UUID player) { - Instant endTime = this.tagOuts.get(player); + Instant endTime = this.tagOuts.getIfPresent(player); if (endTime == null) { return false; From a36af22d0f04d1f26ea4bdfd7519b07855b889cc Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:42:43 +0200 Subject: [PATCH 12/17] fix: parenthesize pearl cooldown tick conversion The (int) cast bound tighter than the division, casting the long millis to int before dividing by 50, which could overflow for very large durations. Divide first, then cast. Co-Authored-By: Claude Opus 4.8 --- .../com/eternalcode/combat/fight/pearl/PearlServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/pearl/PearlServiceImpl.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/pearl/PearlServiceImpl.java index e26d796c..d7579126 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/pearl/PearlServiceImpl.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/pearl/PearlServiceImpl.java @@ -59,7 +59,7 @@ public void handleDelay(Player player) { this.scheduler.runLater( () -> player.setCooldown( Material.ENDER_PEARL, - (int) this.pluginConfig.pearl.pearlThrowDelay.toMillis() / 50 + (int) (this.pluginConfig.pearl.pearlThrowDelay.toMillis() / 50) ), Duration.ofMillis(50) ); From 02040c35a6822403b425341c8b44ca0d609600f7 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:43:15 +0200 Subject: [PATCH 13/17] fix: avoid half-applied tag and dead code in tagMultiple Both targets were tagged before checking cancellation, so a cancelled second tag left the first player tagged with no rollback, and the trailing combined-cancel check was unreachable. Tag the second player only after the first succeeds and untag the first if the second is cancelled. Co-Authored-By: Claude Opus 4.8 --- .../com/eternalcode/combat/fight/FightTagCommand.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/FightTagCommand.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/FightTagCommand.java index 228f8288..2153a366 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/FightTagCommand.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/FightTagCommand.java @@ -90,7 +90,6 @@ void tagMultiple(@Context CommandSender sender, @Arg Player firstTarget, @Arg Pl } FightTagEvent firstTagEvent = this.fightManager.tag(firstTarget.getUniqueId(), combatTime, CauseOfTag.COMMAND); - FightTagEvent secondTagEvent = this.fightManager.tag(secondTarget.getUniqueId(), combatTime, CauseOfTag.COMMAND); if (firstTagEvent.isCancelled()) { CancelTagReason cancelReason = firstTagEvent.getCancelReason(); @@ -100,15 +99,16 @@ void tagMultiple(@Context CommandSender sender, @Arg Player firstTarget, @Arg Pl return; } + FightTagEvent secondTagEvent = this.fightManager.tag(secondTarget.getUniqueId(), combatTime, CauseOfTag.COMMAND); + if (secondTagEvent.isCancelled()) { CancelTagReason cancelReason = secondTagEvent.getCancelReason(); - this.tagoutReasonHandler(sender, cancelReason, messagesSettings); + // Roll back the first tag so we do not leave only one player tagged. + this.fightManager.untag(firstTarget.getUniqueId(), CauseOfUnTag.COMMAND); - return; - } + this.tagoutReasonHandler(sender, cancelReason, messagesSettings); - if (firstTagEvent.isCancelled() && secondTagEvent.isCancelled()) { return; } From 6876a2dcf8602791a838060fe5c10b2d232d5cd6 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:02:50 +0200 Subject: [PATCH 14/17] fix: drop respawn leftovers at the respawn location During PlayerRespawnEvent the player has not been teleported yet, so player.getLocation() is still the death location. Use the event's respawn location (and its world) so leftover kept items land at the player's feet. Addresses PR review feedback. Co-Authored-By: Claude Opus 4.8 --- .../com/eternalcode/combat/fight/drop/DropController.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/drop/DropController.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/drop/DropController.java index d344867a..efca9f8d 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/drop/DropController.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/drop/DropController.java @@ -4,6 +4,7 @@ import com.eternalcode.combat.fight.FightManager; import com.eternalcode.commons.adventure.AdventureUtil; import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.enchantments.Enchantment; import org.bukkit.entity.Player; @@ -140,9 +141,13 @@ public void onPlayerRespawn(PlayerRespawnEvent event) { ItemStack[] itemsToGive = this.keepInventoryManager.nextItems(playerUniqueId) .toArray(new ItemStack[0]); + // The player is not teleported yet during PlayerRespawnEvent, so + // player.getLocation() would still be the death location. + Location respawnLocation = event.getRespawnLocation(); + Map leftover = playerInventory.addItem(itemsToGive); leftover.values().forEach(item -> - player.getWorld().dropItemNaturally(player.getLocation(), item)); + respawnLocation.getWorld().dropItemNaturally(respawnLocation, item)); } } } From 7e484c22352aee552d5e18f80cb8a501a8608783 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:03:15 +0200 Subject: [PATCH 15/17] fix: do not untag already-in-combat player on tagMultiple rollback The rollback for a cancelled second tag unconditionally untagged the first target, which would wrongly remove a player who was already legitimately in combat before the command ran. Capture the prior state and only roll back tags the command itself created. Addresses PR review feedback. Co-Authored-By: Claude Opus 4.8 --- .../com/eternalcode/combat/fight/FightTagCommand.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/FightTagCommand.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/FightTagCommand.java index 2153a366..aa8c2a0c 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/FightTagCommand.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/FightTagCommand.java @@ -89,6 +89,8 @@ void tagMultiple(@Context CommandSender sender, @Arg Player firstTarget, @Arg Pl return; } + boolean firstAlreadyInCombat = this.fightManager.isInCombat(firstTarget.getUniqueId()); + FightTagEvent firstTagEvent = this.fightManager.tag(firstTarget.getUniqueId(), combatTime, CauseOfTag.COMMAND); if (firstTagEvent.isCancelled()) { @@ -104,8 +106,11 @@ void tagMultiple(@Context CommandSender sender, @Arg Player firstTarget, @Arg Pl if (secondTagEvent.isCancelled()) { CancelTagReason cancelReason = secondTagEvent.getCancelReason(); - // Roll back the first tag so we do not leave only one player tagged. - this.fightManager.untag(firstTarget.getUniqueId(), CauseOfUnTag.COMMAND); + // Only roll back the first tag when this command actually created it; + // never untag a player who was already in combat beforehand. + if (!firstAlreadyInCombat) { + this.fightManager.untag(firstTarget.getUniqueId(), CauseOfUnTag.COMMAND); + } this.tagoutReasonHandler(sender, cancelReason, messagesSettings); From 51a0bed671eb995dc3ac988357d9de9548e336a0 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:03:47 +0200 Subject: [PATCH 16/17] fix: guard against null slots in removeRandomItems Bukkit inventory lists can contain null elements for empty slots, which would throw NPE on getAmount(). Skip nulls when summing and drop them from the working list. Addresses PR review feedback. Co-Authored-By: Claude Opus 4.8 --- .../com/eternalcode/combat/util/InventoryUtil.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/util/InventoryUtil.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/util/InventoryUtil.java index 13439caf..a26196d1 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/util/InventoryUtil.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/util/InventoryUtil.java @@ -26,7 +26,9 @@ public static RemoveItemResult removeRandomItems(List list, int items int totalAvailable = 0; for (ItemStack item : currentItems) { - totalAvailable += Math.max(0, item.getAmount()); + if (item != null) { + totalAvailable += Math.max(0, item.getAmount()); + } } // Never try to remove more than is actually available, otherwise the loop @@ -37,6 +39,12 @@ public static RemoveItemResult removeRandomItems(List list, int items int randomIndex = RANDOM.nextInt(currentItems.size()); ItemStack currentItem = currentItems.get(randomIndex); + // Bukkit inventory lists can contain null entries for empty slots. + if (currentItem == null) { + currentItems.remove(randomIndex); + continue; + } + int amount = currentItem.getAmount(); if (amount <= 0) { currentItems.remove(randomIndex); From e3b81e01e973a7bb22c9bc40892cc295d3fdc75e Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:42:17 +0200 Subject: [PATCH 17/17] Respect namespace when checking restricted commands Compare both original and normalized command so namespace-specific entries (e.g., eternalcore:msg) are handled separately from unprefixed commands. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../eternalcode/combat/fight/blocker/CommandsBlocker.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/blocker/CommandsBlocker.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/blocker/CommandsBlocker.java index f56e8ed8..473905b5 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/blocker/CommandsBlocker.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/blocker/CommandsBlocker.java @@ -32,10 +32,12 @@ void onPlayerCommandPreprocess(PlayerCommandPreprocessEvent event) { return; } - String command = normalizeCommand(event.getMessage().substring(1)); + String original = event.getMessage().substring(1).stripLeading(); + String command = normalizeCommand(original); boolean isAnyMatch = this.config.commands.restrictedCommands.stream() - .anyMatch(restrictedCommand -> StringUtil.startsWithIgnoreCase(command, restrictedCommand)); + .anyMatch(restrictedCommand -> StringUtil.startsWithIgnoreCase(original, restrictedCommand) + || StringUtil.startsWithIgnoreCase(command, restrictedCommand)); WhitelistBlacklistMode mode = this.config.commands.commandRestrictionMode;