From b18288a5f4c7157c6703de41add9787af8530fce Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 14 Aug 2026 19:54:39 -0700 Subject: [PATCH 1/3] fix: address SonarCloud findings on the counters foundation - recordActivity instead of record: 'record' is a restricted identifier - pin LocalDate.now to the system zone explicitly - static imports and an unused import in the new tests Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W8GghDu6oXiUf6eCoimJqD --- .../chunkblock/activity/ActivityManager.java | 11 +++--- .../listeners/ActivityListener.java | 6 ++-- .../chunkblock/listeners/LevelListener.java | 2 +- .../activity/ActivityManagerTest.java | 34 +++++++++---------- .../listeners/ActivityListenerTest.java | 12 ++++--- .../requests/MemberActivityHandlerTest.java | 1 - 6 files changed, 34 insertions(+), 32 deletions(-) diff --git a/src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java b/src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java index 4fdd2ff..dfd5740 100644 --- a/src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java +++ b/src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java @@ -1,6 +1,7 @@ package world.bentobox.chunkblock.activity; import java.time.LocalDate; +import java.time.ZoneId; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -43,8 +44,8 @@ public class ActivityManager { /** Unsaved frequent-counter records per island, so block breaks don't save every hit */ private final Map unsavedCounts = new HashMap<>(); - /** Today as an epoch day; replaceable so tests can move time */ - private LongSupplier daySupplier = () -> LocalDate.now().toEpochDay(); + /** Today as an epoch day in the server's zone; replaceable so tests can move time */ + private LongSupplier daySupplier = () -> LocalDate.now(ZoneId.systemDefault()).toEpochDay(); public ActivityManager(ChunkBlock addon) { this.addon = addon; @@ -69,7 +70,7 @@ public void setDaySupplier(LongSupplier daySupplier) { * @param type the counter * @param amount the amount to add, > 0 (anything else is ignored) */ - public void record(@NonNull Island island, @Nullable UUID member, @NonNull CounterType type, long amount) { + public void recordActivity(@NonNull Island island, @Nullable UUID member, @NonNull CounterType type, long amount) { if (amount <= 0 || (member != null && !island.getMemberSet().contains(member))) { return; } @@ -94,8 +95,8 @@ public void record(@NonNull Island island, @Nullable UUID member, @NonNull Count public boolean recordClaim(@NonNull Island island, int dx, int dz, @Nullable UUID member) { IslandActivity data = getActivity(island.getUniqueId()); boolean first = data.getClaimedEver().add(dx + "," + dz); - record(island, member, first ? CounterType.CHUNKS_CLAIMED : CounterType.CHUNKS_RECLAIMED, 1); - // record() may have skipped saving (non-member) but the claimedEver set changed + recordActivity(island, member, first ? CounterType.CHUNKS_CLAIMED : CounterType.CHUNKS_RECLAIMED, 1); + // recordActivity() may have skipped saving (non-member) but the claimedEver set changed if (first) { save(data, false); } diff --git a/src/main/java/world/bentobox/chunkblock/listeners/ActivityListener.java b/src/main/java/world/bentobox/chunkblock/listeners/ActivityListener.java index 9b47669..2e1900a 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/ActivityListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/ActivityListener.java @@ -38,7 +38,7 @@ public ActivityListener(ChunkBlock addon) { @EventHandler(priority = EventPriority.MONITOR) public void onMagicBlock(MagicBlockEvent e) { if (addon.inWorld(e.getIsland().getWorld())) { - addon.getActivityManager().record(e.getIsland(), e.getPlayerUUID(), CounterType.MAGIC_BLOCKS, 1); + addon.getActivityManager().recordActivity(e.getIsland(), e.getPlayerUUID(), CounterType.MAGIC_BLOCKS, 1); } } @@ -60,7 +60,7 @@ public void onChunkUnlock(ChunkUnlockEvent e) { @EventHandler(priority = EventPriority.MONITOR) public void onChunkRelock(ChunkRelockEvent e) { if (addon.inWorld(e.getIsland().getWorld())) { - addon.getActivityManager().record(e.getIsland(), null, CounterType.CHUNKS_RELOCKED, 1); + addon.getActivityManager().recordActivity(e.getIsland(), null, CounterType.CHUNKS_RELOCKED, 1); } } @@ -72,7 +72,7 @@ public void onChunkRelock(ChunkRelockEvent e) { @EventHandler(priority = EventPriority.MONITOR) public void onRingComplete(RingCompleteEvent e) { if (addon.inWorld(e.getIsland().getWorld())) { - addon.getActivityManager().record(e.getIsland(), null, CounterType.RINGS_COMPLETED, 1); + addon.getActivityManager().recordActivity(e.getIsland(), null, CounterType.RINGS_COMPLETED, 1); } } diff --git a/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java b/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java index def1933..061e941 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java @@ -99,7 +99,7 @@ public void applyLevel(Island island, long level) { // The delta is only known here, so level gains are recorded directly rather than // through ActivityListener. Island scope: Level cannot attribute levels to a member. if (level > oldLevel && addon.getActivityManager() != null) { - addon.getActivityManager().record(island, null, CounterType.LEVELS_EARNED, level - oldLevel); + addon.getActivityManager().recordActivity(island, null, CounterType.LEVELS_EARNED, level - oldLevel); } // Level dropped below what has been spent → the most recent claims are lost if (addon.getSettings().isRelockOnLevelLoss() && cm.getSpentLevels(island) > Math.max(0, level)) { diff --git a/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java b/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java index 9ad5ff8..32bfc4b 100644 --- a/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java +++ b/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java @@ -79,7 +79,7 @@ public void tearDown() throws Exception { @Test void testRecordAndLifetime() { - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 5); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 5); assertEquals(5, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 0)); // Island total includes the member's contribution assertEquals(5, am.getCount(island, null, CounterType.MAGIC_BLOCKS, 0)); @@ -90,21 +90,21 @@ void testRecordAndLifetime() { @Test void testNonMemberIsNotCounted() { UUID stranger = UUID.randomUUID(); - am.record(island, stranger, CounterType.MAGIC_BLOCKS, 5); + am.recordActivity(island, stranger, CounterType.MAGIC_BLOCKS, 5); assertEquals(0, am.getCount(island, stranger, CounterType.MAGIC_BLOCKS, 0)); assertEquals(0, am.getCount(island, null, CounterType.MAGIC_BLOCKS, 0)); } @Test void testNonPositiveAmountIgnored() { - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 0); - am.record(island, uuid, CounterType.MAGIC_BLOCKS, -3); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 0); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, -3); assertEquals(0, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 0)); } @Test void testIslandScopeRecording() { - am.record(island, null, CounterType.LEVELS_EARNED, 10); + am.recordActivity(island, null, CounterType.LEVELS_EARNED, 10); assertEquals(10, am.getCount(island, null, CounterType.LEVELS_EARNED, 0)); // Nothing was attributed to the member assertEquals(0, am.getCount(island, uuid, CounterType.LEVELS_EARNED, 0)); @@ -112,9 +112,9 @@ void testIslandScopeRecording() { @Test void testTimeWindows() { - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 3); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 3); day += 5; - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 2); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 2); // Today only assertEquals(2, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 1)); // Last 3 days miss the older record @@ -130,10 +130,10 @@ void testTimeWindows() { @Test void testRetentionPrunesDailyButNotLifetime() { settings.setActivityRetentionDays(7); - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 3); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 3); day += 10; // Recording again prunes the now-too-old bucket - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 1); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 1); assertEquals(1, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 30)); // Lifetime is never pruned assertEquals(4, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 0)); @@ -157,8 +157,8 @@ void testRecordClaimDistinguishesFirstClaimFromRecovery() { void testMembersKeepSeparateCounts() { UUID mate = UUID.randomUUID(); when(island.getMemberSet()).thenReturn(ImmutableSet.of(uuid, mate)); - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 3); - am.record(island, mate, CounterType.MAGIC_BLOCKS, 4); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 3); + am.recordActivity(island, mate, CounterType.MAGIC_BLOCKS, 4); assertEquals(3, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 0)); assertEquals(4, am.getCount(island, mate, CounterType.MAGIC_BLOCKS, 0)); assertEquals(7, am.getCount(island, null, CounterType.MAGIC_BLOCKS, 0)); @@ -167,13 +167,13 @@ void testMembersKeepSeparateCounts() { @Test void testContributorsExcludeIslandScope() { - am.record(island, null, CounterType.LEVELS_EARNED, 10); + am.recordActivity(island, null, CounterType.LEVELS_EARNED, 10); assertTrue(am.getContributors(island).isEmpty()); } @Test void testResetIslandClearsEverything() { - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 5); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 5); assertTrue(am.recordClaim(island, 1, 0, uuid)); am.resetIsland("island-id"); assertEquals(0, am.getCount(island, uuid, CounterType.MAGIC_BLOCKS, 0)); @@ -184,22 +184,22 @@ void testResetIslandClearsEverything() { @Test void testFrequentCounterSavesAreThrottled() throws Exception { for (int i = 0; i < 19; i++) { - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 1); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 1); } verify(h, never()).saveObject(any()); - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 1); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 1); verify(h).saveObject(any()); } @Test void testRareCountersSaveImmediately() throws Exception { - am.record(island, uuid, CounterType.CHUNKS_CLAIMED, 1); + am.recordActivity(island, uuid, CounterType.CHUNKS_CLAIMED, 1); verify(h).saveObject(any()); } @Test void testSaveCacheNowWritesDirectly() throws Exception { - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 1); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 1); am.saveCacheNow(); verify(h).saveObjectNow(any()); } diff --git a/src/test/java/world/bentobox/chunkblock/listeners/ActivityListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/ActivityListenerTest.java index 0aae28d..b0a00c6 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/ActivityListenerTest.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/ActivityListenerTest.java @@ -1,7 +1,9 @@ package world.bentobox.chunkblock.listeners; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -55,13 +57,13 @@ public void setUp() throws Exception { @Test void testMagicBlockBreakByPlayer() { listener.onMagicBlock(new MagicBlockEvent(island, uuid, null, block, null)); - verify(am).record(island, uuid, CounterType.MAGIC_BLOCKS, 1); + verify(am).recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 1); } @Test void testMagicBlockBreakByMinionIsIslandScope() { listener.onMagicBlock(new MagicBlockEvent(island, null, null, block, null)); - verify(am).record(island, null, CounterType.MAGIC_BLOCKS, 1); + verify(am).recordActivity(island, null, CounterType.MAGIC_BLOCKS, 1); } @Test @@ -73,7 +75,7 @@ void testChunkUnlockRecordsClaimWithClaimer() { @Test void testChunkRelockIsIslandScope() { listener.onChunkRelock(new ChunkRelockEvent(island, new Vector(1, 0, 0), 3)); - verify(am).record(island, null, CounterType.CHUNKS_RELOCKED, 1); + verify(am).recordActivity(island, null, CounterType.CHUNKS_RELOCKED, 1); } @Test @@ -81,7 +83,7 @@ void testRingCompleteCountsEvenWhenCancelled() { RingCompleteEvent event = new RingCompleteEvent(island, 1, 9); event.setCancelled(true); listener.onRingComplete(event); - verify(am).record(island, null, CounterType.RINGS_COMPLETED, 1); + verify(am).recordActivity(island, null, CounterType.RINGS_COMPLETED, 1); } @Test @@ -129,6 +131,6 @@ void testNullWorldNeverRecords() { when(island.getWorld()).thenReturn(null); when(addon.inWorld((org.bukkit.World) null)).thenReturn(false); listener.onMagicBlock(new MagicBlockEvent(island, uuid, null, block, null)); - verify(am, org.mockito.Mockito.never()).record(any(), any(), any(), org.mockito.ArgumentMatchers.anyLong()); + verify(am, never()).recordActivity(any(), any(), any(), anyLong()); } } diff --git a/src/test/java/world/bentobox/chunkblock/requests/MemberActivityHandlerTest.java b/src/test/java/world/bentobox/chunkblock/requests/MemberActivityHandlerTest.java index 78340aa..13cba8b 100644 --- a/src/test/java/world/bentobox/chunkblock/requests/MemberActivityHandlerTest.java +++ b/src/test/java/world/bentobox/chunkblock/requests/MemberActivityHandlerTest.java @@ -6,7 +6,6 @@ import java.util.Map; import java.util.Optional; -import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; From 311ddb4918515f2a1ccdc17f87fdaca53751dbd5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 14 Aug 2026 19:16:21 -0700 Subject: [PATCH 2/3] feat: island trophies and titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reward layer over the activity counters: config-defined trophies an island earns once and keeps, and the titles they carry. One system for the ring milestones (#3), relay scores (#9) and competition placements (#10) to emit into, instead of three unrelated ones. A trophy in trophies.yml is a name, an icon, a condition and what it grants. Conditions read the ring state (RING) or the activity counters (COUNTER, island total or any single member, lifetime or windowed). Earned trophies persist on the island and only an island create or reset clears them — the highestRingRewarded lesson: re-locking a ring on level loss and claiming it back never re-awards. TrophyAwardEvent mirrors RingCompleteEvent: cancellable, and the trophy stays earned either way, so another plugin can take the reward over entirely without the addon also acting. trophies.yml carries the warning against paying out island levels — levels buy chunks, so a level-granting trophy makes each milestone buy the next one. Titles are the display half: one active title per island chosen from earned trophies with /ch title, surfaced as the %chunkblock_island_title% placeholder (plus %chunkblock_island_trophies%). Foundation for #31; builds on the #30 counters. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W8GghDu6oXiUf6eCoimJqD --- .../world/bentobox/chunkblock/ChunkBlock.java | 19 +- .../chunkblock/ChunkBlockPlaceholders.java | 27 ++ .../chunkblock/activity/ActivityManager.java | 3 + .../commands/island/IslandTitleCommand.java | 115 ++++++ .../commands/island/PlayerCommand.java | 2 + .../dataobjects/OneBlockIslands.java | 48 +++ .../chunkblock/events/TrophyAwardEvent.java | 73 ++++ .../chunkblock/listeners/LevelListener.java | 2 + .../bentobox/chunkblock/trophies/Trophy.java | 61 ++++ .../chunkblock/trophies/TrophyManager.java | 327 ++++++++++++++++++ src/main/resources/addon.yml | 3 + src/main/resources/locales/en-US.yml | 15 + src/main/resources/trophies.yml | 64 ++++ .../bentobox/chunkblock/ChunkBlockTest.java | 9 + .../activity/ActivityManagerTest.java | 13 + .../island/IslandTitleCommandTest.java | 139 ++++++++ .../trophies/TrophyManagerTest.java | 261 ++++++++++++++ 17 files changed, 1180 insertions(+), 1 deletion(-) create mode 100644 src/main/java/world/bentobox/chunkblock/commands/island/IslandTitleCommand.java create mode 100644 src/main/java/world/bentobox/chunkblock/events/TrophyAwardEvent.java create mode 100644 src/main/java/world/bentobox/chunkblock/trophies/Trophy.java create mode 100644 src/main/java/world/bentobox/chunkblock/trophies/TrophyManager.java create mode 100644 src/main/resources/trophies.yml create mode 100644 src/test/java/world/bentobox/chunkblock/commands/island/IslandTitleCommandTest.java create mode 100644 src/test/java/world/bentobox/chunkblock/trophies/TrophyManagerTest.java diff --git a/src/main/java/world/bentobox/chunkblock/ChunkBlock.java b/src/main/java/world/bentobox/chunkblock/ChunkBlock.java index 4346765..4f0f2de 100644 --- a/src/main/java/world/bentobox/chunkblock/ChunkBlock.java +++ b/src/main/java/world/bentobox/chunkblock/ChunkBlock.java @@ -17,6 +17,7 @@ import world.bentobox.chunkblock.chunks.BorderDisplay; import world.bentobox.chunkblock.chunks.ChunkManager; import world.bentobox.chunkblock.listeners.ActivityListener; +import world.bentobox.chunkblock.trophies.TrophyManager; import world.bentobox.chunkblock.commands.admin.AdminCommand; import world.bentobox.chunkblock.commands.island.PlayerCommand; import world.bentobox.chunkblock.dataobjects.OneBlockIslands; @@ -93,6 +94,8 @@ public class ChunkBlock extends GameModeAddon { private ChunkManager chunkManager; /** The per-member activity counters (the ledger/leaderboard/trophy substrate) */ private ActivityManager activityManager; + /** The config-defined island trophies and titles */ + private TrophyManager trophyManager; /** The placeholder manager for ChunkBlock */ private ChunkBlockPlaceholders phManager; /** The listener for hologram-related events */ @@ -249,8 +252,10 @@ public void onEnable() { oneBlockManager = new OneBlocksManager(this); // Initialize the chunk lock manager chunkManager = new ChunkManager(this); - // Initialize the activity counters + // Initialize the activity counters and the trophies that read them activityManager = new ActivityManager(this); + trophyManager = new TrophyManager(this); + trophyManager.loadTrophies(); // Load phase data if (loadData()) { // Failed to load - don't register anything @@ -345,6 +350,9 @@ public void onReload() { log("Reloaded ChunkBlock settings"); loadData(); } + if (trophyManager != null) { + trophyManager.loadTrophies(); + } } /** @@ -368,6 +376,13 @@ public ActivityManager getActivityManager() { return activityManager; } + /** + * @return the trophy and title manager, or null before the addon is enabled + */ + public TrophyManager getTrophyManager() { + return trophyManager; + } + /** * @return the chunk guard listener (containment and backtracking) */ @@ -503,6 +518,8 @@ public void saveDefaultConfig() { super.saveDefaultConfig(); // Save default phases panel this.saveResource("panels/phases_panel.yml", false); + // Save default trophy definitions + this.saveResource("trophies.yml", false); } /* diff --git a/src/main/java/world/bentobox/chunkblock/ChunkBlockPlaceholders.java b/src/main/java/world/bentobox/chunkblock/ChunkBlockPlaceholders.java index 5279212..902c0ec 100644 --- a/src/main/java/world/bentobox/chunkblock/ChunkBlockPlaceholders.java +++ b/src/main/java/world/bentobox/chunkblock/ChunkBlockPlaceholders.java @@ -68,6 +68,33 @@ public ChunkBlockPlaceholders(ChunkBlock addon, placeholdersManager.registerPlaceholder(addon, "island_chunk_credit", this::getIslandChunkCredit); placeholdersManager.registerPlaceholder(addon, "island_ring", this::getIslandRing); placeholdersManager.registerPlaceholder(addon, "island_rings_complete", this::getIslandRingsComplete); + placeholdersManager.registerPlaceholder(addon, "island_title", this::getIslandTitle); + placeholdersManager.registerPlaceholder(addon, "island_trophies", this::getIslandTrophies); + } + + /** + * @param user user + * @return the user's island's active trophy title as configured (MiniMessage text), + * or an empty string for no title + */ + public String getIslandTitle(User user) { + if (user == null || user.getUniqueId() == null || addon.getTrophyManager() == null) { + return ""; + } + return getUsersIsland(user).map(i -> addon.getTrophyManager().getActiveTitleText(i)).orElse(""); + } + + /** + * @param user user + * @return how many trophies the user's island has earned + */ + public String getIslandTrophies(User user) { + if (user == null || user.getUniqueId() == null || addon.getTrophyManager() == null) { + return ""; + } + return getUsersIsland(user) + .map(i -> String.valueOf(addon.getOneBlocksIsland(i).getEarnedTrophies().size())) + .orElse(""); } /** diff --git a/src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java b/src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java index dfd5740..3069152 100644 --- a/src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java +++ b/src/main/java/world/bentobox/chunkblock/activity/ActivityManager.java @@ -79,6 +79,9 @@ public void recordActivity(@NonNull Island island, @Nullable UUID member, @NonNu data.add(member == null ? ISLAND_SCOPE : member.toString(), type.name(), today, amount); data.prune(today - Math.max(1, addon.getSettings().getActivityRetentionDays()) + 1); save(data, type == CounterType.MAGIC_BLOCKS); + if (addon.getTrophyManager() != null) { + addon.getTrophyManager().check(island); + } } /** diff --git a/src/main/java/world/bentobox/chunkblock/commands/island/IslandTitleCommand.java b/src/main/java/world/bentobox/chunkblock/commands/island/IslandTitleCommand.java new file mode 100644 index 0000000..27b51ce --- /dev/null +++ b/src/main/java/world/bentobox/chunkblock/commands/island/IslandTitleCommand.java @@ -0,0 +1,115 @@ +package world.bentobox.chunkblock.commands.island; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +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.chunkblock.ChunkBlock; +import world.bentobox.chunkblock.trophies.Trophy; + +/** + * /ch title — shows the island's earned trophies and the titles they carry, and lets a + * member pick which title the island shows. One active title per island, chosen from the + * trophies it has earned; "none" clears it. + * + * @author tastybento + */ +public class IslandTitleCommand extends CompositeCommand { + + private static final String CLEAR = "none"; + + private ChunkBlock addon; + + public IslandTitleCommand(CompositeCommand islandCommand, String label, String[] aliases) { + super(islandCommand, label, aliases); + } + + @Override + public void setup() { + setDescription("chunkblock.commands.title.description"); + setParametersHelp("chunkblock.commands.title.parameters"); + setOnlyPlayer(true); + setPermission("island.title"); + addon = getAddon(); + } + + @Override + public boolean canExecute(User user, String label, List args) { + if (!Util.sameWorld(getWorld(), user.getWorld())) { + user.sendMessage("general.errors.wrong-world"); + return false; + } + if (getIslands().getIsland(getWorld(), user) == null) { + user.sendMessage("general.errors.no-island"); + return false; + } + return true; + } + + @Override + public boolean execute(User user, String label, List args) { + Island island = getIslands().getIsland(getWorld(), user); + if (args.isEmpty()) { + showTitles(user, island); + return true; + } + String id = args.get(0); + if (CLEAR.equalsIgnoreCase(id)) { + addon.getTrophyManager().setActiveTitle(island, null); + user.sendMessage("chunkblock.commands.title.cleared"); + return true; + } + if (!addon.getTrophyManager().setActiveTitle(island, id)) { + user.sendMessage("chunkblock.commands.title.not-earned"); + return false; + } + addon.getTrophyManager().getTrophy(id).ifPresent(trophy -> user + .sendMessage("chunkblock.commands.title.set", "[title]", trophy.title())); + return true; + } + + /** + * Lists the island's earned trophies, marking the ones that carry a title and which + * title is active. + */ + private void showTitles(User user, Island island) { + List earned = addon.getTrophyManager().getEarned(island); + if (earned.isEmpty()) { + user.sendMessage("chunkblock.commands.title.none-earned-yet"); + return; + } + user.sendMessage("chunkblock.commands.title.header"); + for (Trophy trophy : earned) { + if (trophy.title() == null) { + user.sendMessage("chunkblock.commands.title.trophy-entry", "[name]", trophy.name()); + } else { + user.sendMessage("chunkblock.commands.title.title-entry", "[name]", trophy.name(), + "[title]", trophy.title(), "[id]", trophy.id()); + } + } + String active = addon.getTrophyManager().getActiveTitleText(island); + if (active.isEmpty()) { + user.sendMessage("chunkblock.commands.title.no-active"); + } else { + user.sendMessage("chunkblock.commands.title.active", "[title]", active); + } + } + + @Override + public Optional> tabComplete(User user, String alias, List args) { + Island island = getIslands().getIsland(getWorld(), user); + if (island == null) { + return Optional.empty(); + } + List options = new ArrayList<>(); + options.add(CLEAR); + addon.getTrophyManager().getEarned(island).stream().filter(t -> t.title() != null) + .map(Trophy::id).forEach(options::add); + String last = args.isEmpty() ? "" : args.get(args.size() - 1); + return Optional.of(Util.tabLimit(options, last)); + } +} diff --git a/src/main/java/world/bentobox/chunkblock/commands/island/PlayerCommand.java b/src/main/java/world/bentobox/chunkblock/commands/island/PlayerCommand.java index 9641d54..b99d6db 100644 --- a/src/main/java/world/bentobox/chunkblock/commands/island/PlayerCommand.java +++ b/src/main/java/world/bentobox/chunkblock/commands/island/PlayerCommand.java @@ -33,6 +33,8 @@ public void setup() { settings.getSetCountCommand().split(" ")); // Chunk territory info and map new IslandChunksCommand(this, "chunks", new String[] {"chunks"}); + // Trophy titles + new IslandTitleCommand(this, "title", new String[] {"title"}); // Force block respawn new IslandRespawnBlockCommand(this, settings.getRespawnBlockCommand().split(" ")[0], diff --git a/src/main/java/world/bentobox/chunkblock/dataobjects/OneBlockIslands.java b/src/main/java/world/bentobox/chunkblock/dataobjects/OneBlockIslands.java index 37369ed..8da52fd 100644 --- a/src/main/java/world/bentobox/chunkblock/dataobjects/OneBlockIslands.java +++ b/src/main/java/world/bentobox/chunkblock/dataobjects/OneBlockIslands.java @@ -77,6 +77,21 @@ public class OneBlockIslands implements DataObject { @Expose private int highestRingRewarded = 0; + /** + * The trophies this island has earned, by trophy id. Earned once, stays earned: + * re-locking and re-claiming never re-awards, and only an island create or reset + * clears this. + */ + @Expose + private Set earnedTrophies = new HashSet<>(); + + /** + * The id of the earned trophy whose title the island currently shows, or empty for + * no title. + */ + @Expose + private String activeTitle = ""; + /** Fast membership view of {@link #unlockedChunks}; rebuilt lazily after loads/edits */ private transient Set unlockedSet; @@ -201,6 +216,39 @@ public void setHighestRingRewarded(int highestRingRewarded) { this.highestRingRewarded = highestRingRewarded; } + /** + * @return the earned trophy ids, never null. Mutable — callers add and clear in place. + */ + @NonNull + public Set getEarnedTrophies() { + if (earnedTrophies == null) { + earnedTrophies = new HashSet<>(); + } + return earnedTrophies; + } + + /** + * @param earnedTrophies the earned trophy ids to set + */ + public void setEarnedTrophies(Set earnedTrophies) { + this.earnedTrophies = earnedTrophies; + } + + /** + * @return the id of the trophy whose title the island shows, or an empty string + */ + @NonNull + public String getActiveTitle() { + return activeTitle == null ? "" : activeTitle; + } + + /** + * @param activeTitle the trophy id whose title to show, or an empty string for none + */ + public void setActiveTitle(String activeTitle) { + this.activeTitle = activeTitle; + } + /** * @return the phaseName */ diff --git a/src/main/java/world/bentobox/chunkblock/events/TrophyAwardEvent.java b/src/main/java/world/bentobox/chunkblock/events/TrophyAwardEvent.java new file mode 100644 index 0000000..3ededdd --- /dev/null +++ b/src/main/java/world/bentobox/chunkblock/events/TrophyAwardEvent.java @@ -0,0 +1,73 @@ +package world.bentobox.chunkblock.events; + +import org.bukkit.event.Cancellable; +import org.bukkit.event.HandlerList; +import org.eclipse.jdt.annotation.NonNull; + +import world.bentobox.bentobox.api.events.BentoBoxEvent; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.chunkblock.trophies.Trophy; + +/** + * Fired once when an island earns a trophy. Trophies are earned once and stay earned: the + * trophy is persisted whether or not this event is cancelled, so it can never be awarded + * again — mirroring {@link RingCompleteEvent}. + *

+ * Cancelling suppresses the addon's own award handling (messages, the celebration sound + * and the configured reward commands), so another plugin can take the reward over + * entirely without the addon also acting. + * + * @author tastybento + */ +public class TrophyAwardEvent extends BentoBoxEvent implements Cancellable { + + private static final HandlerList handlers = new HandlerList(); + + private final Island island; + private final Trophy trophy; + private boolean cancelled; + + /** + * @param island the island that earned the trophy + * @param trophy the trophy earned + */ + public TrophyAwardEvent(@NonNull Island island, @NonNull Trophy trophy) { + this.island = island; + this.trophy = trophy; + } + + @Override + public HandlerList getHandlers() { + return getHandlerList(); + } + + public static HandlerList getHandlerList() { + return handlers; + } + + /** + * @return the island that earned the trophy + */ + @NonNull + public Island getIsland() { + return island; + } + + /** + * @return the trophy earned + */ + @NonNull + public Trophy getTrophy() { + return trophy; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } +} diff --git a/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java b/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java index 061e941..a76f81b 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java @@ -81,6 +81,8 @@ private void resetIsland(Island island) { data.resetUnlockedChunks(); data.setLastKnownLevel(0); data.setHighestRingRewarded(0); + data.getEarnedTrophies().clear(); + data.setActiveTitle(""); } /** diff --git a/src/main/java/world/bentobox/chunkblock/trophies/Trophy.java b/src/main/java/world/bentobox/chunkblock/trophies/Trophy.java new file mode 100644 index 0000000..f0c66f8 --- /dev/null +++ b/src/main/java/world/bentobox/chunkblock/trophies/Trophy.java @@ -0,0 +1,61 @@ +package world.bentobox.chunkblock.trophies; + +import java.util.List; + +import org.bukkit.Material; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + +import world.bentobox.chunkblock.activity.CounterType; + +/** + * One config-defined trophy: a name, an icon, a condition, and what earning it grants. + * Trophies are earned once and stay earned — re-locking and re-claiming never re-awards — + * and are cleared only by an island create or reset. + * + * @param id the trophy's config key, unique among trophies + * @param name display name, MiniMessage text + * @param description one-line description, MiniMessage text + * @param icon icon material for panels and dialogs + * @param title the island title this trophy makes available, MiniMessage text, or null + * if the trophy carries no title + * @param criteria when the trophy is earned + * @param commands console commands run once on award; placeholders [owner] and [trophy] + * @param playerCommands console commands run once per island member on award; + * placeholders [player] and [trophy] + * @author tastybento + */ +public record Trophy(@NonNull String id, @NonNull String name, @NonNull String description, + @NonNull Material icon, @Nullable String title, @NonNull Criteria criteria, + @NonNull List commands, @NonNull List playerCommands) { + + /** What kind of condition earns the trophy */ + public enum CriteriaType { + /** Earned when the island has completed the given ring */ + RING, + /** Earned when an activity counter reaches a threshold */ + COUNTER + } + + /** Whose counter a COUNTER criteria reads */ + public enum Scope { + /** The island's total across all members */ + ISLAND, + /** Any single member's own count */ + MEMBER + } + + /** + * A trophy's earning condition. + * + * @param type the condition kind + * @param ring for RING: the ring that must be complete, >= 1 + * @param counter for COUNTER: the counter read + * @param scope for COUNTER: island total or any single member + * @param threshold for COUNTER: the amount that must be reached, >= 1 + * @param windowDays for COUNTER: how many days back to count, 0 for lifetime + */ + public record Criteria(@NonNull CriteriaType type, int ring, @Nullable CounterType counter, + @NonNull Scope scope, long threshold, int windowDays) { + } +} diff --git a/src/main/java/world/bentobox/chunkblock/trophies/TrophyManager.java b/src/main/java/world/bentobox/chunkblock/trophies/TrophyManager.java new file mode 100644 index 0000000..b2a3254 --- /dev/null +++ b/src/main/java/world/bentobox/chunkblock/trophies/TrophyManager.java @@ -0,0 +1,327 @@ +package world.bentobox.chunkblock.trophies; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.chunkblock.ChunkBlock; +import world.bentobox.chunkblock.activity.CounterType; +import world.bentobox.chunkblock.dataobjects.OneBlockIslands; +import world.bentobox.chunkblock.events.TrophyAwardEvent; +import world.bentobox.chunkblock.trophies.Trophy.Criteria; +import world.bentobox.chunkblock.trophies.Trophy.CriteriaType; +import world.bentobox.chunkblock.trophies.Trophy.Scope; + +/** + * The reward layer over the activity counters: config-defined trophies an island earns + * once and keeps, and the titles they carry. Conditions read the counters and the ring + * state; nothing here is hardcoded to a particular milestone. + *

+ * Earned trophies persist on the island and only an island create or reset clears them, + * so re-locking a ring on level loss and claiming it back never re-awards — the same + * lesson as {@code highestRingRewarded}. + * + * @author tastybento + */ +public class TrophyManager { + + private static final String TROPHIES_FILE = "trophies.yml"; + + private final ChunkBlock addon; + /** Trophies by id, in config order */ + private Map trophies = new LinkedHashMap<>(); + /** Islands currently mid-check, so an award's own side effects cannot re-enter */ + private final Set checking = new HashSet<>(); + + public TrophyManager(ChunkBlock addon) { + this.addon = addon; + } + + /** + * Loads trophy definitions from trophies.yml in the addon's data folder. Invalid + * trophies are logged and skipped; the rest still load. + */ + public void loadTrophies() { + File file = new File(addon.getDataFolder(), TROPHIES_FILE); + if (!file.exists()) { + addon.saveResource(TROPHIES_FILE, false); + } + loadTrophies(YamlConfiguration.loadConfiguration(file).getConfigurationSection("trophies")); + } + + /** + * Loads trophy definitions from a configuration section. Exposed for testing. + * + * @param section the "trophies" section, each key one trophy; null loads nothing + */ + public void loadTrophies(@Nullable ConfigurationSection section) { + Map loaded = new LinkedHashMap<>(); + if (section != null) { + for (String id : section.getKeys(false)) { + ConfigurationSection ts = section.getConfigurationSection(id); + if (ts == null) { + addon.logError("Trophy '" + id + "' is not a section - skipping."); + continue; + } + Trophy trophy = parseTrophy(id, ts); + if (trophy != null) { + loaded.put(id, trophy); + } + } + } + trophies = loaded; + } + + @Nullable + private Trophy parseTrophy(String id, ConfigurationSection ts) { + Material icon = Material.matchMaterial(ts.getString("icon", "GOLD_INGOT")); + if (icon == null) { + addon.logError("Trophy '" + id + "': unknown icon material '" + ts.getString("icon") + + "' - skipping."); + return null; + } + Criteria criteria = parseCriteria(id, ts.getConfigurationSection("criteria")); + if (criteria == null) { + return null; + } + return new Trophy(id, ts.getString("name", id), ts.getString("description", ""), icon, + ts.getString("title"), criteria, ts.getStringList("rewards.commands"), + ts.getStringList("rewards.player-commands")); + } + + @Nullable + private Criteria parseCriteria(String id, @Nullable ConfigurationSection cs) { + if (cs == null) { + addon.logError("Trophy '" + id + "': no criteria section - skipping."); + return null; + } + CriteriaType type = matchEnum(CriteriaType.class, cs.getString("type")); + if (type == null) { + addon.logError("Trophy '" + id + "': criteria type must be RING or COUNTER - skipping."); + return null; + } + if (type == CriteriaType.RING) { + int ring = cs.getInt("ring", 0); + if (ring < 1) { + addon.logError("Trophy '" + id + "': ring must be 1 or more - skipping."); + return null; + } + return new Criteria(type, ring, null, Scope.ISLAND, 0, 0); + } + CounterType counter = matchEnum(CounterType.class, cs.getString("counter")); + if (counter == null) { + addon.logError("Trophy '" + id + "': unknown counter '" + cs.getString("counter") + + "' - skipping."); + return null; + } + long threshold = cs.getLong("threshold", 0); + if (threshold < 1) { + addon.logError("Trophy '" + id + "': threshold must be 1 or more - skipping."); + return null; + } + Scope scope = matchEnum(Scope.class, cs.getString("scope", "ISLAND")); + if (scope == null) { + addon.logError("Trophy '" + id + "': scope must be ISLAND or MEMBER - skipping."); + return null; + } + return new Criteria(type, 0, counter, scope, threshold, Math.max(0, cs.getInt("window-days", 0))); + } + + @Nullable + private > T matchEnum(Class clazz, @Nullable String name) { + if (name == null) { + return null; + } + try { + return Enum.valueOf(clazz, name.toUpperCase(Locale.ENGLISH).replace('-', '_')); + } catch (IllegalArgumentException e) { + return null; + } + } + + /** + * @return all loaded trophies in config order + */ + @NonNull + public Collection getTrophies() { + return Collections.unmodifiableCollection(trophies.values()); + } + + /** + * @param id trophy id + * @return the trophy, or empty if no such trophy is defined + */ + @NonNull + public Optional getTrophy(@Nullable String id) { + return id == null ? Optional.empty() : Optional.ofNullable(trophies.get(id)); + } + + /** + * Evaluates every trophy this island has not yet earned and awards the ones whose + * condition is now met. Called whenever activity is recorded; cheap unless something + * is actually awarded. + * + * @param island the island to check + */ + public void check(@NonNull Island island) { + if (trophies.isEmpty() || !checking.add(island.getUniqueId())) { + // Re-entered from an award's own side effects — the outer check finishes the job + return; + } + try { + OneBlockIslands data = addon.getOneBlocksIsland(island); + for (Trophy trophy : trophies.values()) { + if (!data.getEarnedTrophies().contains(trophy.id()) && isMet(island, trophy)) { + award(island, data, trophy); + } + } + } finally { + checking.remove(island.getUniqueId()); + } + } + + private boolean isMet(Island island, Trophy trophy) { + Criteria c = trophy.criteria(); + if (c.type() == CriteriaType.RING) { + return addon.getChunkManager().completedRings(island) >= c.ring(); + } + if (c.scope() == Scope.ISLAND) { + return addon.getActivityManager().getCount(island, null, c.counter(), c.windowDays()) >= c + .threshold(); + } + return addon.getActivityManager().getContributors(island).stream().anyMatch( + uuid -> addon.getActivityManager().getCount(island, uuid, c.counter(), c.windowDays()) >= c + .threshold()); + } + + /** + * Awards a trophy. The trophy is persisted as earned before the event goes out, so it + * can never be awarded twice; cancelling the event suppresses only the addon's own + * handling — messages, sound, title and reward commands. + */ + private void award(Island island, OneBlockIslands data, Trophy trophy) { + data.getEarnedTrophies().add(trophy.id()); + // First titled trophy becomes the island's title until someone picks another + if (trophy.title() != null && data.getActiveTitle().isEmpty()) { + data.setActiveTitle(trophy.id()); + } + addon.getBlockListener().saveIsland(island); + TrophyAwardEvent event = new TrophyAwardEvent(island, trophy); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + return; + } + island.getMemberSet().forEach(uuid -> { + User user = User.getInstance(uuid); + if (user.isOnline() && addon.inWorld(user.getWorld())) { + user.sendMessage("chunkblock.trophies.awarded", "[name]", trophy.name()); + if (trophy.title() != null) { + user.sendMessage("chunkblock.trophies.title-available", "[title]", trophy.title()); + } + user.getPlayer().playSound(user.getLocation(), Sound.UI_TOAST_CHALLENGE_COMPLETE, 1F, 1.2F); + } + }); + runCommands(island, trophy); + } + + /** + * Runs the trophy's console reward commands. The config warns against paying out + * island levels here: levels buy chunks, so a level-granting trophy makes each + * milestone buy the next one. + */ + private void runCommands(Island island, Trophy trophy) { + String ownerName = island.getOwner() == null ? "" : addon.getPlayers().getName(island.getOwner()); + for (String command : trophy.commands()) { + if (!ownerName.isEmpty()) { + dispatch(command.replace("[owner]", ownerName).replace("[trophy]", trophy.id())); + } + } + if (!trophy.playerCommands().isEmpty()) { + for (UUID uuid : island.getMemberSet()) { + String name = addon.getPlayers().getName(uuid); + if (name != null && !name.isEmpty()) { + for (String command : trophy.playerCommands()) { + dispatch(command.replace("[player]", name).replace("[trophy]", trophy.id())); + } + } + } + } + } + + private void dispatch(String command) { + if (!Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command)) { + addon.logError("Trophy reward command failed: " + command); + } + } + + /** + * @param island the island + * @return the trophies this island has earned, in config order + */ + @NonNull + public List getEarned(@NonNull Island island) { + Set earned = addon.getOneBlocksIsland(island).getEarnedTrophies(); + List result = new ArrayList<>(); + for (Trophy trophy : trophies.values()) { + if (earned.contains(trophy.id())) { + result.add(trophy); + } + } + return result; + } + + /** + * @param island the island + * @return the island's active title text (MiniMessage), or an empty string if the + * island has no title or its trophy is no longer defined + */ + @NonNull + public String getActiveTitleText(@NonNull Island island) { + return getTrophy(addon.getOneBlocksIsland(island).getActiveTitle()).map(Trophy::title) + .orElse(""); + } + + /** + * Sets the island's active title to the one carried by an earned trophy, or clears it. + * + * @param island the island + * @param trophyId an earned trophy's id whose title to show, or null to clear + * @return true if the title was set or cleared, false if the trophy is unknown, + * unearned or carries no title + */ + public boolean setActiveTitle(@NonNull Island island, @Nullable String trophyId) { + OneBlockIslands data = addon.getOneBlocksIsland(island); + if (trophyId == null) { + data.setActiveTitle(""); + addon.getBlockListener().saveIsland(island); + return true; + } + Optional trophy = getTrophy(trophyId); + if (trophy.isEmpty() || trophy.get().title() == null + || !data.getEarnedTrophies().contains(trophyId)) { + return false; + } + data.setActiveTitle(trophyId); + addon.getBlockListener().saveIsland(island); + return true; + } +} diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index 1904e32..375f4d1 100755 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -331,6 +331,9 @@ permissions: chunkblock.island.chunks: description: Allow use of '/ch chunks' command - show your unlocked chunks and territory map default: TRUE + chunkblock.island.title: + description: Allow use of '/ch title' command - show earned trophies and choose the island's title + default: TRUE chunkblock.admin.chunks: description: Allow use of '/chadmin chunks' command - inspect, set or recalculate a player's unlocked chunks default: OP diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index f16f8c9..bb8d7b0 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -85,9 +85,24 @@ chunkblock: actionbar: status: "Phase: [phase-name] | Blocks: [done] / [total] | Progression: [percent-done]" not-active: "Action Bar is not active for this island" + trophies: + awarded: "Trophy earned: [name]!" + title-available: "Your island can now show the title [title] — pick it with /ch title." commands: chunks: description: "show your unlocked chunks and a map of your territory" + title: + description: "show your island's trophies and choose its title" + parameters: "[trophy-id | none]" + header: "Your island's trophies:" + trophy-entry: " - [name]" + title-entry: " - [name] — title [title] (/ch title [id])" + none-earned-yet: "Your island has not earned any trophies yet." + active: "Active title: [title]" + no-active: "No title is active. Pick one with /ch title." + set: "Your island's title is now [title]." + cleared: "Your island no longer shows a title." + not-earned: "Your island has not earned a trophy with that title." admin: chunks: parameters: " [reset]" diff --git a/src/main/resources/trophies.yml b/src/main/resources/trophies.yml new file mode 100644 index 0000000..1df9d47 --- /dev/null +++ b/src/main/resources/trophies.yml @@ -0,0 +1,64 @@ +# ChunkBlock trophies. +# +# A trophy is a name, an icon, a condition, and what earning it grants. An island earns +# each trophy once and keeps it: re-locking chunks on level loss and claiming them back +# never re-awards, and only an island create or reset clears earned trophies. +# +# A trophy with a "title" makes that title available to the island. The island's active +# title is chosen with /ch title, shown by the %chunkblock_island_title% placeholder. +# +# ------------------------------------------------------------------------------------ +# DO NOT reward island levels from trophy commands. Levels are the currency that buys +# chunks in ChunkBlock, so a trophy that grants levels makes each milestone pay for the +# next one — a compounding loop, not a reward. Economy money, items, cosmetics and +# titles are all fine; levels are not. +# ------------------------------------------------------------------------------------ +# +# Format, all fields under a unique trophy id: +# name: display name (MiniMessage text, e.g. "First Ring") +# description: one line shown in listings (MiniMessage) +# icon: a Bukkit Material name for panels/dialogs +# title: OPTIONAL island title this trophy carries (MiniMessage) +# criteria: +# type: RING or COUNTER +# # for RING: +# ring: 1 # earned when this ring of chunks is complete (1 = the eight +# # chunks around the center) +# # for COUNTER: +# counter: one of MAGIC_BLOCKS, LEVELS_EARNED, CHUNKS_CLAIMED, CHUNKS_RECLAIMED, +# CHUNKS_RELOCKED, RINGS_COMPLETED +# scope: ISLAND # ISLAND = the island's total, MEMBER = any single member +# threshold: 10 # the amount that must be reached +# window-days: 0 # 0 = lifetime; otherwise only the last N days count (must be +# # within chunkblock.activity.daily-retention-days in config.yml) +# rewards: +# commands: [] # console commands run once. Placeholders: [owner], [trophy] +# player-commands: [] # console commands run once per member. Placeholders: [player], [trophy] +trophies: + first-ring: + name: "First Ring" + description: "Complete the first ring of chunks around your magic block." + icon: GOLD_INGOT + title: "Ring Bearer" + criteria: + type: RING + ring: 1 + homesteader: + name: "Homesteader" + description: "Claim ten different chunks for your island." + icon: GRASS_BLOCK + criteria: + type: COUNTER + counter: CHUNKS_CLAIMED + scope: ISLAND + threshold: 10 + ten-thousand-blocks: + name: "Ten Thousand Blocks" + description: "Break ten thousand magic blocks as an island." + icon: DIAMOND_PICKAXE + title: "Block Breaker" + criteria: + type: COUNTER + counter: MAGIC_BLOCKS + scope: ISLAND + threshold: 10000 diff --git a/src/test/java/world/bentobox/chunkblock/ChunkBlockTest.java b/src/test/java/world/bentobox/chunkblock/ChunkBlockTest.java index 73c8bf4..fd3649b 100644 --- a/src/test/java/world/bentobox/chunkblock/ChunkBlockTest.java +++ b/src/test/java/world/bentobox/chunkblock/ChunkBlockTest.java @@ -70,6 +70,7 @@ public void tearDown() throws Exception { deleteAll(new File("database")); deleteAll(new File("database_backup")); new File("config.yml").delete(); + new File("trophies.yml").delete(); deleteAll(new File("addons")); deleteAll(new File("panels")); } @@ -134,6 +135,14 @@ public void setUp() throws Exception { // Add the new files to the jar. add(path, tempJarOutputStream); + + // Copy over trophies file from src folder + fromPath = Paths.get("src/main/resources/trophies.yml"); + path = Paths.get("trophies.yml"); + Files.copy(fromPath, path); + + // Add the new files to the jar. + add(path, tempJarOutputStream); } File dataFolder = new File("addons/ChunkBlock"); diff --git a/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java b/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java index 32bfc4b..55ec00a 100644 --- a/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java +++ b/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.any; 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; @@ -204,6 +205,18 @@ void testSaveCacheNowWritesDirectly() throws Exception { verify(h).saveObjectNow(any()); } + @Test + void testTrophyCheckRunsOnRecord() { + world.bentobox.chunkblock.trophies.TrophyManager tm = mock( + world.bentobox.chunkblock.trophies.TrophyManager.class); + when(addon.getTrophyManager()).thenReturn(tm); + am.record(island, uuid, CounterType.MAGIC_BLOCKS, 1); + verify(tm).check(island); + // A dropped record still checks nothing new but must not blow up + am.record(island, UUID.randomUUID(), CounterType.MAGIC_BLOCKS, 1); + verify(tm, times(1)).check(island); + } + @Test void testDataSurvivesSerializationShape() { // The flat map shape is the storage contract: composite keys, plain longs diff --git a/src/test/java/world/bentobox/chunkblock/commands/island/IslandTitleCommandTest.java b/src/test/java/world/bentobox/chunkblock/commands/island/IslandTitleCommandTest.java new file mode 100644 index 0000000..e0652fd --- /dev/null +++ b/src/test/java/world/bentobox/chunkblock/commands/island/IslandTitleCommandTest.java @@ -0,0 +1,139 @@ +package world.bentobox.chunkblock.commands.island; + +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.bukkit.Material; +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.chunkblock.ChunkBlock; +import world.bentobox.chunkblock.CommonTestSetup; +import world.bentobox.chunkblock.trophies.Trophy; +import world.bentobox.chunkblock.trophies.Trophy.Criteria; +import world.bentobox.chunkblock.trophies.Trophy.CriteriaType; +import world.bentobox.chunkblock.trophies.Trophy.Scope; +import world.bentobox.chunkblock.trophies.TrophyManager; + +/** + * Tests /ch title: listing earned trophies and choosing or clearing the island's title. + */ +class IslandTitleCommandTest extends CommonTestSetup { + + private static final Trophy TITLED = new Trophy("first-ring", "First Ring", "", + Material.GOLD_INGOT, "Ring Bearer", + new Criteria(CriteriaType.RING, 1, null, Scope.ISLAND, 0, 0), List.of(), List.of()); + private static final Trophy UNTITLED = new Trophy("homesteader", "Homesteader", "", + Material.GRASS_BLOCK, null, + new Criteria(CriteriaType.RING, 2, null, Scope.ISLAND, 0, 0), List.of(), List.of()); + + @Mock + private CompositeCommand ac; + @Mock + private User user; + @Mock + private ChunkBlock addon; + @Mock + private TrophyManager tm; + + private IslandTitleCommand command; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + when(ac.getAddon()).thenReturn(addon); + when(ac.getWorld()).thenReturn(world); + when(world.getName()).thenReturn("chunkblock_world"); + when(addon.getTrophyManager()).thenReturn(tm); + when(im.getIsland(any(), any(User.class))).thenReturn(island); + command = new IslandTitleCommand(ac, "title", new String[] { "title" }); + } + + @Test + void testSetup() { + assertEquals("island.title", command.getPermission()); + assertEquals("chunkblock.commands.title.description", command.getDescription()); + assertTrue(command.isOnlyPlayer()); + } + + @Test + void testListWithNothingEarned() { + when(tm.getEarned(island)).thenReturn(List.of()); + assertTrue(command.execute(user, "title", List.of())); + verify(user).sendMessage("chunkblock.commands.title.none-earned-yet"); + } + + @Test + void testListShowsTrophiesTitlesAndActiveTitle() { + when(tm.getEarned(island)).thenReturn(List.of(TITLED, UNTITLED)); + when(tm.getActiveTitleText(island)).thenReturn("Ring Bearer"); + assertTrue(command.execute(user, "title", List.of())); + verify(user).sendMessage("chunkblock.commands.title.header"); + verify(user).sendMessage("chunkblock.commands.title.title-entry", "[name]", "First Ring", + "[title]", "Ring Bearer", "[id]", "first-ring"); + verify(user).sendMessage("chunkblock.commands.title.trophy-entry", "[name]", "Homesteader"); + verify(user).sendMessage("chunkblock.commands.title.active", "[title]", "Ring Bearer"); + } + + @Test + void testListMentionsWhenNoTitleIsActive() { + when(tm.getEarned(island)).thenReturn(List.of(UNTITLED)); + when(tm.getActiveTitleText(island)).thenReturn(""); + assertTrue(command.execute(user, "title", List.of())); + verify(user).sendMessage("chunkblock.commands.title.no-active"); + } + + @Test + void testSetTitle() { + when(tm.setActiveTitle(island, "first-ring")).thenReturn(true); + when(tm.getTrophy("first-ring")).thenReturn(Optional.of(TITLED)); + assertTrue(command.execute(user, "title", List.of("first-ring"))); + verify(user).sendMessage("chunkblock.commands.title.set", "[title]", "Ring Bearer"); + } + + @Test + void testSetUnearnedTitleFails() { + when(tm.setActiveTitle(island, "first-ring")).thenReturn(false); + assertFalse(command.execute(user, "title", List.of("first-ring"))); + verify(user).sendMessage("chunkblock.commands.title.not-earned"); + } + + @Test + void testClearTitle() { + assertTrue(command.execute(user, "title", List.of("none"))); + verify(tm).setActiveTitle(island, null); + verify(user).sendMessage("chunkblock.commands.title.cleared"); + } + + @Test + void testTabCompleteOffersNoneAndTitledTrophies() { + when(tm.getEarned(island)).thenReturn(List.of(TITLED, UNTITLED)); + Optional> options = command.tabComplete(user, "title", List.of("")); + assertTrue(options.isPresent()); + assertTrue(options.get().contains("none")); + assertTrue(options.get().contains("first-ring")); + // A trophy with no title is not offered + assertFalse(options.get().contains("homesteader")); + } + + @Test + void testNoIslandCannotExecute() { + when(im.getIsland(any(), any(User.class))).thenReturn(null); + when(user.getWorld()).thenReturn(world); + assertFalse(command.canExecute(user, "title", List.of())); + verify(user).sendMessage("general.errors.no-island"); + verify(tm, never()).setActiveTitle(any(), any()); + } +} diff --git a/src/test/java/world/bentobox/chunkblock/trophies/TrophyManagerTest.java b/src/test/java/world/bentobox/chunkblock/trophies/TrophyManagerTest.java new file mode 100644 index 0000000..5cb2c73 --- /dev/null +++ b/src/test/java/world/bentobox/chunkblock/trophies/TrophyManagerTest.java @@ -0,0 +1,261 @@ +package world.bentobox.chunkblock.trophies; + +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.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +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.Set; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +import world.bentobox.bentobox.managers.PlayersManager; +import world.bentobox.chunkblock.ChunkBlock; +import world.bentobox.chunkblock.CommonTestSetup; +import world.bentobox.chunkblock.activity.ActivityManager; +import world.bentobox.chunkblock.activity.CounterType; +import world.bentobox.chunkblock.chunks.ChunkManager; +import world.bentobox.chunkblock.dataobjects.OneBlockIslands; +import world.bentobox.chunkblock.events.TrophyAwardEvent; +import world.bentobox.chunkblock.listeners.BlockListener; + +/** + * The spec for trophies: config parsing, criteria over rings and counters, the + * earned-once guarantee, the cancellable award event, and titles. + * + * @author tastybento + */ +class TrophyManagerTest extends CommonTestSetup { + + private static final String TROPHY_YAML = """ + trophies: + first-ring: + name: "First Ring" + description: "Complete ring 1" + icon: GOLD_INGOT + title: "Ring Bearer" + criteria: + type: RING + ring: 1 + rewards: + commands: + - "eco give [owner] 100" + homesteader: + name: "Homesteader" + icon: GRASS_BLOCK + criteria: + type: COUNTER + counter: CHUNKS_CLAIMED + scope: ISLAND + threshold: 10 + breaker: + name: "Breaker" + icon: DIAMOND_PICKAXE + criteria: + type: COUNTER + counter: MAGIC_BLOCKS + scope: MEMBER + threshold: 100 + window-days: 7 + bad-icon: + name: "Bad" + icon: NOT_A_MATERIAL + criteria: + type: RING + ring: 1 + bad-counter: + name: "Bad" + icon: STONE + criteria: + type: COUNTER + counter: NOT_A_COUNTER + threshold: 1 + no-criteria: + name: "Bad" + icon: STONE + """; + + @Mock + private ChunkBlock addon; + @Mock + private ChunkManager cm; + @Mock + private ActivityManager am; + @Mock + private BlockListener blockListener; + @Mock + private PlayersManager playersManager; + + private OneBlockIslands data; + private TrophyManager tm; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + when(addon.getChunkManager()).thenReturn(cm); + when(addon.getActivityManager()).thenReturn(am); + when(addon.getBlockListener()).thenReturn(blockListener); + when(addon.getPlayers()).thenReturn(playersManager); + when(playersManager.getName(any())).thenReturn("tastybento"); + data = new OneBlockIslands("island-id"); + when(addon.getOneBlocksIsland(island)).thenReturn(data); + when(island.getUniqueId()).thenReturn("island-id"); + + tm = new TrophyManager(addon); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.loadFromString(TROPHY_YAML); + tm.loadTrophies(yaml.getConfigurationSection("trophies")); + } + + @Test + void testInvalidTrophiesAreSkippedValidOnesLoad() { + assertEquals(3, tm.getTrophies().size()); + assertTrue(tm.getTrophy("first-ring").isPresent()); + assertTrue(tm.getTrophy("bad-icon").isEmpty()); + assertTrue(tm.getTrophy("bad-counter").isEmpty()); + assertTrue(tm.getTrophy("no-criteria").isEmpty()); + verify(addon, times(3)).logError(anyString()); + } + + @Test + void testNothingAwardedWhenNothingIsMet() { + tm.check(island); + assertTrue(data.getEarnedTrophies().isEmpty()); + verify(pim, never()).callEvent(any(TrophyAwardEvent.class)); + } + + @Test + void testRingTrophyAwardedOnceAndOnlyOnce() { + when(cm.completedRings(island)).thenReturn(1); + tm.check(island); + assertTrue(data.getEarnedTrophies().contains("first-ring")); + verify(pim, times(1)).callEvent(any(TrophyAwardEvent.class)); + verify(blockListener).saveIsland(island); + // Earned once, stays earned: a re-lock and re-claim cannot re-award + tm.check(island); + verify(pim, times(1)).callEvent(any(TrophyAwardEvent.class)); + } + + @Test + void testIslandCounterTrophy() { + when(am.getCount(island, null, CounterType.CHUNKS_CLAIMED, 0)).thenReturn(10L); + tm.check(island); + assertTrue(data.getEarnedTrophies().contains("homesteader")); + assertFalse(data.getEarnedTrophies().contains("breaker")); + } + + @Test + void testMemberCounterTrophyReadsEachMembersOwnCount() { + UUID mate = UUID.randomUUID(); + when(am.getContributors(island)).thenReturn(Set.of(uuid, mate)); + // The island total would pass a naive check, but no single member has 100 + when(am.getCount(eq(island), any(UUID.class), eq(CounterType.MAGIC_BLOCKS), anyInt())) + .thenReturn(60L); + tm.check(island); + assertFalse(data.getEarnedTrophies().contains("breaker")); + // Now one member crosses the line, inside the 7-day window + when(am.getCount(island, mate, CounterType.MAGIC_BLOCKS, 7)).thenReturn(100L); + tm.check(island); + assertTrue(data.getEarnedTrophies().contains("breaker")); + } + + @Test + void testAwardEventCarriesTheTrophy() { + when(cm.completedRings(island)).thenReturn(1); + tm.check(island); + ArgumentCaptor captor = ArgumentCaptor.forClass(TrophyAwardEvent.class); + verify(pim).callEvent(captor.capture()); + assertEquals("first-ring", captor.getValue().getTrophy().id()); + assertEquals(island, captor.getValue().getIsland()); + } + + @Test + void testCancelledAwardStaysEarnedButRunsNoCommands() { + doAnswer(invocation -> { + invocation.getArgument(0, TrophyAwardEvent.class).setCancelled(true); + return null; + }).when(pim).callEvent(any(TrophyAwardEvent.class)); + when(cm.completedRings(island)).thenReturn(1); + tm.check(island); + // The trophy persists — it can never be awarded twice — but the addon did not act + assertTrue(data.getEarnedTrophies().contains("first-ring")); + mockedBukkit.verify(() -> Bukkit.dispatchCommand(any(), anyString()), never()); + tm.check(island); + verify(pim, times(1)).callEvent(any(TrophyAwardEvent.class)); + } + + @Test + void testRewardCommandsRunWithPlaceholders() { + when(cm.completedRings(island)).thenReturn(1); + tm.check(island); + mockedBukkit.verify(() -> Bukkit.dispatchCommand(any(), eq("eco give tastybento 100"))); + } + + @Test + void testFirstTitledTrophyBecomesActiveTitle() { + when(cm.completedRings(island)).thenReturn(1); + tm.check(island); + assertEquals("first-ring", data.getActiveTitle()); + assertEquals("Ring Bearer", tm.getActiveTitleText(island)); + } + + @Test + void testUntitledTrophyDoesNotSetTitle() { + when(am.getCount(island, null, CounterType.CHUNKS_CLAIMED, 0)).thenReturn(10L); + tm.check(island); + assertTrue(data.getEarnedTrophies().contains("homesteader")); + assertEquals("", data.getActiveTitle()); + assertEquals("", tm.getActiveTitleText(island)); + } + + @Test + void testSetActiveTitleValidation() { + // Not earned yet + assertFalse(tm.setActiveTitle(island, "first-ring")); + // Unknown trophy + assertFalse(tm.setActiveTitle(island, "nope")); + data.getEarnedTrophies().add("homesteader"); + // Earned but carries no title + assertFalse(tm.setActiveTitle(island, "homesteader")); + data.getEarnedTrophies().add("first-ring"); + assertTrue(tm.setActiveTitle(island, "first-ring")); + assertEquals("first-ring", data.getActiveTitle()); + // Clearing always works + assertTrue(tm.setActiveTitle(island, null)); + assertEquals("", data.getActiveTitle()); + } + + @Test + void testGetEarnedKeepsConfigOrder() { + data.getEarnedTrophies().add("breaker"); + data.getEarnedTrophies().add("first-ring"); + assertEquals(2, tm.getEarned(island).size()); + assertEquals("first-ring", tm.getEarned(island).get(0).id()); + assertEquals("breaker", tm.getEarned(island).get(1).id()); + } + + @Test + void testActiveTitleOfRemovedTrophyIsEmpty() { + data.getEarnedTrophies().add("first-ring"); + data.setActiveTitle("first-ring"); + // The admin removes the trophy from trophies.yml and reloads + tm.loadTrophies(mock(org.bukkit.configuration.ConfigurationSection.class)); + assertEquals("", tm.getActiveTitleText(island)); + } +} From 20a714f3f82fc75131b19338095edfce1189ae5f Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 14 Aug 2026 19:56:25 -0700 Subject: [PATCH 3/3] fix: address SonarCloud findings on the trophy layer - guard execute() against a null island instead of trusting canExecute - constants for the repeated '[title]' and "Trophy '" literals - make the COUNTER criteria's non-null counter explicit for analysis - never map a nullable trophy title into the active-title text - suppress S9149 on getHandlerList: Bukkit's event bus requires the exact static method name on every event class Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W8GghDu6oXiUf6eCoimJqD --- .../commands/island/IslandTitleCommand.java | 12 ++++++-- .../chunkblock/events/TrophyAwardEvent.java | 3 ++ .../chunkblock/trophies/TrophyManager.java | 29 +++++++++++-------- .../activity/ActivityManagerTest.java | 4 +-- 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/main/java/world/bentobox/chunkblock/commands/island/IslandTitleCommand.java b/src/main/java/world/bentobox/chunkblock/commands/island/IslandTitleCommand.java index 27b51ce..7ea83af 100644 --- a/src/main/java/world/bentobox/chunkblock/commands/island/IslandTitleCommand.java +++ b/src/main/java/world/bentobox/chunkblock/commands/island/IslandTitleCommand.java @@ -21,6 +21,7 @@ public class IslandTitleCommand extends CompositeCommand { private static final String CLEAR = "none"; + private static final String TITLE_VAR = "[title]"; private ChunkBlock addon; @@ -53,6 +54,11 @@ public boolean canExecute(User user, String label, List args) { @Override public boolean execute(User user, String label, List args) { Island island = getIslands().getIsland(getWorld(), user); + if (island == null) { + // canExecute already refused this, but never spend credit on a null island + user.sendMessage("general.errors.no-island"); + return false; + } if (args.isEmpty()) { showTitles(user, island); return true; @@ -68,7 +74,7 @@ public boolean execute(User user, String label, List args) { return false; } addon.getTrophyManager().getTrophy(id).ifPresent(trophy -> user - .sendMessage("chunkblock.commands.title.set", "[title]", trophy.title())); + .sendMessage("chunkblock.commands.title.set", TITLE_VAR, trophy.title())); return true; } @@ -88,14 +94,14 @@ private void showTitles(User user, Island island) { user.sendMessage("chunkblock.commands.title.trophy-entry", "[name]", trophy.name()); } else { user.sendMessage("chunkblock.commands.title.title-entry", "[name]", trophy.name(), - "[title]", trophy.title(), "[id]", trophy.id()); + TITLE_VAR, trophy.title(), "[id]", trophy.id()); } } String active = addon.getTrophyManager().getActiveTitleText(island); if (active.isEmpty()) { user.sendMessage("chunkblock.commands.title.no-active"); } else { - user.sendMessage("chunkblock.commands.title.active", "[title]", active); + user.sendMessage("chunkblock.commands.title.active", TITLE_VAR, active); } } diff --git a/src/main/java/world/bentobox/chunkblock/events/TrophyAwardEvent.java b/src/main/java/world/bentobox/chunkblock/events/TrophyAwardEvent.java index 3ededdd..0183657 100644 --- a/src/main/java/world/bentobox/chunkblock/events/TrophyAwardEvent.java +++ b/src/main/java/world/bentobox/chunkblock/events/TrophyAwardEvent.java @@ -41,6 +41,9 @@ public HandlerList getHandlers() { return getHandlerList(); } + // Bukkit's event bus looks this method up reflectively by this exact name on every + // event class, so it must shadow the superclass method + @SuppressWarnings("java:S9149") public static HandlerList getHandlerList() { return handlers; } diff --git a/src/main/java/world/bentobox/chunkblock/trophies/TrophyManager.java b/src/main/java/world/bentobox/chunkblock/trophies/TrophyManager.java index b2a3254..fc9cf02 100644 --- a/src/main/java/world/bentobox/chunkblock/trophies/TrophyManager.java +++ b/src/main/java/world/bentobox/chunkblock/trophies/TrophyManager.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -45,6 +46,8 @@ public class TrophyManager { private static final String TROPHIES_FILE = "trophies.yml"; + /** Prefix for config-validation complaints */ + private static final String TROPHY = "Trophy '"; private final ChunkBlock addon; /** Trophies by id, in config order */ @@ -79,7 +82,7 @@ public void loadTrophies(@Nullable ConfigurationSection section) { for (String id : section.getKeys(false)) { ConfigurationSection ts = section.getConfigurationSection(id); if (ts == null) { - addon.logError("Trophy '" + id + "' is not a section - skipping."); + addon.logError(TROPHY + id + "' is not a section - skipping."); continue; } Trophy trophy = parseTrophy(id, ts); @@ -95,7 +98,7 @@ public void loadTrophies(@Nullable ConfigurationSection section) { private Trophy parseTrophy(String id, ConfigurationSection ts) { Material icon = Material.matchMaterial(ts.getString("icon", "GOLD_INGOT")); if (icon == null) { - addon.logError("Trophy '" + id + "': unknown icon material '" + ts.getString("icon") + addon.logError(TROPHY + id + "': unknown icon material '" + ts.getString("icon") + "' - skipping."); return null; } @@ -111,36 +114,36 @@ private Trophy parseTrophy(String id, ConfigurationSection ts) { @Nullable private Criteria parseCriteria(String id, @Nullable ConfigurationSection cs) { if (cs == null) { - addon.logError("Trophy '" + id + "': no criteria section - skipping."); + addon.logError(TROPHY + id + "': no criteria section - skipping."); return null; } CriteriaType type = matchEnum(CriteriaType.class, cs.getString("type")); if (type == null) { - addon.logError("Trophy '" + id + "': criteria type must be RING or COUNTER - skipping."); + addon.logError(TROPHY + id + "': criteria type must be RING or COUNTER - skipping."); return null; } if (type == CriteriaType.RING) { int ring = cs.getInt("ring", 0); if (ring < 1) { - addon.logError("Trophy '" + id + "': ring must be 1 or more - skipping."); + addon.logError(TROPHY + id + "': ring must be 1 or more - skipping."); return null; } return new Criteria(type, ring, null, Scope.ISLAND, 0, 0); } CounterType counter = matchEnum(CounterType.class, cs.getString("counter")); if (counter == null) { - addon.logError("Trophy '" + id + "': unknown counter '" + cs.getString("counter") + addon.logError(TROPHY + id + "': unknown counter '" + cs.getString("counter") + "' - skipping."); return null; } long threshold = cs.getLong("threshold", 0); if (threshold < 1) { - addon.logError("Trophy '" + id + "': threshold must be 1 or more - skipping."); + addon.logError(TROPHY + id + "': threshold must be 1 or more - skipping."); return null; } Scope scope = matchEnum(Scope.class, cs.getString("scope", "ISLAND")); if (scope == null) { - addon.logError("Trophy '" + id + "': scope must be ISLAND or MEMBER - skipping."); + addon.logError(TROPHY + id + "': scope must be ISLAND or MEMBER - skipping."); return null; } return new Criteria(type, 0, counter, scope, threshold, Math.max(0, cs.getInt("window-days", 0))); @@ -204,12 +207,14 @@ private boolean isMet(Island island, Trophy trophy) { if (c.type() == CriteriaType.RING) { return addon.getChunkManager().completedRings(island) >= c.ring(); } + // Parsing guarantees every COUNTER criteria has a counter + CounterType counter = Objects.requireNonNull(c.counter()); if (c.scope() == Scope.ISLAND) { - return addon.getActivityManager().getCount(island, null, c.counter(), c.windowDays()) >= c + return addon.getActivityManager().getCount(island, null, counter, c.windowDays()) >= c .threshold(); } return addon.getActivityManager().getContributors(island).stream().anyMatch( - uuid -> addon.getActivityManager().getCount(island, uuid, c.counter(), c.windowDays()) >= c + uuid -> addon.getActivityManager().getCount(island, uuid, counter, c.windowDays()) >= c .threshold()); } @@ -296,8 +301,8 @@ public List getEarned(@NonNull Island island) { */ @NonNull public String getActiveTitleText(@NonNull Island island) { - return getTrophy(addon.getOneBlocksIsland(island).getActiveTitle()).map(Trophy::title) - .orElse(""); + return getTrophy(addon.getOneBlocksIsland(island).getActiveTitle()) + .map(trophy -> trophy.title() == null ? "" : trophy.title()).orElse(""); } /** diff --git a/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java b/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java index 55ec00a..a6d6a1e 100644 --- a/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java +++ b/src/test/java/world/bentobox/chunkblock/activity/ActivityManagerTest.java @@ -210,10 +210,10 @@ void testTrophyCheckRunsOnRecord() { world.bentobox.chunkblock.trophies.TrophyManager tm = mock( world.bentobox.chunkblock.trophies.TrophyManager.class); when(addon.getTrophyManager()).thenReturn(tm); - am.record(island, uuid, CounterType.MAGIC_BLOCKS, 1); + am.recordActivity(island, uuid, CounterType.MAGIC_BLOCKS, 1); verify(tm).check(island); // A dropped record still checks nothing new but must not blow up - am.record(island, UUID.randomUUID(), CounterType.MAGIC_BLOCKS, 1); + am.recordActivity(island, UUID.randomUUID(), CounterType.MAGIC_BLOCKS, 1); verify(tm, times(1)).check(island); }