Skip to content
Merged
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: 'adopt'
java-version: '21'
java-version: '25'
- name: Cache SonarCloud packages
uses: actions/cache@v4
with:
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/modrinth-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
- name: Set up Java 21
uses: actions/setup-java@v4
with:
java-version: '21'
java-version: '25'
distribution: 'temurin'

# 3. Cache Maven dependencies to speed up builds
Expand Down Expand Up @@ -95,6 +95,8 @@ jobs:
26.1
26.1.1
26.1.2
26.2
26.3

# Path to the built JAR — Maven produces Level-{version}.jar in target/
files: target/Level-${{ github.event.release.tag_name || inputs.tag }}.jar
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
hangar_slug: "Level"
curseforge_id: "1514824"
# Keep in sync with the game-versions list in modrinth-publish.yml
game_versions: "26.1.2,26.1.1,26.1,1.21.11,1.21.10,1.21.9,1.21.8,1.21.7,1.21.6,1.21.5"
game_versions: "26.3,26.2,26.1.2,26.1.1,26.1,1.21.11,1.21.10,1.21.9,1.21.8,1.21.7,1.21.6,1.21.5"
version: ${{ inputs.version }} # empty on release events -> falls back to the release tag
secrets:
HANGAR_API_KEY: ${{ secrets.HANGAR_API_KEY }}
Expand Down
23 changes: 9 additions & 14 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@
<!-- Non-minecraft related dependencies -->
<junit.version>5.10.2</junit.version>
<mockito.version>5.11.0</mockito.version>
<mock-bukkit.version>4.110.0</mock-bukkit.version>
<mock-bukkit.version>4.119.0</mock-bukkit.version>
<!-- More visible way how to change dependency versions -->
<paper.version>1.21.11-R0.1-SNAPSHOT</paper.version>
<bentobox.version>3.15.1-SNAPSHOT</bentobox.version>
<paper.version>26.3.build.231-stable</paper.version>
<bentobox.version>3.23.3</bentobox.version>
<!-- Warps addon version -->
<warps.version>1.12.0</warps.version>
<!-- Visit addon version -->
Expand All @@ -69,7 +69,7 @@
<!-- Do not change unless you want different name for local builds. -->
<build.number>-LOCAL</build.number>
<!-- This allows to change between versions. -->
<build.version>2.29.0</build.version>
<build.version>2.30.0</build.version>
<sonar.projectKey>BentoBoxWorld_Level</sonar.projectKey>
<sonar.organization>bentobox-world</sonar.organization>
<sonar.host.url>https://sonarcloud.io</sonar.host.url>
Expand Down Expand Up @@ -160,11 +160,6 @@
<id>songoda-plugins</id>
<url>https://repo.songoda.com/repository/minecraft-plugins/</url>
</repository>
<!-- ItemsAdder API repo-->
<repository>
<id>matteodev</id>
<url>https://maven.devs.beer/</url>
</repository>
<!-- Oraxen repo-->
<repository>
<id>oraxen</id>
Expand All @@ -183,7 +178,7 @@
<!-- Mock Bukkit - used to mock the server in tests -->
<dependency>
<groupId>org.mockbukkit.mockbukkit</groupId>
<artifactId>mockbukkit-v1.21</artifactId>
<artifactId>mockbukkit-v26.3</artifactId>
<version>${mock-bukkit.version}</version>
<scope>test</scope>
</dependency>
Expand Down Expand Up @@ -276,11 +271,11 @@
<version>1.0.0-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<!-- ItemsAdder -->
<!-- ItemsAdder - published to Maven Central -->
<dependency>
<groupId>dev.lone</groupId>
<artifactId>api-itemsadder</artifactId>
<version>4.0.10</version>
<groupId>beer.devs</groupId>
<artifactId>itemsadder-api</artifactId>
<version>4.0.17</version>
<scope>provided</scope>
</dependency>
<!-- Nexo -->
Expand Down
8 changes: 8 additions & 0 deletions src/main/java/world/bentobox/level/Level.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
70 changes: 70 additions & 0 deletions src/main/java/world/bentobox/level/LevelsManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/world/bentobox/level/config/ConfigSettings.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 /<admin> 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.
* <p>
* 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());
}
}
}
}
2 changes: 2 additions & 0 deletions src/main/resources/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions src/test/java/world/bentobox/level/LevelsManagerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

}
Loading
Loading