From ff59b025e5516181e7c7438401b828d4a1f569fc Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 10:28:40 -0700 Subject: [PATCH] feat: track death handicap per island instead of per world Deaths were read from BentoBox's per-player, per-world counter at calculation time, which caused several problems now that players can have multiple islands and join teams while keeping their own islands: - A player joining a team immediately raised the team island's death handicap with deaths from their entire world history (core's team-join-reset only fires when team members' own islands are deleted on join). - reset-on-new-island zeroed the whole world count, wiping deaths that were effectively counting against the player's other islands. - Deaths anywhere in the world counted, even while visiting someone else's island. Deaths are now recorded by Level itself, per island, in IslandLevels: - A death only counts if it happens in the island space of an island the player is a member of. Deaths elsewhere are ignored. - Per-player counts are kept for the admin level report ("who died"), capped at the game mode's deaths max setting. - When a member leaves or is kicked, their balance is folded into an anonymous per-island count, so an island's handicap never decreases just because an often-dying member left. Island reset/deletion clears everything, giving per-island reset-on-new-island semantics. - Legacy per-world counts are migrated once per island on first touch: the seed reproduces exactly what the old formula would compute now (sum of member deaths if sumteamdeaths, else owner deaths) and is stored as anonymous deaths, so levels do not change at upgrade. - sumteamdeaths is deprecated; it is only read for the migration seed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NSvRYW3Rgh6Vgvx62QRYE1 --- .../world/bentobox/level/LevelsManager.java | 80 ++++++++++++ .../calculators/IslandLevelCalculator.java | 23 ++-- .../bentobox/level/config/ConfigSettings.java | 17 ++- .../listeners/IslandActivitiesListeners.java | 30 ++++- .../bentobox/level/objects/IslandLevels.java | 85 +++++++++++++ src/main/resources/config.yml | 14 ++- .../bentobox/level/LevelsManagerTest.java | 115 ++++++++++++++++++ .../IslandLevelCalculatorTidyUpTest.java | 24 +++- .../IslandActivitiesListenersTest.java | 70 +++++++++++ .../level/objects/IslandLevelsTest.java | 27 ++++ 10 files changed, 457 insertions(+), 28 deletions(-) diff --git a/src/main/java/world/bentobox/level/LevelsManager.java b/src/main/java/world/bentobox/level/LevelsManager.java index 3816947c..c7521b32 100644 --- a/src/main/java/world/bentobox/level/LevelsManager.java +++ b/src/main/java/world/bentobox/level/LevelsManager.java @@ -592,4 +592,84 @@ public Map getDonatedBlocks(@NonNull Island island) { return getLevelsData(island).getDonatedBlocks(); } + // ---- Per-island death tracking ---- + + /** + * Ensure this island's death data has been seeded from the legacy per-world death + * counts. The seed reproduces what the old calculation would produce right now: + * the sum of all members' world death counts if {@code sumteamdeaths} is set, + * otherwise the owner's count. It is stored as anonymous deaths so the island's + * level does not change when the tracking model changes. Runs at most once per + * island; new islands are created already migrated with zero deaths. + * + * @param island the island to check + * @return the island's levels data, migrated + */ + @SuppressWarnings("deprecation") // sumteamdeaths is intentionally read for the legacy seed + @NonNull + public IslandLevels checkDeathsMigration(@NonNull Island island) { + IslandLevels data = getLevelsData(island); + if (data.isDeathsMigrated()) { + return data; + } + long seed = 0; + if (island.getWorld() != null) { + if (addon.getSettings().isSumTeamDeaths()) { + for (UUID uuid : island.getMemberSet()) { + seed += addon.getPlayers().getDeaths(island.getWorld(), uuid); + } + } else if (island.getOwner() != null) { + seed = addon.getPlayers().getDeaths(island.getWorld(), island.getOwner()); + } + } + data.setAnonymousDeaths(seed); + data.setDeathsMigrated(true); + handler.saveObjectAsync(data); + return data; + } + + /** + * Record a death for a player on this island. The per-player count is capped at + * the game mode's {@code deaths.max} setting. + * + * @param island the island in whose space the player died + * @param playerUUID the player who died + */ + public void addDeath(@NonNull Island island, @NonNull UUID playerUUID) { + IslandLevels data = checkDeathsMigration(island); + int max = addon.getPlugin().getIWM().getDeathsMax(island.getWorld()); + if (max <= 0) { + return; + } + data.getMemberDeaths().merge(playerUUID.toString(), 1, (old, one) -> Math.min(max, old + one)); + handler.saveObjectAsync(data); + } + + /** + * Fold a departing member's death balance into the island's anonymous death count + * so that the island's level does not change when they leave. + * + * @param island the island the player is leaving + * @param playerUUID the departing player + */ + public void rollDeathsToAnonymous(@NonNull Island island, @NonNull UUID playerUUID) { + IslandLevels data = checkDeathsMigration(island); + Integer balance = data.getMemberDeaths().remove(playerUUID.toString()); + if (balance != null && balance > 0) { + data.setAnonymousDeaths(data.getAnonymousDeaths() + balance); + } + handler.saveObjectAsync(data); + } + + /** + * Get the death handicap for an island: anonymous deaths plus all current member + * deaths in this island's space. + * + * @param island the island + * @return total deaths counting against the island + */ + public int getDeathHandicap(@NonNull Island island) { + return (int) Math.min(Integer.MAX_VALUE, checkDeathsMigration(island).getTotalDeaths()); + } + } diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index b4aa2f23..afa9d1cb 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -62,6 +62,7 @@ import world.bentobox.level.Level; import world.bentobox.level.calculators.Results.Result; import world.bentobox.level.config.BlockConfig; +import world.bentobox.level.objects.IslandLevels; import world.bentobox.level.util.Utils; public class IslandLevelCalculator { @@ -264,6 +265,15 @@ private List getReport() { reportLines.add("Level cost = " + addon.getSettings().getLevelCost()); reportLines.add("Island members = " + island.getMemberSet().size()); reportLines.add("Deaths handicap = " + results.deathHandicap.get()); + IslandLevels levelsData = addon.getManager().checkDeathsMigration(island); + levelsData.getMemberDeaths().forEach((uuid, deaths) -> { + String name = addon.getPlayers().getName(UUID.fromString(uuid)); + reportLines.add(" Deaths by " + (name.isEmpty() ? uuid : name) + " = " + deaths); + }); + if (levelsData.getAnonymousDeaths() > 0) { + reportLines.add(" Deaths by former members or migrated from pre-island tracking = " + + levelsData.getAnonymousDeaths()); + } /* if (addon.getSettings().isZeroNewIslandLevels()) { reportLines.add("Initial island level = " + (0L - addon.getManager().getInitialLevel(island))); @@ -746,16 +756,9 @@ public void tidyUp() { results.rawBlockCount.addAndGet(donatedPoints); results.donatedPoints.set(donatedPoints); - // Set the death penalty - if (this.addon.getSettings().isSumTeamDeaths()) { - for (UUID uuid : this.island.getMemberSet()) { - this.results.deathHandicap.addAndGet(this.addon.getPlayers().getDeaths(island.getWorld(), uuid)); - } - } else { - // At this point, it may be that the island has become unowned. - this.results.deathHandicap.set(this.island.getOwner() == null ? 0 - : this.addon.getPlayers().getDeaths(island.getWorld(), this.island.getOwner())); - } + // Set the death penalty. Deaths are tracked per island: only deaths in this + // island's space count, and deaths of former members are retained anonymously. + this.results.deathHandicap.set(this.addon.getManager().getDeathHandicap(island)); long blockAndDeathPoints = this.results.rawBlockCount.get(); this.results.totalPoints.set(blockAndDeathPoints); diff --git a/src/main/java/world/bentobox/level/config/ConfigSettings.java b/src/main/java/world/bentobox/level/config/ConfigSettings.java index ef470032..2d7aa675 100644 --- a/src/main/java/world/bentobox/level/config/ConfigSettings.java +++ b/src/main/java/world/bentobox/level/config/ConfigSettings.java @@ -123,15 +123,19 @@ public class ConfigSettings implements ConfigObject { @ConfigComment("") @ConfigComment("Death penalty") - @ConfigComment("How many block values a player will lose per death.") - @ConfigComment("Default value of 100 means that for every death, the player will lose 1 level (if levelcost is 100)") + @ConfigComment("How many block values the island will lose per death.") + @ConfigComment("Default value of 100 means that for every death, the island will lose 1 level (if levelcost is 100)") + @ConfigComment("Deaths are tracked per island: only deaths that happen in the island's space count against it,") + @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("Set to zero to not use this feature") @ConfigEntry(path = "deathpenalty") private int deathPenalty = 100; - @ConfigComment("Sum team deaths - if true, all the teams deaths are summed") - @ConfigComment("If false, only the leader's deaths counts") - @ConfigComment("For other death related settings, see the GameModeAddon's config.yml settings.") + @ConfigComment("Deprecated - no longer used for level calculation, which now always counts all deaths") + @ConfigComment("that occurred on the island. Only read once, when migrating legacy per-world death counts:") + @ConfigComment("if true the migrated count is the sum of all team members' deaths, otherwise the owner's deaths.") @ConfigEntry(path = "sumteamdeaths") private boolean sumTeamDeaths = false; @@ -318,7 +322,10 @@ public void setDeathPenalty(int deathPenalty) { /** * @return the sumTeamDeaths + * @deprecated deaths are now tracked per island; this setting is only read when + * migrating legacy per-world death counts */ + @Deprecated(since = "2.29.0") public boolean isSumTeamDeaths() { return sumTeamDeaths; } diff --git a/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java b/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java index f139d70b..713a3064 100644 --- a/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java +++ b/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java @@ -1,10 +1,12 @@ package world.bentobox.level.listeners; import org.bukkit.Bukkit; +import org.bukkit.Location; import org.bukkit.World; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.entity.PlayerDeathEvent; import world.bentobox.bentobox.api.events.island.IslandCreatedEvent; import world.bentobox.bentobox.api.events.island.IslandDeleteEvent; @@ -108,16 +110,32 @@ public void onIsland(IslandRegisteredEvent e) { @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onIsland(TeamLeaveEvent e) { - // TODO: anything to do here? - // Remove player from the top ten and level - // remove(e.getIsland().getWorld(), e.getPlayerUUID()); + // Deaths stay with the island: fold the leaver's balance into the anonymous count + addon.getManager().rollDeathsToAnonymous(e.getIsland(), e.getPlayerUUID()); } @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onIsland(TeamKickEvent e) { - //// TODO: anything to do here? - // Remove player from the top ten and level - // remove(e.getIsland().getWorld(), e.getPlayerUUID()); + // Deaths stay with the island: fold the kicked player's balance into the anonymous count + addon.getManager().rollDeathsToAnonymous(e.getIsland(), e.getPlayerUUID()); + } + + /** + * Record deaths per island. A death only counts if it happens in the island space + * of an island the player is a member of; deaths anywhere else are ignored. + * @param e death event + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerDeath(PlayerDeathEvent e) { + Location location = e.getEntity().getLocation(); + World world = location.getWorld(); + if (world == null || !addon.isRegisteredGameModeWorld(world) + || !addon.getPlugin().getIWM().getWorldSettings(world).isDeathsCounted()) { + return; + } + addon.getIslands().getIslandAt(location) + .filter(island -> island.getMemberSet().contains(e.getEntity().getUniqueId())) + .ifPresent(island -> addon.getManager().addDeath(island, e.getEntity().getUniqueId())); } } diff --git a/src/main/java/world/bentobox/level/objects/IslandLevels.java b/src/main/java/world/bentobox/level/objects/IslandLevels.java index b15bb698..561a0555 100644 --- a/src/main/java/world/bentobox/level/objects/IslandLevels.java +++ b/src/main/java/world/bentobox/level/objects/IslandLevels.java @@ -98,6 +98,30 @@ public class IslandLevels implements DataObject { @Expose private List donationLog; + /** + * Deaths on this island by current members. Key is the player's UUID as a string, + * value is the number of times they died in this island's space. + * Null-safe for backwards compatibility with legacy data. + */ + @Expose + private Map memberDeaths; + + /** + * Deaths that count against this island but are no longer attributable to a current + * member: the one-time migration seed from the legacy per-world death counts, plus + * the balances of members who have since left the team. + */ + @Expose + private long anonymousDeaths; + + /** + * Whether the legacy per-world death counts have been folded into this island's + * death data. Legacy records load as false and are seeded on first touch; new + * islands start migrated with zero deaths. + */ + @Expose + private boolean deathsMigrated; + /** * Constructor for new island * @param islandUUID - island UUID @@ -106,6 +130,8 @@ public IslandLevels(String islandUUID) { uniqueId = islandUUID; uwCount = new HashMap<>(); mdCount = new HashMap<>(); + // A brand-new record has no legacy death history to import + deathsMigrated = true; } /** @@ -352,6 +378,65 @@ public void addDonation(String donorUUID, String material, int count, long point getDonationLog().add(new DonationRecord(System.currentTimeMillis(), donorUUID, material, count, points)); } + // ---- Death tracking fields (null-safe for backwards compatibility) ---- + + /** + * Get the deaths of current members in this island's space. + * @return map of player UUID string to death count, never null + */ + public Map getMemberDeaths() { + if (memberDeaths == null) { + memberDeaths = new HashMap<>(); + } + return memberDeaths; + } + + /** + * @param memberDeaths the memberDeaths to set + */ + public void setMemberDeaths(Map memberDeaths) { + this.memberDeaths = memberDeaths; + } + + /** + * Get the deaths not attributable to a current member (migration seed plus + * balances of former members). + * @return the anonymousDeaths + */ + public long getAnonymousDeaths() { + return anonymousDeaths; + } + + /** + * @param anonymousDeaths the anonymousDeaths to set + */ + public void setAnonymousDeaths(long anonymousDeaths) { + this.anonymousDeaths = anonymousDeaths; + } + + /** + * @return true if the legacy per-world death counts have been folded into this island + */ + public boolean isDeathsMigrated() { + return deathsMigrated; + } + + /** + * @param deathsMigrated the deathsMigrated to set + */ + public void setDeathsMigrated(boolean deathsMigrated) { + this.deathsMigrated = deathsMigrated; + } + + /** + * Get the total number of deaths counting against this island: anonymous deaths + * plus all current member deaths. + * @return total deaths for this island + */ + public long getTotalDeaths() { + return anonymousDeaths + getMemberDeaths().values().stream().mapToLong(Integer::longValue).sum(); + } + /** * @return the initialLevel * @deprecated only used for backwards compatibility. Use {@link #getInitialCount()} instead diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index c101ad85..57e5dd2b 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -74,13 +74,17 @@ level-calc: blocks / level_cost levelwait: 60 # # Death penalty -# How many block values a player will lose per death. -# Default value of 100 means that for every death, the player will lose 1 level (if levelcost is 100) +# How many block values the island will lose per death. +# Default value of 100 means that for every death, the island will lose 1 level (if levelcost is 100) +# Deaths are tracked per island: only deaths that happen in the island's space count against it, +# 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. # Set to zero to not use this feature deathpenalty: 100 -# Sum team deaths - if true, all the teams deaths are summed -# If false, only the leader's deaths counts -# For other death related settings, see the GameModeAddon's config.yml settings. +# Deprecated - no longer used for level calculation, which now always counts all deaths +# that occurred on the island. Only read once, when migrating legacy per-world death counts: +# if true the migrated count is the sum of all team members' deaths, otherwise the owner's deaths. sumteamdeaths: false # Shorthand island level # Shows large level values rounded down, e.g., 10,345 -> 10k diff --git a/src/test/java/world/bentobox/level/LevelsManagerTest.java b/src/test/java/world/bentobox/level/LevelsManagerTest.java index 91d841d0..e8718e50 100644 --- a/src/test/java/world/bentobox/level/LevelsManagerTest.java +++ b/src/test/java/world/bentobox/level/LevelsManagerTest.java @@ -439,4 +439,119 @@ void testGetRank() { assertEquals(52, lm.getRank(world, UUID.randomUUID())); } + // --- Per-island death tracking --- + + /** + * Make a real IslandLevels object for this island and put it in the manager's cache + * via the database handler. + * @param migrated whether the object should already be marked as migrated + * @return the levels object the manager will use + */ + private IslandLevels deathData(boolean migrated) throws Exception { + IslandLevels data = new IslandLevels(uuid.toString()); + data.setDeathsMigrated(migrated); + when(handler.loadObject(uuid.toString())).thenReturn(data); + return data; + } + + /** + * Test method for + * {@link world.bentobox.level.LevelsManager#checkDeathsMigration(Island)} + */ + @Test + void testCheckDeathsMigrationSeedsOwnerDeaths() throws Exception { + // Legacy record, sumteamdeaths false (default) - owner's world deaths are the seed + IslandLevels data = deathData(false); + when(pm.getDeaths(world, uuid)).thenReturn(7); + + lm.checkDeathsMigration(island); + + assertTrue(data.isDeathsMigrated()); + assertEquals(7L, data.getAnonymousDeaths()); + // A second call must not re-seed + when(pm.getDeaths(world, uuid)).thenReturn(99); + lm.checkDeathsMigration(island); + assertEquals(7L, data.getAnonymousDeaths()); + } + + /** + * Test method for + * {@link world.bentobox.level.LevelsManager#checkDeathsMigration(Island)} + */ + @Test + void testCheckDeathsMigrationSeedsTeamDeathsWhenSumTeamDeaths() throws Exception { + settings.setSumTeamDeaths(true); + UUID mate = UUID.randomUUID(); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(uuid, mate)); + IslandLevels data = deathData(false); + when(pm.getDeaths(world, uuid)).thenReturn(3); + when(pm.getDeaths(world, mate)).thenReturn(4); + + lm.checkDeathsMigration(island); + + assertEquals(7L, data.getAnonymousDeaths()); + } + + /** + * Test method for + * {@link world.bentobox.level.LevelsManager#addDeath(Island, UUID)} + */ + @Test + void testAddDeathCapsAtDeathsMax() throws Exception { + IslandLevels data = deathData(true); + when(iwm.getDeathsMax(world)).thenReturn(3); + + for (int i = 0; i < 5; i++) { + lm.addDeath(island, uuid); + } + + assertEquals(3, data.getMemberDeaths().get(uuid.toString()).intValue()); + assertEquals(3L, data.getTotalDeaths()); + } + + /** + * Test method for + * {@link world.bentobox.level.LevelsManager#addDeath(Island, UUID)} + */ + @Test + void testAddDeathMaxZeroRecordsNothing() throws Exception { + IslandLevels data = deathData(true); + when(iwm.getDeathsMax(world)).thenReturn(0); + + lm.addDeath(island, uuid); + + assertTrue(data.getMemberDeaths().isEmpty()); + } + + /** + * Test method for + * {@link world.bentobox.level.LevelsManager#rollDeathsToAnonymous(Island, UUID)} + */ + @Test + void testRollDeathsToAnonymousKeepsIslandTotal() throws Exception { + IslandLevels data = deathData(true); + data.setAnonymousDeaths(2); + data.getMemberDeaths().put(uuid.toString(), 4); + + lm.rollDeathsToAnonymous(island, uuid); + + assertFalse(data.getMemberDeaths().containsKey(uuid.toString())); + assertEquals(6L, data.getAnonymousDeaths()); + assertEquals(6, lm.getDeathHandicap(island)); + } + + /** + * Test method for + * {@link world.bentobox.level.LevelsManager#getDeathHandicap(Island)} + */ + @Test + void testGetDeathHandicapSumsAnonymousAndMembers() throws Exception { + IslandLevels data = deathData(true); + data.setAnonymousDeaths(2); + data.getMemberDeaths().put(uuid.toString(), 3); + data.getMemberDeaths().put(UUID.randomUUID().toString(), 1); + + assertEquals(6, lm.getDeathHandicap(island)); + } + } diff --git a/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java b/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java index 02c6e683..af35aaeb 100644 --- a/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java +++ b/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java @@ -24,6 +24,7 @@ import world.bentobox.level.LevelsManager; import world.bentobox.level.config.BlockConfig; import world.bentobox.level.config.ConfigSettings; +import world.bentobox.level.objects.IslandLevels; /** * Pins down the contract of {@link IslandLevelCalculator#tidyUp()} for the @@ -62,7 +63,6 @@ protected void setUp() throws Exception { when(settings.isDonationsOnly()).thenReturn(false); when(settings.getDeathPenalty()).thenReturn(0); when(settings.getUnderWaterMultiplier()).thenReturn(1.0); - when(settings.isSumTeamDeaths()).thenReturn(false); when(settings.isNether()).thenReturn(false); when(settings.isEnd()).thenReturn(false); @@ -71,6 +71,8 @@ protected void setUp() throws Exception { when(manager.getDonatedBlocks(any(Island.class))).thenReturn(Collections.emptyMap()); when(manager.getIslandLevel(any(), any())).thenReturn(0L); when(manager.getInitialCount(any(Island.class))).thenReturn(INITIAL_COUNT); + when(manager.getDeathHandicap(any(Island.class))).thenReturn(0); + when(manager.checkDeathsMigration(any(Island.class))).thenReturn(new IslandLevels("test")); when(addon.getBlockConfig()).thenReturn(blockConfig); when(blockConfig.getValue(any(), any())).thenReturn(0); @@ -81,7 +83,6 @@ protected void setUp() throws Exception { when(addon.getInitialIslandCount(any(Island.class))).thenReturn(INITIAL_COUNT); PlayersManager players = mock(PlayersManager.class); - when(players.getDeaths(any(), any())).thenReturn(0); when(addon.getPlayers()).thenReturn(players); // Island — tiny protection range keeps the chunks-to-scan queue small @@ -186,6 +187,25 @@ void belowStart_sqrtFormula() { assertTrue(remaining > 0, "pointsToNextLevel should be positive, got " + remaining); } + @Test + @DisplayName("Death handicap from the per-island tracker reduces the level") + void deathHandicapReducesLevel() { + when(settings.getDeathPenalty()).thenReturn(100); + when(manager.getDeathHandicap(any(Island.class))).thenReturn(2); + IslandLevels deathData = new IslandLevels("test"); + deathData.setAnonymousDeaths(2); + when(manager.checkDeathsMigration(any(Island.class))).thenReturn(deathData); + + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT + 3 * LEVEL_COST); // 520 → level 3 without deaths + calc.tidyUp(); + + assertEquals(2, r.getDeathHandicap(), "death handicap should come from the island tracker"); + // 520 - 2*100 penalty = 320; (320 - 130 initial) / 130 = 1 + assertEquals(1L, r.getLevel(), "2 deaths at penalty 100 should cost 2 levels"); + } + @Test @DisplayName("Donated blocks under limit: full donation count is used") void donatedBlocksUnderLimit() { diff --git a/src/test/java/world/bentobox/level/listeners/IslandActivitiesListenersTest.java b/src/test/java/world/bentobox/level/listeners/IslandActivitiesListenersTest.java index d0d80fb6..8fb08c4a 100644 --- a/src/test/java/world/bentobox/level/listeners/IslandActivitiesListenersTest.java +++ b/src/test/java/world/bentobox/level/listeners/IslandActivitiesListenersTest.java @@ -4,10 +4,13 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +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.util.Collections; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -16,11 +19,16 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; +import com.google.common.collect.ImmutableSet; + +import world.bentobox.bentobox.api.configuration.WorldSettings; import world.bentobox.bentobox.api.events.island.IslandCreatedEvent; import world.bentobox.bentobox.api.events.island.IslandDeleteEvent; import world.bentobox.bentobox.api.events.island.IslandPreclearEvent; import world.bentobox.bentobox.api.events.island.IslandResettedEvent; import world.bentobox.bentobox.api.events.island.IslandUnregisteredEvent; +import world.bentobox.bentobox.api.events.team.TeamKickEvent; +import world.bentobox.bentobox.api.events.team.TeamLeaveEvent; import world.bentobox.bentobox.api.events.team.TeamSetownerEvent; import world.bentobox.level.CommonTestSetup; import world.bentobox.level.LevelsManager; @@ -149,4 +157,66 @@ void testOnNewIslandOwnerRemovesEntry() { listener.onNewIslandOwner(event); verify(manager).removeEntry(world, uuid.toString()); } + + // --- TeamLeaveEvent / TeamKickEvent (deaths stay with the island) --- + + @Test + void testOnTeamLeaveRollsDeathsToAnonymous() { + UUID leaver = UUID.randomUUID(); + TeamLeaveEvent event = new TeamLeaveEvent(island, leaver, false, location); + listener.onIsland(event); + verify(manager).rollDeathsToAnonymous(island, leaver); + } + + @Test + void testOnTeamKickRollsDeathsToAnonymous() { + UUID kicked = UUID.randomUUID(); + TeamKickEvent event = new TeamKickEvent(island, kicked, false, location); + listener.onIsland(event); + verify(manager).rollDeathsToAnonymous(island, kicked); + } + + // --- PlayerDeathEvent (per-island death counting) --- + + @Test + void testOnPlayerDeathOnOwnIslandCountsDeath() { + when(addon.isRegisteredGameModeWorld(world)).thenReturn(true); + when(im.getIslandAt(location)).thenReturn(Optional.of(island)); + listener.onPlayerDeath(getPlayerDeathEvent(p, Collections.emptyList(), 0, 0, 0, 0, null)); + verify(manager).addDeath(island, uuid); + } + + @Test + void testOnPlayerDeathNotAMemberNoCount() { + when(addon.isRegisteredGameModeWorld(world)).thenReturn(true); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(UUID.randomUUID())); + when(im.getIslandAt(location)).thenReturn(Optional.of(island)); + listener.onPlayerDeath(getPlayerDeathEvent(p, Collections.emptyList(), 0, 0, 0, 0, null)); + verify(manager, never()).addDeath(any(), any()); + } + + @Test + void testOnPlayerDeathOutsideIslandSpaceNoCount() { + when(addon.isRegisteredGameModeWorld(world)).thenReturn(true); + when(im.getIslandAt(location)).thenReturn(Optional.empty()); + listener.onPlayerDeath(getPlayerDeathEvent(p, Collections.emptyList(), 0, 0, 0, 0, null)); + verify(manager, never()).addDeath(any(), any()); + } + + @Test + void testOnPlayerDeathNotGameModeWorldNoCount() { + when(addon.isRegisteredGameModeWorld(world)).thenReturn(false); + listener.onPlayerDeath(getPlayerDeathEvent(p, Collections.emptyList(), 0, 0, 0, 0, null)); + verify(manager, never()).addDeath(any(), any()); + } + + @Test + void testOnPlayerDeathDeathsNotCountedNoCount() { + when(addon.isRegisteredGameModeWorld(world)).thenReturn(true); + WorldSettings ws = mock(WorldSettings.class); + when(ws.isDeathsCounted()).thenReturn(false); + when(iwm.getWorldSettings(world)).thenReturn(ws); + listener.onPlayerDeath(getPlayerDeathEvent(p, Collections.emptyList(), 0, 0, 0, 0, null)); + verify(manager, never()).addDeath(any(), any()); + } } diff --git a/src/test/java/world/bentobox/level/objects/IslandLevelsTest.java b/src/test/java/world/bentobox/level/objects/IslandLevelsTest.java index 0c62fe55..3b13477e 100644 --- a/src/test/java/world/bentobox/level/objects/IslandLevelsTest.java +++ b/src/test/java/world/bentobox/level/objects/IslandLevelsTest.java @@ -230,4 +230,31 @@ void testDeprecatedInitialLevel() { islandLevels.setInitialLevel(100L); assertEquals(100L, islandLevels.getInitialLevel()); } + + // --- Per-island death tracking --- + + @Test + void testNewIslandStartsMigratedWithNoDeaths() { + assertTrue(islandLevels.isDeathsMigrated()); + assertNotNull(islandLevels.getMemberDeaths()); + assertTrue(islandLevels.getMemberDeaths().isEmpty()); + assertEquals(0L, islandLevels.getAnonymousDeaths()); + assertEquals(0L, islandLevels.getTotalDeaths()); + } + + @Test + void testTotalDeathsSumsAnonymousAndMembers() { + islandLevels.setAnonymousDeaths(5); + islandLevels.getMemberDeaths().put("uuid-1", 3); + islandLevels.getMemberDeaths().put("uuid-2", 2); + assertEquals(10L, islandLevels.getTotalDeaths()); + } + + @Test + void testSetMemberDeaths() { + Map deaths = new HashMap<>(); + deaths.put("uuid-1", 4); + islandLevels.setMemberDeaths(deaths); + assertEquals(4, islandLevels.getMemberDeaths().get("uuid-1")); + } }