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); + } +}