From e775a4b0810a287af41191cbfa85d22d88ced9c5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 4 Oct 2026 07:57:38 -0700 Subject: [PATCH 1/2] Initial IslandStats addon Keeps statistics per island rather than per player, starting with mob deaths: KILL_ENTITY (killed by a player on the island) and ENTITY_DEATH (died on the island from any cause, including mob farms), per entity type. - Counts from EntityDeathEvent with a cheap early-exit handler and an in-memory cache; one database record per island via the BentoBox database API, saved on a timer and at shutdown, deleted with the island - / stats shows paged dialog pages (BentoBox Dialogs API) - /admin stats [reset] for admins and the console - Placeholders for island totals, per-mob counts and the visited island - Targets BentoBox 3.23.3, Paper 26.2, Java 25 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 48 ++ .gitignore | 16 + README.md | 89 ++++ pom.xml | 310 +++++++++++++ .../bentobox/islandstats/IslandStats.java | 179 ++++++++ .../islandstats/IslandStatsPladdon.java | 22 + .../world/bentobox/islandstats/Settings.java | 125 ++++++ .../islandstats/StatsPlaceholders.java | 77 ++++ .../commands/AdminIslandStatsCommand.java | 97 +++++ .../commands/IslandStatsCommand.java | 59 +++ .../bentobox/islandstats/data/IslandStat.java | 47 ++ .../islandstats/data/IslandStatsData.java | 121 ++++++ .../islandstats/data/IslandStatsManager.java | 184 ++++++++ .../listeners/EntityDeathListener.java | 76 ++++ .../islandstats/listeners/IslandListener.java | 31 ++ .../islandstats/panels/StatsDialog.java | 170 ++++++++ src/main/resources/addon.yml | 19 + src/main/resources/config.yml | 28 ++ src/main/resources/locales/en-US.yml | 36 ++ src/main/resources/plugin.yml | 9 + .../bentobox/islandstats/CommonTestSetup.java | 304 +++++++++++++ .../bentobox/islandstats/SettingsTest.java | 53 +++ .../islandstats/TestWorldSettings.java | 409 ++++++++++++++++++ .../world/bentobox/islandstats/WhiteBox.java | 26 ++ .../commands/AdminIslandStatsCommandTest.java | 120 +++++ .../commands/IslandStatsCommandTest.java | 98 +++++ .../data/IslandStatsDataJsonTest.java | 32 ++ .../islandstats/data/IslandStatsDataTest.java | 82 ++++ .../data/IslandStatsManagerTest.java | 185 ++++++++ .../listeners/EntityDeathListenerTest.java | 162 +++++++ .../listeners/IslandListenerTest.java | 56 +++ .../islandstats/panels/StatsDialogTest.java | 139 ++++++ 32 files changed, 3409 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/world/bentobox/islandstats/IslandStats.java create mode 100644 src/main/java/world/bentobox/islandstats/IslandStatsPladdon.java create mode 100644 src/main/java/world/bentobox/islandstats/Settings.java create mode 100644 src/main/java/world/bentobox/islandstats/StatsPlaceholders.java create mode 100644 src/main/java/world/bentobox/islandstats/commands/AdminIslandStatsCommand.java create mode 100644 src/main/java/world/bentobox/islandstats/commands/IslandStatsCommand.java create mode 100644 src/main/java/world/bentobox/islandstats/data/IslandStat.java create mode 100644 src/main/java/world/bentobox/islandstats/data/IslandStatsData.java create mode 100644 src/main/java/world/bentobox/islandstats/data/IslandStatsManager.java create mode 100644 src/main/java/world/bentobox/islandstats/listeners/EntityDeathListener.java create mode 100644 src/main/java/world/bentobox/islandstats/listeners/IslandListener.java create mode 100644 src/main/java/world/bentobox/islandstats/panels/StatsDialog.java create mode 100644 src/main/resources/addon.yml create mode 100644 src/main/resources/config.yml create mode 100644 src/main/resources/locales/en-US.yml create mode 100644 src/main/resources/plugin.yml create mode 100644 src/test/java/world/bentobox/islandstats/CommonTestSetup.java create mode 100644 src/test/java/world/bentobox/islandstats/SettingsTest.java create mode 100644 src/test/java/world/bentobox/islandstats/TestWorldSettings.java create mode 100644 src/test/java/world/bentobox/islandstats/WhiteBox.java create mode 100644 src/test/java/world/bentobox/islandstats/commands/AdminIslandStatsCommandTest.java create mode 100644 src/test/java/world/bentobox/islandstats/commands/IslandStatsCommandTest.java create mode 100644 src/test/java/world/bentobox/islandstats/data/IslandStatsDataJsonTest.java create mode 100644 src/test/java/world/bentobox/islandstats/data/IslandStatsDataTest.java create mode 100644 src/test/java/world/bentobox/islandstats/data/IslandStatsManagerTest.java create mode 100644 src/test/java/world/bentobox/islandstats/listeners/EntityDeathListenerTest.java create mode 100644 src/test/java/world/bentobox/islandstats/listeners/IslandListenerTest.java create mode 100644 src/test/java/world/bentobox/islandstats/panels/StatsDialogTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..936d0bc --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,48 @@ +name: Build +on: + push: + branches: + - develop + - main + - master + pull_request: + types: [opened, synchronize, reopened] +jobs: + build: + name: Build + runs-on: ubuntu-latest + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones hurt Sonar's relevancy + # JDK 25: Paper 26.2 and BentoBox 3.23 ship Java 25 bytecode, and this + # addon compiles to Java 25 as well. + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: 25 + - name: Cache SonarCloud packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2 + - name: Build + run: mvn -B verify + # Runs only when a SONAR_TOKEN is available and never fails the build. + - name: SonarCloud analysis + if: ${{ env.SONAR_TOKEN != '' }} + continue-on-error: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: mvn -B org.sonarsource.scanner.maven:sonar-maven-plugin:sonar diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9dbce6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +target/ +.idea/ +*.iml +*.ipr +*.iws +.classpath +.project +.settings/ +bin/ +.DS_Store +# Artifacts left in the working directory by the test suite +/database/ +/database_backup/ +/addons/ +/addon.jar +/config.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..31cb7b7 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# IslandStats + +An addon for [BentoBox](https://bentobox.world) that keeps statistics for each **island** +rather than for each player. It works with every game mode (BSkyBlock, AcidIsland, CaveBlock, +OneBlock, ...) and is not a game mode itself. + +To start with it counts mob deaths: how many of each mob has died on an island, and how many of +those a player killed. + +## Statistics + +| Stat | Meaning | +| --- | --- | +| `KILL_ENTITY` | A mob was killed by a player on the island. Same meaning as the vanilla `KILL_ENTITY` player statistic. | +| `ENTITY_DEATH` | A mob died on the island from any cause, including mob farms, fall damage and lava. | + +Both are counted per entity type. A death counts for the island whose protected area it happened +in. Players and armor stands are not counted (the ignored list is configurable). + +### Why events and not player statistics + +Piggybacking on vanilla player statistics (`PlayerStatisticIncrementEvent`) was considered. It +only fires when a player is the killer and it says nothing about where the mob was, so it would +miss every mob farm and would credit the killer's island rather than the island the mob died on. +IslandStats listens to `EntityDeathEvent` at `MONITOR` priority instead, and keeps the handler +cheap: it returns early for players, ignored types and non-game-mode worlds, then does one island +grid lookup and an in-memory map update. + +## Storage + +Stats are stored with the BentoBox database API (whatever database BentoBox is set up to use), +one `IslandStatsData` record per island, keyed by the island's unique id +(with the JSON database these are in `plugins/BentoBox/database/IslandStatsData/`). A record is loaded the first +time its island is needed and then cached. Changes are written every `save-interval` minutes and +when the server stops. When an island is deleted or reset, its stats are deleted too. + +## Commands + +| Command | Permission | Description | +| --- | --- | --- | +| `/ stats` | `.island.stats` (default: true) | Open your island's stats dialog. Must be used in that game mode's world. | +| `/admin stats ` | `.admin.stats` (default: op) | Show a player's island stats. Players get the dialog, the console gets chat. | +| `/admin stats reset` | `.admin.stats` | Erase a player's island stats, after confirmation. | + +The stats are shown as dialog pages (BentoBox Dialogs API): a summary with the total for each +stat, then a page per stat listing each mob, highest first, with Previous and Next buttons. Mob +names are sent as translatable text, so each player sees them in their own client language. + +## Placeholders + +For each game mode, with PlaceholderAPI they look like `%IslandStats_bskyblock_island_kill_entity_zombie%`. + +| Placeholder | Value | +| --- | --- | +| `_island__total` | Total for the player's island | +| `_island__` | Count of one mob for the player's island | +| `_visited_island__total` | Total for the island the player is standing on | + +`` is `kill_entity` or `entity_death`; `` is the lower case entity type, such as +`zombie` or `iron_golem`. + +## Configuration + +See `config.yml`: + +* `disabled-gamemodes` - game modes to leave alone +* `count-visitor-kills` - whether kills by non-members count toward `KILL_ENTITY` (default true) +* `ignored-entities` - entity types never counted (default `ARMOR_STAND`) +* `save-interval` - minutes between database saves (default 5) +* `dialog.page-size` - mob lines per dialog page (default 15) + +## For addon developers + +```java +IslandStats stats = (IslandStats) BentoBox.getInstance().getAddonsManager().getAddonByName("IslandStats").orElseThrow(); +long zombies = stats.getManager().getCount(island, IslandStat.KILL_ENTITY, EntityType.ZOMBIE); +long allDeaths = stats.getManager().getTotal(island, IslandStat.ENTITY_DEATH); +``` + +## Requirements + +* Paper 26.2 or later (Java 25) +* BentoBox 3.23.0 or later + +## Building + +``` +mvn clean package +``` diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..132c91a --- /dev/null +++ b/pom.xml @@ -0,0 +1,310 @@ + + + 4.0.0 + + world.bentobox + islandstats + ${revision} + jar + + IslandStats + Collects statistics for each island, such as how many of each mob has been killed there, and shows them in dialogs and placeholders. + https://github.com/BentoBoxWorld/IslandStats + 2026 + + + + tastybento + tastybento@bentobox.world + -8 + + Lead Developer + + + + + + scm:git:https://github.com/BentoBoxWorld/IslandStats.git + scm:git:git@github.com:BentoBoxWorld/IslandStats.git + https://github.com/BentoBoxWorld/IslandStats + + + + jenkins + https://ci.codemc.org/job/BentoBoxWorld/job/IslandStats + + + + GitHub + https://github.com/BentoBoxWorld/IslandStats/issues + + + + + bentoboxworld + https://repo.codemc.org/repository/bentoboxworld/ + + + + + UTF-8 + UTF-8 + 25 + + 5.10.2 + 5.11.0 + + 4.116.1 + 26.2.build.111-stable + 3.23.3 + + ${build.version}-SNAPSHOT + + -LOCAL + + 1.0.0 + + + BentoBoxWorld_IslandStats + bentobox-world + https://sonarcloud.io + ${project.build.directory}/site/jacoco/jacoco.xml + + + + + + ci + + + env.BUILD_NUMBER + + + + -b${env.BUILD_NUMBER} + + + + + master + + + env.GIT_BRANCH + origin/master + + + + ${build.version} + + + + + + + + papermc + https://repo.papermc.io/repository/maven-public/ + true + true + + + bentoboxworld + https://repo.codemc.org/repository/bentoboxworld/ + true + true + + + codemc-repo + https://repo.codemc.org/repository/maven-public/ + true + false + + + jitpack.io + https://jitpack.io + true + true + + + + + + io.papermc.paper + paper-api + ${paper.version} + provided + + + world.bentobox + bentobox + ${bentobox.version} + provided + + + + + org.mockbukkit.mockbukkit + mockbukkit-v26.2 + ${mock-bukkit.version} + test + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + + + ${project.name}-${revision}${build.number} + clean package + + + + src/main/resources + true + + locales/** + + + + src/main/resources/locales + ./locales + false + + *.yml + + + + + + + org.apache.maven.plugins + maven-clean-plugin + 3.5.0 + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + ${java.version} + true + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + ${argLine} + -XX:+EnableDynamicAgentLoading + --add-opens java.base/java.lang=ALL-UNNAMED + --add-opens java.base/java.math=ALL-UNNAMED + --add-opens java.base/java.io=ALL-UNNAMED + --add-opens java.base/java.util=ALL-UNNAMED + --add-opens java.base/java.util.stream=ALL-UNNAMED + --add-opens java.base/java.text=ALL-UNNAMED + --add-opens java.base/java.util.regex=ALL-UNNAMED + --add-opens java.base/java.nio.channels.spi=ALL-UNNAMED + --add-opens java.base/sun.nio.ch=ALL-UNNAMED + --add-opens java.base/java.net=ALL-UNNAMED + --add-opens java.base/java.util.concurrent=ALL-UNNAMED + --add-opens java.base/sun.nio.fs=ALL-UNNAMED + --add-opens java.base/sun.nio.cs=ALL-UNNAMED + --add-opens java.base/java.nio.file=ALL-UNNAMED + --add-opens java.base/java.nio.charset=ALL-UNNAMED + --add-opens java.base/java.lang.reflect=ALL-UNNAMED + --add-opens java.logging/java.util.logging=ALL-UNNAMED + --add-opens java.base/java.lang.ref=ALL-UNNAMED + --add-opens java.base/java.util.jar=ALL-UNNAMED + --add-opens java.base/java.util.zip=ALL-UNNAMED + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.5.0 + + + org.apache.maven.plugins + maven-source-plugin + 3.4.0 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-install-plugin + 3.1.4 + + + org.apache.maven.plugins + maven-deploy-plugin + 3.1.4 + + + org.jacoco + jacoco-maven-plugin + 0.8.14 + + true + + **/*Names* + org/bukkit/Material* + + + + + prepare-agent + + prepare-agent + + + + report + + report + + + + XML + + + + + + + + diff --git a/src/main/java/world/bentobox/islandstats/IslandStats.java b/src/main/java/world/bentobox/islandstats/IslandStats.java new file mode 100644 index 0000000..c459167 --- /dev/null +++ b/src/main/java/world/bentobox/islandstats/IslandStats.java @@ -0,0 +1,179 @@ +package world.bentobox.islandstats; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.scheduler.BukkitTask; +import org.eclipse.jdt.annotation.NonNull; + +import world.bentobox.bentobox.api.addons.Addon; +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.api.configuration.Config; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.islandstats.commands.AdminIslandStatsCommand; +import world.bentobox.islandstats.commands.IslandStatsCommand; +import world.bentobox.islandstats.data.IslandStatsManager; +import world.bentobox.islandstats.listeners.EntityDeathListener; +import world.bentobox.islandstats.listeners.IslandListener; + +/** + * IslandStats addon entry point. + *

+ * Keeps statistics for each island rather than for each player - to start with, how many of each + * mob has died on the island, and how many of those a player killed. The stats are stored in the + * BentoBox database, one record per island, and shown with {@code / stats} as dialog + * pages and through placeholders. + * + * @author tastybento + */ +public class IslandStats extends Addon { + + private Settings settings; + private final Config config = new Config<>(this, Settings.class); + private final @NonNull List gameModes = new ArrayList<>(); + private IslandStatsManager manager; + private EntityDeathListener deathListener; + private BukkitTask saveTask; + + @Override + public void onLoad() { + saveDefaultConfig(); + loadSettings(); + } + + @Override + public void onEnable() { + if (getState() == State.DISABLED) { + return; + } + manager = new IslandStatsManager(this); + StatsPlaceholders placeholders = new StatsPlaceholders(this); + gameModes.clear(); + getPlugin().getAddonsManager().getGameModeAddons().stream() + .filter(gm -> !settings.getDisabledGameModes().contains(gm.getDescription().getName())) + .forEach(gm -> { + gameModes.add(gm); + log("IslandStats hooking into " + gm.getDescription().getName()); + gm.getPlayerCommand().ifPresent(c -> new IslandStatsCommand(this, c)); + gm.getAdminCommand().ifPresent(c -> new AdminIslandStatsCommand(this, c)); + placeholders.register(gm); + }); + + if (gameModes.isEmpty()) { + logWarning("IslandStats is not hooked into any game mode, so it will do nothing."); + return; + } + + deathListener = new EntityDeathListener(this); + registerListener(deathListener); + registerListener(new IslandListener(this)); + startSaveTask(); + } + + @Override + public void onReload() { + loadSettings(); + if (settings == null) { + stopSaveTask(); + return; + } + if (deathListener != null) { + deathListener.loadIgnored(); + startSaveTask(); + } + } + + @Override + public void onDisable() { + stopSaveTask(); + if (manager != null) { + manager.close(); + } + } + + /** + * Start, or restart, the repeating task that saves changed stats. + */ + private void startSaveTask() { + stopSaveTask(); + long period = settings.getSaveInterval() * 60L * 20L; + saveTask = Bukkit.getScheduler().runTaskTimer(getPlugin(), () -> manager.saveDirty(), period, period); + } + + private void stopSaveTask() { + if (saveTask != null) { + saveTask.cancel(); + saveTask = null; + } + } + + private void loadSettings() { + settings = config.loadConfigObject(); + if (settings == null) { + logError("IslandStats settings could not load! Addon disabled."); + setState(State.DISABLED); + return; + } + config.saveConfigObject(settings); + } + + /** + * @return the addon settings, or null if they have not loaded + */ + public Settings getSettings() { + return settings; + } + + /** + * @return the island stats manager, or null if the addon is not enabled. Other addons can use + * this to read island stats. + */ + public IslandStatsManager getManager() { + return manager; + } + + /** + * @return the game modes this addon is hooked into + */ + public List getGameModes() { + return gameModes; + } + + /** + * @param world world to check + * @return true if IslandStats is counting in this world + */ + public boolean inGameWorld(World world) { + if (world == null) { + return false; + } + for (GameModeAddon gm : gameModes) { + if (gm.inWorld(world)) { + return true; + } + } + return false; + } + + /** + * The name to show for an island: its own name if it has one, otherwise its owner's name. + * + * @param island the island + * @return the display name, never null + */ + @NonNull + public String getIslandName(@NonNull Island island) { + if (island.getName() != null && !island.getName().isBlank()) { + return island.getName(); + } + if (island.getOwner() != null) { + String name = getPlayers().getName(island.getOwner()); + if (name != null) { + return name; + } + } + return ""; + } +} diff --git a/src/main/java/world/bentobox/islandstats/IslandStatsPladdon.java b/src/main/java/world/bentobox/islandstats/IslandStatsPladdon.java new file mode 100644 index 0000000..a79ae1c --- /dev/null +++ b/src/main/java/world/bentobox/islandstats/IslandStatsPladdon.java @@ -0,0 +1,22 @@ +package world.bentobox.islandstats; + +import world.bentobox.bentobox.api.addons.Addon; +import world.bentobox.bentobox.api.addons.Pladdon; + +/** + * Plugin wrapper so IslandStats can be loaded as a Bukkit plugin as well as a BentoBox addon. + * + * @author tastybento + */ +public class IslandStatsPladdon extends Pladdon { + + private Addon addon; + + @Override + public Addon getAddon() { + if (addon == null) { + addon = new IslandStats(); + } + return addon; + } +} diff --git a/src/main/java/world/bentobox/islandstats/Settings.java b/src/main/java/world/bentobox/islandstats/Settings.java new file mode 100644 index 0000000..86bad1a --- /dev/null +++ b/src/main/java/world/bentobox/islandstats/Settings.java @@ -0,0 +1,125 @@ +package world.bentobox.islandstats; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import world.bentobox.bentobox.api.configuration.ConfigComment; +import world.bentobox.bentobox.api.configuration.ConfigEntry; +import world.bentobox.bentobox.api.configuration.ConfigObject; +import world.bentobox.bentobox.api.configuration.StoreAt; + +/** + * IslandStats addon settings, saved to and loaded from {@code addons/IslandStats/config.yml}. + * + * @author tastybento + */ +@StoreAt(filename = "config.yml", path = "addons/IslandStats") +public class Settings implements ConfigObject { + + @ConfigComment("IslandStats addon configuration file") + @ConfigComment("") + @ConfigComment("IslandStats keeps statistics for each island rather than for each player,") + @ConfigComment("such as how many of each mob has been killed on the island.") + @ConfigComment("") + @ConfigComment("Game modes listed here are ignored by IslandStats. Example:") + @ConfigComment("disabled-gamemodes:") + @ConfigComment(" - BSkyBlock") + @ConfigEntry(path = "disabled-gamemodes") + private Set disabledGameModes = new HashSet<>(); + + @ConfigComment("") + @ConfigComment("Count mobs killed on an island by players who are not members of it,") + @ConfigComment("such as visitors or coops. This only affects the 'killed by players' stat.") + @ConfigComment("Every mob death on the island is always counted in the 'all mob deaths' stat.") + @ConfigEntry(path = "count-visitor-kills") + private boolean countVisitorKills = true; + + @ConfigComment("") + @ConfigComment("Entity types that are never counted. Use the Bukkit entity type names.") + @ConfigComment("Armor stands are living entities to the server, so they are ignored by default.") + @ConfigEntry(path = "ignored-entities") + private List ignoredEntities = List.of("ARMOR_STAND"); + + @ConfigComment("") + @ConfigComment("How often, in minutes, changed stats are written to the database.") + @ConfigComment("Stats are counted in memory and always saved when the server stops.") + @ConfigComment("Minimum 1.") + @ConfigEntry(path = "save-interval") + private int saveInterval = 5; + + @ConfigComment("") + @ConfigComment("How many mob lines to show on each page of the stats dialog. Range 1 to 50.") + @ConfigEntry(path = "dialog.page-size") + private int pageSize = 15; + + /** + * @return the names of game modes that IslandStats ignores + */ + public Set getDisabledGameModes() { + return disabledGameModes; + } + + /** + * @param disabledGameModes the names of game modes that IslandStats ignores + */ + public void setDisabledGameModes(Set disabledGameModes) { + this.disabledGameModes = disabledGameModes; + } + + /** + * @return true if kills by non-members count toward the island's player kill stat + */ + public boolean isCountVisitorKills() { + return countVisitorKills; + } + + /** + * @param countVisitorKills true if kills by non-members count toward the island's player kill stat + */ + public void setCountVisitorKills(boolean countVisitorKills) { + this.countVisitorKills = countVisitorKills; + } + + /** + * @return entity type names that are never counted + */ + public List getIgnoredEntities() { + return ignoredEntities; + } + + /** + * @param ignoredEntities entity type names that are never counted + */ + public void setIgnoredEntities(List ignoredEntities) { + this.ignoredEntities = ignoredEntities; + } + + /** + * @return minutes between database saves, at least 1 + */ + public int getSaveInterval() { + return Math.max(1, saveInterval); + } + + /** + * @param saveInterval minutes between database saves + */ + public void setSaveInterval(int saveInterval) { + this.saveInterval = saveInterval; + } + + /** + * @return mob lines per dialog page, 1 to 50 + */ + public int getPageSize() { + return Math.clamp(pageSize, 1, 50); + } + + /** + * @param pageSize mob lines per dialog page + */ + public void setPageSize(int pageSize) { + this.pageSize = pageSize; + } +} diff --git a/src/main/java/world/bentobox/islandstats/StatsPlaceholders.java b/src/main/java/world/bentobox/islandstats/StatsPlaceholders.java new file mode 100644 index 0000000..8927807 --- /dev/null +++ b/src/main/java/world/bentobox/islandstats/StatsPlaceholders.java @@ -0,0 +1,77 @@ +package world.bentobox.islandstats; + +import java.util.Locale; + +import org.bukkit.entity.EntityType; + +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.PlaceholdersManager; +import world.bentobox.islandstats.data.IslandStat; + +/** + * Registers the island stats placeholders for a game mode. With BSkyBlock and PlaceholderAPI they + * look like {@code %IslandStats_bskyblock_island_kill_entity_zombie%}: + *

    + *
  • {@code _island__total} - the total for the player's island
  • + *
  • {@code _island__} - the count of one mob for the player's island
  • + *
  • {@code _visited_island__total} - the total for the island the player is on
  • + *
+ * where {@code } is {@code kill_entity} or {@code entity_death} and {@code } is the + * lower case entity type, e.g. {@code zombie}. + * + * @author tastybento + */ +public class StatsPlaceholders { + + private final IslandStats addon; + + public StatsPlaceholders(IslandStats addon) { + this.addon = addon; + } + + /** + * Register every placeholder for a game mode. + * + * @param gm the game mode + */ + public void register(GameModeAddon gm) { + PlaceholdersManager pm = addon.getPlugin().getPlaceholdersManager(); + if (pm == null) { + return; + } + String prefix = gm.getDescription().getName().toLowerCase(Locale.ENGLISH) + "_"; + for (IslandStat stat : IslandStat.values()) { + String statKey = stat.name().toLowerCase(Locale.ENGLISH); + pm.registerPlaceholder(addon, prefix + "island_" + statKey + "_total", + "Island total: " + statKey.replace('_', ' '), + user -> String.valueOf(addon.getManager().getTotal(getIsland(gm, user), stat))); + pm.registerPlaceholder(addon, prefix + "visited_island_" + statKey + "_total", + "Total for the island the player is on: " + statKey.replace('_', ' '), + user -> String.valueOf(addon.getManager().getTotal(getVisitedIsland(gm, user), stat))); + for (EntityType type : EntityType.values()) { + if (type.isAlive() && type != EntityType.PLAYER) { + String name = type.name().toLowerCase(Locale.ENGLISH); + pm.registerPlaceholder(addon, prefix + "island_" + statKey + "_" + name, + "Island " + statKey.replace('_', ' ') + ": " + name, + user -> String.valueOf(addon.getManager().getCount(getIsland(gm, user), stat, type))); + } + } + } + } + + private Island getIsland(GameModeAddon gm, User user) { + if (user == null || user.getUniqueId() == null) { + return null; + } + return addon.getIslands().getIsland(gm.getOverWorld(), user); + } + + private Island getVisitedIsland(GameModeAddon gm, User user) { + if (user == null || !user.isPlayer() || user.getLocation() == null || !gm.inWorld(user.getWorld())) { + return null; + } + return addon.getIslands().getProtectedIslandAt(user.getLocation()).orElse(null); + } +} diff --git a/src/main/java/world/bentobox/islandstats/commands/AdminIslandStatsCommand.java b/src/main/java/world/bentobox/islandstats/commands/AdminIslandStatsCommand.java new file mode 100644 index 0000000..d89a719 --- /dev/null +++ b/src/main/java/world/bentobox/islandstats/commands/AdminIslandStatsCommand.java @@ -0,0 +1,97 @@ +package world.bentobox.islandstats.commands; + +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; + +import world.bentobox.bentobox.api.commands.ConfirmableCommand; +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.util.Util; +import world.bentobox.islandstats.IslandStats; +import world.bentobox.islandstats.panels.StatsDialog; + +/** + * {@code /admin stats [reset]} - show, or erase, the stats of a player's + * island. Players see the dialog; the console gets the stats as chat lines. + * + * @author tastybento + */ +public class AdminIslandStatsCommand extends ConfirmableCommand { + + private static final String RESET = "reset"; + + private final IslandStats addon; + private Island island; + private String targetName; + + public AdminIslandStatsCommand(IslandStats addon, CompositeCommand parent) { + super(addon, parent, "stats", "islandstats"); + this.addon = addon; + } + + @Override + public void setup() { + setPermission("admin.stats"); + setDescription("islandstats.commands.admin.description"); + setParametersHelp("islandstats.commands.admin.parameters"); + setOnlyPlayer(false); + } + + @Override + public boolean canExecute(User user, String label, List args) { + if (args.isEmpty() || args.size() > 2 || (args.size() == 2 && !RESET.equalsIgnoreCase(args.get(1)))) { + showHelp(this, user); + return false; + } + UUID target = getPlayers().getUUID(args.get(0)); + if (target == null) { + user.sendMessage("general.errors.unknown-player", TextVariables.NAME, args.get(0)); + return false; + } + island = getIslands().getIsland(getWorld(), target); + if (island == null) { + user.sendMessage("general.errors.player-has-no-island"); + return false; + } + targetName = getPlayers().getName(target); + return true; + } + + @Override + public boolean execute(User user, String label, List args) { + if (args.size() == 2) { + Island toReset = island; + String name = targetName; + askConfirmation(user, user.getTranslation("islandstats.commands.admin.confirm-reset", TextVariables.NAME, + name), () -> { + addon.getManager().reset(toReset); + user.sendMessage("islandstats.commands.admin.reset-done", TextVariables.NAME, name); + }); + return true; + } + StatsDialog dialog = new StatsDialog(addon, island, addon.getIslandName(island)); + if (user.isPlayer()) { + dialog.showSummary(user); + } else { + dialog.sendChat(user); + } + return true; + } + + @Override + public Optional> tabComplete(User user, String alias, List args) { + String last = args.isEmpty() ? "" : args.getLast(); + if (args.size() == 1) { + List names = Util.getOnlinePlayerList(user); + return Optional.of(Util.tabLimit(names, last)); + } + if (args.size() == 2) { + return Optional.of(Util.tabLimit(List.of(RESET), last.toLowerCase(Locale.ENGLISH))); + } + return Optional.empty(); + } +} diff --git a/src/main/java/world/bentobox/islandstats/commands/IslandStatsCommand.java b/src/main/java/world/bentobox/islandstats/commands/IslandStatsCommand.java new file mode 100644 index 0000000..fff1dd5 --- /dev/null +++ b/src/main/java/world/bentobox/islandstats/commands/IslandStatsCommand.java @@ -0,0 +1,59 @@ +package world.bentobox.islandstats.commands; + +import java.util.List; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.util.Util; +import world.bentobox.islandstats.IslandStats; +import world.bentobox.islandstats.panels.StatsDialog; + +/** + * {@code / stats} - show the player's island stats in a dialog. + * + * @author tastybento + */ +public class IslandStatsCommand extends CompositeCommand { + + private final IslandStats addon; + private Island island; + + public IslandStatsCommand(IslandStats addon, CompositeCommand parent) { + super(addon, parent, "stats", "islandstats"); + this.addon = addon; + } + + @Override + public void setup() { + setPermission("island.stats"); + setDescription("islandstats.commands.player.description"); + setOnlyPlayer(true); + } + + @Override + public boolean canExecute(User user, String label, List args) { + if (!args.isEmpty()) { + showHelp(this, user); + return false; + } + // Without this, /is stats typed in another game mode's world shows the stats of an + // island the player is not looking at, if they have one in both + if (!Util.sameWorld(getWorld(), user.getWorld())) { + user.sendMessage("general.errors.wrong-world"); + return false; + } + island = getIslands().getIsland(getWorld(), user); + if (island == null) { + user.sendMessage("general.errors.no-island"); + return false; + } + return true; + } + + @Override + public boolean execute(User user, String label, List args) { + new StatsDialog(addon, island, addon.getIslandName(island)).showSummary(user); + return true; + } +} diff --git a/src/main/java/world/bentobox/islandstats/data/IslandStat.java b/src/main/java/world/bentobox/islandstats/data/IslandStat.java new file mode 100644 index 0000000..4610116 --- /dev/null +++ b/src/main/java/world/bentobox/islandstats/data/IslandStat.java @@ -0,0 +1,47 @@ +package world.bentobox.islandstats.data; + +import java.util.Locale; + +/** + * The statistics kept for each island. Where there is a matching vanilla player statistic the + * name is the same as {@link org.bukkit.Statistic}, so the island figure can be read as "the + * sum of that statistic for everything that happened on the island". + *

+ * The enum name is the key the counts are stored under in the database, so constants must not + * be renamed once released. New statistics can be added freely. + * + * @author tastybento + */ +public enum IslandStat { + /** + * A mob was killed by a player on the island. Same meaning as + * {@link org.bukkit.Statistic#KILL_ENTITY}, counted per entity type. + */ + KILL_ENTITY, + /** + * A mob died on the island from any cause - players, mob farms, the environment. Counted per + * entity type. + */ + ENTITY_DEATH; + + /** + * @return the key used for this stat in locale files and placeholders, e.g. {@code kill-entity} + */ + public String getKey() { + return name().toLowerCase(Locale.ENGLISH).replace('_', '-'); + } + + /** + * @return the locale reference for this stat's display name + */ + public String getNameReference() { + return "islandstats.stats." + getKey() + ".name"; + } + + /** + * @return the locale reference for this stat's description + */ + public String getDescriptionReference() { + return "islandstats.stats." + getKey() + ".description"; + } +} diff --git a/src/main/java/world/bentobox/islandstats/data/IslandStatsData.java b/src/main/java/world/bentobox/islandstats/data/IslandStatsData.java new file mode 100644 index 0000000..403163a --- /dev/null +++ b/src/main/java/world/bentobox/islandstats/data/IslandStatsData.java @@ -0,0 +1,121 @@ +package world.bentobox.islandstats.data; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import com.google.gson.annotations.Expose; + +import world.bentobox.bentobox.database.objects.DataObject; +import world.bentobox.bentobox.database.objects.Table; + +/** + * The statistics for one island. The unique id is the island's unique id. + *

+ * Counts are held as stat name to sub-key to count, where the stat name is an + * {@link IslandStat} name and the sub-key says what was counted - an entity type name such as + * {@code ZOMBIE} for the mob stats. Plain strings are used rather than enums so that a stat or + * an entity type that a later server version drops still loads. + * + * @author tastybento + */ +@Table(name = "IslandStats") +public class IslandStatsData implements DataObject { + + @Expose + private String uniqueId; + + @Expose + private Map> stats = new HashMap<>(); + + /** + * Required by the database for loading. + */ + public IslandStatsData() { + // Required by the database + } + + /** + * @param islandId the unique id of the island these stats are for + */ + public IslandStatsData(String islandId) { + this.uniqueId = islandId; + } + + @Override + public String getUniqueId() { + return uniqueId; + } + + @Override + public void setUniqueId(String uniqueId) { + this.uniqueId = uniqueId; + } + + /** + * Add to a count. + * + * @param stat the statistic + * @param subKey what was counted, such as an entity type name + * @param amount how much to add + */ + public void add(IslandStat stat, String subKey, long amount) { + getStatsMap().computeIfAbsent(stat.name(), k -> new HashMap<>()).merge(subKey, amount, Long::sum); + } + + /** + * @param stat the statistic + * @param subKey what was counted, such as an entity type name + * @return the count, or 0 if nothing has been counted + */ + public long get(IslandStat stat, String subKey) { + Map counts = getStatsMap().get(stat.name()); + return counts == null ? 0 : counts.getOrDefault(subKey, 0L); + } + + /** + * @param stat the statistic + * @return every count for the stat, keyed by sub-key. Unmodifiable, may be empty + */ + public Map getAll(IslandStat stat) { + Map counts = getStatsMap().get(stat.name()); + return counts == null ? Collections.emptyMap() : Collections.unmodifiableMap(counts); + } + + /** + * @param stat the statistic + * @return the sum of every count for the stat + */ + public long getTotal(IslandStat stat) { + return getAll(stat).values().stream().mapToLong(Long::longValue).sum(); + } + + /** + * Forget every count. + */ + public void clear() { + getStatsMap().clear(); + } + + /** + * @return the raw stats map, for the database + */ + public Map> getStats() { + return getStatsMap(); + } + + /** + * @param stats the raw stats map, for the database + */ + public void setStats(Map> stats) { + this.stats = stats; + } + + private Map> getStatsMap() { + // A record saved with no stats can load with a null map + if (stats == null) { + stats = new HashMap<>(); + } + return stats; + } +} diff --git a/src/main/java/world/bentobox/islandstats/data/IslandStatsManager.java b/src/main/java/world/bentobox/islandstats/data/IslandStatsManager.java new file mode 100644 index 0000000..dd038b7 --- /dev/null +++ b/src/main/java/world/bentobox/islandstats/data/IslandStatsManager.java @@ -0,0 +1,184 @@ +package world.bentobox.islandstats.data; + +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.entity.EntityType; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + +import world.bentobox.bentobox.database.Database; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.islandstats.IslandStats; + +/** + * Owns the island stats: a cache of the records in the database, the counting, and saving. + *

+ * Counting happens on every mob death, so it only ever touches memory. A record is loaded from + * the database the first time its island is needed, after which it stays cached. Changed records + * are marked dirty and written out by {@link #saveDirty()}, which the addon runs on a timer, and + * by {@link #close()} when the server stops. + * + * @author tastybento + */ +public class IslandStatsManager { + + private final Database handler; + + /** + * Records by island unique id. Concurrent because placeholder requests can arrive off the + * main thread; all counting is done on the main thread. + */ + private final Map cache = new ConcurrentHashMap<>(); + + /** Island ids whose records have changed since they were last saved. */ + private final Set dirty = ConcurrentHashMap.newKeySet(); + + public IslandStatsManager(IslandStats addon) { + this.handler = new Database<>(addon, IslandStatsData.class); + } + + /** + * Get the stats for an island, loading them from the database if they are not cached. + * + * @param island the island + * @return the island's stats, never null + */ + @NonNull + public IslandStatsData getStats(@NonNull Island island) { + return getStats(island.getUniqueId()); + } + + /** + * Get the stats for an island, loading them from the database if they are not cached. + * + * @param islandId the island's unique id + * @return the island's stats, never null + */ + @NonNull + public IslandStatsData getStats(@NonNull String islandId) { + return cache.computeIfAbsent(islandId, this::load); + } + + private IslandStatsData load(String islandId) { + IslandStatsData data = handler.objectExists(islandId) ? handler.loadObject(islandId) : null; + return data == null ? new IslandStatsData(islandId) : data; + } + + /** + * Count one more for a stat on an island. + * + * @param island the island + * @param stat the statistic + * @param type the entity type counted + */ + public void increment(@NonNull Island island, @NonNull IslandStat stat, @NonNull EntityType type) { + getStats(island).add(stat, type.name(), 1); + dirty.add(island.getUniqueId()); + } + + /** + * @param island the island, or null + * @param stat the statistic + * @param type the entity type + * @return the count, or 0 if there is no island + */ + public long getCount(@Nullable Island island, @NonNull IslandStat stat, @NonNull EntityType type) { + return island == null ? 0 : getStats(island).get(stat, type.name()); + } + + /** + * @param island the island, or null + * @param stat the statistic + * @return the sum of every count for the stat, or 0 if there is no island + */ + public long getTotal(@Nullable Island island, @NonNull IslandStat stat) { + return island == null ? 0 : getStats(island).getTotal(stat); + } + + /** + * @param island the island + * @param stat the statistic + * @return every non-zero count for the stat, highest first + */ + public List> getSorted(@NonNull Island island, @NonNull IslandStat stat) { + return getStats(island).getAll(stat).entrySet().stream().filter(e -> e.getValue() > 0) + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()) + .thenComparing(Map.Entry.comparingByKey())) + .toList(); + } + + /** + * Erase every stat for an island and save the empty record. + * + * @param island the island + */ + public void reset(@NonNull Island island) { + IslandStatsData data = getStats(island); + data.clear(); + dirty.remove(island.getUniqueId()); + handler.saveObjectAsync(data); + } + + /** + * Forget an island completely, from the cache and the database. Used when the island is + * deleted. + * + * @param islandId the island's unique id + */ + public void delete(@NonNull String islandId) { + cache.remove(islandId); + dirty.remove(islandId); + if (handler.objectExists(islandId)) { + handler.deleteID(islandId); + } + } + + /** + * Write every changed record to the database. The records are serialized on the calling + * thread and written asynchronously, so this must be called on the main thread. + */ + public void saveDirty() { + Iterator it = dirty.iterator(); + while (it.hasNext()) { + IslandStatsData data = cache.get(it.next()); + it.remove(); + if (data != null) { + handler.saveObjectAsync(data); + } + } + } + + /** + * Save every changed record and wait for the writes to finish. Used when the addon is + * disabled, when there is no later chance to retry. + */ + public void close() { + for (String id : dirty) { + IslandStatsData data = cache.get(id); + if (data != null) { + handler.saveObjectNow(data); + } + } + dirty.clear(); + cache.clear(); + } + + /** + * @return the number of island records held in memory + */ + public int getCacheSize() { + return cache.size(); + } + + /** + * @return true if there are changes waiting to be saved + */ + public boolean hasUnsavedChanges() { + return !dirty.isEmpty(); + } +} diff --git a/src/main/java/world/bentobox/islandstats/listeners/EntityDeathListener.java b/src/main/java/world/bentobox/islandstats/listeners/EntityDeathListener.java new file mode 100644 index 0000000..ba7c2b6 --- /dev/null +++ b/src/main/java/world/bentobox/islandstats/listeners/EntityDeathListener.java @@ -0,0 +1,76 @@ +package world.bentobox.islandstats.listeners; + +import java.util.EnumSet; +import java.util.Locale; +import java.util.Set; + +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDeathEvent; + +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.islandstats.IslandStats; +import world.bentobox.islandstats.data.IslandStat; + +/** + * Counts mob deaths on islands. + *

+ * This listens to {@link EntityDeathEvent} rather than piggybacking on player statistics, + * because the player statistic event only fires when a player is the killer and carries no + * location for the mob. Mob farms kill with fall damage, lava and the like, and the island a + * mob belongs to is where it died, not where the killer stood. + *

+ * The handler runs on every mob death on the server, so it bails out as early and as cheaply as + * it can: players and ignored types first, then the world, then one island grid lookup. The count + * itself is an in-memory map update. + * + * @author tastybento + */ +public class EntityDeathListener implements Listener { + + private final IslandStats addon; + private final Set ignored = EnumSet.noneOf(EntityType.class); + + public EntityDeathListener(IslandStats addon) { + this.addon = addon; + loadIgnored(); + } + + /** + * Read the ignored entity types from the settings. Called again when the settings reload. + */ + public void loadIgnored() { + ignored.clear(); + for (String name : addon.getSettings().getIgnoredEntities()) { + try { + ignored.add(EntityType.valueOf(name.toUpperCase(Locale.ENGLISH))); + } catch (IllegalArgumentException e) { + addon.logWarning("Unknown entity type in ignored-entities: " + name); + } + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onEntityDeath(EntityDeathEvent e) { + LivingEntity entity = e.getEntity(); + if (entity instanceof Player || ignored.contains(entity.getType()) + || !addon.inGameWorld(entity.getWorld())) { + return; + } + addon.getIslands().getProtectedIslandAt(entity.getLocation()).ifPresent(island -> count(island, entity)); + } + + private void count(Island island, LivingEntity entity) { + EntityType type = entity.getType(); + addon.getManager().increment(island, IslandStat.ENTITY_DEATH, type); + Player killer = entity.getKiller(); + if (killer != null && (addon.getSettings().isCountVisitorKills() + || island.getMemberSet().contains(killer.getUniqueId()))) { + addon.getManager().increment(island, IslandStat.KILL_ENTITY, type); + } + } +} diff --git a/src/main/java/world/bentobox/islandstats/listeners/IslandListener.java b/src/main/java/world/bentobox/islandstats/listeners/IslandListener.java new file mode 100644 index 0000000..e42e36c --- /dev/null +++ b/src/main/java/world/bentobox/islandstats/listeners/IslandListener.java @@ -0,0 +1,31 @@ +package world.bentobox.islandstats.listeners; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +import world.bentobox.bentobox.api.events.island.IslandDeleteEvent; +import world.bentobox.islandstats.IslandStats; + +/** + * Removes an island's stats when the island is deleted, so the database does not fill up with + * records for islands that no longer exist. Resetting an island makes a new island with a new + * id, so the old island's delete covers resets as well. + * + * @author tastybento + */ +public class IslandListener implements Listener { + + private final IslandStats addon; + + public IslandListener(IslandStats addon) { + this.addon = addon; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onIslandDelete(IslandDeleteEvent e) { + if (e.getIsland() != null) { + addon.getManager().delete(e.getIsland().getUniqueId()); + } + } +} diff --git a/src/main/java/world/bentobox/islandstats/panels/StatsDialog.java b/src/main/java/world/bentobox/islandstats/panels/StatsDialog.java new file mode 100644 index 0000000..606cffc --- /dev/null +++ b/src/main/java/world/bentobox/islandstats/panels/StatsDialog.java @@ -0,0 +1,170 @@ +package world.bentobox.islandstats.panels; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.bukkit.entity.EntityType; +import org.eclipse.jdt.annotation.NonNull; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; +import world.bentobox.bentobox.api.dialogs.DialogBuilder; +import world.bentobox.bentobox.api.dialogs.DialogButton; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.util.Util; +import world.bentobox.islandstats.IslandStats; +import world.bentobox.islandstats.data.IslandStat; + +/** + * The island stats pages, shown as modal dialogs. + *

+ * The first page is a summary with the total for each stat and a button to open it. A stat page + * lists the counts for each mob, highest first, a page at a time. Mob names are sent as + * translatable components so each player sees them in their own client language. + * + * @author tastybento + */ +public class StatsDialog { + + private static final String DIALOG = "islandstats.dialog."; + private static final String MOB = "[mob]"; + private static final String STAT = "[stat]"; + + private final IslandStats addon; + private final Island island; + private final String islandName; + + /** + * @param addon the addon + * @param island the island whose stats are shown + * @param islandName the name to show in the title + */ + public StatsDialog(@NonNull IslandStats addon, @NonNull Island island, @NonNull String islandName) { + this.addon = addon; + this.island = island; + this.islandName = islandName; + } + + /** + * Show the summary page. + * + * @param user the player to show it to + */ + public void showSummary(@NonNull User user) { + summary(user).build().show(user); + } + + /** + * Lay out the summary page. Separate from {@link #showSummary(User)} so the layout can be + * tested without a server to build the dialog. + */ + DialogBuilder summary(User user) { + DialogBuilder builder = new DialogBuilder().title(user, DIALOG + "title", TextVariables.NAME, islandName); + for (IslandStat stat : IslandStat.values()) { + builder.body(user, DIALOG + "summary-line", STAT, user.getTranslation(stat.getNameReference()), + TextVariables.NUMBER, format(addon.getManager().getTotal(island, stat))); + builder.button(new DialogButton(text(user, stat.getNameReference()), + text(user, stat.getDescriptionReference()), u -> showStat(u, stat, 0))); + } + builder.button(DialogButton.of(user, DIALOG + "buttons.close", null)); + return builder; + } + + /** + * Show one page of a stat. + * + * @param user the player to show it to + * @param stat the statistic + * @param page the page, counting from 0. Out of range pages are clamped + */ + public void showStat(@NonNull User user, @NonNull IslandStat stat, int page) { + statPage(user, stat, page).build().show(user); + } + + /** + * Lay out one page of a stat. + */ + DialogBuilder statPage(User user, IslandStat stat, int page) { + List> entries = addon.getManager().getSorted(island, stat); + int pageSize = addon.getSettings().getPageSize(); + int pages = Math.max(1, (entries.size() + pageSize - 1) / pageSize); + int current = Math.clamp(page, 0, pages - 1); + + DialogBuilder builder = new DialogBuilder().title(user, DIALOG + "stat-title", STAT, + user.getTranslation(stat.getNameReference())); + if (entries.isEmpty()) { + builder.body(user, DIALOG + "empty"); + } else { + if (pages > 1) { + builder.body(user, DIALOG + "page", TextVariables.NUMBER, String.valueOf(current + 1), + "[total]", String.valueOf(pages)); + } + int from = current * pageSize; + List lines = new ArrayList<>(); + for (Map.Entry entry : entries.subList(from, Math.min(entries.size(), from + pageSize))) { + lines.add(entryLine(user, entry.getKey(), entry.getValue())); + } + // One body block keeps the list tight rather than spacing each line out + builder.body(Component.join(JoinConfiguration.newlines(), lines)); + } + if (current > 0) { + builder.button(DialogButton.of(user, DIALOG + "buttons.previous", u -> showStat(u, stat, current - 1))); + } + if (current < pages - 1) { + builder.button(DialogButton.of(user, DIALOG + "buttons.next", u -> showStat(u, stat, current + 1))); + } + builder.button(DialogButton.of(user, DIALOG + "buttons.back", this::showSummary)); + builder.button(DialogButton.of(user, DIALOG + "buttons.close", null)); + return builder; + } + + /** + * Send the stats as chat lines. Used for the console, which cannot see a dialog. + * + * @param user the user to send them to + */ + public void sendChat(@NonNull User user) { + user.sendMessage("islandstats.chat.header", TextVariables.NAME, islandName); + for (IslandStat stat : IslandStat.values()) { + user.sendMessage("islandstats.chat.stat-header", STAT, user.getTranslation(stat.getNameReference()), + TextVariables.NUMBER, format(addon.getManager().getTotal(island, stat))); + for (Map.Entry entry : addon.getManager().getSorted(island, stat)) { + user.sendMessage("islandstats.chat.entry", MOB, Util.prettifyText(entry.getKey()), + TextVariables.NUMBER, format(entry.getValue())); + } + } + } + + /** + * Build one mob line. The locale entry is translated with the count filled in, and then the + * mob placeholder is swapped for a translatable component, which a plain string cannot carry. + */ + private Component entryLine(User user, String key, long count) { + Component line = text(user, DIALOG + "entry", TextVariables.NUMBER, format(count)); + return line.replaceText(b -> b.matchLiteral(MOB).once().replacement(mobName(key))); + } + + /** + * @param key a stored entity type name + * @return the mob name as a translatable component, or the prettified key if the type is no + * longer known to the server + */ + static Component mobName(String key) { + try { + return Component.translatable(EntityType.valueOf(key).translationKey()); + } catch (IllegalArgumentException e) { + return Component.text(Util.prettifyText(key)); + } + } + + private static Component text(User user, String reference, String... variables) { + return Util.parseMiniMessageOrLegacy(user.getTranslation(reference, variables)); + } + + private static String format(long number) { + return String.format("%,d", number); + } +} diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml new file mode 100644 index 0000000..e4a72f2 --- /dev/null +++ b/src/main/resources/addon.yml @@ -0,0 +1,19 @@ +name: IslandStats +main: world.bentobox.islandstats.IslandStats +version: ${version}${build.number} +api-version: 3.23.0 +metrics: true +icon: WRITABLE_BOOK +repository: "BentoBoxWorld/IslandStats" + +prefix: IslandStats + +authors: tastybento + +permissions: + '[gamemode].island.stats': + description: Allow use of the '/[label] stats' command to see the island's stats + default: true + '[gamemode].admin.stats': + description: Allow use of the '/[label] stats' admin command to see and reset an island's stats + default: op diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..e1d07c6 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,28 @@ +# IslandStats addon configuration file +# +# IslandStats keeps statistics for each island rather than for each player, +# such as how many of each mob has been killed on the island. +# +# Game modes listed here are ignored by IslandStats. Example: +# disabled-gamemodes: +# - BSkyBlock +disabled-gamemodes: [] +# +# Count mobs killed on an island by players who are not members of it, +# such as visitors or coops. This only affects the 'killed by players' stat. +# Every mob death on the island is always counted in the 'all mob deaths' stat. +count-visitor-kills: true +# +# Entity types that are never counted. Use the Bukkit entity type names. +# Armor stands are living entities to the server, so they are ignored by default. +ignored-entities: +- ARMOR_STAND +# +# How often, in minutes, changed stats are written to the database. +# Stats are counted in memory and always saved when the server stops. +# Minimum 1. +save-interval: 5 +dialog: + # + # How many mob lines to show on each page of the stats dialog. Range 1 to 50. + page-size: 15 diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml new file mode 100644 index 0000000..09e6dda --- /dev/null +++ b/src/main/resources/locales/en-US.yml @@ -0,0 +1,36 @@ +islandstats: + # Names of the statistics, shown as buttons, titles and in chat. + stats: + kill-entity: + name: "Mobs killed by players" + description: "Mobs that a player killed on the island" + entity-death: + name: "All mob deaths" + description: "Every mob that died on the island, including in mob farms" + dialog: + # [name] is the island name, or the owner's name if the island has none + title: "Island Stats: [name]" + summary-line: "[stat]: [number]" + stat-title: "[stat]" + # [number] is the page number and [total] is how many pages there are + page: "Page [number] of [total]" + # [mob] is replaced by the mob's name in the player's own language + entry: "[mob]: [number]" + empty: "Nothing has been recorded yet." + buttons: + previous: "Previous" + next: "Next" + back: "Back" + close: "Close" + chat: + header: "Island Stats: [name]" + stat-header: "[stat]: [number]" + entry: " [mob]: [number]" + commands: + player: + description: "see your island's statistics" + admin: + description: "see or reset an island's statistics" + parameters: " [reset]" + reset-done: "The stats for [name]'s island have been reset." + confirm-reset: "This will permanently erase all of the stats for [name]'s island." diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..a9df18a --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,9 @@ +name: BentoBox-IslandStats +main: world.bentobox.islandstats.IslandStatsPladdon +version: ${project.version}${build.number} +api-version: "1.21" + +authors: [tastybento] +contributors: ["The BentoBoxWorld Community"] +website: https://bentobox.world +description: ${project.description} diff --git a/src/test/java/world/bentobox/islandstats/CommonTestSetup.java b/src/test/java/world/bentobox/islandstats/CommonTestSetup.java new file mode 100644 index 0000000..f8fe227 --- /dev/null +++ b/src/test/java/world/bentobox/islandstats/CommonTestSetup.java @@ -0,0 +1,304 @@ +package world.bentobox.islandstats; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.entity.Player.Spigot; +import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.inventory.ItemFactory; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.metadata.FixedMetadataValue; +import org.bukkit.metadata.MetadataValue; +import org.bukkit.plugin.PluginManager; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.stubbing.Answer; + +import com.google.common.collect.ImmutableSet; + +import net.md_5.bungee.api.chat.TextComponent; +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.configuration.WorldSettings; +import world.bentobox.bentobox.api.user.Notifier; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.database.objects.Players; +import world.bentobox.bentobox.managers.BlueprintsManager; +import world.bentobox.bentobox.managers.FlagsManager; +import world.bentobox.bentobox.managers.HooksManager; +import world.bentobox.bentobox.managers.IslandWorldManager; +import world.bentobox.bentobox.managers.IslandsManager; +import world.bentobox.bentobox.managers.LocalesManager; +import world.bentobox.bentobox.managers.PlaceholdersManager; +import world.bentobox.bentobox.managers.PlayersManager; +import world.bentobox.bentobox.util.Util; + +/** + * Common items for testing. Don't forget to use super.setUp()! + *

+ * Sets up BentoBox plugin, pluginManager and ItemFactory. + * Location, world, playersManager and player. + * IWM, Addon and WorldSettings. IslandManager with one + * island with protection and nothing allowed by default. + * Owner of island is player with same UUID. + * Locales, placeholders. + * @author tastybento + * + */ +public abstract class CommonTestSetup { + + protected UUID uuid = UUID.randomUUID(); + + @Mock + protected Player mockPlayer; + @Mock + protected PluginManager pim; + @Mock + protected ItemFactory itemFactory; + @Mock + protected Location location; + @Mock + protected World world; + @Mock + protected IslandWorldManager iwm; + @Mock + protected IslandsManager im; + @Mock + protected Island island; + @Mock + protected BentoBox plugin; + @Mock + protected PlayerInventory inv; + @Mock + protected Notifier notifier; + @Mock + protected FlagsManager fm; + @Mock + protected Spigot spigot; + @Mock + protected HooksManager hooksManager; + @Mock + protected BlueprintsManager bm; + + protected ServerMock server; + + protected MockedStatic mockedBukkit; + protected MockedStatic mockedUtil; + + protected AutoCloseable closeable; + + @Mock + protected BukkitScheduler sch; + @Mock + protected LocalesManager lm; + + @Mock + protected PlaceholdersManager phm; + + + @SuppressWarnings("java:S1130") // subclasses override and declare checked exceptions + @BeforeEach + public void setUp() throws Exception { + // Processes the @Mock annotations and initializes the field + closeable = MockitoAnnotations.openMocks(this); + server = MockBukkit.mock(); + // Bukkit + // Set up plugin + WhiteBox.setInternalState(BentoBox.class, "instance", plugin); + + // Force Tag static fields (e.g. Tag.LEAVES) to be initialized NOW, while the + // real MockBukkit server is active, rather than later when mockedBukkit is in + // place. If Tag fields load under mockedBukkit (RETURNS_DEEP_STUBS), they + // become Mockito deep-stub mocks that stale across tests after clearInlineMocks(). + @SuppressWarnings("unused") + var unusedTagRef = org.bukkit.Tag.LEAVES; + + // Register the static mock + mockedBukkit = Mockito.mockStatic(Bukkit.class, Mockito.RETURNS_DEEP_STUBS); + mockedBukkit.when(Bukkit::getMinecraftVersion).thenReturn("1.21.10"); + mockedBukkit.when(Bukkit::getBukkitVersion).thenReturn(""); + mockedBukkit.when(Bukkit::getPluginManager).thenReturn(pim); + mockedBukkit.when(Bukkit::getItemFactory).thenReturn(itemFactory); + mockedBukkit.when(Bukkit::getServer).thenReturn(server); + // Location + when(location.getWorld()).thenReturn(world); + when(location.getBlockX()).thenReturn(0); + when(location.getBlockY()).thenReturn(0); + when(location.getBlockZ()).thenReturn(0); + when(location.toVector()).thenReturn(new Vector(0,0,0)); + when(location.clone()).thenReturn(location); // Paper + + // Players Manager and meta data + PlayersManager pm = mock(PlayersManager.class); + when(plugin.getPlayers()).thenReturn(pm); + Players players = mock(Players.class); + when(players.getMetaData()).thenReturn(Optional.empty()); + when(pm.getPlayer(any(UUID.class))).thenReturn(players); + + // Player + when(mockPlayer.getUniqueId()).thenReturn(uuid); + when(mockPlayer.getLocation()).thenReturn(location); + when(mockPlayer.getWorld()).thenReturn(world); + when(mockPlayer.getName()).thenReturn("tastybento"); + when(mockPlayer.getInventory()).thenReturn(inv); + when(mockPlayer.spigot()).thenReturn(spigot); + when(mockPlayer.getType()).thenReturn(EntityType.PLAYER); + when(mockPlayer.getWorld()).thenReturn(world); + + User.setPlugin(plugin); + User.clearUsers(); + User.getInstance(mockPlayer); + + // IWM + when(plugin.getIWM()).thenReturn(iwm); + when(iwm.inWorld(any(Location.class))).thenReturn(true); + when(iwm.inWorld(any(World.class))).thenReturn(true); + when(iwm.getFriendlyName(any())).thenReturn("IslandStats"); + // Addon + when(iwm.getAddon(any())).thenReturn(Optional.empty()); + + // World Settings + WorldSettings worldSet = new TestWorldSettings(); + when(iwm.getWorldSettings(any())).thenReturn(worldSet); + + // Island Manager + when(plugin.getIslands()).thenReturn(im); + Optional optionalIsland = Optional.of(island); + when(im.getProtectedIslandAt(any())).thenReturn(optionalIsland); + + // Island - nothing is allowed by default + when(island.isAllowed(any())).thenReturn(false); + when(island.isAllowed(any(User.class), any())).thenReturn(false); + when(island.getOwner()).thenReturn(uuid); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(uuid)); + + // Enable reporting from Flags class + @SuppressWarnings("deprecation") + MetadataValue mdv = new FixedMetadataValue(plugin, "_why_debug"); + when(mockPlayer.getMetadata(anyString())).thenReturn(Collections.singletonList(mdv)); + + // Locales & Placeholders + when(lm.get(any(), any())).thenAnswer((Answer) invocation -> invocation.getArgument(1, String.class)); + when(plugin.getPlaceholdersManager()).thenReturn(phm); + when(phm.replacePlaceholders(any(), any())).thenAnswer((Answer) invocation -> invocation.getArgument(1, String.class)); + when(plugin.getLocalesManager()).thenReturn(lm); + // Notifier + when(plugin.getNotifier()).thenReturn(notifier); + + // Logger - Addon.getLogger() delegates to plugin.getLogger() + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("IslandStats-test")); + + // Fake players + world.bentobox.bentobox.Settings settings = new world.bentobox.bentobox.Settings(); + when(plugin.getSettings()).thenReturn(settings); + + //Util + mockedUtil = Mockito.mockStatic(Util.class, Mockito.CALLS_REAL_METHODS); + mockedUtil.when(() -> Util.getWorld(any())).thenReturn(mock(World.class)); + Util.setPlugin(plugin); + + // Util + mockedUtil.when(() -> Util.findFirstMatchingEnum(any(), any())).thenCallRealMethod(); + // Server & Scheduler + mockedBukkit.when(Bukkit::getScheduler).thenReturn(sch); + + // Hooks + when(hooksManager.getHook(anyString())).thenReturn(Optional.empty()); + when(plugin.getHooks()).thenReturn(hooksManager); + + // Blueprints Manager + when(plugin.getBlueprintsManager()).thenReturn(bm); + } + + /** + */ + @AfterEach + public void tearDown() throws Exception { + // IMPORTANT: Explicitly close the mock to prevent leakage + mockedBukkit.closeOnDemand(); + mockedUtil.closeOnDemand(); + closeable.close(); + MockBukkit.unmock(); + User.clearUsers(); + Mockito.framework().clearInlineMocks(); + deleteAll(new File("database")); + deleteAll(new File("database_backup")); + } + + protected static void deleteAll(File file) throws IOException { + if (file.exists()) { + Files.walk(file.toPath()).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete); + } + + } + + /** + * Check that spigot sent the message + * @param expectedMessage - message to check + */ + public void checkSpigotMessage(String expectedMessage) { + checkSpigotMessage(expectedMessage, 1); + } + + @SuppressWarnings("deprecation") + public void checkSpigotMessage(String expectedMessage, int expectedOccurrences) { + // Capture the argument passed to spigot().sendMessage(...) if messages are sent + ArgumentCaptor captor = ArgumentCaptor.forClass(TextComponent.class); + + // Verify that sendMessage() was called at least 0 times (capture any sent messages) + verify(spigot, atLeast(0)).sendMessage(captor.capture()); + + // Get all captured TextComponents + List capturedMessages = captor.getAllValues(); + + // Count the number of occurrences of the expectedMessage in the captured messages + // NOSONAR S1612: BaseComponent overloads toLegacyText() (instance) and toLegacyText(BaseComponent...) (static), + // so a method reference is ambiguous and will not compile; the lambda is required. + long actualOccurrences = capturedMessages.stream().map(component -> component.toLegacyText()) // NOSONAR + .filter(messageText -> messageText.contains(expectedMessage)) // Check if the message contains the expected text + .count(); // Count how many times the expected message appears + + // Assert that the number of occurrences matches the expectedOccurrences + assertEquals(expectedOccurrences, + actualOccurrences, "Expected message occurrence mismatch: " + expectedMessage); + } + + /** + * Get the exploded event + */ + public EntityExplodeEvent getExplodeEvent(Entity entity, Location l, List list) { + return new EntityExplodeEvent(entity, l, list, 0, org.bukkit.ExplosionResult.DESTROY); + } + +} diff --git a/src/test/java/world/bentobox/islandstats/SettingsTest.java b/src/test/java/world/bentobox/islandstats/SettingsTest.java new file mode 100644 index 0000000..af0266a --- /dev/null +++ b/src/test/java/world/bentobox/islandstats/SettingsTest.java @@ -0,0 +1,53 @@ +package world.bentobox.islandstats; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +/** + * Tests the settings defaults and bounds. + */ +class SettingsTest { + + private final Settings settings = new Settings(); + + @Test + void testDefaults() { + assertTrue(settings.getDisabledGameModes().isEmpty()); + assertTrue(settings.isCountVisitorKills()); + assertEquals(List.of("ARMOR_STAND"), settings.getIgnoredEntities()); + assertEquals(5, settings.getSaveInterval()); + assertEquals(15, settings.getPageSize()); + } + + @Test + void testSetters() { + settings.setDisabledGameModes(Set.of("BSkyBlock")); + settings.setCountVisitorKills(false); + settings.setIgnoredEntities(List.of("BAT")); + assertEquals(Set.of("BSkyBlock"), settings.getDisabledGameModes()); + assertFalse(settings.isCountVisitorKills()); + assertEquals(List.of("BAT"), settings.getIgnoredEntities()); + } + + @Test + void testSaveIntervalAtLeastOneMinute() { + settings.setSaveInterval(0); + assertEquals(1, settings.getSaveInterval()); + settings.setSaveInterval(10); + assertEquals(10, settings.getSaveInterval()); + } + + @Test + void testPageSizeClamped() { + settings.setPageSize(0); + assertEquals(1, settings.getPageSize()); + settings.setPageSize(500); + assertEquals(50, settings.getPageSize()); + } +} diff --git a/src/test/java/world/bentobox/islandstats/TestWorldSettings.java b/src/test/java/world/bentobox/islandstats/TestWorldSettings.java new file mode 100644 index 0000000..fc69403 --- /dev/null +++ b/src/test/java/world/bentobox/islandstats/TestWorldSettings.java @@ -0,0 +1,409 @@ +package world.bentobox.islandstats; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.bukkit.Difficulty; +import org.bukkit.GameMode; +import org.bukkit.entity.EntityType; +import org.eclipse.jdt.annotation.NonNull; + +import world.bentobox.bentobox.api.configuration.WorldSettings; +import world.bentobox.bentobox.api.flags.Flag; + +/** + * Class for tests that require world settings + * @author tastybento + * + */ +public class TestWorldSettings implements WorldSettings { + + private long epoch; + + @Override + public GameMode getDefaultGameMode() { + + return GameMode.SURVIVAL; + } + + @Override + public Map getDefaultIslandFlags() { + + return Collections.emptyMap(); + } + + @Override + public Map getDefaultIslandSettings() { + + return Collections.emptyMap(); + } + + @Override + public Difficulty getDifficulty() { + + return Difficulty.EASY; + } + + @Override + public void setDifficulty(Difficulty difficulty) { + // Do nothing + + } + + @Override + public String getFriendlyName() { + + return "friendly_name"; + } + + @Override + public int getIslandDistance() { + + return 0; + } + + @Override + public int getIslandHeight() { + + return 0; + } + + @Override + public int getIslandProtectionRange() { + + return 0; + } + + @Override + public int getIslandStartX() { + + return 0; + } + + @Override + public int getIslandStartZ() { + + return 0; + } + + @Override + public int getIslandXOffset() { + + return 0; + } + + @Override + public int getIslandZOffset() { + + return 0; + } + + @Override + public List getIvSettings() { + + return Collections.emptyList(); + } + + @Override + public int getMaxHomes() { + + return 3; + } + + @Override + public int getMaxIslands() { + + return 0; + } + + @Override + public int getMaxTeamSize() { + + return 4; + } + + @Override + public int getNetherSpawnRadius() { + + return 10; + } + + @Override + public String getPermissionPrefix() { + + return "perm."; + } + + @Override + public Set getRemoveMobsWhitelist() { + + return Collections.emptySet(); + } + + @Override + public int getSeaHeight() { + + return 0; + } + + @Override + public List getHiddenFlags() { + + return Collections.emptyList(); + } + + @Override + public List getVisitorBannedCommands() { + + return Collections.emptyList(); + } + + @Override + public Map getWorldFlags() { + + return Collections.emptyMap(); + } + + @Override + public String getWorldName() { + + return "world_name"; + } + + @Override + public boolean isDragonSpawn() { + + return false; + } + + @Override + public boolean isEndGenerate() { + + return true; + } + + @Override + public boolean isEndIslands() { + + return true; + } + + @Override + public boolean isNetherGenerate() { + + return true; + } + + @Override + public boolean isNetherIslands() { + + return true; + } + + @Override + public boolean isOnJoinResetEnderChest() { + + return false; + } + + @Override + public boolean isOnJoinResetInventory() { + + return false; + } + + @Override + public boolean isOnJoinResetMoney() { + + return false; + } + + @Override + public boolean isOnJoinResetHealth() { + + return false; + } + + @Override + public boolean isOnJoinResetHunger() { + + return false; + } + + @Override + public boolean isOnJoinResetXP() { + + return false; + } + + @Override + public @NonNull List getOnJoinCommands() { + + return Collections.emptyList(); + } + + @Override + public boolean isOnLeaveResetEnderChest() { + + return false; + } + + @Override + public boolean isOnLeaveResetInventory() { + + return false; + } + + @Override + public boolean isOnLeaveResetMoney() { + + return false; + } + + @Override + public boolean isOnLeaveResetHealth() { + + return false; + } + + @Override + public boolean isOnLeaveResetHunger() { + + return false; + } + + @Override + public boolean isOnLeaveResetXP() { + + return false; + } + + @Override + public @NonNull List getOnLeaveCommands() { + + return Collections.emptyList(); + } + + @Override + public boolean isUseOwnGenerator() { + + return false; + } + + @Override + public boolean isWaterUnsafe() { + + return false; + } + + @Override + public List getGeoLimitSettings() { + + return Collections.emptyList(); + } + + @Override + public int getResetLimit() { + + return 0; + } + + @Override + public long getResetEpoch() { + + return epoch; + } + + @Override + public void setResetEpoch(long timestamp) { + this.epoch = timestamp; + + } + + @Override + public boolean isTeamJoinDeathReset() { + + return false; + } + + @Override + public int getDeathsMax() { + + return 0; + } + + @Override + public boolean isDeathsCounted() { + + return true; + } + + @Override + public boolean isDeathsResetOnNewIsland() { + + return true; + } + + @Override + public boolean isAllowSetHomeInNether() { + + return false; + } + + @Override + public boolean isAllowSetHomeInTheEnd() { + + return false; + } + + @Override + public boolean isRequireConfirmationToSetHomeInNether() { + + return false; + } + + @Override + public boolean isRequireConfirmationToSetHomeInTheEnd() { + + return false; + } + + @Override + public int getBanLimit() { + + return 10; + } + + @Override + public boolean isLeaversLoseReset() { + + return true; + } + + @Override + public boolean isKickedKeepInventory() { + + return true; + } + + @Override + public boolean isCreateIslandOnFirstLoginEnabled() { + + return false; + } + + @Override + public int getCreateIslandOnFirstLoginDelay() { + + return 0; + } + + @Override + public boolean isCreateIslandOnFirstLoginAbortOnLogout() { + + return false; + } + +} diff --git a/src/test/java/world/bentobox/islandstats/WhiteBox.java b/src/test/java/world/bentobox/islandstats/WhiteBox.java new file mode 100644 index 0000000..1973e88 --- /dev/null +++ b/src/test/java/world/bentobox/islandstats/WhiteBox.java @@ -0,0 +1,26 @@ +package world.bentobox.islandstats; + +public class WhiteBox { + /** + * Sets the value of a private static field using Java Reflection. + * @param targetClass The class containing the static field. + * @param fieldName The name of the private static field. + * @param value The value to set the field to. + */ + public static void setInternalState(Class targetClass, String fieldName, Object value) { + try { + // 1. Get the Field object from the class + java.lang.reflect.Field field = targetClass.getDeclaredField(fieldName); + + // 2. Make the field accessible (required for private fields) + field.setAccessible(true); + + // 3. Set the new value. The first argument is 'null' for static fields. + field.set(null, value); + + } catch (NoSuchFieldException | IllegalAccessException e) { + // Wrap reflection exceptions in a runtime exception for clarity + throw new RuntimeException("Failed to set static field '" + fieldName + "' on class " + targetClass.getName(), e); + } + } +} diff --git a/src/test/java/world/bentobox/islandstats/commands/AdminIslandStatsCommandTest.java b/src/test/java/world/bentobox/islandstats/commands/AdminIslandStatsCommandTest.java new file mode 100644 index 0000000..39c77dd --- /dev/null +++ b/src/test/java/world/bentobox/islandstats/commands/AdminIslandStatsCommandTest.java @@ -0,0 +1,120 @@ +package world.bentobox.islandstats.commands; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.managers.CommandsManager; +import world.bentobox.bentobox.managers.PlayersManager; +import world.bentobox.islandstats.CommonTestSetup; +import world.bentobox.islandstats.IslandStats; +import world.bentobox.islandstats.data.IslandStatsManager; + +/** + * Tests the admin stats command. + */ +class AdminIslandStatsCommandTest extends CommonTestSetup { + + @Mock + private CompositeCommand parent; + @Mock + private User user; + @Mock + private IslandStats addon; + @Mock + private IslandStatsManager manager; + @Mock + private PlayersManager pm; + + private final UUID target = UUID.randomUUID(); + private AdminIslandStatsCommand command; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + CommandsManager cm = mock(CommandsManager.class); + when(plugin.getCommandsManager()).thenReturn(cm); + when(plugin.getPlayers()).thenReturn(pm); + when(addon.getPlugin()).thenReturn(plugin); + when(addon.getManager()).thenReturn(manager); + when(addon.getIslandName(any())).thenReturn("someone"); + when(parent.getSubCommandAliases()).thenReturn(new HashMap<>()); + when(parent.getWorld()).thenReturn(world); + when(parent.getPermissionPrefix()).thenReturn("bskyblock."); + when(user.getUniqueId()).thenReturn(uuid); + when(user.getTranslation(anyString())).thenAnswer(i -> i.getArgument(0, String.class)); + + when(pm.getUUID("someone")).thenReturn(target); + when(pm.getName(target)).thenReturn("someone"); + when(im.getIsland(world, target)).thenReturn(island); + command = new AdminIslandStatsCommand(addon, parent); + } + + @Test + void testSetup() { + assertEquals("bskyblock.admin.stats", command.getPermission()); + assertFalse(command.isOnlyPlayer()); + } + + @Test + void testBadArguments() { + assertFalse(command.canExecute(user, "stats", List.of())); + assertFalse(command.canExecute(user, "stats", List.of("someone", "wipe"))); + assertFalse(command.canExecute(user, "stats", List.of("someone", "reset", "now"))); + } + + @Test + void testUnknownPlayer() { + assertFalse(command.canExecute(user, "stats", List.of("nobody"))); + + verify(user).sendMessage("general.errors.unknown-player", "[name]", "nobody"); + } + + @Test + void testPlayerWithNoIsland() { + when(im.getIsland(world, target)).thenReturn(null); + + assertFalse(command.canExecute(user, "stats", List.of("someone"))); + + verify(user).sendMessage("general.errors.player-has-no-island"); + } + + @Test + void testConsoleGetsChat() { + when(user.isPlayer()).thenReturn(false); + when(manager.getSorted(any(), any())).thenReturn(List.of()); + + assertTrue(command.canExecute(user, "stats", List.of("someone"))); + assertTrue(command.execute(user, "stats", List.of("someone"))); + + verify(user).sendMessage("islandstats.chat.header", "[name]", "someone"); + } + + @Test + void testResetCanExecute() { + assertTrue(command.canExecute(user, "stats", List.of("someone", "RESET"))); + } + + @Test + void testTabComplete() { + assertEquals(Optional.of(List.of("reset")), command.tabComplete(user, "stats", List.of("someone", "r"))); + assertEquals(Optional.empty(), command.tabComplete(user, "stats", List.of("someone", "reset", ""))); + } +} diff --git a/src/test/java/world/bentobox/islandstats/commands/IslandStatsCommandTest.java b/src/test/java/world/bentobox/islandstats/commands/IslandStatsCommandTest.java new file mode 100644 index 0000000..6a9d44b --- /dev/null +++ b/src/test/java/world/bentobox/islandstats/commands/IslandStatsCommandTest.java @@ -0,0 +1,98 @@ +package world.bentobox.islandstats.commands; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +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.HashMap; +import java.util.List; + +import org.bukkit.World; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.managers.CommandsManager; +import world.bentobox.bentobox.util.Util; +import world.bentobox.islandstats.CommonTestSetup; +import world.bentobox.islandstats.IslandStats; + +/** + * Tests the player stats command. + */ +class IslandStatsCommandTest extends CommonTestSetup { + + @Mock + private CompositeCommand parent; + @Mock + private User user; + @Mock + private IslandStats addon; + + private IslandStatsCommand command; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + CommandsManager cm = mock(CommandsManager.class); + when(plugin.getCommandsManager()).thenReturn(cm); + when(addon.getPlugin()).thenReturn(plugin); + when(parent.getSubCommandAliases()).thenReturn(new HashMap<>()); + when(parent.getWorld()).thenReturn(world); + when(parent.getPermissionPrefix()).thenReturn("bskyblock."); + when(user.getUniqueId()).thenReturn(uuid); + when(user.getWorld()).thenReturn(world); + mockedUtil.when(() -> Util.sameWorld(world, world)).thenReturn(true); + command = new IslandStatsCommand(addon, parent); + } + + @Test + void testSetup() { + assertEquals("bskyblock.island.stats", command.getPermission()); + assertTrue(command.isOnlyPlayer()); + assertEquals("islandstats.commands.player.description", command.getDescription()); + } + + @Test + void testNoIsland() { + when(im.getIsland(world, user)).thenReturn(null); + + assertFalse(command.canExecute(user, "stats", List.of())); + + verify(user).sendMessage("general.errors.no-island"); + } + + @Test + void testWrongWorld() { + World other = mock(World.class); + when(user.getWorld()).thenReturn(other); + mockedUtil.when(() -> Util.sameWorld(world, other)).thenReturn(false); + + assertFalse(command.canExecute(user, "stats", List.of())); + + verify(user).sendMessage("general.errors.wrong-world"); + verify(im, never()).getIsland(any(World.class), any(User.class)); + } + + @Test + void testHasIsland() { + when(im.getIsland(world, user)).thenReturn(island); + + assertTrue(command.canExecute(user, "stats", List.of())); + } + + @Test + void testArgumentsShowHelp() { + assertFalse(command.canExecute(user, "stats", List.of("extra"))); + + verify(im, never()).getIsland(any(World.class), any(User.class)); + } +} diff --git a/src/test/java/world/bentobox/islandstats/data/IslandStatsDataJsonTest.java b/src/test/java/world/bentobox/islandstats/data/IslandStatsDataJsonTest.java new file mode 100644 index 0000000..6cd6503 --- /dev/null +++ b/src/test/java/world/bentobox/islandstats/data/IslandStatsDataJsonTest.java @@ -0,0 +1,32 @@ +package world.bentobox.islandstats.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +/** + * The database stores records as JSON with Gson, using only exposed fields. Check that counts + * survive the round trip as longs. + */ +class IslandStatsDataJsonTest { + + @Test + void testRoundTrip() { + Gson gson = new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create(); + IslandStatsData data = new IslandStatsData("island-1"); + data.add(IslandStat.KILL_ENTITY, "ZOMBIE", 5_000_000_000L); + data.add(IslandStat.ENTITY_DEATH, "COW", 2); + + IslandStatsData loaded = gson.fromJson(gson.toJson(data), IslandStatsData.class); + + assertEquals("island-1", loaded.getUniqueId()); + assertEquals(5_000_000_000L, loaded.get(IslandStat.KILL_ENTITY, "ZOMBIE")); + assertEquals(2, loaded.getTotal(IslandStat.ENTITY_DEATH)); + // Loaded maps must still accept new counts + loaded.add(IslandStat.ENTITY_DEATH, "COW", 1); + assertEquals(3, loaded.get(IslandStat.ENTITY_DEATH, "COW")); + } +} diff --git a/src/test/java/world/bentobox/islandstats/data/IslandStatsDataTest.java b/src/test/java/world/bentobox/islandstats/data/IslandStatsDataTest.java new file mode 100644 index 0000000..a5c4d1e --- /dev/null +++ b/src/test/java/world/bentobox/islandstats/data/IslandStatsDataTest.java @@ -0,0 +1,82 @@ +package world.bentobox.islandstats.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests the per-island stats record. + */ +class IslandStatsDataTest { + + private IslandStatsData data; + + @BeforeEach + void setUp() { + data = new IslandStatsData("island-1"); + } + + @Test + void testUniqueIdIsTheIslandId() { + assertEquals("island-1", data.getUniqueId()); + data.setUniqueId("island-2"); + assertEquals("island-2", data.getUniqueId()); + } + + @Test + void testAddAndGet() { + data.add(IslandStat.KILL_ENTITY, "ZOMBIE", 1); + data.add(IslandStat.KILL_ENTITY, "ZOMBIE", 2); + data.add(IslandStat.KILL_ENTITY, "SKELETON", 5); + + assertEquals(3, data.get(IslandStat.KILL_ENTITY, "ZOMBIE")); + assertEquals(5, data.get(IslandStat.KILL_ENTITY, "SKELETON")); + assertEquals(0, data.get(IslandStat.KILL_ENTITY, "CREEPER")); + // Stats are kept apart + assertEquals(0, data.get(IslandStat.ENTITY_DEATH, "ZOMBIE")); + } + + @Test + void testTotal() { + data.add(IslandStat.ENTITY_DEATH, "ZOMBIE", 10); + data.add(IslandStat.ENTITY_DEATH, "COW", 4); + + assertEquals(14, data.getTotal(IslandStat.ENTITY_DEATH)); + assertEquals(0, data.getTotal(IslandStat.KILL_ENTITY)); + } + + @Test + void testGetAllIsUnmodifiable() { + data.add(IslandStat.ENTITY_DEATH, "ZOMBIE", 1); + var all = data.getAll(IslandStat.ENTITY_DEATH); + assertThrows(UnsupportedOperationException.class, () -> all.put("COW", 1L)); + assertTrue(data.getAll(IslandStat.KILL_ENTITY).isEmpty()); + } + + @Test + void testClear() { + data.add(IslandStat.ENTITY_DEATH, "ZOMBIE", 1); + data.clear(); + assertEquals(0, data.getTotal(IslandStat.ENTITY_DEATH)); + } + + @Test + void testNullStatsMapFromDatabaseIsSafe() { + IslandStatsData loaded = new IslandStatsData(); + loaded.setStats(null); + assertEquals(0, loaded.get(IslandStat.KILL_ENTITY, "ZOMBIE")); + loaded.add(IslandStat.KILL_ENTITY, "ZOMBIE", 1); + assertEquals(1, loaded.getStats().get("KILL_ENTITY").get("ZOMBIE")); + } + + @Test + void testStatKeys() { + assertEquals("kill-entity", IslandStat.KILL_ENTITY.getKey()); + assertEquals("islandstats.stats.entity-death.name", IslandStat.ENTITY_DEATH.getNameReference()); + assertEquals("islandstats.stats.entity-death.description", + IslandStat.ENTITY_DEATH.getDescriptionReference()); + } +} diff --git a/src/test/java/world/bentobox/islandstats/data/IslandStatsManagerTest.java b/src/test/java/world/bentobox/islandstats/data/IslandStatsManagerTest.java new file mode 100644 index 0000000..106ae36 --- /dev/null +++ b/src/test/java/world/bentobox/islandstats/data/IslandStatsManagerTest.java @@ -0,0 +1,185 @@ +package world.bentobox.islandstats.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.bukkit.entity.EntityType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import world.bentobox.bentobox.database.AbstractDatabaseHandler; +import world.bentobox.bentobox.database.DatabaseSetup; +import world.bentobox.bentobox.database.DatabaseSetup.DatabaseType; +import world.bentobox.islandstats.CommonTestSetup; +import world.bentobox.islandstats.IslandStats; + +/** + * Tests the stats manager: lazy loading, counting, saving and deleting. + */ +class IslandStatsManagerTest extends CommonTestSetup { + + @Mock + private IslandStats addon; + @Mock + private world.bentobox.bentobox.Settings pluginSettings; + + private AbstractDatabaseHandler h; + private MockedStatic mockDb; + private IslandStatsManager manager; + + @SuppressWarnings("unchecked") + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + h = mock(AbstractDatabaseHandler.class); + mockDb = Mockito.mockStatic(DatabaseSetup.class); + DatabaseSetup dbSetup = mock(DatabaseSetup.class); + mockDb.when(DatabaseSetup::getDatabase).thenReturn(dbSetup); + when(dbSetup.getHandler(any())).thenReturn(h); + when(h.saveObject(any())).thenReturn(CompletableFuture.completedFuture(true)); + when(h.saveObjectNow(any())).thenReturn(CompletableFuture.completedFuture(true)); + when(plugin.getSettings()).thenReturn(pluginSettings); + when(pluginSettings.getDatabaseType()).thenReturn(DatabaseType.JSON); + when(addon.getPlugin()).thenReturn(plugin); + when(addon.getLogger()).thenReturn(java.util.logging.Logger.getLogger("IslandStatsManagerTest")); + + when(island.getUniqueId()).thenReturn("island-1"); + manager = new IslandStatsManager(addon); + } + + @Override + @AfterEach + public void tearDown() throws Exception { + mockDb.closeOnDemand(); + super.tearDown(); + } + + @Test + void testNewIslandStartsEmptyWithoutLoading() throws Exception { + when(h.objectExists("island-1")).thenReturn(false); + + IslandStatsData data = manager.getStats(island); + + assertEquals("island-1", data.getUniqueId()); + assertEquals(0, data.getTotal(IslandStat.ENTITY_DEATH)); + verify(h, never()).loadObject(anyString()); + } + + @Test + void testExistingStatsAreLoadedOnceAndCached() throws Exception { + IslandStatsData stored = new IslandStatsData("island-1"); + stored.add(IslandStat.KILL_ENTITY, "ZOMBIE", 7); + when(h.objectExists("island-1")).thenReturn(true); + when(h.loadObject("island-1")).thenReturn(stored); + + assertSame(stored, manager.getStats(island)); + assertSame(stored, manager.getStats(island)); + + verify(h, times(1)).loadObject("island-1"); + assertEquals(7, manager.getCount(island, IslandStat.KILL_ENTITY, EntityType.ZOMBIE)); + assertEquals(1, manager.getCacheSize()); + } + + @Test + void testIncrementCountsAndMarksDirty() { + assertFalse(manager.hasUnsavedChanges()); + + manager.increment(island, IslandStat.ENTITY_DEATH, EntityType.ZOMBIE); + manager.increment(island, IslandStat.ENTITY_DEATH, EntityType.ZOMBIE); + manager.increment(island, IslandStat.ENTITY_DEATH, EntityType.COW); + + assertEquals(2, manager.getCount(island, IslandStat.ENTITY_DEATH, EntityType.ZOMBIE)); + assertEquals(3, manager.getTotal(island, IslandStat.ENTITY_DEATH)); + assertTrue(manager.hasUnsavedChanges()); + } + + @Test + void testNullIslandCountsAreZero() { + assertEquals(0, manager.getCount(null, IslandStat.ENTITY_DEATH, EntityType.ZOMBIE)); + assertEquals(0, manager.getTotal(null, IslandStat.ENTITY_DEATH)); + } + + @Test + void testSortedHighestFirstThenByName() { + manager.increment(island, IslandStat.ENTITY_DEATH, EntityType.COW); + manager.increment(island, IslandStat.ENTITY_DEATH, EntityType.ZOMBIE); + manager.increment(island, IslandStat.ENTITY_DEATH, EntityType.ZOMBIE); + manager.increment(island, IslandStat.ENTITY_DEATH, EntityType.CHICKEN); + + List> sorted = manager.getSorted(island, IslandStat.ENTITY_DEATH); + + assertEquals(List.of("ZOMBIE", "CHICKEN", "COW"), sorted.stream().map(Map.Entry::getKey).toList()); + } + + @Test + void testSaveDirtyOnlySavesChangedRecords() throws Exception { + manager.increment(island, IslandStat.ENTITY_DEATH, EntityType.ZOMBIE); + + manager.saveDirty(); + // Nothing has changed since, so a second save writes nothing + manager.saveDirty(); + + verify(h, times(1)).saveObject(any()); + assertFalse(manager.hasUnsavedChanges()); + } + + @Test + void testCloseSavesNowAndEmptiesCache() throws Exception { + manager.increment(island, IslandStat.ENTITY_DEATH, EntityType.ZOMBIE); + + manager.close(); + + verify(h).saveObjectNow(any()); + assertEquals(0, manager.getCacheSize()); + } + + @Test + void testReset() throws Exception { + manager.increment(island, IslandStat.ENTITY_DEATH, EntityType.ZOMBIE); + + manager.reset(island); + + assertEquals(0, manager.getTotal(island, IslandStat.ENTITY_DEATH)); + assertFalse(manager.hasUnsavedChanges()); + verify(h).saveObject(any()); + } + + @Test + void testDelete() throws Exception { + manager.increment(island, IslandStat.ENTITY_DEATH, EntityType.ZOMBIE); + when(h.objectExists("island-1")).thenReturn(true); + + manager.delete("island-1"); + + assertEquals(0, manager.getCacheSize()); + assertFalse(manager.hasUnsavedChanges()); + verify(h).deleteID("island-1"); + } + + @Test + void testDeleteOfUnknownIslandDoesNotTouchDatabase() throws Exception { + when(h.objectExists("island-1")).thenReturn(false); + + manager.delete("island-1"); + + verify(h, never()).deleteID(anyString()); + } +} diff --git a/src/test/java/world/bentobox/islandstats/listeners/EntityDeathListenerTest.java b/src/test/java/world/bentobox/islandstats/listeners/EntityDeathListenerTest.java new file mode 100644 index 0000000..68c4692 --- /dev/null +++ b/src/test/java/world/bentobox/islandstats/listeners/EntityDeathListenerTest.java @@ -0,0 +1,162 @@ +package world.bentobox.islandstats.listeners; + +import static org.mockito.ArgumentMatchers.any; +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.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.damage.DamageSource; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.EntityDeathEvent; +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.islandstats.CommonTestSetup; +import world.bentobox.islandstats.IslandStats; +import world.bentobox.islandstats.Settings; +import world.bentobox.islandstats.data.IslandStat; +import world.bentobox.islandstats.data.IslandStatsManager; + +/** + * Tests which mob deaths are counted, and against which stat. + */ +class EntityDeathListenerTest extends CommonTestSetup { + + @Mock + private IslandStats addon; + @Mock + private IslandStatsManager manager; + @Mock + private LivingEntity zombie; + @Mock + private Player killer; + + private Settings settings; + private EntityDeathListener listener; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + when(addon.getManager()).thenReturn(manager); + when(addon.getIslands()).thenReturn(im); + when(addon.inGameWorld(world)).thenReturn(true); + when(im.getProtectedIslandAt(location)).thenReturn(Optional.of(island)); + + when(zombie.getType()).thenReturn(EntityType.ZOMBIE); + when(zombie.getWorld()).thenReturn(world); + when(zombie.getLocation()).thenReturn(location); + + when(killer.getUniqueId()).thenReturn(uuid); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(uuid)); + + listener = new EntityDeathListener(addon); + } + + private EntityDeathEvent deathOf(LivingEntity entity) { + return new EntityDeathEvent(entity, mock(DamageSource.class), new ArrayList<>()); + } + + @Test + void testFarmDeathCountsOnlyAsDeath() { + listener.onEntityDeath(deathOf(zombie)); + + verify(manager).increment(island, IslandStat.ENTITY_DEATH, EntityType.ZOMBIE); + verify(manager, never()).increment(island, IslandStat.KILL_ENTITY, EntityType.ZOMBIE); + } + + @Test + void testPlayerKillCountsAsBoth() { + when(zombie.getKiller()).thenReturn(killer); + + listener.onEntityDeath(deathOf(zombie)); + + verify(manager).increment(island, IslandStat.ENTITY_DEATH, EntityType.ZOMBIE); + verify(manager).increment(island, IslandStat.KILL_ENTITY, EntityType.ZOMBIE); + } + + @Test + void testVisitorKillCountsByDefault() { + when(killer.getUniqueId()).thenReturn(UUID.randomUUID()); + when(zombie.getKiller()).thenReturn(killer); + + listener.onEntityDeath(deathOf(zombie)); + + verify(manager).increment(island, IslandStat.KILL_ENTITY, EntityType.ZOMBIE); + } + + @Test + void testVisitorKillNotCountedWhenTurnedOff() { + settings.setCountVisitorKills(false); + when(killer.getUniqueId()).thenReturn(UUID.randomUUID()); + when(zombie.getKiller()).thenReturn(killer); + + listener.onEntityDeath(deathOf(zombie)); + + verify(manager).increment(island, IslandStat.ENTITY_DEATH, EntityType.ZOMBIE); + verify(manager, never()).increment(island, IslandStat.KILL_ENTITY, EntityType.ZOMBIE); + } + + @Test + void testPlayerDeathIgnored() { + when(mockPlayer.getLocation()).thenReturn(location); + + listener.onEntityDeath(deathOf(mockPlayer)); + + verify(manager, never()).increment(any(), any(), any()); + } + + @Test + void testIgnoredEntityType() { + LivingEntity stand = mock(LivingEntity.class); + when(stand.getType()).thenReturn(EntityType.ARMOR_STAND); + when(stand.getWorld()).thenReturn(world); + when(stand.getLocation()).thenReturn(location); + + listener.onEntityDeath(deathOf(stand)); + + verify(manager, never()).increment(any(), any(), any()); + } + + @Test + void testIgnoredListReloads() { + settings.setIgnoredEntities(List.of("zombie", "NOT_A_MOB")); + listener.loadIgnored(); + + listener.onEntityDeath(deathOf(zombie)); + + verify(manager, never()).increment(any(), any(), any()); + verify(addon).logWarning("Unknown entity type in ignored-entities: NOT_A_MOB"); + } + + @Test + void testNotInGameWorld() { + when(addon.inGameWorld(world)).thenReturn(false); + + listener.onEntityDeath(deathOf(zombie)); + + verify(manager, never()).increment(any(), any(), any()); + } + + @Test + void testNotOnAnIsland() { + when(im.getProtectedIslandAt(location)).thenReturn(Optional.empty()); + + listener.onEntityDeath(deathOf(zombie)); + + verify(manager, never()).increment(any(), any(), any()); + } +} diff --git a/src/test/java/world/bentobox/islandstats/listeners/IslandListenerTest.java b/src/test/java/world/bentobox/islandstats/listeners/IslandListenerTest.java new file mode 100644 index 0000000..e5b1514 --- /dev/null +++ b/src/test/java/world/bentobox/islandstats/listeners/IslandListenerTest.java @@ -0,0 +1,56 @@ +package world.bentobox.islandstats.listeners; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import world.bentobox.bentobox.api.events.island.IslandDeleteEvent; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.islandstats.CommonTestSetup; +import world.bentobox.islandstats.IslandStats; +import world.bentobox.islandstats.data.IslandStatsManager; + +/** + * Island stats must not outlive their island. + */ +class IslandListenerTest extends CommonTestSetup { + + @Mock + private IslandStats addon; + @Mock + private IslandStatsManager manager; + + private IslandListener listener; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + when(addon.getManager()).thenReturn(manager); + listener = new IslandListener(addon); + } + + @Test + void testIslandDeleteRemovesItsStats() { + // The event copies the island it is given, so use a real one with a known id + Island deleted = new Island(location, UUID.randomUUID(), 100); + + listener.onIslandDelete(new IslandDeleteEvent(deleted, UUID.randomUUID(), false, location)); + + verify(manager).delete(deleted.getUniqueId()); + } + + @Test + void testIslandDeleteWithNoIslandIsSafe() { + listener.onIslandDelete(new IslandDeleteEvent(null, UUID.randomUUID(), false, location)); + + verify(manager, never()).delete(any()); + } +} diff --git a/src/test/java/world/bentobox/islandstats/panels/StatsDialogTest.java b/src/test/java/world/bentobox/islandstats/panels/StatsDialogTest.java new file mode 100644 index 0000000..98e323f --- /dev/null +++ b/src/test/java/world/bentobox/islandstats/panels/StatsDialogTest.java @@ -0,0 +1,139 @@ +package world.bentobox.islandstats.panels; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; + +import org.bukkit.command.CommandSender; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.TranslatableComponent; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import world.bentobox.bentobox.api.dialogs.DialogBuilder; +import world.bentobox.bentobox.api.dialogs.DialogButton; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.islandstats.CommonTestSetup; +import world.bentobox.islandstats.IslandStats; +import world.bentobox.islandstats.Settings; +import world.bentobox.islandstats.data.IslandStat; +import world.bentobox.islandstats.data.IslandStatsManager; + +/** + * Tests the stats dialog pages and the chat version used by the console. + */ +class StatsDialogTest extends CommonTestSetup { + + @Mock + private IslandStats addon; + @Mock + private IslandStatsManager manager; + + private StatsDialog dialog; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + when(addon.getManager()).thenReturn(manager); + when(addon.getSettings()).thenReturn(new Settings()); + when(manager.getTotal(any(), any())).thenReturn(3L); + when(manager.getSorted(any(), any())) + .thenReturn(List.of(Map.entry("ZOMBIE", 2L), Map.entry("GONE_IN_A_LATER_VERSION", 1L))); + dialog = new StatsDialog(addon, island, "tastybento"); + } + + @Test + void testMobNameIsTranslatable() { + TranslatableComponent name = assertInstanceOf(TranslatableComponent.class, StatsDialog.mobName("ZOMBIE")); + assertEquals("entity.minecraft.zombie", name.key()); + } + + @Test + void testUnknownMobNameFallsBackToText() { + TextComponent name = assertInstanceOf(TextComponent.class, StatsDialog.mobName("GONE_IN_A_LATER_VERSION")); + assertEquals("Gone In A Later Version", name.content()); + } + + @SuppressWarnings("unchecked") + private static List field(DialogBuilder builder, String name) throws ReflectiveOperationException { + Field f = DialogBuilder.class.getDeclaredField(name); + f.setAccessible(true); + return (List) f.get(builder); + } + + private static List buttonLabels(DialogBuilder builder) throws ReflectiveOperationException { + return StatsDialogTest.field(builder, "buttons").stream() + .map(b -> PlainTextComponentSerializer.plainText().serialize(b.label())).toList(); + } + + @Test + void testSummaryHasALineAndButtonPerStatPlusClose() throws ReflectiveOperationException { + DialogBuilder builder = dialog.summary(User.getInstance(mockPlayer)); + + assertEquals(IslandStat.values().length, field(builder, "body").size()); + assertEquals(List.of(IslandStat.KILL_ENTITY.getNameReference(), IslandStat.ENTITY_DEATH.getNameReference(), + "islandstats.dialog.buttons.close"), buttonLabels(builder)); + } + + @Test + void testSinglePageHasNoPaging() throws ReflectiveOperationException { + DialogBuilder builder = dialog.statPage(User.getInstance(mockPlayer), IslandStat.ENTITY_DEATH, 0); + + // Just the mob list, no page line + assertEquals(1, field(builder, "body").size()); + assertEquals(List.of("islandstats.dialog.buttons.back", "islandstats.dialog.buttons.close"), + buttonLabels(builder)); + } + + @Test + void testPaging() throws ReflectiveOperationException { + Settings settings = new Settings(); + settings.setPageSize(1); + when(addon.getSettings()).thenReturn(settings); + User user = User.getInstance(mockPlayer); + + DialogBuilder first = dialog.statPage(user, IslandStat.ENTITY_DEATH, 0); + // Page line and the mob list + assertEquals(2, field(first, "body").size()); + assertEquals(List.of("islandstats.dialog.buttons.next", "islandstats.dialog.buttons.back", + "islandstats.dialog.buttons.close"), buttonLabels(first)); + + // Past the end is clamped to the last page + DialogBuilder last = dialog.statPage(user, IslandStat.ENTITY_DEATH, 99); + assertEquals(List.of("islandstats.dialog.buttons.previous", "islandstats.dialog.buttons.back", + "islandstats.dialog.buttons.close"), buttonLabels(last)); + } + + @Test + void testEmptyStat() throws ReflectiveOperationException { + when(manager.getSorted(any(), any())).thenReturn(List.of()); + + DialogBuilder builder = dialog.statPage(User.getInstance(mockPlayer), IslandStat.KILL_ENTITY, 0); + + assertEquals(1, field(builder, "body").size()); + verify(lm).get(any(), org.mockito.ArgumentMatchers.eq("islandstats.dialog.empty")); + } + + @Test + void testSendChat() { + CommandSender console = mock(CommandSender.class); + User user = User.getInstance(console); + + dialog.sendChat(user); + + verify(lm).get(any(), org.mockito.ArgumentMatchers.eq("islandstats.chat.header")); + // Two mob lines for each stat + verify(lm, times(4)).get(any(), org.mockito.ArgumentMatchers.eq("islandstats.chat.entry")); + } +} From af73640b77e5c34fd0001f70b09548acdce07183 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 4 Oct 2026 08:16:01 -0700 Subject: [PATCH 2/2] Clear the SonarCloud issues on the release PR - getIslandName: read the island name once, so a null is never returned where @NonNull is promised (reliability), and drop a null check on PlayersManager.getName, which never returns null - Drop always-false user null checks in the placeholders - Admin execute returns false if called without a successful canExecute - Unnamed catch variables, a stream for the dialog lines, and test tidy-ups Co-Authored-By: Claude Opus 5.5 --- .../world/bentobox/islandstats/IslandStats.java | 14 +++++--------- .../bentobox/islandstats/StatsPlaceholders.java | 4 ++-- .../commands/AdminIslandStatsCommand.java | 5 +++++ .../islandstats/listeners/EntityDeathListener.java | 2 +- .../bentobox/islandstats/panels/StatsDialog.java | 9 +++------ .../bentobox/islandstats/CommonTestSetup.java | 3 ++- .../commands/AdminIslandStatsCommandTest.java | 5 +++++ .../islandstats/data/IslandStatsManagerTest.java | 4 ++-- 8 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/main/java/world/bentobox/islandstats/IslandStats.java b/src/main/java/world/bentobox/islandstats/IslandStats.java index c459167..d028a75 100644 --- a/src/main/java/world/bentobox/islandstats/IslandStats.java +++ b/src/main/java/world/bentobox/islandstats/IslandStats.java @@ -165,15 +165,11 @@ public boolean inGameWorld(World world) { */ @NonNull public String getIslandName(@NonNull Island island) { - if (island.getName() != null && !island.getName().isBlank()) { - return island.getName(); + String name = island.getName(); + if (name != null && !name.isBlank()) { + return name; } - if (island.getOwner() != null) { - String name = getPlayers().getName(island.getOwner()); - if (name != null) { - return name; - } - } - return ""; + // Empty if the island has no owner + return getPlayers().getName(island.getOwner()); } } diff --git a/src/main/java/world/bentobox/islandstats/StatsPlaceholders.java b/src/main/java/world/bentobox/islandstats/StatsPlaceholders.java index 8927807..856f2cd 100644 --- a/src/main/java/world/bentobox/islandstats/StatsPlaceholders.java +++ b/src/main/java/world/bentobox/islandstats/StatsPlaceholders.java @@ -62,14 +62,14 @@ public void register(GameModeAddon gm) { } private Island getIsland(GameModeAddon gm, User user) { - if (user == null || user.getUniqueId() == null) { + if (user.getUniqueId() == null) { return null; } return addon.getIslands().getIsland(gm.getOverWorld(), user); } private Island getVisitedIsland(GameModeAddon gm, User user) { - if (user == null || !user.isPlayer() || user.getLocation() == null || !gm.inWorld(user.getWorld())) { + if (!user.isPlayer() || user.getLocation() == null || !gm.inWorld(user.getWorld())) { return null; } return addon.getIslands().getProtectedIslandAt(user.getLocation()).orElse(null); diff --git a/src/main/java/world/bentobox/islandstats/commands/AdminIslandStatsCommand.java b/src/main/java/world/bentobox/islandstats/commands/AdminIslandStatsCommand.java index d89a719..d80f91b 100644 --- a/src/main/java/world/bentobox/islandstats/commands/AdminIslandStatsCommand.java +++ b/src/main/java/world/bentobox/islandstats/commands/AdminIslandStatsCommand.java @@ -63,6 +63,11 @@ public boolean canExecute(User user, String label, List args) { @Override public boolean execute(User user, String label, List args) { + if (island == null) { + // execute was called without a successful canExecute + showHelp(this, user); + return false; + } if (args.size() == 2) { Island toReset = island; String name = targetName; diff --git a/src/main/java/world/bentobox/islandstats/listeners/EntityDeathListener.java b/src/main/java/world/bentobox/islandstats/listeners/EntityDeathListener.java index ba7c2b6..c45abee 100644 --- a/src/main/java/world/bentobox/islandstats/listeners/EntityDeathListener.java +++ b/src/main/java/world/bentobox/islandstats/listeners/EntityDeathListener.java @@ -48,7 +48,7 @@ public void loadIgnored() { for (String name : addon.getSettings().getIgnoredEntities()) { try { ignored.add(EntityType.valueOf(name.toUpperCase(Locale.ENGLISH))); - } catch (IllegalArgumentException e) { + } catch (IllegalArgumentException _) { addon.logWarning("Unknown entity type in ignored-entities: " + name); } } diff --git a/src/main/java/world/bentobox/islandstats/panels/StatsDialog.java b/src/main/java/world/bentobox/islandstats/panels/StatsDialog.java index 606cffc..a89e84b 100644 --- a/src/main/java/world/bentobox/islandstats/panels/StatsDialog.java +++ b/src/main/java/world/bentobox/islandstats/panels/StatsDialog.java @@ -1,6 +1,5 @@ package world.bentobox.islandstats.panels; -import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -103,10 +102,8 @@ DialogBuilder statPage(User user, IslandStat stat, int page) { "[total]", String.valueOf(pages)); } int from = current * pageSize; - List lines = new ArrayList<>(); - for (Map.Entry entry : entries.subList(from, Math.min(entries.size(), from + pageSize))) { - lines.add(entryLine(user, entry.getKey(), entry.getValue())); - } + List lines = entries.subList(from, Math.min(entries.size(), from + pageSize)).stream() + .map(entry -> entryLine(user, entry.getKey(), entry.getValue())).toList(); // One body block keeps the list tight rather than spacing each line out builder.body(Component.join(JoinConfiguration.newlines(), lines)); } @@ -155,7 +152,7 @@ private Component entryLine(User user, String key, long count) { static Component mobName(String key) { try { return Component.translatable(EntityType.valueOf(key).translationKey()); - } catch (IllegalArgumentException e) { + } catch (IllegalArgumentException _) { return Component.text(Util.prettifyText(key)); } } diff --git a/src/test/java/world/bentobox/islandstats/CommonTestSetup.java b/src/test/java/world/bentobox/islandstats/CommonTestSetup.java index f8fe227..6a07ba0 100644 --- a/src/test/java/world/bentobox/islandstats/CommonTestSetup.java +++ b/src/test/java/world/bentobox/islandstats/CommonTestSetup.java @@ -225,7 +225,8 @@ public void setUp() throws Exception { //Util mockedUtil = Mockito.mockStatic(Util.class, Mockito.CALLS_REAL_METHODS); - mockedUtil.when(() -> Util.getWorld(any())).thenReturn(mock(World.class)); + World utilWorld = mock(World.class); + mockedUtil.when(() -> Util.getWorld(any())).thenReturn(utilWorld); Util.setPlugin(plugin); // Util diff --git a/src/test/java/world/bentobox/islandstats/commands/AdminIslandStatsCommandTest.java b/src/test/java/world/bentobox/islandstats/commands/AdminIslandStatsCommandTest.java index 39c77dd..ebb71d2 100644 --- a/src/test/java/world/bentobox/islandstats/commands/AdminIslandStatsCommandTest.java +++ b/src/test/java/world/bentobox/islandstats/commands/AdminIslandStatsCommandTest.java @@ -107,6 +107,11 @@ void testConsoleGetsChat() { verify(user).sendMessage("islandstats.chat.header", "[name]", "someone"); } + @Test + void testExecuteWithoutCanExecute() { + assertFalse(command.execute(user, "stats", List.of("someone"))); + } + @Test void testResetCanExecute() { assertTrue(command.canExecute(user, "stats", List.of("someone", "RESET"))); diff --git a/src/test/java/world/bentobox/islandstats/data/IslandStatsManagerTest.java b/src/test/java/world/bentobox/islandstats/data/IslandStatsManagerTest.java index 106ae36..4e541b1 100644 --- a/src/test/java/world/bentobox/islandstats/data/IslandStatsManagerTest.java +++ b/src/test/java/world/bentobox/islandstats/data/IslandStatsManagerTest.java @@ -163,7 +163,7 @@ void testReset() throws Exception { } @Test - void testDelete() throws Exception { + void testDelete() { manager.increment(island, IslandStat.ENTITY_DEATH, EntityType.ZOMBIE); when(h.objectExists("island-1")).thenReturn(true); @@ -175,7 +175,7 @@ void testDelete() throws Exception { } @Test - void testDeleteOfUnknownIslandDoesNotTouchDatabase() throws Exception { + void testDeleteOfUnknownIslandDoesNotTouchDatabase() { when(h.objectExists("island-1")).thenReturn(false); manager.delete("island-1");