diff --git a/CLAUDE.md b/CLAUDE.md index b9a34d7..d95bb3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project -TopBlock is a BentoBox addon that produces a Top Ten ranking for the AOneBlock game mode based on how many magic blocks each island has mined. It is **not** standalone — it depends on the BentoBox plugin and the AOneBlock addon being present at runtime, and refuses to enable otherwise. +TopBlock is a BentoBox addon that produces a Top Ten ranking for the AOneBlock and ChunkBlock game modes based on how many magic blocks each island has mined. It is **not** standalone — it depends on the BentoBox plugin plus at least one of the AOneBlock or ChunkBlock addons being present at runtime, and refuses to enable otherwise. Each hooked game mode gets its own independent top ten, command, and placeholders. ## Build & Test -Maven project, Java 21, Paper 1.21.11 API, BentoBox 3.14.0, AOneBlock 1.18.0. +Maven project, Java 21, Paper 1.21.11 API, BentoBox 3.14.0, AOneBlock 1.18.0, ChunkBlock 1.0.1 (both game modes `provided`; Level is a `test`-only dependency because mocking ChunkBlock requires its hard dependency on the classpath). - Build (default goal is `clean package`): `mvn package` — produces a shaded jar in `target/` named `TopBlock-.jar`. The shade plugin bundles only `lv.id.bonne:panelutils`; everything else is `provided`. - Run tests: `mvn test` @@ -16,7 +16,7 @@ Maven project, Java 21, Paper 1.21.11 API, BentoBox 3.14.0, AOneBlock 1.18.0. - Run a single test method: `mvn test -Dtest=TopBlockManagerTest#testFormatLevelShorthandKilo` - The Surefire config sets a long list of `--add-opens` JVM flags — required for Mockito + MockBukkit reflection on Java 21; do not remove them when tweaking the build. -Version handling is driven by Maven properties: `build.version` is the human version (currently 1.1.0), `revision` resolves to `${build.version}-SNAPSHOT` locally and to `${build.version}` under the `master` profile (activated by `GIT_BRANCH=origin/master` on Jenkins). `build.number` is `-LOCAL` locally, `-b` on CI, empty on master. Don't hand-edit `` — bump `build.version`. +Version handling is driven by Maven properties: `build.version` is the human version (currently 2.0.0), `revision` resolves to `${build.version}-SNAPSHOT` locally and to `${build.version}` under the `master` profile (activated by `GIT_BRANCH=origin/master` on Jenkins). `build.number` is `-LOCAL` locally, `-b` on CI, empty on master. Don't hand-edit `` — bump `build.version`. ## Runtime entry points (Pladdon pattern) @@ -25,21 +25,30 @@ There are **two** main classes and the distinction matters: - `TopBlockPladdon` (referenced by `plugin.yml`) is the Bukkit-facing `Pladdon`. Spigot loads this; its only job is `getAddon() → new TopBlock()`. - `TopBlock` (referenced by `addon.yml`) is the BentoBox `Addon`. All real lifecycle (`onLoad`, `onEnable`, `onDisable`) lives here. -`onEnable` looks up the AOneBlock addon via `getPlugin().getAddonsManager().getAddonByName("aoneblock")`; if missing or not a `GameModeAddon`, the addon disables itself. The `/ topblock` command is registered against AOneBlock's player command, not as a top-level command. +`onEnable` looks up each supported game mode via `getPlugin().getAddonsManager().getAddonByName(...)` (`"aoneblock"`, `"chunkblock"` — lookup is case-insensitive); for each one present, enabled, and a `GameModeAddon`, it registers a `topblock` subcommand on that game mode's player command and adds a hook (see below). If no game mode hooks, the addon disables itself. `addon.yml` declares `softdepend: AOneBlock, ChunkBlock` (soft, because either alone is enough). + +## Game mode hooks + +AOneBlock and ChunkBlock have twin APIs (`getBlockListener().getAllIslands()`, `getOneBlockManager().getBlockProbs()`, `OneBlockIslands` data objects) but in unrelated packages, so `world.bentobox.topblock.hooks` abstracts them: + +- `TopBlockHook` — interface: `getGameMode()`, `getAllIslandData()` (returns neutral `IslandBlockData` records), `getPhaseCount(blockNumber)`. +- `AOneBlockHook` / `ChunkBlockHook` — the **only** classes allowed to import their game mode's packages. Class loading is lazy, so a missing game mode is never class-loaded as long as its hook is only instantiated after the presence check in `onEnable`. Keep it that way: never import `world.bentobox.aoneblock.*` or `world.bentobox.chunkblock.*` anywhere else in main code. + +`TopBlock.getHooks()` lists active hooks; `TopBlock.getHook(World)` resolves the hook owning a world via `GameModeAddon.inWorld`. ## Data flow `TopBlockManager` is a `Listener` that reacts to `BentoBoxReadyEvent` (handler is `public void onBentoBoxReady` — Bukkit silently skips private @EventHandler methods, which is what broke the addon historically) to start a repeating Bukkit task. The task period is `settings.getRefreshTime() * 20L * 60` ticks (minutes → ticks). Each tick of the task: -1. Calls `AOneBlock.getBlockListener().getAllIslands()` — this reads every island, so the refresh interval is intentionally coarse (default 5 min, min 1 min). -2. Builds a fresh `List` (record of island + blockNumber + lifetime + phaseName) — sorted at read time via `Comparator` on `lifetime` then `blockNumber`. -3. Updates `PlaceholderManager`'s cached snapshot. +1. Calls `refreshAll()` — for every hook, reads every island of that game mode via `hook.getAllIslandData()`, so the refresh interval is intentionally coarse (default 5 min, min 1 min). +2. Builds a fresh `List` (record of island + blockNumber + lifetime + phaseName) per hook, kept in a `Map>` — sorted at read time via `Comparator` on `lifetime` then `blockNumber`. +3. Updates `PlaceholderManager`'s cached per-hook snapshots. -Placeholders are registered once via a `runTaskLater` 10-tick delay after the first ready event (so PAPI / BentoBox's `PlaceholdersManager` is up). Names follow `island__top_<1..10>` and are scoped to the AOneBlock `GameModeAddon`. The `TopBlock.TEN` constant is the source of truth for the list size. +Placeholders are registered once per hook via a `runTaskLater` 10-tick delay after the first ready event (so PAPI / BentoBox's `PlaceholdersManager` is up). Names follow `island__top_<1..10>` and are scoped to each hook's `GameModeAddon`, so the PAPI prefix keeps game modes apart (`%aoneblock_...%` vs `%chunkblock_...%`). The `TopBlock.TEN` constant is the source of truth for the list size. ## Panel -`TopLevelPanel` uses BentoBox's `TemplatedPanelBuilder`. The template file is shipped in `src/main/resources/panels/top_panel.yml` and copied to the data folder on load via `saveResource("panels/top_panel.yml", false)` — players' edits to the on-disk file persist across restarts. Localization keys live under `topblock.gui.buttons.island.*` in `src/main/resources/locales/en-US.yml`. The icon material can be overridden per-player via the `topblock.icon.` permission. +`TopLevelPanel` uses BentoBox's `TemplatedPanelBuilder`. The template file is shipped in `src/main/resources/panels/top_panel.yml` and copied to the data folder on load via `saveResource("panels/top_panel.yml", false)` — players' edits to the on-disk file persist across restarts. The panel shows the top ten of whichever game mode owns the command's world (`getTopTen(world, TEN)`). Localization keys live under `topblock.gui.buttons.island.*` in `src/main/resources/locales/en-US.yml`. The icon material can be overridden per-player via the `topblock.icon.` permission. The panel has no click actions (TopBlock doesn't bundle Warp/Visit hooks like Level does). The YAML still declares `warp`/`visit` actions with tooltips, but no click handler is registered — clicking does nothing. @@ -57,4 +66,6 @@ JUnit 5 + Mockito + MockBukkit. Test classes extend `CommonTestSetup` which: `TestWorldSettings` returns `"TopBlock"` for friendly name and `"topblock."` for permission prefix. The addon test (`TopBlockTest`) builds an in-memory `addon.jar` containing `config.yml` + `panels/top_panel.yml` because `Addon.saveResource` reads from a real JarFile. +Manager and placeholder tests mock `TopBlockHook` directly (no game mode classes needed); `AOneBlockHookTest` / `ChunkBlockHookTest` cover the real hook mapping. A freshly constructed `Addon` starts in `State.DISABLED` and only AddonsManager sets ENABLED — so enable-path tests call `setState(State.LOADED)` before `onEnable()` and treat DISABLED afterwards as "the addon disabled itself"; don't assert ENABLED after `onEnable()`. + JaCoCo excludes `**/*Names*` to avoid synthetic-field issues on JavaBeans — keep that exclusion if adding similar classes. diff --git a/README.md b/README.md index 55c5d44..2dd399d 100644 --- a/README.md +++ b/README.md @@ -3,18 +3,20 @@ ## About -TopBlock is a [BentoBox](https://github.com/BentoBoxWorld/BentoBox) addon that produces a Top Ten ranking for the [AOneBlock](https://github.com/BentoBoxWorld/AOneBlock) game mode based on how many magic blocks each island has mined. +TopBlock is a [BentoBox](https://github.com/BentoBoxWorld/BentoBox) addon that produces a Top Ten ranking for the [AOneBlock](https://github.com/BentoBoxWorld/AOneBlock) and [ChunkBlock](https://github.com/BentoBoxWorld/ChunkBlock) game modes based on how many magic blocks each island has mined. Either game mode (or both) can be installed — each gets its own independent top ten, command, and placeholders. ## Requirements - Paper 1.21.x (Spigot is no longer supported) - Java 21 - BentoBox 3.14.0 or later -- AOneBlock 1.18.0 or later +- At least one of: + - AOneBlock 1.18.0 or later + - ChunkBlock 1.0.1 or later ## How to use -1. Drop the TopBlock jar into your server's `plugins/BentoBox/addons/` folder. AOneBlock must already be installed there too. +1. Drop the TopBlock jar into your server's `plugins/BentoBox/addons/` folder. AOneBlock and/or ChunkBlock must already be installed there too. 2. Restart the server. TopBlock will create `addons/TopBlock/config.yml` and `addons/TopBlock/panels/top_panel.yml`. 3. Edit `config.yml` if you want to tune anything (see below) and restart the server again to apply. @@ -31,9 +33,11 @@ The panel layout lives in `addons/TopBlock/panels/top_panel.yml`. Edits are pres ## Commands -`/ob topblock` (alias: `/oneblock topblock`) — opens the Top Ten panel. +`/ob topblock` (alias: `/oneblock topblock`) — opens the AOneBlock Top Ten panel. -To get into the top ten, a player just needs to mine at least one magic block on their AOneBlock island. The list refreshes every `refresh-time` minutes; a player who just started mining may need to wait that long before appearing. +If ChunkBlock is installed, its player command gets a `topblock` subcommand too, which opens ChunkBlock's own Top Ten panel. + +To get into the top ten, a player just needs to mine at least one magic block on their island. Each game mode keeps a separate ranking. The lists refresh every `refresh-time` minutes; a player who just started mining may need to wait that long before appearing. ## Permissions @@ -47,11 +51,17 @@ permissions: 'aoneblock.intopten': description: Player's island will be listed in the top ten. Remove from admins or testers to hide them. default: true + 'chunkblock.island.topblock': + description: Player can use the TopBlock command + default: true + 'chunkblock.intopten': + description: Player's island will be listed in the top ten. Remove from admins or testers to hide them. + default: true ``` -If an island owner is **online** and lacks `aoneblock.intopten`, their island is excluded from the top ten panel and from placeholders. **Offline** owners are always included — to hide an admin or tester, remove the perm from the player who can actually log in. Removing the perm from an entire group (e.g. ops) excludes everyone in that group while online. +If an island owner is **online** and lacks `.intopten`, their island is excluded from that game mode's top ten panel and placeholders. **Offline** owners are always included — to hide an admin or tester, remove the perm from the player who can actually log in. Removing the perm from an entire group (e.g. ops) excludes everyone in that group while online. -The icon shown for each rank can be overridden per player by granting `aoneblock.topblock.icon.` (for example `aoneblock.topblock.icon.diamond_block`). Without an override, the rank icon is the player's head. +The icon shown for each rank can be overridden per player by granting `.topblock.icon.` (for example `aoneblock.topblock.icon.diamond_block`). Without an override, the rank icon is the player's head. ## Placeholders @@ -64,6 +74,8 @@ The icon shown for each rank can be overridden per player by granting `aoneblock %aoneblock_island_lifetime_top_RANK% - Lifetime count of magic blocks mined ``` +If ChunkBlock is installed, the same placeholders exist with the `chunkblock` prefix (e.g. `%chunkblock_island_count_top_1%`) and report ChunkBlock's own ranking. + `RANK` is `1` to `10`. If fewer than `RANK` islands qualify for the top ten, the placeholder returns an empty string. ## Building from source diff --git a/pom.xml b/pom.xml index 7c8478c..9702487 100644 --- a/pom.xml +++ b/pom.xml @@ -60,6 +60,10 @@ 3.14.0-SNAPSHOT 1.18.0 + + 1.0.1 + + 2.6.2 1.1.0 @@ -165,6 +169,18 @@ ${aoneblock.version} provided + + world.bentobox + chunkblock + ${chunkblock.version} + provided + + + world.bentobox + level + ${level.version} + test + lv.id.bonne panelutils diff --git a/src/main/java/world/bentobox/topblock/PlaceholderManager.java b/src/main/java/world/bentobox/topblock/PlaceholderManager.java index ca82ca7..e26bb8a 100644 --- a/src/main/java/world/bentobox/topblock/PlaceholderManager.java +++ b/src/main/java/world/bentobox/topblock/PlaceholderManager.java @@ -1,6 +1,7 @@ package world.bentobox.topblock; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -12,9 +13,13 @@ import world.bentobox.bentobox.managers.PlaceholdersManager; import world.bentobox.bentobox.managers.RanksManager; import world.bentobox.topblock.TopBlockManager.TopTenData; +import world.bentobox.topblock.hooks.TopBlockHook; /** - * Handles TopBlock placeholders + * Handles TopBlock placeholders. Placeholders are registered once per hooked + * game mode, scoped to that game mode's addon, so each game mode gets its own + * independent set, e.g. {@code %aoneblock_island_count_top_1%} and + * {@code %chunkblock_island_count_top_1%}. * @author tastybento * */ @@ -22,8 +27,8 @@ public class PlaceholderManager { private final TopBlock addon; private final BentoBox plugin; - private GameModeAddon gm; - private List rList; + // Cached top ten snapshot per hooked game mode + private final Map> topTens = new HashMap<>(); public PlaceholderManager(TopBlock addon) { this.addon = addon; @@ -31,64 +36,64 @@ public PlaceholderManager(TopBlock addon) { } - protected void registerPlaceholders(GameModeAddon gm) { + protected void registerPlaceholders(TopBlockHook hook) { if (plugin.getPlaceholdersManager() == null) return; - this.gm = gm; - updateTopTen(); - registerPlaceHolders(); + topTens.put(hook, addon.getManager().getTopTen(hook, TopBlock.TEN)); + // Register Top Ten Placeholders + for (int i = 1; i <= TopBlock.TEN; i++) { + registerPH(hook, i); + } } /** - * Update the top ten + * Update the cached top ten for every registered game mode */ public void updateTopTen() { - rList = addon.getManager().getTopTen(TopBlock.TEN); - } - - - private void registerPlaceHolders() { - // Register Top Ten Placeholders - for (int i = 1; i <= TopBlock.TEN; i++) { - registerPH(gm, i); - } - + topTens.replaceAll((hook, list) -> addon.getManager().getTopTen(hook, TopBlock.TEN)); } - private void registerPH(GameModeAddon gm, int r) { + private void registerPH(TopBlockHook hook, int r) { PlaceholdersManager bpm = plugin.getPlaceholdersManager(); + GameModeAddon gm = hook.getGameMode(); // Name of island owner - bpm.registerPlaceholder(gm, "island_player_name_top_" + r, u -> getPlayerName(r)); + bpm.registerPlaceholder(gm, "island_player_name_top_" + r, u -> getPlayerName(hook, r)); // Name of island team members - bpm.registerPlaceholder(gm, "island_member_names_top_" + r, u -> getMemberNames(r)); + bpm.registerPlaceholder(gm, "island_member_names_top_" + r, u -> getMemberNames(hook, r)); // Name of the phase they have reached - bpm.registerPlaceholder(gm, "island_phase_name_top_" + r, u -> getPhaseName(r)); + bpm.registerPlaceholder(gm, "island_phase_name_top_" + r, u -> getPhaseName(hook, r)); // Phase Number - bpm.registerPlaceholder(gm, "island_phase_number_top_" + r, u -> getPhaseNumber(r)); + bpm.registerPlaceholder(gm, "island_phase_number_top_" + r, u -> getPhaseNumber(hook, r)); // Block Count - bpm.registerPlaceholder(gm, "island_count_top_" + r, u -> getBlockNumber(r)); + bpm.registerPlaceholder(gm, "island_count_top_" + r, u -> getBlockNumber(hook, r)); // Lifetime count - bpm.registerPlaceholder(gm, "island_lifetime_top_" + r, u -> getLifetime(r)); + bpm.registerPlaceholder(gm, "island_lifetime_top_" + r, u -> getLifetime(hook, r)); + } + + private TopTenData getEntry(TopBlockHook hook, int rank) { + List rList = topTens.getOrDefault(hook, List.of()); + return rank - 1 < rList.size() ? rList.get(rank - 1) : null; } - private String getLifetime(int rank) { - TopTenData r = rank - 1 < rList.size() ? rList.get(rank - 1) : null; + private String getLifetime(TopBlockHook hook, int rank) { + TopTenData r = getEntry(hook, rank); if (r == null) return ""; return String.valueOf(r.lifetime()); } - private String getBlockNumber(int rank) { - TopTenData r = rank - 1 < rList.size() ? rList.get(rank - 1) : null; + private String getBlockNumber(TopBlockHook hook, int rank) { + TopTenData r = getEntry(hook, rank); if (r == null) return ""; return String.valueOf(r.blockNumber()); } /** * Gets a comma separated string of island member names - * @param r Top ten entry + * @param hook game mode hook + * @param rank Top ten rank * @return comma separated string of island member names */ - String getMemberNames(int rank) { - TopTenData r = rank - 1 < rList.size() ? rList.get(rank - 1) : null; + String getMemberNames(TopBlockHook hook, int rank) { + TopTenData r = getEntry(hook, rank); if (r == null) return ""; // Sort members by rank return r.island().getMembers().entrySet().stream() @@ -99,24 +104,22 @@ String getMemberNames(int rank) { .collect(Collectors.joining(",")); } - private String getPlayerName(int rank) { - TopTenData r = rank - 1 < rList.size() ? rList.get(rank - 1) : null; + private String getPlayerName(TopBlockHook hook, int rank) { + TopTenData r = getEntry(hook, rank); if (r == null) return ""; UUID owner = r.island().getOwner(); if (owner == null) return ""; return Objects.requireNonNull(addon.getPlayers().getName(owner), ""); } - private String getPhaseNumber(int rank) { - TopTenData r = rank - 1 < rList.size() ? rList.get(rank - 1) : null; + private String getPhaseNumber(TopBlockHook hook, int rank) { + TopTenData r = getEntry(hook, rank); if (r == null) return ""; - long c = addon.getaOneBlock().getOneBlockManager().getBlockProbs().entrySet().stream() - .filter(en -> en.getKey() < r.blockNumber()).count(); - return String.valueOf(c); + return String.valueOf(hook.getPhaseCount(r.blockNumber())); } - private String getPhaseName(int rank) { - TopTenData r = rank - 1 < rList.size() ? rList.get(rank - 1) : null; + private String getPhaseName(TopBlockHook hook, int rank) { + TopTenData r = getEntry(hook, rank); if (r == null) return ""; return r.phaseName(); } diff --git a/src/main/java/world/bentobox/topblock/TopBlock.java b/src/main/java/world/bentobox/topblock/TopBlock.java index 719246a..d0d3336 100644 --- a/src/main/java/world/bentobox/topblock/TopBlock.java +++ b/src/main/java/world/bentobox/topblock/TopBlock.java @@ -1,11 +1,19 @@ package world.bentobox.topblock; -import world.bentobox.aoneblock.AOneBlock; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.bukkit.World; + import world.bentobox.bentobox.api.addons.Addon; import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.bentobox.api.configuration.Config; import world.bentobox.topblock.commands.TopBlockCommand; import world.bentobox.topblock.config.ConfigSettings; +import world.bentobox.topblock.hooks.AOneBlockHook; +import world.bentobox.topblock.hooks.ChunkBlockHook; +import world.bentobox.topblock.hooks.TopBlockHook; /** @@ -21,7 +29,8 @@ public class TopBlock extends Addon { private ConfigSettings settings; private Config configObject = new Config<>(this, ConfigSettings.class); private TopBlockManager manager; - private Addon aOneBlock; + // Hooks into the game modes this addon supports; each keeps its own top ten + private final List hooks = new ArrayList<>(); @Override public void onLoad() { @@ -53,18 +62,31 @@ public void onEnable() { manager = new TopBlockManager(this); this.registerListener(manager); - // Find AOneBlock - getPlugin().getAddonsManager().getAddonByName("aoneblock") - .filter(Addon::isEnabled) - .filter(GameModeAddon.class::isInstance) - .map(GameModeAddon.class::cast).ifPresentOrElse(gm -> { + // Hook into whichever supported game modes are present. Game-mode classes are + // only referenced inside the hook constructors, which run after the presence + // check, so a game mode that is not installed is never class-loaded. + findGameMode("aoneblock").ifPresent(gm -> { log("TopBlock hooking into AOneBlock"); registerCommands(gm); - aOneBlock = gm; - }, () -> { - logError("Could not hook into AOneBlock. Is it loaded?"); - this.setState(State.DISABLED); + hooks.add(new AOneBlockHook(gm)); + }); + findGameMode("chunkblock").ifPresent(gm -> { + log("TopBlock hooking into ChunkBlock"); + registerCommands(gm); + hooks.add(new ChunkBlockHook(gm)); }); + + if (hooks.isEmpty()) { + logError("Could not hook into AOneBlock or ChunkBlock. Is at least one loaded?"); + this.setState(State.DISABLED); + } + } + + private Optional findGameMode(String name) { + return getPlugin().getAddonsManager().getAddonByName(name) + .filter(Addon::isEnabled) + .filter(GameModeAddon.class::isInstance) + .map(GameModeAddon.class::cast); } private void registerCommands(GameModeAddon gm) { @@ -101,8 +123,20 @@ public void onDisable() { } - public AOneBlock getaOneBlock() { - return (AOneBlock) aOneBlock; + /** + * @return the game mode hooks that are active + */ + public List getHooks() { + return hooks; + } + + /** + * Get the hook whose game mode owns the given world, if any. + * @param world world to look up + * @return hook for that world's game mode + */ + public Optional getHook(World world) { + return hooks.stream().filter(h -> h.getGameMode().inWorld(world)).findFirst(); } diff --git a/src/main/java/world/bentobox/topblock/TopBlockManager.java b/src/main/java/world/bentobox/topblock/TopBlockManager.java index 1f9b159..383de34 100644 --- a/src/main/java/world/bentobox/topblock/TopBlockManager.java +++ b/src/main/java/world/bentobox/topblock/TopBlockManager.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -12,6 +13,7 @@ import java.util.UUID; import org.bukkit.Bukkit; +import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -19,9 +21,9 @@ import org.eclipse.jdt.annotation.NonNull; import org.eclipse.jdt.annotation.Nullable; -import world.bentobox.aoneblock.AOneBlock; import world.bentobox.bentobox.api.events.BentoBoxReadyEvent; import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.topblock.hooks.TopBlockHook; public class TopBlockManager implements Listener { @@ -55,8 +57,8 @@ public int compareTo(TopTenData o) { } } - // Top ten lists - private final List topTen = new ArrayList<>(); + // Top ten lists, one per hooked game mode + private final Map> topTens = new HashMap<>(); /** @@ -70,25 +72,30 @@ public TopBlockManager(TopBlock addon) { @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onBentoBoxReady(BentoBoxReadyEvent e) { - // Load the top ten from AOneBlock every so often + // Load the top ten from each hooked game mode every so often Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), () -> { - // Update TopTen - getOneBlockData(); + // Update TopTens + refreshAll(); // Update placeholders phm.updateTopTen(); }, 0, addon.getSettings().getRefreshTime() * 20L * 60); // Register placeholders after everything is loaded - Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> phm.registerPlaceholders(addon.getaOneBlock()), 10L); + Bukkit.getScheduler().runTaskLater(addon.getPlugin(), + () -> addon.getHooks().forEach(phm::registerPlaceholders), 10L); } - void getOneBlockData() { - AOneBlock ob = addon.getaOneBlock(); - topTen.clear(); - ob.getBlockListener().getAllIslands().stream().filter(i -> i.getLifetime() > 0).forEach(i -> - addon.getIslands().getIslandById(i.getUniqueId()) + void refreshAll() { + addon.getHooks().forEach(this::refresh); + } + + void refresh(TopBlockHook hook) { + List data = new ArrayList<>(); + hook.getAllIslandData().stream().filter(i -> i.lifetime() > 0).forEach(i -> + addon.getIslands().getIslandById(i.uniqueId()) .filter(this::ownerInTopTen) .ifPresent(island -> - topTen.add(new TopTenData(island, i.getBlockNumber(), i.getLifetime(), i.getPhaseName())))); + data.add(new TopTenData(island, i.blockNumber(), i.lifetime(), i.phaseName())))); + topTens.put(hook, data); } /** @@ -136,16 +143,28 @@ public String formatLevel(@Nullable Long lvl) { } /** - * Get the top ten. Returns offline players or players with the intopten permission. + * Get the top ten for a hooked game mode. Returns offline players or players with the intopten permission. + * @param hook - game mode hook * @param size - size of the top ten - * @return sorted top ten map + * @return sorted top ten list */ @NonNull - public List getTopTen(int size) { - // Return the sorted map - return topTen.stream() + public List getTopTen(TopBlockHook hook, int size) { + // Return the sorted list + return topTens.getOrDefault(hook, List.of()).stream() .sorted(Collections.reverseOrder()).limit(size) .toList(); } + /** + * Get the top ten for the game mode that owns the given world. + * @param world - world of a hooked game mode + * @param size - size of the top ten + * @return sorted top ten list, empty if no game mode owns this world + */ + @NonNull + public List getTopTen(World world, int size) { + return addon.getHook(world).map(h -> getTopTen(h, size)).orElseGet(List::of); + } + } diff --git a/src/main/java/world/bentobox/topblock/config/ConfigSettings.java b/src/main/java/world/bentobox/topblock/config/ConfigSettings.java index 95f6801..ddfc8f1 100644 --- a/src/main/java/world/bentobox/topblock/config/ConfigSettings.java +++ b/src/main/java/world/bentobox/topblock/config/ConfigSettings.java @@ -10,7 +10,7 @@ @ConfigComment("") public class ConfigSettings implements ConfigObject { @ConfigComment("How often the Top Ten should be refreshed in minutes. Minimum is 1 minute, default is 5.") - @ConfigComment("Each refresh requires reading every island from the database, so this should not be done too often.") + @ConfigComment("Each refresh requires reading every island of every hooked game mode from the database, so this should not be done too often.") @ConfigEntry(path = "refresh-time") private int refreshTime = 5; diff --git a/src/main/java/world/bentobox/topblock/hooks/AOneBlockHook.java b/src/main/java/world/bentobox/topblock/hooks/AOneBlockHook.java new file mode 100644 index 0000000..a37f490 --- /dev/null +++ b/src/main/java/world/bentobox/topblock/hooks/AOneBlockHook.java @@ -0,0 +1,37 @@ +package world.bentobox.topblock.hooks; + +import java.util.List; + +import world.bentobox.aoneblock.AOneBlock; +import world.bentobox.bentobox.api.addons.GameModeAddon; + +/** + * Hook for the AOneBlock game mode. + * + * @author tastybento + */ +public class AOneBlockHook implements TopBlockHook { + + private final AOneBlock addon; + + public AOneBlockHook(GameModeAddon addon) { + this.addon = (AOneBlock) addon; + } + + @Override + public GameModeAddon getGameMode() { + return addon; + } + + @Override + public List getAllIslandData() { + return addon.getBlockListener().getAllIslands().stream() + .map(i -> new IslandBlockData(i.getUniqueId(), i.getBlockNumber(), i.getLifetime(), i.getPhaseName())) + .toList(); + } + + @Override + public long getPhaseCount(int blockNumber) { + return addon.getOneBlockManager().getBlockProbs().headMap(blockNumber).size(); + } +} diff --git a/src/main/java/world/bentobox/topblock/hooks/ChunkBlockHook.java b/src/main/java/world/bentobox/topblock/hooks/ChunkBlockHook.java new file mode 100644 index 0000000..c38813d --- /dev/null +++ b/src/main/java/world/bentobox/topblock/hooks/ChunkBlockHook.java @@ -0,0 +1,37 @@ +package world.bentobox.topblock.hooks; + +import java.util.List; + +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.chunkblock.ChunkBlock; + +/** + * Hook for the ChunkBlock game mode. + * + * @author tastybento + */ +public class ChunkBlockHook implements TopBlockHook { + + private final ChunkBlock addon; + + public ChunkBlockHook(GameModeAddon addon) { + this.addon = (ChunkBlock) addon; + } + + @Override + public GameModeAddon getGameMode() { + return addon; + } + + @Override + public List getAllIslandData() { + return addon.getBlockListener().getAllIslands().stream() + .map(i -> new IslandBlockData(i.getUniqueId(), i.getBlockNumber(), i.getLifetime(), i.getPhaseName())) + .toList(); + } + + @Override + public long getPhaseCount(int blockNumber) { + return addon.getOneBlockManager().getBlockProbs().headMap(blockNumber).size(); + } +} diff --git a/src/main/java/world/bentobox/topblock/hooks/IslandBlockData.java b/src/main/java/world/bentobox/topblock/hooks/IslandBlockData.java new file mode 100644 index 0000000..14b7000 --- /dev/null +++ b/src/main/java/world/bentobox/topblock/hooks/IslandBlockData.java @@ -0,0 +1,13 @@ +package world.bentobox.topblock.hooks; + +/** + * Game-mode-neutral snapshot of an island's magic block progress. + * @param uniqueId BentoBox island unique id + * @param blockNumber the number of blocks mined this time around + * @param lifetime the lifetime number of blocks mined + * @param phaseName the name of the current phase + * + * @author tastybento + */ +public record IslandBlockData(String uniqueId, int blockNumber, long lifetime, String phaseName) { +} diff --git a/src/main/java/world/bentobox/topblock/hooks/TopBlockHook.java b/src/main/java/world/bentobox/topblock/hooks/TopBlockHook.java new file mode 100644 index 0000000..2708e89 --- /dev/null +++ b/src/main/java/world/bentobox/topblock/hooks/TopBlockHook.java @@ -0,0 +1,32 @@ +package world.bentobox.topblock.hooks; + +import java.util.List; + +import world.bentobox.bentobox.api.addons.GameModeAddon; + +/** + * Abstraction over a supported magic-block game mode (AOneBlock, ChunkBlock). + * The two game modes have twin APIs but unrelated types, so each implementation + * must be the only class that references its game mode's packages — that way a + * game mode that is not installed is never class-loaded. + * + * @author tastybento + */ +public interface TopBlockHook { + + /** + * @return the game mode addon this hook wraps + */ + GameModeAddon getGameMode(); + + /** + * @return magic block progress for every island in this game mode + */ + List getAllIslandData(); + + /** + * @param blockNumber block count to compare phase start blocks against + * @return the number of phases that start strictly below the given block count + */ + long getPhaseCount(int blockNumber); +} diff --git a/src/main/java/world/bentobox/topblock/panels/TopLevelPanel.java b/src/main/java/world/bentobox/topblock/panels/TopLevelPanel.java index 1347f47..1ea1884 100644 --- a/src/main/java/world/bentobox/topblock/panels/TopLevelPanel.java +++ b/src/main/java/world/bentobox/topblock/panels/TopLevelPanel.java @@ -27,7 +27,7 @@ /** - * This panel opens the top ten panel for AOneBlock. + * This panel opens the top ten panel for the game mode that owns the given world. */ public class TopLevelPanel { @@ -46,7 +46,7 @@ private TopLevelPanel(TopBlock addon, User user, World world, String permissionP this.user = user; this.world = world; this.iconPermission = permissionPrefix + "topblock.icon"; - this.topIslands = this.addon.getManager().getTopTen(TopBlock.TEN); + this.topIslands = this.addon.getManager().getTopTen(world, TopBlock.TEN); } diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index 9180b5d..3e88018 100755 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -6,7 +6,7 @@ api-version: 2.7.1 authors: tastybento -depend: AOneBlock +softdepend: AOneBlock, ChunkBlock permissions: 'aoneblock.island.topblock': @@ -15,3 +15,9 @@ permissions: 'aoneblock.intopten': description: Player's island will be listed in the top ten. Remove from admins or testers to hide them. default: true + 'chunkblock.island.topblock': + description: Player can use TopBlock command + default: true + 'chunkblock.intopten': + description: Player's island will be listed in the top ten. Remove from admins or testers to hide them. + default: true diff --git a/src/test/java/world/bentobox/topblock/PlaceholderManagerTest.java b/src/test/java/world/bentobox/topblock/PlaceholderManagerTest.java index 8cd3249..8c8710a 100644 --- a/src/test/java/world/bentobox/topblock/PlaceholderManagerTest.java +++ b/src/test/java/world/bentobox/topblock/PlaceholderManagerTest.java @@ -3,6 +3,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.anyString; 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.util.List; @@ -11,25 +13,26 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; import org.mockito.Mock; import com.google.common.collect.ImmutableSet; -import world.bentobox.aoneblock.AOneBlock; -import world.bentobox.aoneblock.dataobjects.OneBlockIslands; -import world.bentobox.aoneblock.listeners.BlockListener; +import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.bentobox.managers.PlayersManager; import world.bentobox.bentobox.managers.RanksManager; import world.bentobox.topblock.config.ConfigSettings; +import world.bentobox.topblock.hooks.IslandBlockData; +import world.bentobox.topblock.hooks.TopBlockHook; class PlaceholderManagerTest extends CommonTestSetup { @Mock private TopBlock addon; @Mock - private AOneBlock aob; + private TopBlockHook hook; @Mock - private BlockListener bl; + private GameModeAddon gameMode; @Mock private PlayersManager playersMgr; @@ -44,38 +47,49 @@ public void setUp() throws Exception { ConfigSettings settings = new ConfigSettings(); when(addon.getPlugin()).thenReturn(plugin); when(addon.getSettings()).thenReturn(settings); - when(addon.getaOneBlock()).thenReturn(aob); + when(addon.getHooks()).thenReturn(List.of(hook)); when(addon.getIslands()).thenReturn(im); when(addon.getPlayers()).thenReturn(playersMgr); - when(aob.getBlockListener()).thenReturn(bl); + when(hook.getGameMode()).thenReturn(gameMode); when(im.getIslandById(anyString())).thenReturn(Optional.of(island)); // Single island in top ten - OneBlockIslands ob = new OneBlockIslands(UUID.randomUUID().toString()); - ob.setBlockNumber(80); - ob.setLifetime(250); - ob.setPhaseName("Underground"); - when(bl.getAllIslands()).thenReturn(List.of(ob)); + when(hook.getAllIslandData()).thenReturn(List.of( + new IslandBlockData(UUID.randomUUID().toString(), 80, 250, "Underground"))); tbm = new TopBlockManager(addon); when(addon.getManager()).thenReturn(tbm); - tbm.getOneBlockData(); + tbm.refreshAll(); phMgr = new PlaceholderManager(addon); + phMgr.registerPlaceholders(hook); phMgr.updateTopTen(); } + @Test + void testRegisterPlaceholdersRegistersTenOfEach() { + // 6 placeholders per rank, ranks 1..10, all scoped to the hook's game mode + verify(phm, times(60)).registerPlaceholder(ArgumentMatchers.eq(gameMode), anyString(), + ArgumentMatchers.any()); + } + @Test void testGetMemberNamesEmptyForSingleMemberIsland() { when(island.getMembers()).thenReturn(java.util.Collections.emptyMap()); - assertEquals("", phMgr.getMemberNames(1)); + assertEquals("", phMgr.getMemberNames(hook, 1)); } @Test void testGetMemberNamesPastEndReturnsEmpty() { // Rank 5 with only 1 island in the list → empty - assertEquals("", phMgr.getMemberNames(5)); + assertEquals("", phMgr.getMemberNames(hook, 5)); + } + + @Test + void testGetMemberNamesUnregisteredHookReturnsEmpty() { + TopBlockHook other = mock(TopBlockHook.class); + assertEquals("", phMgr.getMemberNames(other, 1)); } @Test @@ -89,7 +103,7 @@ void testGetMemberNamesJoinsMembers() { when(playersMgr.getName(a)).thenReturn("Alice"); when(playersMgr.getName(b)).thenReturn("Bob"); - String names = phMgr.getMemberNames(1); + String names = phMgr.getMemberNames(hook, 1); // SUB_OWNER_RANK > MEMBER_RANK, so Bob comes first assertEquals("Bob,Alice", names); } diff --git a/src/test/java/world/bentobox/topblock/TopBlockManagerTest.java b/src/test/java/world/bentobox/topblock/TopBlockManagerTest.java index dd7a94b..986cfed 100644 --- a/src/test/java/world/bentobox/topblock/TopBlockManagerTest.java +++ b/src/test/java/world/bentobox/topblock/TopBlockManagerTest.java @@ -5,6 +5,7 @@ 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.when; import java.util.List; @@ -17,20 +18,17 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; -import world.bentobox.aoneblock.AOneBlock; -import world.bentobox.aoneblock.dataobjects.OneBlockIslands; -import world.bentobox.aoneblock.listeners.BlockListener; import world.bentobox.topblock.TopBlockManager.TopTenData; import world.bentobox.topblock.config.ConfigSettings; +import world.bentobox.topblock.hooks.IslandBlockData; +import world.bentobox.topblock.hooks.TopBlockHook; class TopBlockManagerTest extends CommonTestSetup { @Mock private TopBlock addon; @Mock - private AOneBlock aob; - @Mock - private BlockListener bl; + private TopBlockHook hook; private TopBlockManager tbm; private ConfigSettings settings; @@ -43,9 +41,8 @@ public void setUp() throws Exception { settings = new ConfigSettings(); when(addon.getPlugin()).thenReturn(plugin); when(addon.getSettings()).thenReturn(settings); - when(addon.getaOneBlock()).thenReturn(aob); + when(addon.getHooks()).thenReturn(List.of(hook)); when(addon.getIslands()).thenReturn(im); - when(aob.getBlockListener()).thenReturn(bl); when(im.getIslandById(anyString())).thenReturn(Optional.of(island)); when(island.getWorld()).thenReturn(world); when(iwm.getPermissionPrefix(any())).thenReturn("aoneblock."); @@ -53,28 +50,24 @@ public void setUp() throws Exception { tbm = new TopBlockManager(addon); } - private static OneBlockIslands ob(int blockNumber, long lifetime, String phase) { - OneBlockIslands i = new OneBlockIslands(UUID.randomUUID().toString()); - i.setBlockNumber(blockNumber); - i.setLifetime(lifetime); - i.setPhaseName(phase); - return i; + private static IslandBlockData ib(int blockNumber, long lifetime, String phase) { + return new IslandBlockData(UUID.randomUUID().toString(), blockNumber, lifetime, phase); } @Test void testGetTopTenEmptyByDefault() { - assertTrue(tbm.getTopTen(10).isEmpty()); + assertTrue(tbm.getTopTen(hook, 10).isEmpty()); } @Test - void testGetOneBlockDataPopulatesTopTen() { - when(bl.getAllIslands()).thenReturn(List.of( - ob(50, 100, "Plains"), - ob(80, 250, "Underground"))); + void testRefreshPopulatesTopTen() { + when(hook.getAllIslandData()).thenReturn(List.of( + ib(50, 100, "Plains"), + ib(80, 250, "Underground"))); - tbm.getOneBlockData(); + tbm.refreshAll(); - List top = tbm.getTopTen(10); + List top = tbm.getTopTen(hook, 10); assertEquals(2, top.size()); // Sorted descending by lifetime assertEquals(250L, top.get(0).lifetime()); @@ -82,48 +75,83 @@ void testGetOneBlockDataPopulatesTopTen() { } @Test - void testGetOneBlockDataFiltersZeroLifetime() { - when(bl.getAllIslands()).thenReturn(List.of( - ob(0, 0, "Plains"), - ob(80, 250, "Underground"))); + void testRefreshFiltersZeroLifetime() { + when(hook.getAllIslandData()).thenReturn(List.of( + ib(0, 0, "Plains"), + ib(80, 250, "Underground"))); - tbm.getOneBlockData(); + tbm.refreshAll(); - List top = tbm.getTopTen(10); + List top = tbm.getTopTen(hook, 10); assertEquals(1, top.size()); assertEquals(250L, top.get(0).lifetime()); } @Test - void testGetOneBlockDataSkipsIslandsWithoutBentoBoxIsland() { + void testRefreshSkipsIslandsWithoutBentoBoxIsland() { when(im.getIslandById(anyString())).thenReturn(Optional.empty()); - when(bl.getAllIslands()).thenReturn(List.of(ob(80, 250, "Underground"))); + when(hook.getAllIslandData()).thenReturn(List.of(ib(80, 250, "Underground"))); + + tbm.refreshAll(); + + assertTrue(tbm.getTopTen(hook, 10).isEmpty()); + } - tbm.getOneBlockData(); + @Test + void testRefreshReplacesPreviousResults() { + when(hook.getAllIslandData()).thenReturn(List.of(ib(80, 250, "Underground"))); + tbm.refreshAll(); + assertEquals(1, tbm.getTopTen(hook, 10).size()); - assertTrue(tbm.getTopTen(10).isEmpty()); + when(hook.getAllIslandData()).thenReturn(List.of()); + tbm.refreshAll(); + assertTrue(tbm.getTopTen(hook, 10).isEmpty()); } @Test - void testGetOneBlockDataReplacesPreviousResults() { - when(bl.getAllIslands()).thenReturn(List.of(ob(80, 250, "Underground"))); - tbm.getOneBlockData(); - assertEquals(1, tbm.getTopTen(10).size()); + void testTopTensAreSeparatePerHook() { + TopBlockHook hook2 = mock(TopBlockHook.class); + when(addon.getHooks()).thenReturn(List.of(hook, hook2)); + when(hook.getAllIslandData()).thenReturn(List.of(ib(80, 250, "Underground"))); + when(hook2.getAllIslandData()).thenReturn(List.of( + ib(10, 30, "Plains"), + ib(20, 40, "Plains"))); + + tbm.refreshAll(); - when(bl.getAllIslands()).thenReturn(List.of()); - tbm.getOneBlockData(); - assertTrue(tbm.getTopTen(10).isEmpty()); + assertEquals(1, tbm.getTopTen(hook, 10).size()); + assertEquals(250L, tbm.getTopTen(hook, 10).get(0).lifetime()); + assertEquals(2, tbm.getTopTen(hook2, 10).size()); + assertEquals(40L, tbm.getTopTen(hook2, 10).get(0).lifetime()); + } + + @Test + void testGetTopTenByWorld() { + when(addon.getHook(world)).thenReturn(Optional.of(hook)); + when(hook.getAllIslandData()).thenReturn(List.of(ib(80, 250, "Underground"))); + tbm.refreshAll(); + + assertEquals(1, tbm.getTopTen(world, 10).size()); + } + + @Test + void testGetTopTenByWorldWithoutHookIsEmpty() { + when(addon.getHook(world)).thenReturn(Optional.empty()); + when(hook.getAllIslandData()).thenReturn(List.of(ib(80, 250, "Underground"))); + tbm.refreshAll(); + + assertTrue(tbm.getTopTen(world, 10).isEmpty()); } @Test void testGetTopTenLimitsSize() { - when(bl.getAllIslands()).thenReturn(List.of( - ob(10, 10, "a"), - ob(20, 20, "b"), - ob(30, 30, "c"))); - tbm.getOneBlockData(); + when(hook.getAllIslandData()).thenReturn(List.of( + ib(10, 10, "a"), + ib(20, 20, "b"), + ib(30, 30, "c"))); + tbm.refreshAll(); - assertEquals(2, tbm.getTopTen(2).size()); + assertEquals(2, tbm.getTopTen(hook, 2).size()); } @Test @@ -162,65 +190,45 @@ void testFormatLevelShorthandGiga() { } @Test - void testGetOneBlockDataExcludesOnlineOwnerWithoutIntoptenPerm() { - Player p = org.mockito.Mockito.mock(Player.class); + void testRefreshExcludesOnlineOwnerWithoutIntoptenPerm() { + Player p = mock(Player.class); when(p.hasPermission("aoneblock.intopten")).thenReturn(false); mockedBukkit.when(() -> Bukkit.getPlayer(any(UUID.class))).thenReturn(p); - when(bl.getAllIslands()).thenReturn(List.of( - new OneBlockIslands(UUID.randomUUID().toString()) {{ - setBlockNumber(80); - setLifetime(250); - setPhaseName("Underground"); - }})); - tbm.getOneBlockData(); + when(hook.getAllIslandData()).thenReturn(List.of(ib(80, 250, "Underground"))); + tbm.refreshAll(); - assertTrue(tbm.getTopTen(10).isEmpty()); + assertTrue(tbm.getTopTen(hook, 10).isEmpty()); } @Test - void testGetOneBlockDataIncludesOnlineOwnerWithIntoptenPerm() { - Player p = org.mockito.Mockito.mock(Player.class); + void testRefreshIncludesOnlineOwnerWithIntoptenPerm() { + Player p = mock(Player.class); when(p.hasPermission("aoneblock.intopten")).thenReturn(true); mockedBukkit.when(() -> Bukkit.getPlayer(any(UUID.class))).thenReturn(p); - when(bl.getAllIslands()).thenReturn(List.of( - new OneBlockIslands(UUID.randomUUID().toString()) {{ - setBlockNumber(80); - setLifetime(250); - setPhaseName("Underground"); - }})); - tbm.getOneBlockData(); + when(hook.getAllIslandData()).thenReturn(List.of(ib(80, 250, "Underground"))); + tbm.refreshAll(); - assertEquals(1, tbm.getTopTen(10).size()); + assertEquals(1, tbm.getTopTen(hook, 10).size()); } @Test - void testGetOneBlockDataIncludesOfflineOwner() { + void testRefreshIncludesOfflineOwner() { // CommonTestSetup already stubs Bukkit.getPlayer -> null - when(bl.getAllIslands()).thenReturn(List.of( - new OneBlockIslands(UUID.randomUUID().toString()) {{ - setBlockNumber(80); - setLifetime(250); - setPhaseName("Underground"); - }})); - tbm.getOneBlockData(); + when(hook.getAllIslandData()).thenReturn(List.of(ib(80, 250, "Underground"))); + tbm.refreshAll(); - assertEquals(1, tbm.getTopTen(10).size()); + assertEquals(1, tbm.getTopTen(hook, 10).size()); } @Test - void testGetOneBlockDataExcludesIslandWithoutOwner() { + void testRefreshExcludesIslandWithoutOwner() { when(island.getOwner()).thenReturn(null); - when(bl.getAllIslands()).thenReturn(List.of( - new OneBlockIslands(UUID.randomUUID().toString()) {{ - setBlockNumber(80); - setLifetime(250); - setPhaseName("Underground"); - }})); - tbm.getOneBlockData(); - - assertTrue(tbm.getTopTen(10).isEmpty()); + when(hook.getAllIslandData()).thenReturn(List.of(ib(80, 250, "Underground"))); + tbm.refreshAll(); + + assertTrue(tbm.getTopTen(hook, 10).isEmpty()); } @Test diff --git a/src/test/java/world/bentobox/topblock/TopBlockTest.java b/src/test/java/world/bentobox/topblock/TopBlockTest.java index 4f2c33e..51e90d3 100644 --- a/src/test/java/world/bentobox/topblock/TopBlockTest.java +++ b/src/test/java/world/bentobox/topblock/TopBlockTest.java @@ -1,5 +1,8 @@ package world.bentobox.topblock; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -23,13 +26,17 @@ import org.mockito.MockedStatic; import org.mockito.Mockito; +import world.bentobox.aoneblock.AOneBlock; import world.bentobox.bentobox.api.addons.Addon.State; import world.bentobox.bentobox.api.addons.AddonDescription; import world.bentobox.bentobox.database.AbstractDatabaseHandler; import world.bentobox.bentobox.database.DatabaseSetup; import world.bentobox.bentobox.managers.AddonsManager; import world.bentobox.bentobox.managers.CommandsManager; +import world.bentobox.chunkblock.ChunkBlock; import world.bentobox.topblock.config.ConfigSettings; +import world.bentobox.topblock.hooks.AOneBlockHook; +import world.bentobox.topblock.hooks.ChunkBlockHook; class TopBlockTest extends CommonTestSetup { @@ -65,10 +72,11 @@ public void setUp() throws Exception { CommandsManager cm = mock(CommandsManager.class); when(plugin.getCommandsManager()).thenReturn(cm); - // AddonsManager — no aoneblock present + // AddonsManager — no game modes present by default when(plugin.getAddonsManager()).thenReturn(am); when(am.getGameModeAddons()).thenReturn(Collections.emptyList()); when(am.getAddonByName("aoneblock")).thenReturn(Optional.empty()); + when(am.getAddonByName("chunkblock")).thenReturn(Optional.empty()); // FlagsManager when(plugin.getFlagsManager()).thenReturn(fm); @@ -105,6 +113,22 @@ private static void addJarEntry(JarOutputStream jos, String name, String content jos.closeEntry(); } + private AOneBlock mockAOneBlock() { + AOneBlock aob = mock(AOneBlock.class); + when(aob.isEnabled()).thenReturn(true); + when(aob.getPlayerCommand()).thenReturn(Optional.empty()); + when(am.getAddonByName("aoneblock")).thenReturn(Optional.of(aob)); + return aob; + } + + private ChunkBlock mockChunkBlock() { + ChunkBlock cb = mock(ChunkBlock.class); + when(cb.isEnabled()).thenReturn(true); + when(cb.getPlayerCommand()).thenReturn(Optional.empty()); + when(am.getAddonByName("chunkblock")).thenReturn(Optional.of(cb)); + return cb; + } + @Test void testGetSettingsNullBeforeLoad() { assertNull(addon.getSettings()); @@ -127,13 +151,81 @@ void testOnLoadSettingsDefaults() { } @Test - void testOnEnableWithoutAOneBlockDisables() { + void testOnEnableWithoutGameModesDisables() { + loadAndEnable(); + // Neither AOneBlock nor ChunkBlock present → addon disables itself + assertTrue(addon.getState() == State.DISABLED); + assertTrue(addon.getHooks().isEmpty()); + // Manager is still constructed before the game mode lookup + assertNotNull(addon.getManager()); + } + + /** + * A freshly constructed Addon starts in DISABLED state and only AddonsManager + * flips it to ENABLED, so mark it LOADED first — then a DISABLED state after + * onEnable can only mean the addon disabled itself. + */ + private void loadAndEnable() { addon.onLoad(); + addon.setState(State.LOADED); addon.onEnable(); - // AOneBlock not present → addon disables itself + } + + @Test + void testOnEnableWithAOneBlockOnly() { + AOneBlock aob = mockAOneBlock(); + loadAndEnable(); + assertNotEquals(State.DISABLED, addon.getState()); + assertEquals(1, addon.getHooks().size()); + assertInstanceOf(AOneBlockHook.class, addon.getHooks().get(0)); + assertEquals(aob, addon.getHooks().get(0).getGameMode()); + } + + @Test + void testOnEnableWithChunkBlockOnly() { + ChunkBlock cb = mockChunkBlock(); + loadAndEnable(); + assertNotEquals(State.DISABLED, addon.getState()); + assertEquals(1, addon.getHooks().size()); + assertInstanceOf(ChunkBlockHook.class, addon.getHooks().get(0)); + assertEquals(cb, addon.getHooks().get(0).getGameMode()); + } + + @Test + void testOnEnableWithBothGameModes() { + mockAOneBlock(); + mockChunkBlock(); + loadAndEnable(); + assertNotEquals(State.DISABLED, addon.getState()); + assertEquals(2, addon.getHooks().size()); + assertInstanceOf(AOneBlockHook.class, addon.getHooks().get(0)); + assertInstanceOf(ChunkBlockHook.class, addon.getHooks().get(1)); + } + + @Test + void testOnEnableSkipsDisabledGameMode() { + AOneBlock aob = mockAOneBlock(); + when(aob.isEnabled()).thenReturn(false); + loadAndEnable(); assertTrue(addon.getState() == State.DISABLED); - // Manager is still constructed before the AOneBlock lookup - assertNotNull(addon.getManager()); + assertTrue(addon.getHooks().isEmpty()); + } + + @Test + void testGetHookByWorld() { + AOneBlock aob = mockAOneBlock(); + when(aob.inWorld(world)).thenReturn(true); + loadAndEnable(); + assertTrue(addon.getHook(world).isPresent()); + assertEquals(aob, addon.getHook(world).get().getGameMode()); + } + + @Test + void testGetHookByWorldNoMatch() { + AOneBlock aob = mockAOneBlock(); + when(aob.inWorld(world)).thenReturn(false); + loadAndEnable(); + assertTrue(addon.getHook(world).isEmpty()); } @Test diff --git a/src/test/java/world/bentobox/topblock/hooks/AOneBlockHookTest.java b/src/test/java/world/bentobox/topblock/hooks/AOneBlockHookTest.java new file mode 100644 index 0000000..7970f8b --- /dev/null +++ b/src/test/java/world/bentobox/topblock/hooks/AOneBlockHookTest.java @@ -0,0 +1,79 @@ +package world.bentobox.topblock.hooks; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.TreeMap; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import world.bentobox.aoneblock.AOneBlock; +import world.bentobox.aoneblock.dataobjects.OneBlockIslands; +import world.bentobox.aoneblock.listeners.BlockListener; +import world.bentobox.aoneblock.oneblocks.OneBlockPhase; +import world.bentobox.aoneblock.oneblocks.OneBlocksManager; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class AOneBlockHookTest { + + @Mock + private AOneBlock aob; + @Mock + private BlockListener bl; + @Mock + private OneBlocksManager obm; + + private AOneBlockHook hook; + + @BeforeEach + void setUp() { + when(aob.getBlockListener()).thenReturn(bl); + when(aob.getOneBlockManager()).thenReturn(obm); + hook = new AOneBlockHook(aob); + } + + @Test + void testGetGameMode() { + assertEquals(aob, hook.getGameMode()); + } + + @Test + void testGetAllIslandDataMapsFields() { + OneBlockIslands i = new OneBlockIslands("island-id"); + i.setBlockNumber(80); + i.setLifetime(250L); + i.setPhaseName("Underground"); + when(bl.getAllIslands()).thenReturn(List.of(i)); + + List data = hook.getAllIslandData(); + assertEquals(1, data.size()); + assertEquals("island-id", data.get(0).uniqueId()); + assertEquals(80, data.get(0).blockNumber()); + assertEquals(250L, data.get(0).lifetime()); + assertEquals("Underground", data.get(0).phaseName()); + } + + @Test + void testGetPhaseCountCountsPhasesStrictlyBelow() { + TreeMap probs = new TreeMap<>(); + probs.put(0, mock(OneBlockPhase.class)); + probs.put(100, mock(OneBlockPhase.class)); + probs.put(1000, mock(OneBlockPhase.class)); + when(obm.getBlockProbs()).thenReturn(probs); + + assertEquals(2, hook.getPhaseCount(500)); + // A phase starting exactly at the block number is not counted + assertEquals(2, hook.getPhaseCount(1000)); + assertEquals(3, hook.getPhaseCount(1001)); + assertEquals(0, hook.getPhaseCount(0)); + } +} diff --git a/src/test/java/world/bentobox/topblock/hooks/ChunkBlockHookTest.java b/src/test/java/world/bentobox/topblock/hooks/ChunkBlockHookTest.java new file mode 100644 index 0000000..883d88d --- /dev/null +++ b/src/test/java/world/bentobox/topblock/hooks/ChunkBlockHookTest.java @@ -0,0 +1,79 @@ +package world.bentobox.topblock.hooks; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.TreeMap; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import world.bentobox.chunkblock.ChunkBlock; +import world.bentobox.chunkblock.dataobjects.OneBlockIslands; +import world.bentobox.chunkblock.listeners.BlockListener; +import world.bentobox.chunkblock.oneblocks.OneBlockPhase; +import world.bentobox.chunkblock.oneblocks.OneBlocksManager; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ChunkBlockHookTest { + + @Mock + private ChunkBlock cb; + @Mock + private BlockListener bl; + @Mock + private OneBlocksManager obm; + + private ChunkBlockHook hook; + + @BeforeEach + void setUp() { + when(cb.getBlockListener()).thenReturn(bl); + when(cb.getOneBlockManager()).thenReturn(obm); + hook = new ChunkBlockHook(cb); + } + + @Test + void testGetGameMode() { + assertEquals(cb, hook.getGameMode()); + } + + @Test + void testGetAllIslandDataMapsFields() { + OneBlockIslands i = new OneBlockIslands("island-id"); + i.setBlockNumber(80); + i.setLifetime(250L); + i.setPhaseName("Underground"); + when(bl.getAllIslands()).thenReturn(List.of(i)); + + List data = hook.getAllIslandData(); + assertEquals(1, data.size()); + assertEquals("island-id", data.get(0).uniqueId()); + assertEquals(80, data.get(0).blockNumber()); + assertEquals(250L, data.get(0).lifetime()); + assertEquals("Underground", data.get(0).phaseName()); + } + + @Test + void testGetPhaseCountCountsPhasesStrictlyBelow() { + TreeMap probs = new TreeMap<>(); + probs.put(0, mock(OneBlockPhase.class)); + probs.put(100, mock(OneBlockPhase.class)); + probs.put(1000, mock(OneBlockPhase.class)); + when(obm.getBlockProbs()).thenReturn(probs); + + assertEquals(2, hook.getPhaseCount(500)); + // A phase starting exactly at the block number is not counted + assertEquals(2, hook.getPhaseCount(1000)); + assertEquals(3, hook.getPhaseCount(1001)); + assertEquals(0, hook.getPhaseCount(0)); + } +}