From b14355f33d388f834af525793d395cc5f3924a3e Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 26 Sep 2026 15:41:53 +0100 Subject: [PATCH] feat: apply BentoBox admin deaths commands to island deaths Since deaths became per-island (ff59b02), / deaths only changed BentoBox's per-world counter, which Level reads once at migration, so admins could not change an island's death handicap. Level now listens for BentoBox's PlayerDeathsChangedEvent and applies the change to every island the player is a member of in that world: - add: adds to the player's count (capped at deaths.max) - remove: takes from the player's count, then from anonymous deaths - set/reset: sets the player's count and clears anonymous deaths (migrated legacy deaths and deaths of former members) The listener is only registered if the running BentoBox has the event. Bumps the BentoBox dependency to 3.23.1-SNAPSHOT. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 2 +- src/main/java/world/bentobox/level/Level.java | 8 ++ .../world/bentobox/level/LevelsManager.java | 70 ++++++++++++ .../bentobox/level/config/ConfigSettings.java | 2 + .../level/listeners/AdminDeathsListener.java | 57 ++++++++++ src/main/resources/config.yml | 2 + .../bentobox/level/LevelsManagerTest.java | 77 +++++++++++++ .../listeners/AdminDeathsListenerTest.java | 107 ++++++++++++++++++ 8 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 src/main/java/world/bentobox/level/listeners/AdminDeathsListener.java create mode 100644 src/test/java/world/bentobox/level/listeners/AdminDeathsListenerTest.java diff --git a/pom.xml b/pom.xml index c565baf..ba97b52 100644 --- a/pom.xml +++ b/pom.xml @@ -57,7 +57,7 @@ 4.110.0 1.21.11-R0.1-SNAPSHOT - 3.15.1-SNAPSHOT + 3.23.1-SNAPSHOT 1.12.0 diff --git a/src/main/java/world/bentobox/level/Level.java b/src/main/java/world/bentobox/level/Level.java index b903185..6c490d9 100644 --- a/src/main/java/world/bentobox/level/Level.java +++ b/src/main/java/world/bentobox/level/Level.java @@ -43,6 +43,7 @@ import world.bentobox.level.commands.IslandValueCommand; import world.bentobox.level.config.BlockConfig; import world.bentobox.level.config.ConfigSettings; +import world.bentobox.level.listeners.AdminDeathsListener; import world.bentobox.level.listeners.IslandActivitiesListeners; import world.bentobox.level.listeners.JoinLeaveListener; import world.bentobox.level.listeners.MigrationListener; @@ -154,6 +155,13 @@ private void registerAllListeners() { registerListener(new IslandActivitiesListeners(this)); registerListener(new JoinLeaveListener(this)); registerListener(new MigrationListener(this)); + // The admin deaths event only exists in newer BentoBox versions + try { + Class.forName("world.bentobox.bentobox.api.events.player.PlayerDeathsChangedEvent"); + registerListener(new AdminDeathsListener(this)); + } catch (ClassNotFoundException e) { + log("This BentoBox version does not support syncing admin deaths commands to island levels. Update BentoBox to enable it."); + } } private void registerGameModeCommands() { diff --git a/src/main/java/world/bentobox/level/LevelsManager.java b/src/main/java/world/bentobox/level/LevelsManager.java index c7521b3..f79c57e 100644 --- a/src/main/java/world/bentobox/level/LevelsManager.java +++ b/src/main/java/world/bentobox/level/LevelsManager.java @@ -661,6 +661,76 @@ public void rollDeathsToAnonymous(@NonNull Island island, @NonNull UUID playerUU handler.saveObjectAsync(data); } + /** + * Admin set of a player's deaths on this island. The player's count becomes + * {@code deaths}, capped at the game mode's {@code deaths.max} setting. Anonymous + * deaths are cleared too: they are unattributed (migrated legacy deaths or deaths + * of former members), so an admin setting the deaths expects them to go. + * + * @param island the island + * @param playerUUID the player whose deaths are being set + * @param deaths the new death count, zero or more + */ + public void setDeaths(@NonNull Island island, @NonNull UUID playerUUID, int deaths) { + IslandLevels data = checkDeathsMigration(island); + data.setAnonymousDeaths(0); + int count = capDeaths(island, Math.max(0, deaths)); + if (count > 0) { + data.getMemberDeaths().put(playerUUID.toString(), count); + } else { + data.getMemberDeaths().remove(playerUUID.toString()); + } + handler.saveObjectAsync(data); + } + + /** + * Admin addition of deaths to a player on this island, capped at the game mode's + * {@code deaths.max} setting. + * + * @param island the island + * @param playerUUID the player + * @param amount the number of deaths to add + */ + public void addDeaths(@NonNull Island island, @NonNull UUID playerUUID, int amount) { + if (amount <= 0) { + return; + } + IslandLevels data = checkDeathsMigration(island); + int current = data.getMemberDeaths().getOrDefault(playerUUID.toString(), 0); + data.getMemberDeaths().put(playerUUID.toString(), capDeaths(island, (int) Math.min(Integer.MAX_VALUE, (long) current + amount))); + handler.saveObjectAsync(data); + } + + /** + * Admin removal of deaths from a player on this island. Deaths come off the + * player's own count first and any remainder comes off the island's anonymous + * deaths. Neither goes below zero. + * + * @param island the island + * @param playerUUID the player + * @param amount the number of deaths to remove + */ + public void removeDeaths(@NonNull Island island, @NonNull UUID playerUUID, int amount) { + if (amount <= 0) { + return; + } + IslandLevels data = checkDeathsMigration(island); + int current = data.getMemberDeaths().getOrDefault(playerUUID.toString(), 0); + int fromPlayer = Math.min(current, amount); + if (current - fromPlayer > 0) { + data.getMemberDeaths().put(playerUUID.toString(), current - fromPlayer); + } else { + data.getMemberDeaths().remove(playerUUID.toString()); + } + data.setAnonymousDeaths(Math.max(0, data.getAnonymousDeaths() - (amount - fromPlayer))); + handler.saveObjectAsync(data); + } + + private int capDeaths(Island island, int deaths) { + int max = addon.getPlugin().getIWM().getDeathsMax(island.getWorld()); + return max > 0 ? Math.min(max, deaths) : deaths; + } + /** * Get the death handicap for an island: anonymous deaths plus all current member * deaths in this island's space. diff --git a/src/main/java/world/bentobox/level/config/ConfigSettings.java b/src/main/java/world/bentobox/level/config/ConfigSettings.java index 2d7aa67..fe01002 100644 --- a/src/main/java/world/bentobox/level/config/ConfigSettings.java +++ b/src/main/java/world/bentobox/level/config/ConfigSettings.java @@ -129,6 +129,8 @@ public class ConfigSettings implements ConfigObject { @ConfigComment("and they stay with the island even if the player later leaves the team.") @ConfigComment("The per-player count is capped by the deaths max setting in the GameModeAddon's config.yml,") @ConfigComment("and deaths are only recorded if deaths counted is enabled there.") + @ConfigComment("The game mode's admin deaths commands (set/add/remove/reset) also change the island's deaths.") + @ConfigComment("Set and reset also clear deaths left by former members and deaths carried over from older versions.") @ConfigComment("Set to zero to not use this feature") @ConfigEntry(path = "deathpenalty") private int deathPenalty = 100; diff --git a/src/main/java/world/bentobox/level/listeners/AdminDeathsListener.java b/src/main/java/world/bentobox/level/listeners/AdminDeathsListener.java new file mode 100644 index 0000000..3465501 --- /dev/null +++ b/src/main/java/world/bentobox/level/listeners/AdminDeathsListener.java @@ -0,0 +1,57 @@ +package world.bentobox.level.listeners; + +import java.util.UUID; + +import org.bukkit.World; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +import world.bentobox.bentobox.api.events.player.PlayerDeathsChangedEvent; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.level.Level; + +/** + * Applies the BentoBox admin deaths commands ({@code / deaths set|add|remove|reset}) + * to Level's per-island death counts, so admins do not need a separate Level command. + * The change is applied to every island the player is a member of in that game mode. + *

+ * Only registered when the running BentoBox has {@link PlayerDeathsChangedEvent}. + * + * @author tastybento + */ +public class AdminDeathsListener implements Listener { + + private final Level addon; + + /** + * @param addon - addon + */ + public AdminDeathsListener(Level addon) { + this.addon = addon; + } + + /** + * Apply an admin deaths change to the player's islands in that world. + * @param e event + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onDeathsChanged(PlayerDeathsChangedEvent e) { + World world = e.getWorld(); + if (world == null || !addon.isRegisteredGameModeWorld(world)) { + return; + } + UUID uuid = e.getPlayerUUID(); + for (Island island : addon.getIslands().getIslands(world, uuid)) { + if (!island.getMemberSet().contains(uuid)) { + continue; + } + switch (e.getAction()) { + case SET -> addon.getManager().setDeaths(island, uuid, e.getAmount()); + case RESET -> addon.getManager().setDeaths(island, uuid, 0); + case ADD -> addon.getManager().addDeaths(island, uuid, e.getAmount()); + case REMOVE -> addon.getManager().removeDeaths(island, uuid, e.getAmount()); + } + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 57e5dd2..a9207ce 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -80,6 +80,8 @@ levelwait: 60 # and they stay with the island even if the player later leaves the team. # The per-player count is capped by the deaths max setting in the GameModeAddon's config.yml, # and deaths are only recorded if deaths counted is enabled there. +# The game mode's admin deaths commands (set/add/remove/reset) also change the island's deaths. +# Set and reset also clear deaths left by former members and deaths carried over from older versions. # Set to zero to not use this feature deathpenalty: 100 # Deprecated - no longer used for level calculation, which now always counts all deaths diff --git a/src/test/java/world/bentobox/level/LevelsManagerTest.java b/src/test/java/world/bentobox/level/LevelsManagerTest.java index e8718e5..543a5ba 100644 --- a/src/test/java/world/bentobox/level/LevelsManagerTest.java +++ b/src/test/java/world/bentobox/level/LevelsManagerTest.java @@ -554,4 +554,81 @@ void testGetDeathHandicapSumsAnonymousAndMembers() throws Exception { assertEquals(6, lm.getDeathHandicap(island)); } + /** + * Test method for + * {@link world.bentobox.level.LevelsManager#setDeaths(Island, UUID, int)}. + * Reproduces the Discord report: migrated deaths sit in the anonymous count and + * an admin reset must clear them. + */ + @Test + void testSetDeathsZeroClearsAnonymousAndPlayer() throws Exception { + IslandLevels data = deathData(true); + data.setAnonymousDeaths(3); + data.getMemberDeaths().put(uuid.toString(), 2); + UUID mate = UUID.randomUUID(); + data.getMemberDeaths().put(mate.toString(), 1); + + lm.setDeaths(island, uuid, 0); + + assertEquals(0L, data.getAnonymousDeaths()); + assertFalse(data.getMemberDeaths().containsKey(uuid.toString())); + // Other members keep their deaths + assertEquals(1, lm.getDeathHandicap(island)); + } + + /** + * Test method for + * {@link world.bentobox.level.LevelsManager#setDeaths(Island, UUID, int)} + */ + @Test + void testSetDeathsCapsAtDeathsMax() throws Exception { + IslandLevels data = deathData(true); + when(iwm.getDeathsMax(world)).thenReturn(10); + + lm.setDeaths(island, uuid, 5); + assertEquals(5, data.getMemberDeaths().get(uuid.toString()).intValue()); + lm.setDeaths(island, uuid, 50); + assertEquals(10, data.getMemberDeaths().get(uuid.toString()).intValue()); + } + + /** + * Test method for + * {@link world.bentobox.level.LevelsManager#addDeaths(Island, UUID, int)} + */ + @Test + void testAddDeaths() throws Exception { + IslandLevels data = deathData(true); + when(iwm.getDeathsMax(world)).thenReturn(10); + data.setAnonymousDeaths(1); + + lm.addDeaths(island, uuid, 4); + lm.addDeaths(island, uuid, 4); + assertEquals(8, data.getMemberDeaths().get(uuid.toString()).intValue()); + lm.addDeaths(island, uuid, 4); + assertEquals(10, data.getMemberDeaths().get(uuid.toString()).intValue()); + assertEquals(11, lm.getDeathHandicap(island)); + } + + /** + * Test method for + * {@link world.bentobox.level.LevelsManager#removeDeaths(Island, UUID, int)} + */ + @Test + void testRemoveDeathsSpillsIntoAnonymous() throws Exception { + IslandLevels data = deathData(true); + data.setAnonymousDeaths(3); + data.getMemberDeaths().put(uuid.toString(), 2); + + lm.removeDeaths(island, uuid, 1); + assertEquals(1, data.getMemberDeaths().get(uuid.toString()).intValue()); + assertEquals(3L, data.getAnonymousDeaths()); + + lm.removeDeaths(island, uuid, 3); + assertFalse(data.getMemberDeaths().containsKey(uuid.toString())); + assertEquals(1L, data.getAnonymousDeaths()); + + lm.removeDeaths(island, uuid, 100); + assertEquals(0, lm.getDeathHandicap(island)); + } + } diff --git a/src/test/java/world/bentobox/level/listeners/AdminDeathsListenerTest.java b/src/test/java/world/bentobox/level/listeners/AdminDeathsListenerTest.java new file mode 100644 index 0000000..e6bbe31 --- /dev/null +++ b/src/test/java/world/bentobox/level/listeners/AdminDeathsListenerTest.java @@ -0,0 +1,107 @@ +package world.bentobox.level.listeners; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import com.google.common.collect.ImmutableSet; + +import world.bentobox.bentobox.api.events.player.PlayerDeathsChangedEvent; +import world.bentobox.bentobox.api.events.player.PlayerDeathsChangedEvent.Action; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.level.CommonTestSetup; +import world.bentobox.level.LevelsManager; + +/** + * Tests for {@link AdminDeathsListener} + */ +class AdminDeathsListenerTest extends CommonTestSetup { + + @Mock + private LevelsManager manager; + @Mock + private Island otherIsland; + + private AdminDeathsListener listener; + + @Override + @BeforeEach + protected void setUp() throws Exception { + super.setUp(); + when(addon.getManager()).thenReturn(manager); + when(addon.isRegisteredGameModeWorld(world)).thenReturn(true); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(uuid)); + when(im.getIslands(world, uuid)).thenReturn(List.of(island)); + listener = new AdminDeathsListener(addon); + } + + @Override + @AfterEach + protected void tearDown() throws Exception { + super.tearDown(); + } + + private PlayerDeathsChangedEvent event(Action action, int amount) { + return new PlayerDeathsChangedEvent(world, uuid, action, amount, 3, 0); + } + + @Test + void testSet() { + listener.onDeathsChanged(event(Action.SET, 4)); + verify(manager).setDeaths(island, uuid, 4); + } + + @Test + void testReset() { + listener.onDeathsChanged(event(Action.RESET, 0)); + verify(manager).setDeaths(island, uuid, 0); + } + + @Test + void testAdd() { + listener.onDeathsChanged(event(Action.ADD, 2)); + verify(manager).addDeaths(island, uuid, 2); + } + + @Test + void testRemove() { + listener.onDeathsChanged(event(Action.REMOVE, 3)); + verify(manager).removeDeaths(island, uuid, 3); + } + + @Test + void testAppliesToEveryIslandPlayerIsMemberOf() { + when(otherIsland.getMemberSet()).thenReturn(ImmutableSet.of(uuid, UUID.randomUUID())); + when(im.getIslands(world, uuid)).thenReturn(List.of(island, otherIsland)); + listener.onDeathsChanged(event(Action.REMOVE, 3)); + verify(manager).removeDeaths(island, uuid, 3); + verify(manager).removeDeaths(otherIsland, uuid, 3); + } + + @Test + void testSkipsIslandsWherePlayerIsNotMember() { + // e.g. trusted/coop islands returned by the lookup + when(otherIsland.getMemberSet()).thenReturn(ImmutableSet.of(UUID.randomUUID())); + when(im.getIslands(world, uuid)).thenReturn(List.of(otherIsland)); + listener.onDeathsChanged(event(Action.RESET, 0)); + verify(manager, never()).setDeaths(any(), any(), anyInt()); + } + + @Test + void testIgnoresUnregisteredWorld() { + when(addon.isRegisteredGameModeWorld(world)).thenReturn(false); + listener.onDeathsChanged(event(Action.RESET, 0)); + verifyNoInteractions(manager); + } +}