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