From a61d0975998fa01d48c92d1532aecd2dc9f715bc Mon Sep 17 00:00:00 2001 From: tastybento Date: Tue, 30 Jun 2026 23:15:17 -0700 Subject: [PATCH 01/27] ci: bump pinned reusable workflow to fe4b1f0 --- .github/workflows/publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d03b20f0..0891c6f7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ on: jobs: publish: - uses: bentoboxworld/.github/.github/workflows/publish-platforms.yml@71bf927bce32586216baa6995f21852d944b98b9 # master + uses: bentoboxworld/.github/.github/workflows/publish-platforms.yml@fe4b1f03c19f4fd4212020a06a07a7097923adec # master with: use_release_asset: "true" # publish the jar attached to the release; do not rebuild hangar_slug: "Challenges-For-BentoBox" # blank = skip Hangar @@ -27,4 +27,4 @@ jobs: version: ${{ inputs.version }} # empty on release events -> falls back to the release tag secrets: HANGAR_API_KEY: ${{ secrets.HANGAR_API_KEY }} - CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} + CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} \ No newline at end of file From 0625dd3feadf1714bae09fb016655dfee10b08a9 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 3 Jul 2026 11:53:59 -0700 Subject: [PATCH 02/27] ci: bump pinned publish-platforms.yml to ca2dcd1 --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0891c6f7..238d043e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ on: jobs: publish: - uses: bentoboxworld/.github/.github/workflows/publish-platforms.yml@fe4b1f03c19f4fd4212020a06a07a7097923adec # master + uses: bentoboxworld/.github/.github/workflows/publish-platforms.yml@ca2dcd167e8db4e0f671a976080744dda43801a6 # master with: use_release_asset: "true" # publish the jar attached to the release; do not rebuild hangar_slug: "Challenges-For-BentoBox" # blank = skip Hangar From e2f8a7bc4f51372961d2d668c3362e382ee00cd5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 14:03:23 -0700 Subject: [PATCH 03/27] Honour per-challenge removeWhenCompleted flag in player GUI The player panel only checked the global remove-complete-one-time-challenges setting; the per-challenge "remove when completed" flag set in the admin editor was never consulted, so it silently did nothing. Fixes #337 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .../panel/user/ChallengesPanel.java | 20 +- .../panel/user/ChallengesPanelTest.java | 254 ++++++++++++++++++ 2 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 src/test/java/world/bentobox/challenges/panel/user/ChallengesPanelTest.java diff --git a/src/main/java/world/bentobox/challenges/panel/user/ChallengesPanel.java b/src/main/java/world/bentobox/challenges/panel/user/ChallengesPanel.java index 73709f59..cbd4caf5 100644 --- a/src/main/java/world/bentobox/challenges/panel/user/ChallengesPanel.java +++ b/src/main/java/world/bentobox/challenges/panel/user/ChallengesPanel.java @@ -114,11 +114,11 @@ private void updateFreeChallengeList() { this.freeChallengeList = this.manager.getFreeChallenges(this.world); - if (this.addon.getChallengesSettings().isRemoveCompleteOneTimeChallenges()) - { - this.freeChallengeList.removeIf(challenge -> !challenge.isRepeatable() && - this.manager.isChallengeComplete(this.user, this.world, challenge)); - } + // Remove completed non-repeatable challenges if global setting is on OR per-challenge flag is set + final boolean globalRemoveCompleted = this.addon.getChallengesSettings().isRemoveCompleteOneTimeChallenges(); + this.freeChallengeList.removeIf(challenge -> !challenge.isRepeatable() && + this.manager.isChallengeComplete(this.user, this.world, challenge) && + (globalRemoveCompleted || challenge.isRemoveWhenCompleted())); // Remove all undeployed challenges if VisibilityMode is set to Hidden. if (this.addon.getChallengesSettings().getVisibilityMode().equals(SettingsUtils.VisibilityMode.HIDDEN)) @@ -155,11 +155,11 @@ private void updateChallengeList() { this.challengeList = this.manager.getLevelChallenges(this.lastSelectedLevel.getLevel(), true); - if (this.addon.getChallengesSettings().isRemoveCompleteOneTimeChallenges()) - { - this.challengeList.removeIf(challenge -> !challenge.isRepeatable() && - this.manager.isChallengeComplete(this.user, this.world, challenge)); - } + // Remove completed non-repeatable challenges if global setting is on OR per-challenge flag is set + final boolean globalRemoveCompleted = this.addon.getChallengesSettings().isRemoveCompleteOneTimeChallenges(); + this.challengeList.removeIf(challenge -> !challenge.isRepeatable() && + this.manager.isChallengeComplete(this.user, this.world, challenge) && + (globalRemoveCompleted || challenge.isRemoveWhenCompleted())); // Remove all undeployed challenges if VisibilityMode is set to Hidden. if (this.addon.getChallengesSettings().getVisibilityMode().equals(SettingsUtils.VisibilityMode.HIDDEN)) diff --git a/src/test/java/world/bentobox/challenges/panel/user/ChallengesPanelTest.java b/src/test/java/world/bentobox/challenges/panel/user/ChallengesPanelTest.java new file mode 100644 index 00000000..b985b92c --- /dev/null +++ b/src/test/java/world/bentobox/challenges/panel/user/ChallengesPanelTest.java @@ -0,0 +1,254 @@ +package world.bentobox.challenges.panel.user; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.challenges.ChallengesAddon; +import world.bentobox.challenges.config.Settings; +import world.bentobox.challenges.config.SettingsUtils; +import world.bentobox.challenges.database.object.Challenge; +import world.bentobox.challenges.managers.ChallengesManager; +import world.bentobox.challenges.panel.PanelTestHelper; + +/** + * Tests for {@link ChallengesPanel} challenge filtering logic (issue #337). + */ +@DisplayName("ChallengesPanel - Remove when completed flag") +class ChallengesPanelTest { + + @Mock + private ChallengesAddon addon; + @Mock + private User user; + @Mock + private World world; + @Mock + private ChallengesManager manager; + @Mock + private Settings settings; + + private AutoCloseable closeable; + private MockedStatic mockedBukkit; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + ServerMock mbServer = MockBukkit.mock(); + PanelTestHelper.primeBukkitRegistry(); + + when(addon.getChallengesManager()).thenReturn(manager); + when(addon.getChallengesSettings()).thenReturn(settings); + PanelTestHelper.setupUserTranslations(user); + when(user.getWorld()).thenReturn(world); + when(user.getUniqueId()).thenReturn(UUID.randomUUID()); + when(manager.hasAnyChallengeData(world)).thenReturn(true); + + mockedBukkit = Mockito.mockStatic(Bukkit.class, Mockito.RETURNS_DEEP_STUBS); + mockedBukkit.when(Bukkit::getServer).thenReturn(mbServer); + mockedBukkit.when(Bukkit::getItemFactory).thenReturn(mbServer.getItemFactory()); + mockedBukkit.when(Bukkit::getUnsafe).thenReturn(mbServer.getUnsafe()); + } + + @AfterEach + void tearDown() throws Exception { + if (mockedBukkit != null) mockedBukkit.closeOnDemand(); + if (closeable != null) closeable.close(); + MockBukkit.unmock(); + Mockito.framework().clearInlineMocks(); + } + + private ChallengesPanel createPanel() throws Exception { + // Create panel via reflection since constructor is private + java.lang.reflect.Constructor ctor = + ChallengesPanel.class.getDeclaredConstructor( + ChallengesAddon.class, World.class, User.class, String.class, String.class); + ctor.setAccessible(true); + return ctor.newInstance(addon, world, user, "challenges", "challenges"); + } + + private void callUpdateFreeChallengeList(ChallengesPanel panel) throws Exception { + Method method = ChallengesPanel.class.getDeclaredMethod("updateFreeChallengeList"); + method.setAccessible(true); + method.invoke(panel); + } + + private List getFreeChallengeList(ChallengesPanel panel) throws Exception { + Field field = ChallengesPanel.class.getDeclaredField("freeChallengeList"); + field.setAccessible(true); + return (List) field.get(panel); + } + + @Test + @DisplayName("Global OFF, per-challenge OFF: completed non-repeatable should NOT be hidden") + void testGlobalOffPerChallengeOff() throws Exception { + // Setup + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(false); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.VISIBLE); + + Challenge completed = PanelTestHelper.createBasicChallenge("TestChallenge", true); + when(completed.isRepeatable()).thenReturn(false); + when(completed.isRemoveWhenCompleted()).thenReturn(false); + + List challenges = new ArrayList<>(); + challenges.add(completed); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + when(manager.isChallengeComplete(user, world, completed)).thenReturn(true); + + // Execute + ChallengesPanel panel = createPanel(); + callUpdateFreeChallengeList(panel); + + // Verify: challenge should still be in the list + assertTrue(getFreeChallengeList(panel).contains(completed), + "Completed challenge should be visible when both global and per-challenge flags are OFF"); + } + + @Test + @DisplayName("Global ON, per-challenge OFF: completed non-repeatable should be hidden") + void testGlobalOnPerChallengeOff() throws Exception { + // Setup + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(true); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.VISIBLE); + + Challenge completed = PanelTestHelper.createBasicChallenge("TestChallenge", true); + when(completed.isRepeatable()).thenReturn(false); + when(completed.isRemoveWhenCompleted()).thenReturn(false); + + List challenges = new ArrayList<>(); + challenges.add(completed); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + when(manager.isChallengeComplete(user, world, completed)).thenReturn(true); + + // Execute + ChallengesPanel panel = createPanel(); + callUpdateFreeChallengeList(panel); + + // Verify: challenge should be hidden + assertFalse(getFreeChallengeList(panel).contains(completed), + "Completed challenge should be hidden when global setting is ON"); + } + + @Test + @DisplayName("Global OFF, per-challenge ON: completed non-repeatable should be hidden") + void testGlobalOffPerChallengeOn() throws Exception { + // Setup + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(false); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.VISIBLE); + + Challenge completed = PanelTestHelper.createBasicChallenge("TestChallenge", true); + when(completed.isRepeatable()).thenReturn(false); + when(completed.isRemoveWhenCompleted()).thenReturn(true); // Per-challenge flag is ON + + List challenges = new ArrayList<>(); + challenges.add(completed); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + when(manager.isChallengeComplete(user, world, completed)).thenReturn(true); + + // Execute + ChallengesPanel panel = createPanel(); + callUpdateFreeChallengeList(panel); + + // Verify: challenge should be hidden due to per-challenge flag + assertFalse(getFreeChallengeList(panel).contains(completed), + "Completed challenge should be hidden when per-challenge flag is ON"); + } + + @Test + @DisplayName("Global ON, per-challenge ON: completed non-repeatable should be hidden") + void testGlobalOnPerChallengeOn() throws Exception { + // Setup + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(true); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.VISIBLE); + + Challenge completed = PanelTestHelper.createBasicChallenge("TestChallenge", true); + when(completed.isRepeatable()).thenReturn(false); + when(completed.isRemoveWhenCompleted()).thenReturn(true); // Per-challenge flag is ON + + List challenges = new ArrayList<>(); + challenges.add(completed); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + when(manager.isChallengeComplete(user, world, completed)).thenReturn(true); + + // Execute + ChallengesPanel panel = createPanel(); + callUpdateFreeChallengeList(panel); + + // Verify: challenge should be hidden + assertFalse(getFreeChallengeList(panel).contains(completed), + "Completed challenge should be hidden when both flags are ON"); + } + + @Test + @DisplayName("Completed repeatable challenge should never be hidden, regardless of flags") + void testRepeatableChallengeNeverHidden() throws Exception { + // Setup + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(true); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.VISIBLE); + + Challenge completed = PanelTestHelper.createBasicChallenge("TestChallenge", true); + when(completed.isRepeatable()).thenReturn(true); // Repeatable + when(completed.isRemoveWhenCompleted()).thenReturn(true); + when(completed.getMaxTimes()).thenReturn(5); + + List challenges = new ArrayList<>(); + challenges.add(completed); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + when(manager.isChallengeComplete(user, world, completed)).thenReturn(true); + + // Execute + ChallengesPanel panel = createPanel(); + callUpdateFreeChallengeList(panel); + + // Verify: repeatable challenge should still be in the list + assertTrue(getFreeChallengeList(panel).contains(completed), + "Repeatable challenge should always be visible, regardless of completion status or flags"); + } + + @Test + @DisplayName("Incomplete non-repeatable challenge should never be hidden, regardless of flags") + void testIncompleteNonRepeatableNeverHidden() throws Exception { + // Setup + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(true); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.VISIBLE); + + Challenge incomplete = PanelTestHelper.createBasicChallenge("TestChallenge", true); + when(incomplete.isRepeatable()).thenReturn(false); + when(incomplete.isRemoveWhenCompleted()).thenReturn(true); + + List challenges = new ArrayList<>(); + challenges.add(incomplete); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + when(manager.isChallengeComplete(user, world, incomplete)).thenReturn(false); + + // Execute + ChallengesPanel panel = createPanel(); + callUpdateFreeChallengeList(panel); + + // Verify: incomplete challenge should still be in the list + assertTrue(getFreeChallengeList(panel).contains(incomplete), + "Incomplete challenge should always be visible"); + } +} From ef08966af4017e9b5c12219509549b2766b4d7d2 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 14:05:28 -0700 Subject: [PATCH 04/27] Add setting to open the challenges GUI while off-island New gui-settings option (default off) that skips only the location-on-island check when opening /challenges. Completion checks in TryToComplete are unchanged, so island protection still applies when actually completing challenges. Fixes #349 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .../commands/ChallengesPlayerCommand.java | 3 +- .../bentobox/challenges/config/Settings.java | 28 +++++++++++++++++++ .../panel/admin/EditSettingsPanel.java | 23 ++++++++++++++- src/main/resources/config.yml | 4 +++ src/main/resources/locales/en-US.yml | 8 ++++++ .../commands/ChallengesCommandTest.java | 21 ++++++++++++++ 6 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/main/java/world/bentobox/challenges/commands/ChallengesPlayerCommand.java b/src/main/java/world/bentobox/challenges/commands/ChallengesPlayerCommand.java index e0b517ad..c2bc82d4 100644 --- a/src/main/java/world/bentobox/challenges/commands/ChallengesPlayerCommand.java +++ b/src/main/java/world/bentobox/challenges/commands/ChallengesPlayerCommand.java @@ -66,10 +66,11 @@ public boolean canExecute(User user, String label, List args) Utils.sendMessage(user, this.getWorld(), "general.errors.no-island"); return false; } else if (ChallengesAddon.CHALLENGES_WORLD_PROTECTION.isSetForWorld(this.getWorld()) && + !((ChallengesAddon) this.getAddon()).getChallengesSettings().isOpenAnywhere() && !this.getIslands().locationIsOnIsland(user.getPlayer(), user.getLocation())) { // Do not open gui if player is not on the island, but challenges requires island for - // completion. + // completion, unless open-anywhere is enabled. Utils.sendMessage(user, this.getWorld(), Constants.ERRORS + "not-on-island"); return false; } diff --git a/src/main/java/world/bentobox/challenges/config/Settings.java b/src/main/java/world/bentobox/challenges/config/Settings.java index 1822c133..505d4147 100644 --- a/src/main/java/world/bentobox/challenges/config/Settings.java +++ b/src/main/java/world/bentobox/challenges/config/Settings.java @@ -115,6 +115,12 @@ public class Settings implements ConfigObject @ConfigEntry(path = "gui-settings.undeployed-view-mode") private VisibilityMode visibilityMode = VisibilityMode.VISIBLE; + @ConfigComment("") + @ConfigComment("Allow players to open the challenges GUI without being on their island.") + @ConfigComment("Note: Challenges completion still requires being on the island when world protection is enabled.") + @ConfigEntry(path = "gui-settings.open-anywhere") + private boolean openAnywhere = false; + @ConfigComment("") @ConfigComment("This allows to change default locked level icon. This option may be") @@ -712,4 +718,26 @@ public void setIncludeUndeployed(boolean includeUndeployed) { this.includeUndeployed = includeUndeployed; } + + + /** + * Is open anywhere boolean. + * + * @return the boolean + */ + public boolean isOpenAnywhere() + { + return openAnywhere; + } + + + /** + * Sets open anywhere. + * + * @param openAnywhere whether to allow opening GUI from anywhere + */ + public void setOpenAnywhere(boolean openAnywhere) + { + this.openAnywhere = openAnywhere; + } } diff --git a/src/main/java/world/bentobox/challenges/panel/admin/EditSettingsPanel.java b/src/main/java/world/bentobox/challenges/panel/admin/EditSettingsPanel.java index 69f0a20e..fe147b4c 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/EditSettingsPanel.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/EditSettingsPanel.java @@ -121,6 +121,7 @@ protected void build() panelBuilder.item(20, this.getSettingsButton(Button.REMOVE_COMPLETED)); panelBuilder.item(29, this.getSettingsButton(Button.VISIBILITY_MODE)); panelBuilder.item(30, this.getSettingsButton(Button.INCLUDE_UNDEPLOYED)); + panelBuilder.item(32, this.getSettingsButton(Button.OPEN_ANYWHERE)); panelBuilder.item(21, this.getSettingsButton(Button.LOCKED_LEVEL_ICON)); @@ -485,6 +486,22 @@ else if (this.settings.getVisibilityMode().equals(VisibilityMode.HIDDEN)) description.add(""); description.add(this.user.getTranslation(Constants.CLICK_TO_TOGGLE)); } + case OPEN_ANYWHERE -> { + description.add(this.user.getTranslation(reference + + (this.settings.isOpenAnywhere() ? Constants.ENABLED_KEY : Constants.DISABLED_KEY))); + + icon = new ItemStack(Material.ELYTRA); + clickHandler = (panel, user1, clickType, i) -> { + this.settings.setOpenAnywhere(!this.settings.isOpenAnywhere()); + panel.getInventory().setItem(i, this.getSettingsButton(button).getItem()); + this.addon.saveSettings(); + return true; + }; + glow = this.settings.isOpenAnywhere(); + + description.add(""); + description.add(this.user.getTranslation(Constants.CLICK_TO_TOGGLE)); + } default -> { icon = new ItemStack(Material.PAPER); clickHandler = null; @@ -596,7 +613,11 @@ private enum Button /** * This allows to switch between different challenges visibility modes. */ - VISIBILITY_MODE + VISIBILITY_MODE, + /** + * This allows players to open the GUI without being on their island. + */ + OPEN_ANYWHERE } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 64f9eeab..d2364a61 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -76,6 +76,10 @@ gui-settings: # TOGGLEABLE - Currently not implemented. undeployed-view-mode: VISIBLE # + # Allow players to open the challenges GUI without being on their island. + # Note: Challenges completion still requires being on the island when world protection is enabled. + open-anywhere: false + # # This allows to change default locked level icon. This option may be # overwritten by each challenge level. If challenge level has specified # their locked level icon, then it will be used, instead of this one. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index c2a95997..28c2e670 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -770,6 +770,14 @@ challenges: should be counted towards level completion. enabled: "Enabled" disabled: "Disabled" + open_anywhere: + name: "Open GUI Anywhere" + description: |- + Allows players to open the challenges GUI + from anywhere. Completion still requires + being on the island when protected. + enabled: "Enabled" + disabled: "Disabled" download: name: "Download Libraries" description: |- diff --git a/src/test/java/world/bentobox/challenges/commands/ChallengesCommandTest.java b/src/test/java/world/bentobox/challenges/commands/ChallengesCommandTest.java index e3061199..ae974fea 100644 --- a/src/test/java/world/bentobox/challenges/commands/ChallengesCommandTest.java +++ b/src/test/java/world/bentobox/challenges/commands/ChallengesCommandTest.java @@ -247,4 +247,25 @@ void testSetup() { assertEquals(1, cc.getSubCommands(true).size()); } + @Test + void testCanExecuteOffIslandWithProtectionAndNoOpenAnywhere() { + // Player is off island and world protection is on, but openAnywhere is false + when(im.locationIsOnIsland(any(Player.class), any())).thenReturn(false); + // Note: Since CHALLENGES_WORLD_PROTECTION flag has default setting true, + // and TestWorldSetting.getWorldFlags() returns an empty map by default, + // the flag will check as enabled. We test behavior when it's restricted. + assertFalse(cc.canExecute(user, "challenges", Collections.emptyList())); + verify(user).getTranslation(world, "challenges.errors.not-on-island"); + } + + @Test + void testCanExecuteOffIslandWithProtectionAndOpenAnywhere() { + // Player is off island but openAnywhere is enabled + when(im.locationIsOnIsland(any(Player.class), any())).thenReturn(false); + Settings settings = (Settings) addon.getChallengesSettings(); + settings.setOpenAnywhere(true); + assertTrue(cc.canExecute(user, "challenges", Collections.emptyList())); + verify(user, never()).getTranslation(world, "challenges.errors.not-on-island"); + } + } From 3532b663375be04772cc1cde8bdf1a8045f16be7 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 14:07:05 -0700 Subject: [PATCH 05/27] Run level-completion check when challenges are completed by admins Normal completion via TryToComplete validated level completion after marking a challenge complete, but the admin command and admin GUI called setChallengeComplete directly, so a level whose last challenge was admin-completed never registered as complete. Fixes #385 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .../commands/admin/CompleteCommand.java | 2 + .../managers/ChallengesManager.java | 105 ++++++++++++++++++ .../panel/admin/ListUsersPanel.java | 9 +- .../challenges/tasks/TryToComplete.java | 82 +++++++------- .../challenges/tasks/TryToCompleteTest.java | 12 +- 5 files changed, 161 insertions(+), 49 deletions(-) diff --git a/src/main/java/world/bentobox/challenges/commands/admin/CompleteCommand.java b/src/main/java/world/bentobox/challenges/commands/admin/CompleteCommand.java index 71f5b3a1..fb1c79a9 100644 --- a/src/main/java/world/bentobox/challenges/commands/admin/CompleteCommand.java +++ b/src/main/java/world/bentobox/challenges/commands/admin/CompleteCommand.java @@ -108,6 +108,8 @@ else if (!args.get(1).isEmpty()) this.addon.getChallengesManager().setChallengeComplete( targetUUID, this.getWorld(), challenge, user.getUniqueId()); + // Try to complete the level if all challenges are done + this.addon.getChallengesManager().tryCompleteLevelAdmin(target, this.getWorld(), challenge); if (user.isPlayer()) { diff --git a/src/main/java/world/bentobox/challenges/managers/ChallengesManager.java b/src/main/java/world/bentobox/challenges/managers/ChallengesManager.java index 7be3baa0..ffb8e6cb 100644 --- a/src/main/java/world/bentobox/challenges/managers/ChallengesManager.java +++ b/src/main/java/world/bentobox/challenges/managers/ChallengesManager.java @@ -1774,6 +1774,111 @@ public boolean validateLevelCompletion(User user, World world, ChallengeLevel le } + /** + * This method attempts to complete a level for a user by validating all challenges + * in the level are complete. If the level is completable, it marks the level as complete + * and fires a LevelCompletedEvent. + * + * @param user User who completed the level. + * @param world World where level must be completed. + * @param challenge Challenge whose level should be checked. + * @return The completed ChallengeLevel if level was completed, null otherwise. + */ + @Nullable + public ChallengeLevel tryCompleteLevel(User user, World world, Challenge challenge) + { + ChallengeLevel level = this.getCompletableLevel(user, world, challenge); + if (level != null) + { + this.setLevelComplete(user, world, level); + } + return level; + } + + + /** + * This method attempts to complete a level for a user via admin action. + * Similar to tryCompleteLevel but fires an admin LevelCompletedEvent. + * + * @param user User who had the level completed by admin. + * @param world World where level must be completed. + * @param challenge Challenge whose level should be checked. + * @return The completed ChallengeLevel if level was completed, null otherwise. + */ + @Nullable + public ChallengeLevel tryCompleteLevelAdmin(User user, World world, Challenge challenge) + { + ChallengeLevel level = this.getCompletableLevel(user, world, challenge); + if (level != null) + { + this.setLevelCompleteAdmin(user, world, level); + } + return level; + } + + + /** + * Helper method that checks if a level is completable (all challenges done, not already + * completed, and not a free level). + * + * @param user User to check for. + * @param world World to check in. + * @param challenge Challenge whose level to check. + * @return The ChallengeLevel if completable, null otherwise. + */ + @Nullable + private ChallengeLevel getCompletableLevel(User user, World world, Challenge challenge) + { + String levelID = challenge.getLevel(); + if (levelID.equals(ChallengesManager.FREE)) + { + return null; + } + + ChallengeLevel level = this.getLevel(challenge); + if (level == null) + { + return null; + } + + if (this.isLevelCompleted(user, world, level)) + { + return null; + } + + if (!this.validateLevelCompletion(user, world, level)) + { + return null; + } + + return level; + } + + + /** + * Helper method to set a level as complete by admin and fire an admin event. + * + * @param user User who had the level completed. + * @param world World where level was completed. + * @param level Level to mark as complete. + */ + private void setLevelCompleteAdmin(User user, World world, ChallengeLevel level) + { + String storageID = this.getDataUniqueID(user, Util.getWorld(world)); + + this.setLevelComplete(storageID, level.getUniqueId()); + this.addLogEntry(storageID, new LogEntry.Builder("COMPLETE_LEVEL"). + data(USER_ID, user.getUniqueId().toString()). + data("level", level.getUniqueId()).build()); + + // Fire admin event that admin completes level + Bukkit.getPluginManager().callEvent( + new LevelCompletedEvent(level.getUniqueId(), + user.getUniqueId(), + true)); + } + + /** * This method returns LevelStatus object for given challenge level. * @param uniqueId UUID of user who need to be validated. diff --git a/src/main/java/world/bentobox/challenges/panel/admin/ListUsersPanel.java b/src/main/java/world/bentobox/challenges/panel/admin/ListUsersPanel.java index fd31eefc..9a29dcf7 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/ListUsersPanel.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/ListUsersPanel.java @@ -222,11 +222,16 @@ protected PanelItem createElementButton(Player player) (status, valueSet) -> { if (Boolean.TRUE.equals(status)) { - valueSet.forEach(challenge -> + valueSet.forEach(challenge -> { manager.setChallengeComplete(player.getUniqueId(), this.world, challenge, - this.user.getUniqueId())); + this.user.getUniqueId()); + // Try to complete the level if all challenges are done + manager.tryCompleteLevelAdmin(User.getInstance(player.getUniqueId()), + this.world, + challenge); + }); } this.build(); diff --git a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java index 30071ff5..1281baf5 100644 --- a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java +++ b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java @@ -364,60 +364,54 @@ ChallengeResult build(int maxTimes) this.manager.setChallengeComplete(this.user, this.world, this.challenge, result.getFactor()); // Check level completion for non-free challenges - if (!result.wasCompleted() && - !this.challenge.getLevel().equals(ChallengesManager.FREE)) + if (!result.wasCompleted()) { - ChallengeLevel level = this.manager.getLevel(this.challenge); + ChallengeLevel level = this.manager.tryCompleteLevel(this.user, this.world, this.challenge); - if (level != null && !this.manager.isLevelCompleted(this.user, this.world, level)) + if (level != null) { - if (this.manager.validateLevelCompletion(this.user, this.world, level)) + // Item rewards + for (ItemStack reward : level.getRewardItems()) { - // Item rewards - for (ItemStack reward : level.getRewardItems()) - { - // Clone is necessary because otherwise it will chane reward itemstack - // amount. - this.user.getInventory().addItem(reward.clone()).forEach((k, v) -> - this.user.getWorld().dropItem(this.user.getLocation(), v)); - } - - // Money Reward - if (this.addon.isEconomyProvided()) - { - this.addon.getEconomyProvider().deposit(this.user, level.getRewardMoney()); - } + // Clone is necessary because otherwise it will chane reward itemstack + // amount. + this.user.getInventory().addItem(reward.clone()).forEach((k, v) -> + this.user.getWorld().dropItem(this.user.getLocation(), v)); + } - // Experience Reward - this.user.getPlayer().giveExp(level.getRewardExperience()); + // Money Reward + if (this.addon.isEconomyProvided()) + { + this.addon.getEconomyProvider().deposit(this.user, level.getRewardMoney()); + } - // Run commands - this.runCommands(level.getRewardCommands()); + // Experience Reward + this.user.getPlayer().giveExp(level.getRewardExperience()); - Utils.sendMessage(this.user, - this.world, Constants.MESSAGES + "you-completed-level", Constants.PARAMETER_VALUE, - level.getFriendlyName()); + // Run commands + this.runCommands(level.getRewardCommands()); - if (this.addon.getChallengesSettings().isBroadcastMessages()) - { - Bukkit.getOnlinePlayers().stream(). - map(User::getInstance).forEach(user -> Utils.sendMessage(user, - this.world, - Constants.MESSAGES + "name-has-completed-level", - Constants.PARAMETER_NAME, this.user.getName(), - Constants.PARAMETER_VALUE, level.getFriendlyName())); - } + Utils.sendMessage(this.user, + this.world, Constants.MESSAGES + "you-completed-level", Constants.PARAMETER_VALUE, + level.getFriendlyName()); - this.manager.setLevelComplete(this.user, this.world, level); + if (this.addon.getChallengesSettings().isBroadcastMessages()) + { + Bukkit.getOnlinePlayers().stream(). + map(User::getInstance).forEach(user -> Utils.sendMessage(user, + this.world, + Constants.MESSAGES + "name-has-completed-level", + Constants.PARAMETER_NAME, this.user.getName(), + Constants.PARAMETER_VALUE, level.getFriendlyName())); + } - // sends title to player on level completion - if (this.addon.getChallengesSettings().isShowCompletionTitle()) - { - this.user.getPlayer().sendTitle( - this.parseLevel(this.user.getTranslation("challenges.titles.level-title"), level), - this.parseLevel(this.user.getTranslation("challenges.titles.level-subtitle"), level), - 10, this.addon.getChallengesSettings().getTitleShowtime(), 20); - } + // sends title to player on level completion + if (this.addon.getChallengesSettings().isShowCompletionTitle()) + { + this.user.getPlayer().sendTitle( + this.parseLevel(this.user.getTranslation("challenges.titles.level-title"), level), + this.parseLevel(this.user.getTranslation("challenges.titles.level-subtitle"), level), + 10, this.addon.getChallengesSettings().getTitleShowtime(), 20); } } } diff --git a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java index 3a0a259c..1d616e51 100644 --- a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java +++ b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java @@ -809,14 +809,17 @@ void testLevelCompletionTriggered() { lvl.setFriendlyName("Novice"); lvl.setRewardExperience(200); // Stub both overloads: getLevel(String) used in checkIfCanCompleteChallenge, - // getLevel(Challenge) used in build() for level completion check + // getLevel(Challenge) used in tryCompleteLevel() when(cm.getLevel(GAME_MODE_NAME + "_novice")).thenReturn(lvl); when(cm.getLevel(any(Challenge.class))).thenReturn(lvl); when(cm.isLevelCompleted(any(), any(), any())).thenReturn(false); when(cm.validateLevelCompletion(any(), any(), any())).thenReturn(true); + // Mock tryCompleteLevel to return the level (which triggers reward logic) + when(cm.tryCompleteLevel(any(), any(), any())).thenReturn(lvl); when(inv.addItem(any())).thenReturn(new HashMap<>()); assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); - verify(cm).setLevelComplete(any(), any(), eq(lvl)); + // Verify that tryCompleteLevel was called to complete the level + verify(cm).tryCompleteLevel(any(), any(), eq(challenge)); verify(player).giveExp(200); } @@ -830,9 +833,12 @@ void testLevelCompletionAlreadyDone() { when(cm.getLevel(GAME_MODE_NAME + "_novice")).thenReturn(lvl); when(cm.getLevel(any(Challenge.class))).thenReturn(lvl); when(cm.isLevelCompleted(any(), any(), any())).thenReturn(true); + // Mock tryCompleteLevel to return null since level is already completed + when(cm.tryCompleteLevel(any(), any(), any())).thenReturn(null); when(inv.addItem(any())).thenReturn(new HashMap<>()); assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); - verify(cm, never()).setLevelComplete(any(), any(), any()); + // Verify tryCompleteLevel was called but didn't complete the level (returned null) + verify(cm).tryCompleteLevel(any(), any(), eq(challenge)); } @Test From 0701389f25b6f43caad79f557e1f79dd259aa7b5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 14:09:04 -0700 Subject: [PATCH 06/27] Compare base potion type when metadata is ignored When a potion material was in the ignore-metadata list, items were matched by Material alone, so any potion matched any other potion and removal could consume the wrong ones. Now potion-like items (POTION, SPLASH_POTION, LINGERING_POTION, TIPPED_ARROW) still compare their base PotionType while ignoring other metadata. Fixes #320 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .../challenges/tasks/TryToComplete.java | 83 ++++++++----- .../bentobox/challenges/utils/Utils.java | 84 ++++++++++++- .../challenges/tasks/TryToCompleteTest.java | 116 ++++++++++++++++++ 3 files changed, 248 insertions(+), 35 deletions(-) diff --git a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java index 1281baf5..de29306b 100644 --- a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java +++ b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java @@ -13,6 +13,7 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Objects; +import java.util.Set; import java.util.PriorityQueue; import java.util.Queue; import java.util.UUID; @@ -1072,18 +1073,13 @@ Map removeItems(List requiredItemList, int factor // Sanity check. User always has inventory at this point of code. itemsInInventory = Collections.emptyList(); } - else if (this.getInventoryRequirements().getIgnoreMetaData().contains(required.getType())) - { - // Use collecting method that ignores item meta. - itemsInInventory = Arrays.stream(user.getInventory().getContents()). - filter(Objects::nonNull).filter(i -> i.getType().equals(required.getType())) - .collect(Collectors.toList()); - } else { - // Use collecting method that compares item meta. + // Use helper method that handles ignore-metadata logic including potion types. itemsInInventory = Arrays.stream(user.getInventory().getContents()). - filter(Objects::nonNull).filter(i -> i.isSimilar(required)).collect(Collectors.toList()); + filter(Objects::nonNull). + filter(i -> itemsMatch(i, required, this.getInventoryRequirements().getIgnoreMetaData())). + collect(Collectors.toList()); } for (ItemStack itemStack : itemsInInventory) @@ -1876,6 +1872,45 @@ private boolean hasRequiredTeamPresence() } + /** + * Checks if two items match, considering the ignore-metadata setting. For potion-like materials + * that are in the ignore-metadata set, compares the base potion type while ignoring other metadata. + * For non-potion materials in the ignore-metadata set, uses type-only comparison. For materials + * not in the ignore-metadata set, uses full similarity comparison. + * + * @param candidate candidate item from inventory + * @param required required item template + * @param ignoreMetaData set of materials to ignore metadata for + * @return true if the items match + */ + private static boolean itemsMatch(ItemStack candidate, ItemStack required, Set ignoreMetaData) + { + if (candidate == null || required == null) + { + return false; + } + + if (!candidate.getType().equals(required.getType())) + { + return false; + } + + // If metadata should not be ignored, use full similarity check + if (!ignoreMetaData.contains(required.getType())) + { + return candidate.isSimilar(required); + } + + // Metadata is being ignored. For potion-like materials, still compare base potion type. + if (Utils.isPotionLike(required.getType())) + { + return Utils.comparePotionType(candidate, required); + } + + // For non-potion materials, type-only matching is sufficient + return true; + } + /** * Counts how many of {@code required} a single player holds, honouring the challenge's * ignore-meta-data setting. @@ -1886,17 +1921,9 @@ private boolean hasRequiredTeamPresence() */ private int countInInventory(Player player, ItemStack required) { - if (this.getInventoryRequirements().getIgnoreMetaData().contains(required.getType())) - { - return Arrays.stream(player.getInventory().getContents()). - filter(Objects::nonNull). - filter(i -> i.getType().equals(required.getType())). - mapToInt(ItemStack::getAmount).sum(); - } - return Arrays.stream(player.getInventory().getContents()). filter(Objects::nonNull). - filter(i -> i.isSimilar(required)). + filter(i -> itemsMatch(i, required, this.getInventoryRequirements().getIgnoreMetaData())). mapToInt(ItemStack::getAmount).sum(); } @@ -1916,22 +1943,10 @@ private int removeFromInventory(Player player, ItemStack required, int amount) return 0; } - List itemsInInventory; - - if (this.getInventoryRequirements().getIgnoreMetaData().contains(required.getType())) - { - itemsInInventory = Arrays.stream(player.getInventory().getContents()). - filter(Objects::nonNull). - filter(i -> i.getType().equals(required.getType())). - toList(); - } - else - { - itemsInInventory = Arrays.stream(player.getInventory().getContents()). - filter(Objects::nonNull). - filter(i -> i.isSimilar(required)). - toList(); - } + List itemsInInventory = Arrays.stream(player.getInventory().getContents()). + filter(Objects::nonNull). + filter(i -> itemsMatch(i, required, this.getInventoryRequirements().getIgnoreMetaData())). + toList(); int toRemove = amount; diff --git a/src/main/java/world/bentobox/challenges/utils/Utils.java b/src/main/java/world/bentobox/challenges/utils/Utils.java index ab0f62f5..b3ccfe54 100644 --- a/src/main/java/world/bentobox/challenges/utils/Utils.java +++ b/src/main/java/world/bentobox/challenges/utils/Utils.java @@ -59,6 +59,52 @@ else if (stack == input) } + /** + * Checks if a material is potion-like (potions, splash potions, lingering potions, tipped arrows). + * + * @param material the material to check + * @return true if the material is potion-like + */ + public static boolean isPotionLike(Material material) + { + return material == Material.POTION || + material == Material.SPLASH_POTION || + material == Material.LINGERING_POTION || + material == Material.TIPPED_ARROW; + } + + /** + * Compares the base potion type of two potion items. Returns true if they have the same + * base potion type, ignoring custom effects, lore, and other metadata. + * + * @param first first potion item + * @param second second potion item + * @return true if both items have the same base potion type + */ + public static boolean comparePotionType(@Nullable ItemStack first, @Nullable ItemStack second) + { + if (first == null || second == null) + { + return false; + } + + PotionType firstType = null; + PotionType secondType = null; + + if (first.hasItemMeta() && first.getItemMeta() instanceof PotionMeta) + { + firstType = ((PotionMeta) first.getItemMeta()).getBasePotionType(); + } + + if (second.hasItemMeta() && second.getItemMeta() instanceof PotionMeta) + { + secondType = ((PotionMeta) second.getItemMeta()).getBasePotionType(); + } + + // If either has no potion meta, they're only equal if both are missing the meta + return java.util.Objects.equals(firstType, secondType); + } + /** * This method groups input items in single itemstack with correct amount and returns it. * Allows to remove duplicate items from list. @@ -84,7 +130,7 @@ public static List groupEqualItems(List requiredItems, Set // Merge items which meta can be ignored or is similar to item in required list. if (Utils.isSimilarNoDurability(required, item) || - ignoreMetaData.contains(item.getType()) && item.getType().equals(required.getType())) + itemsMatchIgnoreMetadata(required, item, ignoreMetaData)) { required.setAmount(required.getAmount() + item.getAmount()); isUnique = false; @@ -103,6 +149,42 @@ public static List groupEqualItems(List requiredItems, Set return returnItems; } + /** + * Checks if two items match when metadata should be ignored. For potion-like materials, + * compares the base potion type. For non-potion materials, uses type-only comparison. + * + * @param first first item + * @param second second item + * @param ignoreMetaData set of materials to ignore metadata for + * @return true if items match according to the ignore-metadata rules + */ + private static boolean itemsMatchIgnoreMetadata(@Nullable ItemStack first, @Nullable ItemStack second, Set ignoreMetaData) + { + if (first == null || second == null) + { + return false; + } + + if (!first.getType().equals(second.getType())) + { + return false; + } + + if (!ignoreMetaData.contains(first.getType())) + { + return false; + } + + // Metadata is being ignored. For potion-like materials, still compare base potion type. + if (isPotionLike(first.getType())) + { + return comparePotionType(first, second); + } + + // For non-potion materials, type-only matching is sufficient + return true; + } + /** * This method transforms given World into GameMode name. If world is not a GameMode diff --git a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java index 1d616e51..fc201491 100644 --- a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java +++ b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java @@ -33,6 +33,8 @@ import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.PotionMeta; +import org.bukkit.potion.PotionType; import org.bukkit.util.BoundingBox; import org.eclipse.jdt.annotation.NonNull; import org.junit.jupiter.api.AfterEach; @@ -55,6 +57,7 @@ import world.bentobox.challenges.database.object.requirements.StatisticRequirements.StatisticRec; import world.bentobox.challenges.managers.ChallengesManager; import world.bentobox.challenges.tasks.TryToComplete.ChallengeResult; +import world.bentobox.challenges.utils.Utils; import world.bentobox.level.Level; /** @@ -849,4 +852,117 @@ void testFreeChallengeNoLevelCheck() { assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); verify(cm, never()).getLevel(any(Challenge.class)); } + + // ------------------------------------------------------------------------- + // Tests for issue #320: Potion type comparison when metadata is ignored + // ------------------------------------------------------------------------- + + @Test + void testPotionComparisonIgnoreMetadataSameType() { + // Test that potions with same base type match when metadata is ignored + // by using Utils.groupEqualItems which should group them together + ItemStack swiftnessPotion1 = createPotion(Material.POTION, PotionType.SWIFTNESS); + swiftnessPotion1.setAmount(1); + ItemStack swiftnessPotion2 = createPotion(Material.POTION, PotionType.SWIFTNESS); + swiftnessPotion2.setAmount(1); + // Add different custom name to swiftnessPotion2 to ensure metadata differs + PotionMeta meta = (PotionMeta) swiftnessPotion2.getItemMeta(); + if (meta != null) { + meta.setDisplayName("Fancy Swiftness"); + swiftnessPotion2.setItemMeta(meta); + } + + // When grouping items with ignore-metadata set for potions, + // potions with the same base type should be grouped together + Set ignoreMetaData = Set.of(Material.POTION); + List requiredItems = Arrays.asList(swiftnessPotion1, swiftnessPotion2); + List grouped = Utils.groupEqualItems(requiredItems, ignoreMetaData); + + // Both swiftness potions should be grouped into one stack with amount 2 + assertEquals(1, grouped.size(), "Potions with same base type should be grouped together"); + assertEquals(2, grouped.get(0).getAmount(), "Grouped potion should have combined amount"); + assertEquals(Material.POTION, grouped.get(0).getType(), "Grouped potion should be POTION"); + } + + @Test + void testPotionComparisonIgnoreMetadataDifferentType() { + // Test that potions with different base types DON'T group when metadata is ignored + ItemStack swiftnessPotion = createPotion(Material.POTION, PotionType.SWIFTNESS); + swiftnessPotion.setAmount(1); + ItemStack strengthPotion = createPotion(Material.POTION, PotionType.STRENGTH); + strengthPotion.setAmount(1); + + // When grouping potions with different base types and ignore-metadata set, + // they should NOT be grouped together + Set ignoreMetaData = Set.of(Material.POTION); + List requiredItems = Arrays.asList(swiftnessPotion, strengthPotion); + List grouped = Utils.groupEqualItems(requiredItems, ignoreMetaData); + + // Different potion types should NOT be grouped + assertEquals(2, grouped.size(), "Potions with different base types should NOT be grouped"); + assertEquals(1, grouped.get(0).getAmount(), "First potion should keep original amount"); + assertEquals(1, grouped.get(1).getAmount(), "Second potion should keep original amount"); + } + + @Test + void testPotionComparisonHelperMethod() { + // Unit test for the helper method that checks if items match with potion type comparison + ItemStack swiftnessPotion1 = createPotion(Material.POTION, PotionType.SWIFTNESS); + ItemStack swiftnessPotion2 = createPotion(Material.POTION, PotionType.SWIFTNESS); + ItemStack strengthPotion = createPotion(Material.POTION, PotionType.STRENGTH); + + Set ignoreMetaData = Set.of(Material.POTION); + + // Test that same potion type matches + assertTrue(Utils.comparePotionType(swiftnessPotion1, swiftnessPotion2), + "Potions with same base type should compare as equal"); + + // Test that different potion types don't match + assertFalse(Utils.comparePotionType(swiftnessPotion1, strengthPotion), + "Potions with different base types should not compare as equal"); + + // Test that potion-like check works + assertTrue(Utils.isPotionLike(Material.POTION), + "POTION should be recognized as potion-like"); + assertTrue(Utils.isPotionLike(Material.SPLASH_POTION), + "SPLASH_POTION should be recognized as potion-like"); + assertTrue(Utils.isPotionLike(Material.LINGERING_POTION), + "LINGERING_POTION should be recognized as potion-like"); + assertTrue(Utils.isPotionLike(Material.TIPPED_ARROW), + "TIPPED_ARROW should be recognized as potion-like"); + assertFalse(Utils.isPotionLike(Material.DIRT), + "DIRT should not be recognized as potion-like"); + } + + @Test + void testNonPotionIgnoreMetadataUnchanged() { + // Test that non-potion materials still use type-only comparison + ItemStack dirt1 = new ItemStack(Material.DIRT); + dirt1.setAmount(1); + ItemStack dirt2 = new ItemStack(Material.DIRT); + dirt2.setAmount(1); + + // When grouping non-potion items with ignore-metadata set, + // they should be grouped together by type alone + Set ignoreMetaData = Set.of(Material.DIRT); + List requiredItems = Arrays.asList(dirt1, dirt2); + List grouped = Utils.groupEqualItems(requiredItems, ignoreMetaData); + + // Both dirt items should be grouped into one stack with amount 2 + assertEquals(1, grouped.size(), "Non-potion items with same type should be grouped"); + assertEquals(2, grouped.get(0).getAmount(), "Grouped items should have combined amount"); + } + + /** + * Helper method to create a potion ItemStack with specified base potion type + */ + private ItemStack createPotion(Material material, PotionType potionType) { + ItemStack potion = new ItemStack(material); + PotionMeta meta = (PotionMeta) potion.getItemMeta(); + if (meta != null) { + meta.setBasePotionType(potionType); + potion.setItemMeta(meta); + } + return potion; + } } From f888514c1f83c8788ca9c0a1219f6c0befdd7cab Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 14:30:02 -0700 Subject: [PATCH 07/27] Add tests verifying item/block/entity/money/XP consumption on completion Covers the removal checklist from #111: items taken (and not taken), block and entity removal for island challenges, money/XP consumption in the full completion flow, and that nothing is consumed when a requirement fails. Tests added: - testInventoryChallengeWithTakeItemsTrue: verifies items removed when takeItems=true - testInventoryChallengeWithTakeItemsFalse: verifies items NOT removed when takeItems=false - testInventoryChallengeMultipleItemsRemoved: verifies multiple item types removed correctly - testIslandChallengeWithRemoveBlocksTrue: verifies blocks set to AIR when removeBlocks=true - testIslandChallengeWithRemoveBlocksFalse: verifies blocks NOT removed when removeBlocks=false - testIslandChallengeWithRemoveEntitiesTrue: verifies entities removed when removeEntities=true - testIslandChallengeWithRemoveEntitiesFalse: verifies entities NOT removed when removeEntities=false - testOtherChallengeMoneyWithdrawn: verifies money withdrawn when takeMoney=true - testOtherChallengeMoneyNotWithdrawn: verifies money NOT withdrawn when takeMoney=false - testOtherChallengeExperienceWithdrawn: verifies XP taken when takeExperience=true - testOtherChallengeExperienceNotWithdrawn: verifies XP NOT taken when takeExperience=false - testInventoryChallengeFailureDoesNotRemoveItems: verifies no items removed on requirement failure - testIslandChallengeBlockRemovalFailureDoesNotRemoveBlocks: verifies no blocks removed on failure - testIslandChallengeEntityRemovalFailureDoesNotRemoveEntities: verifies no entities removed on failure - testOtherChallengeFailureDoesNotWithdrawMoney: verifies no money withdrawn on failure - testOtherChallengeFailureDoesNotWithdrawExperience: verifies no XP taken on failure - testMultipleInventoryItemsPartialRemovalFailure: verifies no items removed if any item requirement fails Part of #111 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .../challenges/tasks/TryToCompleteTest.java | 377 ++++++++++++++++++ 1 file changed, 377 insertions(+) diff --git a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java index 1d616e51..c880f31e 100644 --- a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java +++ b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java @@ -4,6 +4,7 @@ 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.atLeast; @@ -29,6 +30,7 @@ import org.bukkit.Statistic; import org.bukkit.World; import org.bukkit.World.Environment; +import org.bukkit.block.Block; import org.bukkit.entity.Entity; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; @@ -849,4 +851,379 @@ void testFreeChallengeNoLevelCheck() { assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); verify(cm, never()).getLevel(any(Challenge.class)); } + + // ------------------------------------------------------------------------- + // Consumption/Removal tests (Issue #111) + // ------------------------------------------------------------------------- + + @Test + void testInventoryChallengeWithTakeItemsTrue() { + // Setup inventory challenge with takeItems=true + InventoryRequirements req = new InventoryRequirements(); + ItemStack requiredItem = new ItemStack(Material.EMERALD_BLOCK, 1); + req.setRequiredItems(Collections.singletonList(requiredItem)); + req.setTakeItems(true); + challenge.setRequirements(req); + + // Mock inventory with the required item + ItemStack inventoryItem = new ItemStack(Material.EMERALD_BLOCK, 5); + when(inv.getContents()).thenReturn(new ItemStack[]{inventoryItem}); + when(player.getInventory()).thenReturn(inv); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify items were removed by checking the inventory item amount changed + // The removeItems method modifies the ItemStack in-place via setAmount + assertEquals(4, inventoryItem.getAmount(), "Item amount should be reduced by 1"); + } + + @Test + void testInventoryChallengeWithTakeItemsFalse() { + // Setup inventory challenge with takeItems=false + InventoryRequirements req = new InventoryRequirements(); + ItemStack requiredItem = new ItemStack(Material.EMERALD_BLOCK, 1); + req.setRequiredItems(Collections.singletonList(requiredItem)); + req.setTakeItems(false); + challenge.setRequirements(req); + + // Mock inventory with the required item + ItemStack inventoryItem = new ItemStack(Material.EMERALD_BLOCK, 5); + when(inv.getContents()).thenReturn(new ItemStack[]{inventoryItem}); + when(player.getInventory()).thenReturn(inv); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify items were NOT removed + assertEquals(5, inventoryItem.getAmount(), "Item amount should remain unchanged when takeItems=false"); + } + + @Test + void testInventoryChallengeMultipleItemsRemoved() { + // Test that multiple required items are all removed + InventoryRequirements req = new InventoryRequirements(); + ItemStack item1 = new ItemStack(Material.EMERALD, 2); + ItemStack item2 = new ItemStack(Material.DIAMOND, 3); + req.setRequiredItems(Arrays.asList(item1, item2)); + req.setTakeItems(true); + challenge.setRequirements(req); + + // Mock inventory with required items + ItemStack invEmerald = new ItemStack(Material.EMERALD, 10); + ItemStack invDiamond = new ItemStack(Material.DIAMOND, 10); + when(inv.getContents()).thenReturn(new ItemStack[]{invEmerald, invDiamond}); + when(player.getInventory()).thenReturn(inv); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify both items were removed + assertEquals(8, invEmerald.getAmount(), "Emeralds should be reduced by 2"); + assertEquals(7, invDiamond.getAmount(), "Diamonds should be reduced by 3"); + } + + @Test + void testIslandChallengeWithRemoveBlocksTrue() { + // Setup island challenge with removeBlocks=true + challenge.setChallengeType(ChallengeType.ISLAND_TYPE); + IslandRequirements req = new IslandRequirements(); + req.setSearchRadius(10); + req.setRequiredBlocks(Collections.singletonMap(Material.STONE, 1)); + req.setRemoveBlocks(true); + challenge.setRequirements(req); + + // Mock a block in the world + Block mockBlock = mock(Block.class); + when(mockBlock.getType()).thenReturn(Material.STONE); + Location blockLoc = mock(Location.class); + when(mockBlock.getLocation()).thenReturn(blockLoc); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(mockBlock); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify the block was set to AIR + verify(mockBlock).setType(Material.AIR); + } + + @Test + void testIslandChallengeWithRemoveBlocksFalse() { + // Setup island challenge with removeBlocks=false + challenge.setChallengeType(ChallengeType.ISLAND_TYPE); + IslandRequirements req = new IslandRequirements(); + req.setSearchRadius(10); + req.setRequiredBlocks(Collections.singletonMap(Material.STONE, 1)); + req.setRemoveBlocks(false); + challenge.setRequirements(req); + + // Mock a block in the world + Block mockBlock = mock(Block.class); + when(mockBlock.getType()).thenReturn(Material.STONE); + Location blockLoc = mock(Location.class); + when(mockBlock.getLocation()).thenReturn(blockLoc); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(mockBlock); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify the block was NOT removed (setType not called) + verify(mockBlock, never()).setType(any()); + } + + @Test + void testIslandChallengeWithRemoveEntitiesTrue() { + // Setup island challenge with removeEntities=true + challenge.setChallengeType(ChallengeType.ISLAND_TYPE); + IslandRequirements req = new IslandRequirements(); + req.setSearchRadius(10); + req.setRequiredEntities(Collections.singletonMap(EntityType.GHAST, 1)); + req.setRemoveEntities(true); + challenge.setRequirements(req); + + // Mock an entity in the world + Entity mockEntity = mock(Entity.class); + when(mockEntity.getType()).thenReturn(EntityType.GHAST); + Location entityLoc = mock(Location.class); + when(mockEntity.getLocation()).thenReturn(entityLoc); + List entities = Collections.singletonList(mockEntity); + when(world.getNearbyEntities(any(BoundingBox.class))).thenReturn(entities); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify the entity was removed + verify(mockEntity).remove(); + } + + @Test + void testIslandChallengeWithRemoveEntitiesFalse() { + // Setup island challenge with removeEntities=false + challenge.setChallengeType(ChallengeType.ISLAND_TYPE); + IslandRequirements req = new IslandRequirements(); + req.setSearchRadius(10); + req.setRequiredEntities(Collections.singletonMap(EntityType.GHAST, 1)); + req.setRemoveEntities(false); + challenge.setRequirements(req); + + // Mock an entity in the world + Entity mockEntity = mock(Entity.class); + when(mockEntity.getType()).thenReturn(EntityType.GHAST); + Location entityLoc = mock(Location.class); + when(mockEntity.getLocation()).thenReturn(entityLoc); + List entities = Collections.singletonList(mockEntity); + when(world.getNearbyEntities(any(BoundingBox.class))).thenReturn(entities); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify the entity was NOT removed + verify(mockEntity, never()).remove(); + } + + @Test + void testOtherChallengeMoneyWithdrawn() { + // Setup OTHER challenge with money requirement and takeMoney=true + OtherRequirements req = new OtherRequirements(); + req.setRequiredMoney(50.0); + req.setTakeMoney(true); + setupOtherChallenge(req); + + when(addon.isLevelProvided()).thenReturn(false); + VaultHook vault = mockEconomy(true, 100.0); + when(plugin.getHooks()).thenReturn(mock(world.bentobox.bentobox.managers.HooksManager.class)); + when(plugin.getHooks().getHook("PlaceholderAPI")).thenReturn(Optional.empty()); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify money was withdrawn + verify(vault).withdraw(user, 50.0); + } + + @Test + void testOtherChallengeMoneyNotWithdrawn() { + // Setup OTHER challenge with money but takeMoney=false + OtherRequirements req = new OtherRequirements(); + req.setRequiredMoney(50.0); + req.setTakeMoney(false); + setupOtherChallenge(req); + + when(addon.isLevelProvided()).thenReturn(false); + VaultHook vault = mockEconomy(true, 100.0); + when(plugin.getHooks()).thenReturn(mock(world.bentobox.bentobox.managers.HooksManager.class)); + when(plugin.getHooks().getHook("PlaceholderAPI")).thenReturn(Optional.empty()); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify money was NOT withdrawn + verify(vault, never()).withdraw(any(), any(double.class)); + } + + @Test + void testOtherChallengeExperienceWithdrawn() { + // Setup OTHER challenge with XP requirement and takeExperience=true + OtherRequirements req = new OtherRequirements(); + req.setRequiredExperience(50); + req.setTakeExperience(true); + setupOtherChallenge(req); + + when(addon.isLevelProvided()).thenReturn(false); + when(player.getTotalExperience()).thenReturn(100); + when(plugin.getHooks()).thenReturn(mock(world.bentobox.bentobox.managers.HooksManager.class)); + when(plugin.getHooks().getHook("PlaceholderAPI")).thenReturn(Optional.empty()); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify XP was taken + verify(player).setTotalExperience(50); + } + + @Test + void testOtherChallengeExperienceNotWithdrawn() { + // Setup OTHER challenge with XP but takeExperience=false + OtherRequirements req = new OtherRequirements(); + req.setRequiredExperience(50); + req.setTakeExperience(false); + setupOtherChallenge(req); + + when(addon.isLevelProvided()).thenReturn(false); + when(player.getTotalExperience()).thenReturn(100); + when(plugin.getHooks()).thenReturn(mock(world.bentobox.bentobox.managers.HooksManager.class)); + when(plugin.getHooks().getHook("PlaceholderAPI")).thenReturn(Optional.empty()); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify XP was NOT taken + verify(player, never()).setTotalExperience(any(int.class)); + } + + @Test + void testInventoryChallengeFailureDoesNotRemoveItems() { + // Setup inventory challenge with an item we don't have + InventoryRequirements req = new InventoryRequirements(); + ItemStack requiredItem = new ItemStack(Material.EMERALD_BLOCK, 10); + req.setRequiredItems(Collections.singletonList(requiredItem)); + req.setTakeItems(true); + challenge.setRequirements(req); + + // Mock inventory with only 5 items (not enough) + ItemStack inventoryItem = new ItemStack(Material.EMERALD_BLOCK, 5); + when(inv.getContents()).thenReturn(new ItemStack[]{inventoryItem}); + when(player.getInventory()).thenReturn(inv); + + // Try to complete (should fail) + assertFalse(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify no items were removed (amount should still be 5) + assertEquals(5, inventoryItem.getAmount(), "Items should not be removed on failed completion"); + } + + @Test + void testIslandChallengeBlockRemovalFailureDoesNotRemoveBlocks() { + // Setup island challenge with a block we don't have + challenge.setChallengeType(ChallengeType.ISLAND_TYPE); + IslandRequirements req = new IslandRequirements(); + req.setSearchRadius(10); + req.setRequiredBlocks(Collections.singletonMap(Material.DIAMOND_BLOCK, 1)); + req.setRemoveBlocks(true); + challenge.setRequirements(req); + + // Mock world with no diamond blocks + Block mockBlock = mock(Block.class); + when(mockBlock.getType()).thenReturn(Material.STONE); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(mockBlock); + + // Try to complete (should fail) + assertFalse(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify no blocks were modified + verify(mockBlock, never()).setType(any()); + } + + @Test + void testIslandChallengeEntityRemovalFailureDoesNotRemoveEntities() { + // Setup island challenge with an entity we don't have + challenge.setChallengeType(ChallengeType.ISLAND_TYPE); + IslandRequirements req = new IslandRequirements(); + req.setSearchRadius(10); + req.setRequiredEntities(Collections.singletonMap(EntityType.WITHER, 1)); + req.setRemoveEntities(true); + challenge.setRequirements(req); + + // Mock world with wrong entity type + Entity mockEntity = mock(Entity.class); + when(mockEntity.getType()).thenReturn(EntityType.CHICKEN); + Location entityLoc = mock(Location.class); + when(mockEntity.getLocation()).thenReturn(entityLoc); + List entities = Collections.singletonList(mockEntity); + when(world.getNearbyEntities(any(BoundingBox.class))).thenReturn(entities); + + // Try to complete (should fail) + assertFalse(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify entity was not removed + verify(mockEntity, never()).remove(); + } + + @Test + void testOtherChallengeFailureDoesNotWithdrawMoney() { + // Setup OTHER challenge with money we don't have + OtherRequirements req = new OtherRequirements(); + req.setRequiredMoney(100.0); + req.setTakeMoney(true); + setupOtherChallenge(req); + + when(addon.isLevelProvided()).thenReturn(false); + VaultHook vault = mockEconomy(false, 50.0); // Only 50, need 100 + + // Try to complete (should fail) + assertFalse(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify money was NOT withdrawn + verify(vault, never()).withdraw(any(), any(double.class)); + } + + @Test + void testOtherChallengeFailureDoesNotWithdrawExperience() { + // Setup OTHER challenge with XP we don't have + OtherRequirements req = new OtherRequirements(); + req.setRequiredExperience(100); + req.setTakeExperience(true); + setupOtherChallenge(req); + + when(addon.isLevelProvided()).thenReturn(false); + when(player.getTotalExperience()).thenReturn(50); // Only 50, need 100 + + // Try to complete (should fail) + assertFalse(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify XP was NOT taken + verify(player, never()).setTotalExperience(any(int.class)); + } + + @Test + void testMultipleInventoryItemsPartialRemovalFailure() { + // Test that if ANY item removal fails, the entire challenge fails and items are returned + InventoryRequirements req = new InventoryRequirements(); + ItemStack item1 = new ItemStack(Material.EMERALD, 2); + ItemStack item2 = new ItemStack(Material.DIAMOND, 3); + req.setRequiredItems(Arrays.asList(item1, item2)); + req.setTakeItems(true); + challenge.setRequirements(req); + + // Mock inventory with only first item (missing diamonds) + ItemStack invEmerald = new ItemStack(Material.EMERALD, 10); + when(inv.getContents()).thenReturn(new ItemStack[]{invEmerald}); + when(player.getInventory()).thenReturn(inv); + + // Try to complete (should fail due to missing diamonds) + assertFalse(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify items were not removed + assertEquals(10, invEmerald.getAmount(), "Items should not be removed when requirement fails"); + } } From a5983de7f956dfd7308f1dfd09a5e603e8da5e06 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 15:04:41 -0700 Subject: [PATCH 08/27] Add challenges_completed_percent placeholder Fixes #352 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .../bentobox/challenges/ChallengesAddon.java | 38 ++++++++++++++++ .../challenges/ChallengesAddonTest.java | 45 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/main/java/world/bentobox/challenges/ChallengesAddon.java b/src/main/java/world/bentobox/challenges/ChallengesAddon.java index 847bbd98..5c7d6723 100644 --- a/src/main/java/world/bentobox/challenges/ChallengesAddon.java +++ b/src/main/java/world/bentobox/challenges/ChallengesAddon.java @@ -355,6 +355,19 @@ private void registerPlaceholders(GameModeAddon gameModeAddon) user -> String.valueOf(this.challengesManager.getChallengeCount(world) - this.challengesManager.getCompletedChallengeCount(user, world))); + // Completed challenge percent placeholder + this.getPlugin().getPlaceholdersManager().registerPlaceholder(gameModeAddon, + addonName + "_completed_percent", + user -> { + int totalCount = this.challengesManager.getChallengeCount(world); + if (totalCount == 0) { + return "0"; + } + long completedCount = this.challengesManager.getCompletedChallengeCount(user, world); + // Clamp to 100: completions of since-undeployed challenges can exceed the visible total. + return String.valueOf(Math.min(100, (completedCount * 100) / totalCount)); + }); + // Completed challenge level count placeholder this.getPlugin().getPlaceholdersManager().registerPlaceholder(gameModeAddon, addonName + "_completed_level_count", @@ -421,6 +434,31 @@ private void registerPlaceholders(GameModeAddon gameModeAddon) return String.valueOf(challengeCount - this.challengesManager.getLevelCompletedChallengeCount(user, world, level)); }); + + // Completed challenge percent in latest level + this.getPlugin().getPlaceholdersManager().registerPlaceholder(gameModeAddon, + addonName + "_latest_level_completed_percent", + user -> { + ChallengeLevel level = this.challengesManager.getLatestUnlockedLevel(user, world); + + if (level == null) + { + return "0"; + } + + int challengeCount = this.getChallengesSettings().isIncludeUndeployed() ? + level.getChallenges().size() : + this.challengesManager.getLevelChallenges(level, false).size(); + + if (challengeCount == 0) + { + return "0"; + } + + long completedCount = this.challengesManager.getLevelCompletedChallengeCount(user, world, level); + // Clamp to 100: completions of since-undeployed challenges can exceed the visible total. + return String.valueOf(Math.min(100, (completedCount * 100) / challengeCount)); + }); } diff --git a/src/test/java/world/bentobox/challenges/ChallengesAddonTest.java b/src/test/java/world/bentobox/challenges/ChallengesAddonTest.java index 56c66ed3..3b22e472 100644 --- a/src/test/java/world/bentobox/challenges/ChallengesAddonTest.java +++ b/src/test/java/world/bentobox/challenges/ChallengesAddonTest.java @@ -12,6 +12,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import org.mockito.ArgumentCaptor; + import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; @@ -33,6 +35,7 @@ import org.bukkit.Bukkit; import org.bukkit.Server; import org.bukkit.UnsafeValues; +import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemFactory; import org.bukkit.inventory.meta.ItemMeta; @@ -362,4 +365,46 @@ void testGetLevelAddon() { assertNull(addon.getLevelAddon()); } + @Test + void testPlaceholderCompletedPercentRegistered() { + addon.onLoad(); + when(plugin.isEnabled()).thenReturn(true); + addon.setState(State.LOADED); + + // Mock the world + World world = mock(World.class); + when(gameMode.getOverWorld()).thenReturn(world); + + addon.onEnable(); + + // Verify that the completed_percent placeholder was registered + ArgumentCaptor nameCaptor = ArgumentCaptor.forClass(String.class); + verify(phm, org.mockito.Mockito.times(13)).registerPlaceholder(any(GameModeAddon.class), nameCaptor.capture(), any()); + + List names = nameCaptor.getAllValues(); + assertTrue(names.contains("challenges_completed_percent"), + "Placeholder 'challenges_completed_percent' was not registered. Registered: " + names); + } + + @Test + void testPlaceholderLatestLevelCompletedPercentRegistered() { + addon.onLoad(); + when(plugin.isEnabled()).thenReturn(true); + addon.setState(State.LOADED); + + // Mock the world + World world = mock(World.class); + when(gameMode.getOverWorld()).thenReturn(world); + + addon.onEnable(); + + // Verify that the latest_level_completed_percent placeholder was registered + ArgumentCaptor nameCaptor = ArgumentCaptor.forClass(String.class); + verify(phm, org.mockito.Mockito.times(13)).registerPlaceholder(any(GameModeAddon.class), nameCaptor.capture(), any()); + + List names = nameCaptor.getAllValues(); + assertTrue(names.contains("challenges_latest_level_completed_percent"), + "Placeholder 'challenges_latest_level_completed_percent' was not registered. Registered: " + names); + } + } From e40c33e51d4407b5ce5b4e445a20f90d2462d8ca Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 15:10:59 -0700 Subject: [PATCH 09/27] Add island level rewards via the Level addon Challenges and challenge levels can now grant island levels on completion (rewardIslandLevel / repeatIslandLevel, default 0). Applied only when the Level addon is present. Repeat rewards multiply by the completion factor, consistent with money and XP. Fixes #279 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .../challenges/database/object/Challenge.java | 54 +++++++++++++++++ .../database/object/ChallengeLevel.java | 27 +++++++++ .../panel/admin/EditChallengePanel.java | 52 +++++++++++++++- .../panel/admin/EditLevelPanel.java | 29 +++++++++ .../challenges/tasks/TryToComplete.java | 24 ++++++++ src/main/resources/locales/en-US.yml | 15 ++++- .../challenges/tasks/TryToCompleteTest.java | 59 +++++++++++++++++++ 7 files changed, 257 insertions(+), 3 deletions(-) diff --git a/src/main/java/world/bentobox/challenges/database/object/Challenge.java b/src/main/java/world/bentobox/challenges/database/object/Challenge.java index 360a7123..cd252e6d 100644 --- a/src/main/java/world/bentobox/challenges/database/object/Challenge.java +++ b/src/main/java/world/bentobox/challenges/database/object/Challenge.java @@ -224,6 +224,12 @@ public enum ChallengeType @Expose private double rewardMoney = 0; + /** + * Island level reward. Level addon required for this option. + */ + @Expose + private long rewardIslandLevel = 0; + /** * Commands to run when the player completes the challenge for the first time. String List */ @@ -282,6 +288,12 @@ public enum ChallengeType @Expose private double repeatMoneyReward; + /** + * Repeat island level reward. Level addon required for this option. + */ + @Expose + private long repeatIslandLevel = 0; + /** * Commands to run when challenge is repeated. String List. */ @@ -477,6 +489,15 @@ public double getRewardMoney() } + /** + * @return the rewardIslandLevel + */ + public long getRewardIslandLevel() + { + return rewardIslandLevel; + } + + /** * @return the rewardCommands */ @@ -540,6 +561,15 @@ public double getRepeatMoneyReward() } + /** + * @return the repeatIslandLevel + */ + public long getRepeatIslandLevel() + { + return repeatIslandLevel; + } + + /** * @return the repeatRewardCommands */ @@ -773,6 +803,17 @@ public void setRewardMoney(double rewardMoney) } + /** + * This method sets the rewardIslandLevel value. + * @param rewardIslandLevel the rewardIslandLevel new value. + * + */ + public void setRewardIslandLevel(long rewardIslandLevel) + { + this.rewardIslandLevel = rewardIslandLevel; + } + + /** * This method sets the rewardCommands value. * @param rewardCommands the rewardCommands new value. @@ -850,6 +891,17 @@ public void setRepeatMoneyReward(double repeatMoneyReward) } + /** + * This method sets the repeatIslandLevel value. + * @param repeatIslandLevel the repeatIslandLevel new value. + * + */ + public void setRepeatIslandLevel(long repeatIslandLevel) + { + this.repeatIslandLevel = repeatIslandLevel; + } + + /** * This method sets the repeatRewardCommands value. * @param repeatRewardCommands the repeatRewardCommands new value. @@ -1028,12 +1080,14 @@ public Challenge copy() clone.setHideIfNoTeam(this.hideIfNoTeam); clone.setRewardExperience(this.rewardExperience); clone.setRewardMoney(this.rewardMoney); + clone.setRewardIslandLevel(this.rewardIslandLevel); clone.setRepeatable(this.repeatable); clone.setTimeout(this.timeout); clone.setRepeatRewardText(this.repeatRewardText); clone.setMaxTimes(this.maxTimes); clone.setRepeatExperienceReward(this.repeatExperienceReward); clone.setRepeatMoneyReward(this.repeatMoneyReward); + clone.setRepeatIslandLevel(this.repeatIslandLevel); // Copy custom objects clone.setRequirements(this.requirements.copy()); diff --git a/src/main/java/world/bentobox/challenges/database/object/ChallengeLevel.java b/src/main/java/world/bentobox/challenges/database/object/ChallengeLevel.java index 9fba6ac2..20b44d83 100644 --- a/src/main/java/world/bentobox/challenges/database/object/ChallengeLevel.java +++ b/src/main/java/world/bentobox/challenges/database/object/ChallengeLevel.java @@ -108,6 +108,11 @@ public ChallengeLevel() @Expose private double rewardMoney = 0; + @ConfigComment("") + @ConfigComment("Island level reward. Level addon required for this option.") + @Expose + private long rewardIslandLevel = 0; + @ConfigComment("") @ConfigComment("Commands to run when the player completes all challenges in current") @ConfigComment("level. String List") @@ -255,6 +260,16 @@ public double getRewardMoney() } + /** + * This method returns the rewardIslandLevel value. + * @return the value of rewardIslandLevel. + */ + public long getRewardIslandLevel() + { + return rewardIslandLevel; + } + + /** * This method returns the rewardCommands value. * @return the value of rewardCommands. @@ -425,6 +440,17 @@ public void setRewardMoney(double rewardMoney) } + /** + * This method sets the rewardIslandLevel value. + * @param rewardIslandLevel the rewardIslandLevel new value. + * + */ + public void setRewardIslandLevel(long rewardIslandLevel) + { + this.rewardIslandLevel = rewardIslandLevel; + } + + /** * This method sets the rewardCommands value. * @param rewardCommands the rewardCommands new value. @@ -596,6 +622,7 @@ public ChallengeLevel copy() collect(Collectors.toCollection(() -> new ArrayList<>(this.rewardItems.size())))); clone.setRewardExperience(this.rewardExperience); clone.setRewardMoney(this.rewardMoney); + clone.setRewardIslandLevel(this.rewardIslandLevel); clone.setRewardCommands(new ArrayList<>(this.rewardCommands)); clone.setChallenges(new HashSet<>(this.challenges)); clone.setIgnoreRewardMetaData(new HashSet<>(this.ignoreRewardMetaData)); diff --git a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java index 3b4b4abb..27469ade 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java @@ -258,6 +258,7 @@ private void buildRewardsPanel(PanelBuilder panelBuilder) { panelBuilder.item(11, this.createRewardButton(RewardButton.REWARD_ITEMS)); panelBuilder.item(20, this.createRewardButton(RewardButton.REWARD_EXPERIENCE)); panelBuilder.item(29, this.createRewardButton(RewardButton.REWARD_MONEY)); + panelBuilder.item(38, this.createRewardButton(RewardButton.REWARD_ISLAND_LEVEL)); panelBuilder.item(22, this.createRewardButton(RewardButton.REPEATABLE)); @@ -279,6 +280,7 @@ private void buildRewardsPanel(PanelBuilder panelBuilder) { panelBuilder.item(16, this.createRewardButton(RewardButton.REPEAT_REWARD_ITEMS)); panelBuilder.item(25, this.createRewardButton(RewardButton.REPEAT_REWARD_EXPERIENCE)); panelBuilder.item(34, this.createRewardButton(RewardButton.REPEAT_REWARD_MONEY)); + panelBuilder.item(43, this.createRewardButton(RewardButton.REPEAT_REWARD_ISLAND_LEVEL)); } } @@ -1431,6 +1433,29 @@ private PanelItem createRewardButton(RewardButton button) { description.add(""); description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); } + case REWARD_ISLAND_LEVEL -> { + description.add(this.user.getTranslation(reference + Constants.VALUE_KEY, Constants.PARAMETER_NUMBER, + String.valueOf(this.challenge.getRewardIslandLevel()))); + icon = new ItemStack(this.addon.isLevelProvided() ? Material.EXPERIENCE_BOTTLE : Material.BARRIER); + clickHandler = (panel, user, clickType, i) -> { + Consumer numberConsumer = number -> { + if (number != null) { + this.challenge.setRewardIslandLevel(number.longValue()); + } + + // reopen panel + this.build(); + }; + ConversationUtils.createNumericInput(numberConsumer, this.user, + this.user.getTranslation(Constants.INPUT_NUMBER), 0, Long.MAX_VALUE); + + return true; + }; + glow = false; + + description.add(""); + description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); + } case REWARD_COMMANDS -> { icon = new ItemStack(Material.COMMAND_BLOCK); @@ -1643,6 +1668,29 @@ private PanelItem createRewardButton(RewardButton button) { description.add(""); description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); } + case REPEAT_REWARD_ISLAND_LEVEL -> { + description.add(this.user.getTranslation(reference + Constants.VALUE_KEY, Constants.PARAMETER_NUMBER, + String.valueOf(this.challenge.getRepeatIslandLevel()))); + icon = new ItemStack(this.addon.isLevelProvided() ? Material.EXPERIENCE_BOTTLE : Material.BARRIER); + clickHandler = (panel, user, clickType, i) -> { + Consumer numberConsumer = number -> { + if (number != null) { + this.challenge.setRepeatIslandLevel(number.longValue()); + } + + // reopen panel + this.build(); + }; + ConversationUtils.createNumericInput(numberConsumer, this.user, + this.user.getTranslation(Constants.INPUT_NUMBER), 0, Long.MAX_VALUE); + + return true; + }; + glow = false; + + description.add(""); + description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); + } case REPEAT_REWARD_COMMANDS -> { icon = new ItemStack(Material.COMMAND_BLOCK); @@ -1836,11 +1884,11 @@ private enum Button { * Represents different rewards buttons that are used in menus. */ private enum RewardButton { - REWARD_TEXT, REWARD_ITEMS, REWARD_EXPERIENCE, REWARD_MONEY, REWARD_COMMANDS, + REWARD_TEXT, REWARD_ITEMS, REWARD_EXPERIENCE, REWARD_MONEY, REWARD_ISLAND_LEVEL, REWARD_COMMANDS, REPEATABLE, REPEAT_COUNT, COOL_DOWN, - REPEAT_REWARD_TEXT, REPEAT_REWARD_ITEMS, REPEAT_REWARD_EXPERIENCE, REPEAT_REWARD_MONEY, REPEAT_REWARD_COMMANDS, + REPEAT_REWARD_TEXT, REPEAT_REWARD_ITEMS, REPEAT_REWARD_EXPERIENCE, REPEAT_REWARD_MONEY, REPEAT_REWARD_ISLAND_LEVEL, REPEAT_REWARD_COMMANDS, ADD_IGNORED_META, REMOVE_IGNORED_META, } diff --git a/src/main/java/world/bentobox/challenges/panel/admin/EditLevelPanel.java b/src/main/java/world/bentobox/challenges/panel/admin/EditLevelPanel.java index 0863b36b..ca2b66bd 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/EditLevelPanel.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/EditLevelPanel.java @@ -200,6 +200,7 @@ private void buildRewardsPanel(PanelBuilder panelBuilder) panelBuilder.item(13, this.createButton(Button.REWARD_ITEMS)); panelBuilder.item(22, this.createButton(Button.REWARD_EXPERIENCE)); panelBuilder.item(31, this.createButton(Button.REWARD_MONEY)); + panelBuilder.item(40, this.createButton(Button.REWARD_ISLAND_LEVEL)); if (!this.challengeLevel.getRewardItems().isEmpty()) { @@ -489,6 +490,33 @@ private PanelItem createButton(Button button) description.add(""); description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); } + case REWARD_ISLAND_LEVEL -> { + description.add(this.user.getTranslation(reference + Constants.VALUE_KEY, + Constants.PARAMETER_NUMBER, String.valueOf(this.challengeLevel.getRewardIslandLevel()))); + icon = new ItemStack(this.addon.isLevelProvided() ? Material.EXPERIENCE_BOTTLE : Material.BARRIER); + clickHandler = (panel, user, clickType, i) -> { + Consumer numberConsumer = number -> { + if (number != null) + { + this.challengeLevel.setRewardIslandLevel(number.longValue()); + } + + // reopen panel + this.build(); + }; + ConversationUtils.createNumericInput(numberConsumer, + this.user, + this.user.getTranslation(Constants.INPUT_NUMBER), + 0, + Long.MAX_VALUE); + + return true; + }; + glow = false; + + description.add(""); + description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); + } case REWARD_COMMANDS -> { icon = new ItemStack(Material.COMMAND_BLOCK); @@ -967,6 +995,7 @@ private enum Button REWARD_ITEMS, REWARD_EXPERIENCE, REWARD_MONEY, + REWARD_ISLAND_LEVEL, REWARD_COMMANDS, ADD_IGNORED_META, diff --git a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java index 1281baf5..f57e7192 100644 --- a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java +++ b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java @@ -274,6 +274,14 @@ ChallengeResult build(int maxTimes) // Experience Reward recipient.getPlayer().giveExp(this.challenge.getRewardExperience()); + // Island Level Reward + if (this.addon.isLevelProvided() && this.challenge.getRewardIslandLevel() != 0) + { + world.bentobox.level.Level level = this.addon.getLevelAddon(); + level.setIslandLevel(this.world, this.user.getUniqueId(), + level.getIslandLevel(this.world, this.user.getUniqueId()) + this.challenge.getRewardIslandLevel()); + } + // Run commands this.runCommands(this.challenge.getRewardCommands(), recipient); } @@ -339,6 +347,14 @@ ChallengeResult build(int maxTimes) recipient.getPlayer().giveExp( this.challenge.getRepeatExperienceReward() * rewardFactor); + // Island Level Repeat Reward + if (this.addon.isLevelProvided() && this.challenge.getRepeatIslandLevel() != 0) + { + world.bentobox.level.Level level = this.addon.getLevelAddon(); + level.setIslandLevel(this.world, this.user.getUniqueId(), + level.getIslandLevel(this.world, this.user.getUniqueId()) + this.challenge.getRepeatIslandLevel() * rewardFactor); + } + // Run commands for (int i = 0; i < rewardFactor; i++) { @@ -388,6 +404,14 @@ ChallengeResult build(int maxTimes) // Experience Reward this.user.getPlayer().giveExp(level.getRewardExperience()); + // Island Level Reward + if (this.addon.isLevelProvided() && level.getRewardIslandLevel() != 0) + { + world.bentobox.level.Level levelAddon = this.addon.getLevelAddon(); + levelAddon.setIslandLevel(this.world, this.user.getUniqueId(), + levelAddon.getIslandLevel(this.world, this.user.getUniqueId()) + level.getRewardIslandLevel()); + } + // Run commands this.runCommands(level.getRewardCommands()); diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 28c2e670..5af8d2e4 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -584,9 +584,22 @@ challenges: repeat_reward_money: name: "Repeat Reward Money" description: |- - Allows you to change the repeat + Allows you to change the repeat reward money for the challenge. value: "Current value: [number]" + reward_island_level: + name: "Reward Island Level" + description: |- + Allows you to change the reward island level. + Requires the Level addon. + value: "Current value: [number]" + repeat_reward_island_level: + name: "Repeat Reward Island Level" + description: |- + Allows you to change the repeat reward + island level for the challenge. + Requires the Level addon. + value: "Current value: [number]" reward_commands: name: "Reward Commands" description: |- diff --git a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java index 1d616e51..972550c3 100644 --- a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java +++ b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java @@ -4,6 +4,7 @@ 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.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeast; @@ -849,4 +850,62 @@ void testFreeChallengeNoLevelCheck() { assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); verify(cm, never()).getLevel(any(Challenge.class)); } + + @Test + void testFirstTimeRewardIslandLevel() { + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(false); + challenge.setRewardIslandLevel(50L); + Level levelAddon = mockLevelAddon(100L); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + verify(levelAddon).setIslandLevel(world, user.getUniqueId(), 150L); + } + + @Test + void testRepeatRewardIslandLevel() { + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(true); + challenge.setRepeatIslandLevel(25L); + Level levelAddon = mockLevelAddon(100L); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + verify(levelAddon).setIslandLevel(world, user.getUniqueId(), 125L); + } + + @Test + void testFirstTimeRewardIslandLevelNoLevel() { + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(false); + challenge.setRewardIslandLevel(50L); + when(addon.isLevelProvided()).thenReturn(false); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + // Should not call Level addon methods + verify(addon, never()).getLevelAddon(); + } + + @Test + void testFirstTimeRewardIslandLevelZero() { + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(false); + challenge.setRewardIslandLevel(0L); + Level levelAddon = mockLevelAddon(100L); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + // Should not call setIslandLevel when reward is 0 + verify(levelAddon, never()).setIslandLevel(any(), any(), anyLong()); + } + + @Test + void testLevelCompletionRewardIslandLevel() { + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(false); + ChallengeLevel lvl = new ChallengeLevel(); + lvl.setUniqueId(GAME_MODE_NAME + "_novice"); + lvl.setFriendlyName("Novice"); + lvl.setRewardIslandLevel(100L); + when(cm.getLevel(GAME_MODE_NAME + "_novice")).thenReturn(lvl); + when(cm.getLevel(any(Challenge.class))).thenReturn(lvl); + when(cm.tryCompleteLevel(any(), any(), any())).thenReturn(lvl); + Level levelAddon = mockLevelAddon(100L); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + verify(levelAddon).setIslandLevel(world, user.getUniqueId(), 200L); + } } From 15eb3ae201ee19678643e55814d970e735740fe4 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 15:13:54 -0700 Subject: [PATCH 10/27] Add optional per-challenge reward chance New rewardChance percentage on challenges (default 100, so nothing changes for existing data). For first-time completion one roll gates the item, money, and XP rewards together; repeat completions roll once per completion instance. Reward commands and completion state are never gated. Fixes #76 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .../challenges/database/object/Challenge.java | 30 +++++ .../panel/admin/EditChallengePanel.java | 26 ++++- .../challenges/tasks/TryToComplete.java | 104 +++++++++++++----- src/main/resources/locales/en-US.yml | 10 +- .../challenges/tasks/TryToCompleteTest.java | 66 +++++++++++ 5 files changed, 205 insertions(+), 31 deletions(-) diff --git a/src/main/java/world/bentobox/challenges/database/object/Challenge.java b/src/main/java/world/bentobox/challenges/database/object/Challenge.java index 360a7123..7ca42fe3 100644 --- a/src/main/java/world/bentobox/challenges/database/object/Challenge.java +++ b/src/main/java/world/bentobox/challenges/database/object/Challenge.java @@ -230,6 +230,15 @@ public enum ChallengeType @Expose private List rewardCommands = new ArrayList<>(); + /** + * Percentage chance (0-100) that material rewards are given on completion. + * Items, money, and experience are gated by a single roll. + * Default 100 so existing challenges are unchanged. + * Reward commands are never gated. + */ + @Expose + private int rewardChance = 100; + /** * Set of item stacks that should ignore metadata. */ @@ -486,6 +495,16 @@ public List getRewardCommands() } + /** + * Gets the reward chance percentage (0-100). + * @return the rewardChance + */ + public int getRewardChance() + { + return rewardChance; + } + + /** * @return the repeatable */ @@ -784,6 +803,16 @@ public void setRewardCommands(List rewardCommands) } + /** + * Sets the reward chance percentage. + * @param rewardChance the rewardChance new value (0-100). + */ + public void setRewardChance(int rewardChance) + { + this.rewardChance = rewardChance; + } + + /** * This method sets the repeatable value. * @param repeatable the repeatable new value. @@ -1028,6 +1057,7 @@ public Challenge copy() clone.setHideIfNoTeam(this.hideIfNoTeam); clone.setRewardExperience(this.rewardExperience); clone.setRewardMoney(this.rewardMoney); + clone.setRewardChance(this.rewardChance); clone.setRepeatable(this.repeatable); clone.setTimeout(this.timeout); clone.setRepeatRewardText(this.repeatRewardText); diff --git a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java index 3b4b4abb..093025da 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java @@ -258,6 +258,7 @@ private void buildRewardsPanel(PanelBuilder panelBuilder) { panelBuilder.item(11, this.createRewardButton(RewardButton.REWARD_ITEMS)); panelBuilder.item(20, this.createRewardButton(RewardButton.REWARD_EXPERIENCE)); panelBuilder.item(29, this.createRewardButton(RewardButton.REWARD_MONEY)); + panelBuilder.item(38, this.createRewardButton(RewardButton.REWARD_CHANCE)); panelBuilder.item(22, this.createRewardButton(RewardButton.REPEATABLE)); @@ -1431,6 +1432,29 @@ private PanelItem createRewardButton(RewardButton button) { description.add(""); description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); } + case REWARD_CHANCE -> { + description.add(this.user.getTranslation(reference + Constants.VALUE_KEY, Constants.PARAMETER_NUMBER, + String.valueOf(challenge.getRewardChance()))); + icon = new ItemStack(Material.DIAMOND); + clickHandler = (panel, user, clickType, i) -> { + Consumer numberConsumer = number -> { + if (number != null) { + this.challenge.setRewardChance(number.intValue()); + } + + // reopen panel + this.build(); + }; + ConversationUtils.createNumericInput(numberConsumer, this.user, + this.user.getTranslation(Constants.INPUT_NUMBER), 0, 100); + + return true; + }; + glow = false; + + description.add(""); + description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); + } case REWARD_COMMANDS -> { icon = new ItemStack(Material.COMMAND_BLOCK); @@ -1836,7 +1860,7 @@ private enum Button { * Represents different rewards buttons that are used in menus. */ private enum RewardButton { - REWARD_TEXT, REWARD_ITEMS, REWARD_EXPERIENCE, REWARD_MONEY, REWARD_COMMANDS, + REWARD_TEXT, REWARD_ITEMS, REWARD_EXPERIENCE, REWARD_MONEY, REWARD_CHANCE, REWARD_COMMANDS, REPEATABLE, REPEAT_COUNT, COOL_DOWN, diff --git a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java index 1281baf5..9c67cad8 100644 --- a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java +++ b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java @@ -15,6 +15,7 @@ import java.util.Objects; import java.util.PriorityQueue; import java.util.Queue; +import java.util.Random; import java.util.UUID; import java.util.function.BiPredicate; import java.util.stream.Collectors; @@ -105,6 +106,11 @@ public class TryToComplete */ private final ChallengeResult emptyResult = new ChallengeResult(); + /** + * Random number generator for reward chance rolls. + */ + private final Random random = new Random(); + // --------------------------------------------------------------------- // Section: Builder // --------------------------------------------------------------------- @@ -253,28 +259,34 @@ ChallengeResult build(int maxTimes) // If challenge was not completed then reward items for completing it first time. if (!result.wasCompleted()) { + // Roll the reward chance once for all recipients and item types + boolean rewardSuccess = this.shouldRewardItems(); + // Reward every recipient (all present members for a team challenge, else just the user). for (User recipient : this.getRewardRecipients()) { - // Item rewards - for (ItemStack reward : this.challenge.getRewardItems()) + if (rewardSuccess) { - // Clone is necessary because otherwise it will chane reward itemstack - // amount. - recipient.getInventory().addItem(reward.clone()).forEach((k, v) -> - recipient.getWorld().dropItem(recipient.getLocation(), v)); - } + // Item rewards + for (ItemStack reward : this.challenge.getRewardItems()) + { + // Clone is necessary because otherwise it will chane reward itemstack + // amount. + recipient.getInventory().addItem(reward.clone()).forEach((k, v) -> + recipient.getWorld().dropItem(recipient.getLocation(), v)); + } - // Money Reward - if (this.addon.isEconomyProvided()) - { - this.addon.getEconomyProvider().deposit(recipient, this.challenge.getRewardMoney()); - } + // Money Reward + if (this.addon.isEconomyProvided()) + { + this.addon.getEconomyProvider().deposit(recipient, this.challenge.getRewardMoney()); + } - // Experience Reward - recipient.getPlayer().giveExp(this.challenge.getRewardExperience()); + // Experience Reward + recipient.getPlayer().giveExp(this.challenge.getRewardExperience()); + } - // Run commands + // Run commands (not gated by reward chance) this.runCommands(this.challenge.getRewardCommands(), recipient); } @@ -315,31 +327,45 @@ ChallengeResult build(int maxTimes) // Reward every recipient (all present members for a team challenge, else just the user). for (User recipient : this.getRewardRecipients()) { - // Item Repeat Rewards - for (ItemStack reward : this.challenge.getRepeatItemReward()) + // One roll per completion instance gates that instance's item, money and XP rewards + // together. With the default chance of 100 every roll succeeds, so the summed + // rewards match the previous behaviour exactly. + int rewardedCount = 0; + + for (int i = 0; i < rewardFactor; i++) { - // Clone is necessary because otherwise it will chane reward itemstack - // amount. + if (!this.shouldRewardItems()) + { + continue; + } + + rewardedCount++; - for (int i = 0; i < rewardFactor; i++) + // Item Repeat Rewards + for (ItemStack reward : this.challenge.getRepeatItemReward()) { + // Clone is necessary because otherwise it will chane reward itemstack + // amount. recipient.getInventory().addItem(reward.clone()).forEach((k, v) -> recipient.getWorld().dropItem(recipient.getLocation(), v)); } } - // Money Repeat Reward - if (this.addon.isEconomyProvided()) + if (rewardedCount > 0) { - this.addon.getEconomyProvider().deposit(recipient, - this.challenge.getRepeatMoneyReward() * rewardFactor); - } + // Money Repeat Reward + if (this.addon.isEconomyProvided()) + { + this.addon.getEconomyProvider().deposit(recipient, + this.challenge.getRepeatMoneyReward() * rewardedCount); + } - // Experience Repeat Reward - recipient.getPlayer().giveExp( - this.challenge.getRepeatExperienceReward() * rewardFactor); + // Experience Repeat Reward + recipient.getPlayer().giveExp( + this.challenge.getRepeatExperienceReward() * rewardedCount); + } - // Run commands + // Run commands (not gated by reward chance) for (int i = 0; i < rewardFactor; i++) { this.runCommands(this.challenge.getRepeatRewardCommands(), recipient); @@ -420,6 +446,26 @@ ChallengeResult build(int maxTimes) } + /** + * Checks if rewards should be given based on the challenge's reward chance. + * This method is protected to allow testing frameworks to override it. + * @return true if rewards should be given, false otherwise + */ + protected boolean shouldRewardItems() + { + int chance = this.challenge.getRewardChance(); + if (chance >= 100) + { + return true; + } + if (chance <= 0) + { + return false; + } + return this.random.nextInt(100) < chance; + } + + /** * This method fulfills all challenge type requirements, that is not fulfilled yet. * @param result Challenge Results diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 28c2e670..451f1900 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -584,9 +584,17 @@ challenges: repeat_reward_money: name: "Repeat Reward Money" description: |- - Allows you to change the repeat + Allows you to change the repeat reward money for the challenge. value: "Current value: [number]" + reward_chance: + name: "Reward Chance" + description: |- + Chance percentage (0-100) that material + rewards are given on completion. + Items, money, and experience use one roll. + Commands are always executed. + value: "Chance: [number]%" reward_commands: name: "Reward Commands" description: |- diff --git a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java index 1d616e51..ccb2c23f 100644 --- a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java +++ b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java @@ -849,4 +849,70 @@ void testFreeChallengeNoLevelCheck() { assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); verify(cm, never()).getLevel(any(Challenge.class)); } + + // ------------------------------------------------------------------------- + // Reward chance tests + // ------------------------------------------------------------------------- + + @Test + void testRewardChanceDefaultIs100() { + Challenge c = new Challenge(); + assertEquals(100, c.getRewardChance()); + } + + @Test + void testRewardChance100GivesRewards() { + challenge.setRewardChance(100); + challenge.setRewardExperience(50); + challenge.setRewardMoney(100); + challenge.setRewardItems(Collections.singletonList(new ItemStack(Material.EMERALD))); + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(false); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + mockEconomy(true, 1000); + + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + verify(inv, atLeast(1)).addItem(any()); + verify(addon.getEconomyProvider()).deposit(any(), eq(100.0)); + verify(player).giveExp(50); + } + + @Test + void testRewardChance0NoItems() { + challenge.setRewardChance(0); + challenge.setRewardExperience(50); + challenge.setRewardMoney(100); + challenge.setRewardItems(Collections.singletonList(new ItemStack(Material.EMERALD))); + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(false); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + mockEconomy(true, 1000); + + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Should not add reward items + verify(inv, never()).addItem(any()); + // Should not deposit money + verify(addon.getEconomyProvider(), never()).deposit(any(), eq(100.0)); + // Should not give experience + verify(player, never()).giveExp(50); + } + + @Test + void testRewardChanceRepeatRewards() { + challenge.setRewardChance(100); + challenge.setRepeatable(true); + challenge.setRepeatExperienceReward(25); + challenge.setRepeatMoneyReward(50); + challenge.setRepeatItemReward(Collections.singletonList(new ItemStack(Material.DIAMOND))); + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(true); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + mockEconomy(true, 1000); + + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Repeat rewards with 100% chance should be given + verify(inv, atLeast(1)).addItem(any()); + verify(addon.getEconomyProvider()).deposit(any(), eq(50.0)); + verify(player).giveExp(25); + } } From a3a8f3235b0382200ca11232b6e4843bd4e3227e Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 15:41:21 -0700 Subject: [PATCH 11/27] Address SonarCloud findings on PR #409 Remove the always-false inventory null check (User#getInventory is non-null for players), use Stream.toList(), use pattern-matching instanceof in Utils, and drop an unused test variable. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .../challenges/tasks/TryToComplete.java | 20 +++++-------------- .../bentobox/challenges/utils/Utils.java | 8 ++++---- .../challenges/tasks/TryToCompleteTest.java | 2 -- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java index de29306b..46f66bcd 100644 --- a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java +++ b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java @@ -18,7 +18,6 @@ import java.util.Queue; import java.util.UUID; import java.util.function.BiPredicate; -import java.util.stream.Collectors; import org.bukkit.Bukkit; import org.bukkit.ChatColor; @@ -1066,21 +1065,12 @@ Map removeItems(List requiredItemList, int factor for (ItemStack required : requiredItemList) { int amountToBeRemoved = required.getAmount() * factor; - List itemsInInventory; - if (this.user.getInventory() == null) - { - // Sanity check. User always has inventory at this point of code. - itemsInInventory = Collections.emptyList(); - } - else - { - // Use helper method that handles ignore-metadata logic including potion types. - itemsInInventory = Arrays.stream(user.getInventory().getContents()). - filter(Objects::nonNull). - filter(i -> itemsMatch(i, required, this.getInventoryRequirements().getIgnoreMetaData())). - collect(Collectors.toList()); - } + // Use helper method that handles ignore-metadata logic including potion types. + List itemsInInventory = Arrays.stream(user.getInventory().getContents()). + filter(Objects::nonNull). + filter(i -> itemsMatch(i, required, this.getInventoryRequirements().getIgnoreMetaData())). + toList(); for (ItemStack itemStack : itemsInInventory) { diff --git a/src/main/java/world/bentobox/challenges/utils/Utils.java b/src/main/java/world/bentobox/challenges/utils/Utils.java index b3ccfe54..c9b8f525 100644 --- a/src/main/java/world/bentobox/challenges/utils/Utils.java +++ b/src/main/java/world/bentobox/challenges/utils/Utils.java @@ -91,14 +91,14 @@ public static boolean comparePotionType(@Nullable ItemStack first, @Nullable Ite PotionType firstType = null; PotionType secondType = null; - if (first.hasItemMeta() && first.getItemMeta() instanceof PotionMeta) + if (first.hasItemMeta() && first.getItemMeta() instanceof PotionMeta potionMeta) { - firstType = ((PotionMeta) first.getItemMeta()).getBasePotionType(); + firstType = potionMeta.getBasePotionType(); } - if (second.hasItemMeta() && second.getItemMeta() instanceof PotionMeta) + if (second.hasItemMeta() && second.getItemMeta() instanceof PotionMeta potionMeta) { - secondType = ((PotionMeta) second.getItemMeta()).getBasePotionType(); + secondType = potionMeta.getBasePotionType(); } // If either has no potion meta, they're only equal if both are missing the meta diff --git a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java index fc201491..e4ffd75d 100644 --- a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java +++ b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java @@ -911,8 +911,6 @@ void testPotionComparisonHelperMethod() { ItemStack swiftnessPotion2 = createPotion(Material.POTION, PotionType.SWIFTNESS); ItemStack strengthPotion = createPotion(Material.POTION, PotionType.STRENGTH); - Set ignoreMetaData = Set.of(Material.POTION); - // Test that same potion type matches assertTrue(Utils.comparePotionType(swiftnessPotion1, swiftnessPotion2), "Potions with same base type should compare as equal"); From da91fccb21582eb01211d2fdeaef8063b9a14c6f Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 15:44:40 -0700 Subject: [PATCH 12/27] Reduce duplication flagged by SonarCloud Extract the three identical island-level reward blocks into a single rewardIslandLevel() helper. This also fixes the reward being applied once per team member: the island is shared, so it is now applied once per completion, outside the recipient loops. Exclude the Gson data objects from copy-paste detection: their accessors are intentionally identical bean boilerplate across Challenge, ChallengeLevel and ChallengesPlayerData. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- pom.xml | 4 ++ .../challenges/tasks/TryToComplete.java | 47 ++++++++++--------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/pom.xml b/pom.xml index 6e509d8d..79462c68 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,10 @@ BentoBoxWorld_Challenges bentobox-world https://sonarcloud.io + + src/main/java/world/bentobox/challenges/database/object/*.java diff --git a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java index f57e7192..0408b271 100644 --- a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java +++ b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java @@ -274,18 +274,14 @@ ChallengeResult build(int maxTimes) // Experience Reward recipient.getPlayer().giveExp(this.challenge.getRewardExperience()); - // Island Level Reward - if (this.addon.isLevelProvided() && this.challenge.getRewardIslandLevel() != 0) - { - world.bentobox.level.Level level = this.addon.getLevelAddon(); - level.setIslandLevel(this.world, this.user.getUniqueId(), - level.getIslandLevel(this.world, this.user.getUniqueId()) + this.challenge.getRewardIslandLevel()); - } - // Run commands this.runCommands(this.challenge.getRewardCommands(), recipient); } + // Island Level Reward. The island is shared, so this is applied once per + // completion, not once per team member. + this.rewardIslandLevel(this.challenge.getRewardIslandLevel()); + // Send message about first completion only if it is completed only once. if (result.getFactor() == 1) { @@ -347,14 +343,6 @@ ChallengeResult build(int maxTimes) recipient.getPlayer().giveExp( this.challenge.getRepeatExperienceReward() * rewardFactor); - // Island Level Repeat Reward - if (this.addon.isLevelProvided() && this.challenge.getRepeatIslandLevel() != 0) - { - world.bentobox.level.Level level = this.addon.getLevelAddon(); - level.setIslandLevel(this.world, this.user.getUniqueId(), - level.getIslandLevel(this.world, this.user.getUniqueId()) + this.challenge.getRepeatIslandLevel() * rewardFactor); - } - // Run commands for (int i = 0; i < rewardFactor; i++) { @@ -362,6 +350,10 @@ ChallengeResult build(int maxTimes) } } + // Island Level Repeat Reward. The island is shared, so this is applied once per + // completion, not once per team member. + this.rewardIslandLevel(this.challenge.getRepeatIslandLevel() * rewardFactor); + if (result.getFactor() > 1) { Utils.sendMessage(this.user, @@ -405,12 +397,7 @@ ChallengeResult build(int maxTimes) this.user.getPlayer().giveExp(level.getRewardExperience()); // Island Level Reward - if (this.addon.isLevelProvided() && level.getRewardIslandLevel() != 0) - { - world.bentobox.level.Level levelAddon = this.addon.getLevelAddon(); - levelAddon.setIslandLevel(this.world, this.user.getUniqueId(), - levelAddon.getIslandLevel(this.world, this.user.getUniqueId()) + level.getRewardIslandLevel()); - } + this.rewardIslandLevel(level.getRewardIslandLevel()); // Run commands this.runCommands(level.getRewardCommands()); @@ -444,6 +431,22 @@ ChallengeResult build(int maxTimes) } + /** + * This method increases the completing user's island level via the Level addon. + * Does nothing if the Level addon is not present or the reward is 0. + * @param reward Number of island levels to add. + */ + private void rewardIslandLevel(long reward) + { + if (this.addon.isLevelProvided() && reward != 0) + { + world.bentobox.level.Level levelAddon = this.addon.getLevelAddon(); + levelAddon.setIslandLevel(this.world, this.user.getUniqueId(), + levelAddon.getIslandLevel(this.world, this.user.getUniqueId()) + reward); + } + } + + /** * This method fulfills all challenge type requirements, that is not fulfilled yet. * @param result Challenge Results From c38d6c9528cc9288b8c9c9e7b10d8acd1f106eb6 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 15:47:37 -0700 Subject: [PATCH 13/27] Add reward chance translations for all locales Translate the new challenges.gui.buttons.reward_chance strings into cs, de, es, fr, hu, ja, lv, pl, pt, ru, uk, zh-CN, zh-HK and zh-TW, matching each file's existing formatting style. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- src/main/resources/locales/cs.yml | 8 ++++++++ src/main/resources/locales/de.yml | 8 ++++++++ src/main/resources/locales/es.yml | 8 ++++++++ src/main/resources/locales/fr.yml | 8 ++++++++ src/main/resources/locales/hu.yml | 8 ++++++++ src/main/resources/locales/ja.yml | 8 ++++++++ src/main/resources/locales/lv.yml | 8 ++++++++ src/main/resources/locales/pl.yml | 8 ++++++++ src/main/resources/locales/pt.yml | 8 ++++++++ src/main/resources/locales/ru.yml | 8 ++++++++ src/main/resources/locales/uk.yml | 8 ++++++++ src/main/resources/locales/zh-CN.yml | 8 ++++++++ src/main/resources/locales/zh-HK.yml | 8 ++++++++ src/main/resources/locales/zh-TW.yml | 8 ++++++++ 14 files changed, 112 insertions(+) diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index 66dbc305..5580f728 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -467,6 +467,14 @@ challenges: opakování peněz za odměnu za výzvu. value: "Aktuální hodnota: [number]" + reward_chance: + name: "Šance na odměnu" + description: |- + Šance v procentech (0-100), že budou + při splnění uděleny hmotné odměny. + Předměty, peníze a zkušenosti sdílí jeden los. + Příkazy se provedou vždy. + value: "Šance: [number]%" reward_commands: name: "Příkazy odměny" description: |- diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index bedde3c2..1078f69c 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -458,6 +458,14 @@ challenges: Wiederholung des Belohnungsgeldes für die Herausforderung. value: "Aktueller Wert: [number]" + reward_chance: + name: "Belohnungschance" + description: |- + Prozentuale Chance (0-100), dass materielle + Belohnungen beim Abschluss vergeben werden. + Gegenstände, Geld und Erfahrung nutzen einen Wurf. + Befehle werden immer ausgeführt. + value: "Chance: [number]%" reward_commands: name: "Belohnungsbefehle" description: |- diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index 4d404a88..9f6c4764 100755 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -470,6 +470,14 @@ challenges: repetir dinero de recompensa para el desafío. value: "Valor actual: [number]" + reward_chance: + name: "Probabilidad de Recompensa" + description: |- + Probabilidad (0-100) de que se entreguen + las recompensas materiales al completar. + Objetos, dinero y experiencia usan una sola tirada. + Los comandos siempre se ejecutan. + value: "Probabilidad: [number]%" reward_commands: name: "Comandos de recompensa" description: |- diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index d03dc1e8..540839fc 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -462,6 +462,14 @@ challenges: répéter l'argent de la récompense pour le défi. value: "Valeur actuelle : [number]" + reward_chance: + name: "Chance de récompense" + description: |- + Pourcentage de chance (0-100) que les récompenses + matérielles soient données à la fin du défi. + Objets, argent et expérience partagent un seul tirage. + Les commandes sont toujours exécutées. + value: "Chance : [number]%" reward_commands: name: "Commandes de récompense" description: |- diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index 144344e2..bae616a7 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -467,6 +467,14 @@ challenges: ismételt jutalompénz a kihíváshoz. value: "Jelenlegi érték: [number]" + reward_chance: + name: "Jutalom esélye" + description: |- + Százalékos esély (0-100) arra, hogy a teljesítéskor + megkapod a tárgyi jutalmakat. + A tárgyak, a pénz és a tapasztalat egy sorsoláson osztozik. + A parancsok mindig lefutnak. + value: "Esély: [number]%" reward_commands: name: "Jutalomparancsok" description: |- diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml index 990f76d7..2b92b64d 100644 --- a/src/main/resources/locales/ja.yml +++ b/src/main/resources/locales/ja.yml @@ -519,6 +519,14 @@ challenges: 繰り返しを変更できます チャレンジにお金に報いる。 value: 現在の値:[number] + reward_chance: + name: 報酬確率 + description: |- + 達成時に物質的な報酬が与えられる + 確率(0-100)です。 + アイテム、お金、経験値は1回の抽選で決まります。 + コマンドは常に実行されます。 + value: 確率:[number]% reward_commands: name: 報酬コマンド description: |- diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml index a5b015fa..910fa1f5 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -485,6 +485,14 @@ challenges: Ļauj uzstādīt atkārtotas atlīdzības naudu spēlētājam. value: "Vērtība: [number]" + reward_chance: + name: "Atlīdzības Iespēja" + description: |- + Iespēja procentos (0-100), ka izpildot + izaicinājumu tiks piešķirtas atlīdzības. + Priekšmeti, nauda un pieredze izmanto vienu izlozi. + Komandas tiek izpildītas vienmēr. + value: "Iespēja: [number]%" reward_commands: name: "Atlīdzības Komandas" description: |- diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index 9c5ecd49..0232b3b9 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -437,6 +437,14 @@ challenges: name: "Powtarzaj nagrody pieniężne" description: "Pozwala na zmianę nagrody \npieniężnej\npo ponownym wykonaniu zadania" value: "Aktualna wartość: [number]" + reward_chance: + name: "Szansa na nagrodę" + description: |- + Procentowa szansa (0-100), że nagrody + materialne zostaną przyznane po ukończeniu. + Przedmioty, pieniądze i doświadczenie używają jednego losowania. + Komendy są wykonywane zawsze. + value: "Szansa: [number]%" reward_commands: name: "Polecenia nagrody" description: "Konkretne polecenie nagrody:\nPodpowiedź:\nKomenda nie wymaga '/'\njest on dodawany automatycznie.\nDomyślnie polecenie wykonuje serwer\njeżeli chcesz by polecenie zostało \nwykonane przez gracza przed\nnim wpisz [SELF] \nWartość [player] użyta w poleceniu\nbędzie zamieniona na nick gracza\nktóry wykonał zadanie" diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index 5ddca18b..4b053e9f 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -469,6 +469,14 @@ challenges: repetir dinheiro de recompensa para o desafio. value: "Valor atual: [number]" + reward_chance: + name: "Chance de Recompensa" + description: |- + Chance percentual (0-100) de que as recompensas + materiais sejam dadas ao completar. + Itens, dinheiro e experiência usam um único sorteio. + Os comandos são sempre executados. + value: "Chance: [number]%" reward_commands: name: "Comandos de Recompensa" description: |- diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index bec99211..dea0a820 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -490,6 +490,14 @@ challenges: name: Деньги повторной награды description: "Позволяет изменить деньги повторной\nнаграды для испытания." value: 'Текущее значение: [number]' + reward_chance: + name: Шанс награды + description: |- + Шанс в процентах (0-100), что за выполнение + будут выданы материальные награды. + Предметы, деньги и опыт используют один бросок. + Команды выполняются всегда. + value: 'Шанс: [number]%' reward_commands: name: Команды награды description: "Задает команды награды.\nПодсказка:\nКоманда не требует начального `/` так как она\nприменяется автоматически.\nПо умолчанию команды выполняются сервером.\nОднако добавление `[SELF]` в начало приведет к\nвыполнению команды игроком.\nОна также поддерживает заполнитель `[player]` который будет\nзаменен на имя игрока который завершил испытание." diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index 379f33b3..54ac8a8f 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -467,6 +467,14 @@ challenges: повторити грошову винагороду за виклик. value: "Поточне значення: [number]" + reward_chance: + name: "Шанс винагороди" + description: |- + Шанс у відсотках (0-100), що за виконання + будуть видані матеріальні винагороди. + Предмети, гроші та досвід використовують один кидок. + Команди виконуються завжди. + value: "Шанс: [number]%" reward_commands: name: "Команди винагороди" description: |- diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index b3419743..d8630bfa 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -370,6 +370,14 @@ challenges: name: "重复奖励金钱" description: "允许更改重复挑战的金钱奖励" value: "当前值:[number]" + reward_chance: + name: "奖励几率" + description: |- + 完成挑战时发放物质奖励的 + 几率百分比(0-100)。 + 物品、金钱和经验共用一次判定。 + 命令始终会执行。 + value: "几率: [number]%" reward_commands: name: "奖励命令" description: |- diff --git a/src/main/resources/locales/zh-HK.yml b/src/main/resources/locales/zh-HK.yml index 337e1c77..0bd64678 100644 --- a/src/main/resources/locales/zh-HK.yml +++ b/src/main/resources/locales/zh-HK.yml @@ -386,6 +386,14 @@ challenges: name: "獎勵遊戲幣" description: '修改重覆挑戰的獎勵遊戲幣' value: "目前數量: [number]" + reward_chance: + name: "獎勵機率" + description: |- + 完成挑戰時發放物質獎勵的 + 機率百分比(0-100)。 + 物品、金錢和經驗共用一次判定。 + 指令必定會執行。 + value: "機率: [number]%" reward_commands: name: '獎勵指令' description: |- diff --git a/src/main/resources/locales/zh-TW.yml b/src/main/resources/locales/zh-TW.yml index 7091b91b..9065b3cf 100644 --- a/src/main/resources/locales/zh-TW.yml +++ b/src/main/resources/locales/zh-TW.yml @@ -481,6 +481,14 @@ challenges: name: 重複獎勵金錢 description: "允許你變更挑戰的\n重複獎勵金錢。" value: 目前值:[number] + reward_chance: + name: 獎勵機率 + description: |- + 完成挑戰時發放物質獎勵的 + 機率百分比(0-100)。 + 物品、金錢和經驗共用一次判定。 + 指令一定會執行。 + value: 機率:[number]% reward_commands: name: 獎勵指令 description: "指定獎勵指令。\n提示:\n指令不需要前導 `/` 因為它會\n自動套用。\n預設情況下,指令由伺服器執行。\n但是,在開頭新增 `[SELF]` 將導致\n由玩家執行指令。\n它還支援佔位符 `[player]` 將被\n取代為完成挑戰的玩家名稱。" From dcc65ffb86a123e22cb508431f9786ca1e70c633 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 16:34:36 -0700 Subject: [PATCH 14/27] Fix #323: hide level unlock message on locked levels generateLevelDescription() fell back to the level's unlock ("Congratulations...") message as the lore description regardless of whether the level was unlocked, so a locked level showed the congrats text while its status read "locked / N challenges to go". Gate the fallback on levelStatus.isUnlocked() so a locked level keeps its status text and only unlocked levels show the unlock message. Added CommonPanelTest coverage asserting getUnlockMessage() is not consulted for a locked level and is for an unlocked one. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .../challenges/panel/CommonPanel.java | 4 +- .../challenges/panel/CommonPanelTest.java | 60 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/main/java/world/bentobox/challenges/panel/CommonPanel.java b/src/main/java/world/bentobox/challenges/panel/CommonPanel.java index 394364eb..7ba2729c 100644 --- a/src/main/java/world/bentobox/challenges/panel/CommonPanel.java +++ b/src/main/java/world/bentobox/challenges/panel/CommonPanel.java @@ -864,7 +864,9 @@ protected List generateLevelDescription(LevelStatus levelStatus, User us String description = this.user .getTranslationOrNothing("challenges.levels." + level.getUniqueId() + ".description"); - if (description.isEmpty()) { + if (description.isEmpty() && levelStatus.isUnlocked()) { + // Only fall back to the unlock ("Congratulations...") message once the level is + // actually unlocked. A locked level keeps its "locked / N challenges to go" status. description = Util.translateColorCodes(String.join("\n", level.getUnlockMessage())); } diff --git a/src/test/java/world/bentobox/challenges/panel/CommonPanelTest.java b/src/test/java/world/bentobox/challenges/panel/CommonPanelTest.java index 654be426..ae964cb8 100644 --- a/src/test/java/world/bentobox/challenges/panel/CommonPanelTest.java +++ b/src/test/java/world/bentobox/challenges/panel/CommonPanelTest.java @@ -2,6 +2,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Collections; @@ -24,7 +27,9 @@ import world.bentobox.bentobox.api.user.User; import world.bentobox.challenges.ChallengesAddon; import world.bentobox.challenges.database.object.Challenge; +import world.bentobox.challenges.database.object.ChallengeLevel; import world.bentobox.challenges.managers.ChallengesManager; +import world.bentobox.challenges.utils.LevelStatus; /** * Tests for {@link CommonPanel} including description generation. @@ -69,6 +74,10 @@ public List callGenerateChallengeDescription(Challenge challenge, User t return this.generateChallengeDescription(challenge, target); } + public List callGenerateLevelDescription(LevelStatus status, User target) { + return this.generateLevelDescription(status, target); + } + public PanelItem getReturnButton() { return this.returnButton; } @@ -208,4 +217,55 @@ void testGenerateChallengeDescriptionEmptyDescription() { List description = panel.callGenerateChallengeDescription(challenge, null); assertNotNull(description); } + + /** + * Builds a mocked level (with empty description translation so the unlock-message + * fallback path is exercised) plus the deep stubs the description generator needs. + */ + private ChallengeLevel mockLevelWithUnlockMessage() { + ChallengeLevel level = Mockito.mock(ChallengeLevel.class); + when(level.getUniqueId()).thenReturn("bskyblock_level"); + when(level.getUnlockMessage()).thenReturn("Congratulations!"); + when(level.getWaiverAmount()).thenReturn(0); + when(level.getRewardItems()).thenReturn(Collections.emptyList()); + when(level.getRewardCommands()).thenReturn(Collections.emptyList()); + when(level.getRewardExperience()).thenReturn(0); + when(level.getRewardText()).thenReturn(""); + + // Empty custom description forces the unlock-message fallback branch. + when(user.getTranslationOrNothing("challenges.levels.bskyblock_level.description")) + .thenReturn(""); + + // generateLevelDescription reads addon.getPlugin().getIWM().getPermissionPrefix(world) + world.bentobox.bentobox.BentoBox plugin = + Mockito.mock(world.bentobox.bentobox.BentoBox.class, Mockito.RETURNS_DEEP_STUBS); + when(plugin.getIWM().getPermissionPrefix(world)).thenReturn("bskyblock."); + when(addon.getPlugin()).thenReturn(plugin); + return level; + } + + @Test + void testLockedLevelDoesNotShowUnlockMessage() { + ChallengeLevel level = mockLevelWithUnlockMessage(); + LevelStatus locked = new LevelStatus(level, null, 3, false, false); + + List description = panel.callGenerateLevelDescription(locked, user); + + assertNotNull(description); + // A locked level must not fall back to the "Congratulations" unlock message. + verify(level, never()).getUnlockMessage(); + } + + @Test + void testUnlockedLevelShowsUnlockMessage() { + ChallengeLevel level = mockLevelWithUnlockMessage(); + when(manager.getLevelChallenges(level)).thenReturn(Collections.emptyList()); + LevelStatus unlocked = new LevelStatus(level, null, 0, false, true); + + List description = panel.callGenerateLevelDescription(unlocked, user); + + assertNotNull(description); + // An unlocked level with no custom description still falls back to the unlock message. + verify(level, atLeastOnce()).getUnlockMessage(); + } } From b56b8cf2c932da690b73445aac16c5daef17f220 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 16:39:56 -0700 Subject: [PATCH 15/27] Improve #329: tell players how to answer confirmation prompts The confirmation conversation only accepts specific words (confirm / cancel / ...) but the prompt never said so, so players didn't know they had to type "confirm" in chat. createConfirmation() now appends a locale-driven instruction line to the question via a new confirm-instruction locale string, so every confirmation (import, reset, wipe, ...) tells the player what to type. Translators can localise the sentence and their own confirm/deny words. Extracted the assembly into ConversationUtils.appendConfirmationInstruction and added JUnit coverage for both the appended and empty-locale cases. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .../challenges/panel/ConversationUtils.java | 21 +++++++++++++++++-- .../bentobox/challenges/utils/Constants.java | 2 ++ src/main/resources/locales/en-US.yml | 1 + .../panel/ConversationUtilsTest.java | 19 +++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/main/java/world/bentobox/challenges/panel/ConversationUtils.java b/src/main/java/world/bentobox/challenges/panel/ConversationUtils.java index 6c5bc3d7..8830dcd1 100644 --- a/src/main/java/world/bentobox/challenges/panel/ConversationUtils.java +++ b/src/main/java/world/bentobox/challenges/panel/ConversationUtils.java @@ -117,8 +117,9 @@ public String getPromptText(@NotNull ConversationContext conversationContext) { // Close input GUI. user.closeInventory(); - // There are no editable message. Just return question. - return question; + // Append a standard instruction so players know they must type + // 'confirm' (or 'cancel') in chat rather than clicking anything. + return ConversationUtils.appendConfirmationInstruction(user, question); } }; @@ -135,6 +136,22 @@ public String getPromptText(@NotNull ConversationContext conversationContext) } + /** + * Appends the localised confirmation instruction ("Type 'confirm' ... or 'cancel' ...") + * to a confirmation question so players know they must answer in chat. If the locale + * string is empty (e.g. a translator cleared it) the question is returned unchanged. + * + * @param user User whose locale the instruction is taken from. + * @param question The confirmation question being asked. + * @return The question with the instruction appended on a new line, or the question alone. + */ + static String appendConfirmationInstruction(User user, String question) + { + String instruction = user.getTranslationOrNothing(Constants.CONFIRM_INSTRUCTION); + return instruction.isEmpty() ? question : question + "\n" + instruction; + } + + /** * This method will close opened gui and writes question in chat. After players answers on question in chat, message * will trigger consumer and gui will reopen. Be aware, consumer does not return (and validate) sanitized value, diff --git a/src/main/java/world/bentobox/challenges/utils/Constants.java b/src/main/java/world/bentobox/challenges/utils/Constants.java index b36590b2..a61228ca 100644 --- a/src/main/java/world/bentobox/challenges/utils/Constants.java +++ b/src/main/java/world/bentobox/challenges/utils/Constants.java @@ -265,6 +265,8 @@ public class Constants public static final String CANCELLED = CONVERSATIONS + "cancelled"; + public static final String CONFIRM_INSTRUCTION = CONVERSATIONS + "confirm-instruction"; + public static final String PREFIX = CONVERSATIONS + "prefix"; // --------------------------------------------------------------------- diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index b1869709..43bdc38c 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -1218,6 +1218,7 @@ challenges: cancel-string: "cancel" exit-string: "cancel, exit, quit" cancelled: "Conversation cancelled!" + confirm-instruction: "Type 'confirm' in chat to proceed, or 'cancel' to abort." input-number: "Please enter a number in the chat." input-seconds: "Please enter a number of seconds in the chat." numeric-only: "The given [value] is not a number!" diff --git a/src/test/java/world/bentobox/challenges/panel/ConversationUtilsTest.java b/src/test/java/world/bentobox/challenges/panel/ConversationUtilsTest.java index 934def31..8d765224 100644 --- a/src/test/java/world/bentobox/challenges/panel/ConversationUtilsTest.java +++ b/src/test/java/world/bentobox/challenges/panel/ConversationUtilsTest.java @@ -1,5 +1,6 @@ package world.bentobox.challenges.panel; +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.verify; @@ -88,6 +89,24 @@ void testCreateConfirmationWithNullSuccessMessage() { verify(user).getPlayer(); } + @Test + void testConfirmationInstructionAppended() { + when(user.getTranslationOrNothing( + world.bentobox.challenges.utils.Constants.CONFIRM_INSTRUCTION)) + .thenReturn("Type confirm to proceed"); + String result = ConversationUtils.appendConfirmationInstruction(user, "Are you sure?"); + assertEquals("Are you sure?\nType confirm to proceed", result); + } + + @Test + void testConfirmationInstructionEmptyFallsBackToQuestion() { + when(user.getTranslationOrNothing( + world.bentobox.challenges.utils.Constants.CONFIRM_INSTRUCTION)) + .thenReturn(""); + String result = ConversationUtils.appendConfirmationInstruction(user, "Are you sure?"); + assertEquals("Are you sure?", result); + } + @Test void testCreateIDStringInput() { @SuppressWarnings("unchecked") From add3daa79849c8c5a27f401465bf39a9d9c9f17a Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 16:41:43 -0700 Subject: [PATCH 16/27] Add include-undeployed to shipped config.yml (#179) The include-undeployed setting (whether undeployed challenges count towards level completion) existed in Settings and the admin GUI but was missing from the shipped config.yml, so admins couldn't discover it without reading the source. Add it to config.yml with a description, and fix the "undepolyed" typo and clarify the wording in the Settings ConfigComment. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .../java/world/bentobox/challenges/config/Settings.java | 5 +++-- src/main/resources/config.yml | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/world/bentobox/challenges/config/Settings.java b/src/main/java/world/bentobox/challenges/config/Settings.java index 505d4147..62538df6 100644 --- a/src/main/java/world/bentobox/challenges/config/Settings.java +++ b/src/main/java/world/bentobox/challenges/config/Settings.java @@ -156,8 +156,9 @@ public class Settings implements ConfigObject private boolean resetChallenges = true; @ConfigComment("") - @ConfigComment("This option indicates if undepolyed challenges should be counted to level completion.") - @ConfigComment("Disabling this option will make it so that only deployed challenges will be counted.") + @ConfigComment("This option indicates if undeployed challenges should be counted towards level completion.") + @ConfigComment("Disabling this option will make it so that only deployed challenges are counted, so an") + @ConfigComment("undeployed challenge will not block a level from being completed.") @ConfigComment("Default: true") @ConfigEntry(path = "include-undeployed") private boolean includeUndeployed = true; diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index d2364a61..7ab5cd69 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -96,6 +96,12 @@ store-island-data: true # challenges by doing them repeatedly. reset-challenges: true # +# This option indicates if undeployed challenges should be counted towards level completion. +# Disabling this option will make it so that only deployed challenges are counted, so an +# undeployed challenge will not block a level from being completed. +# Default: true +include-undeployed: true +# # Broadcast 1st time challenge completion messages to all players. # Change to false if the spam becomes too much. broadcast-messages: true From 65ebe6177b1b719185ac0aa54f81a1bcd4d82524 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 17:20:15 -0700 Subject: [PATCH 17/27] Implement TOGGLEABLE undeployed view mode (#175) The undeployed-view-mode setting had a TOGGLEABLE option that was never implemented: selecting it behaved exactly like VISIBLE because the player panel only filtered undeployed challenges in HIDDEN mode. Finish it: in TOGGLEABLE mode the player GUI now shows a button (in the free-challenges row) that each player uses to show or hide undeployed challenges for themselves. The preference defaults to shown, so TOGGLEABLE starts out like VISIBLE, and the challenge/level lists are filtered through a single hideUndeployedChallenges() helper that covers both HIDDEN and TOGGLEABLE-hidden. - ChallengesPanel: showUndeployed session field, hideUndeployedChallenges() helper used by both list builders, and createToggleUndeployedButton (returns no button outside TOGGLEABLE mode). - main_panel.yml: TOGGLE_UNDEPLOYED button slot. - en-US.yml: button name + shown/hidden state strings. - config.yml / Settings: document TOGGLEABLE instead of "not implemented". - ChallengesPanelTest: HIDDEN removes, TOGGLEABLE-shown keeps, TOGGLEABLE-hidden removes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .../bentobox/challenges/config/Settings.java | 2 + .../panel/user/ChallengesPanel.java | 107 +++++++++++++++++- src/main/resources/config.yml | 4 +- src/main/resources/locales/en-US.yml | 10 ++ src/main/resources/panels/main_panel.yml | 9 ++ .../panel/user/ChallengesPanelTest.java | 65 +++++++++++ 6 files changed, 191 insertions(+), 6 deletions(-) diff --git a/src/main/java/world/bentobox/challenges/config/Settings.java b/src/main/java/world/bentobox/challenges/config/Settings.java index 62538df6..ee2d5441 100644 --- a/src/main/java/world/bentobox/challenges/config/Settings.java +++ b/src/main/java/world/bentobox/challenges/config/Settings.java @@ -112,6 +112,8 @@ public class Settings implements ConfigObject @ConfigComment("Valid values are:") @ConfigComment(" 'VISIBLE' - there will be no hidden challenges. All challenges will be viewable in GUI.") @ConfigComment(" 'HIDDEN' - shows only deployed challenges.") + @ConfigComment(" 'TOGGLEABLE' - adds a button to the player GUI so each player can show or hide") + @ConfigComment(" undeployed challenges themselves (defaults to shown).") @ConfigEntry(path = "gui-settings.undeployed-view-mode") private VisibilityMode visibilityMode = VisibilityMode.VISIBLE; diff --git a/src/main/java/world/bentobox/challenges/panel/user/ChallengesPanel.java b/src/main/java/world/bentobox/challenges/panel/user/ChallengesPanel.java index cbd4caf5..93493efe 100644 --- a/src/main/java/world/bentobox/challenges/panel/user/ChallengesPanel.java +++ b/src/main/java/world/bentobox/challenges/panel/user/ChallengesPanel.java @@ -102,6 +102,8 @@ protected void build() panelBuilder.registerTypeBuilder("UNASSIGNED_CHALLENGES", this::createFreeChallengesButton); + panelBuilder.registerTypeBuilder("TOGGLE_UNDEPLOYED", this::createToggleUndeployedButton); + panelBuilder.registerTypeBuilder("NEXT", this::createNextButton); panelBuilder.registerTypeBuilder("PREVIOUS", this::createPreviousButton); @@ -120,8 +122,9 @@ private void updateFreeChallengeList() this.manager.isChallengeComplete(this.user, this.world, challenge) && (globalRemoveCompleted || challenge.isRemoveWhenCompleted())); - // Remove all undeployed challenges if VisibilityMode is set to Hidden. - if (this.addon.getChallengesSettings().getVisibilityMode().equals(SettingsUtils.VisibilityMode.HIDDEN)) + // Remove all undeployed challenges if they should be hidden (HIDDEN mode, or + // TOGGLEABLE mode with the player currently hiding them). + if (this.hideUndeployedChallenges()) { this.freeChallengeList.removeIf(challenge -> !challenge.isDeployed()); } @@ -134,6 +137,21 @@ private void updateFreeChallengeList() } + /** + * Whether undeployed challenges should be hidden from the viewing player right now. + * True when the visibility mode is HIDDEN, or when it is TOGGLEABLE and the player has + * chosen to hide them. + * + * @return {@code true} if undeployed challenges must be filtered out of the GUI. + */ + private boolean hideUndeployedChallenges() + { + SettingsUtils.VisibilityMode mode = this.addon.getChallengesSettings().getVisibilityMode(); + return mode == SettingsUtils.VisibilityMode.HIDDEN || + (mode == SettingsUtils.VisibilityMode.TOGGLEABLE && !this.showUndeployed); + } + + /** * @return whether the viewing user currently has a team (island membership > 1). */ @@ -161,8 +179,9 @@ private void updateChallengeList() this.manager.isChallengeComplete(this.user, this.world, challenge) && (globalRemoveCompleted || challenge.isRemoveWhenCompleted())); - // Remove all undeployed challenges if VisibilityMode is set to Hidden. - if (this.addon.getChallengesSettings().getVisibilityMode().equals(SettingsUtils.VisibilityMode.HIDDEN)) + // Remove all undeployed challenges if they should be hidden (HIDDEN mode, or + // TOGGLEABLE mode with the player currently hiding them). + if (this.hideUndeployedChallenges()) { this.challengeList.removeIf(challenge -> !challenge.isDeployed()); } @@ -665,6 +684,78 @@ private PanelItem createFreeChallengesButton(@NonNull ItemTemplateRecord templat } + /** + * Creates the button that lets a player show or hide undeployed challenges when the + * visibility mode is TOGGLEABLE. In any other visibility mode there is nothing to toggle, + * so no button is shown (the slot falls back to the panel background). + * + * @param template the button template. + * @param slot the slot the button occupies. + * @return the toggle button, or {@code null} when the mode is not TOGGLEABLE. + */ + @Nullable + private PanelItem createToggleUndeployedButton(@NonNull ItemTemplateRecord template, TemplatedPanel.ItemSlot slot) + { + // Only meaningful in TOGGLEABLE mode. In VISIBLE / HIDDEN there is nothing to toggle. + if (this.addon.getChallengesSettings().getVisibilityMode() != SettingsUtils.VisibilityMode.TOGGLEABLE) + { + return null; + } + + PanelItemBuilder builder = new PanelItemBuilder(); + + if (template.icon() != null) + { + builder.icon(template.icon().clone()); + } + + if (template.title() != null) + { + builder.name(this.user.getTranslation(this.world, template.title())); + } + + if (template.description() != null) + { + builder.description(this.user.getTranslation(this.world, template.description())); + } + + // Show whether undeployed challenges are currently shown or hidden for this player. + builder.description(this.user.getTranslation(this.world, + this.showUndeployed ? + Constants.BUTTON + "toggle-undeployed.shown" : + Constants.BUTTON + "toggle-undeployed.hidden")); + + // Add ClickHandler: flip the preference, reset paging, and rebuild. + builder.clickHandler((panel, user, clickType, i) -> + { + this.showUndeployed = !this.showUndeployed; + // The visible challenge count changes, so the current page may be out of range. + this.challengeIndex = 0; + this.build(); + + // Always return true. + return true; + }); + + // Collect tooltips. + List tooltips = template.actions().stream(). + filter(action -> action.tooltip() != null). + map(action -> this.user.getTranslation(this.world, action.tooltip())). + filter(text -> !text.isBlank()). + collect(Collectors.toCollection(() -> new ArrayList<>(template.actions().size()))); + + // Add tooltips. + if (!tooltips.isEmpty()) + { + // Empty line and tooltips. + builder.description(""); + builder.description(tooltips); + } + + return builder.build(); + } + + @Nullable private PanelItem createNextButton(@NonNull ItemTemplateRecord template, TemplatedPanel.ItemSlot slot) { @@ -901,4 +992,12 @@ else if (Constants.LEVEL_BUILDER_KEY.equals(target)) * This indicates last selected level. */ private LevelStatus lastSelectedLevel; + + /** + * Per-session preference for the TOGGLEABLE visibility mode: whether this player + * currently wants to see undeployed challenges. Defaults to {@code true} (shown), so + * TOGGLEABLE behaves like VISIBLE until the player hides them with the toggle button. + * Only consulted when the visibility mode is TOGGLEABLE. + */ + private boolean showUndeployed = true; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 7ab5cd69..63310f7b 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -72,8 +72,8 @@ gui-settings: # Valid values are: # 'VISIBLE' - there will be no hidden challenges. All challenges will be viewable in GUI. # 'HIDDEN' - shows only deployed challenges. - # 'TOGGLEABLE' - there will be button in GUI that allows users to switch from ALL modes. - # TOGGLEABLE - Currently not implemented. + # 'TOGGLEABLE' - adds a button to the player GUI so each player can show or hide + # undeployed challenges themselves (defaults to shown). undeployed-view-mode: VISIBLE # # Allow players to open the challenges GUI without being on their island. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 43bdc38c..40fcafe8 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -80,6 +80,16 @@ challenges: description: |- Displays a list of free challenges + # Button shown only when undeployed-view-mode is TOGGLEABLE. It lets each player + # show or hide the challenges that are not yet deployed. + toggle-undeployed: + name: "Undeployed Challenges" + shown: |- + Undeployed challenges are + currently shown. + hidden: |- + Undeployed challenges are + currently hidden. # Button that is used to return to previous GUI or exit it completely. return: name: "Return" diff --git a/src/main/resources/panels/main_panel.yml b/src/main/resources/panels/main_panel.yml index 8508b735..f4272a6a 100644 --- a/src/main/resources/panels/main_panel.yml +++ b/src/main/resources/panels/main_panel.yml @@ -82,6 +82,15 @@ main_panel: left: tooltip: challenges.gui.tips.click-to-next 6: + 3: + # Only shown when undeployed-view-mode is TOGGLEABLE; hidden in VISIBLE / HIDDEN modes. + icon: SPYGLASS + title: challenges.gui.buttons.toggle-undeployed.name + data: + type: TOGGLE_UNDEPLOYED + action: + left: + tooltip: challenges.gui.tips.click-to-toggle 5: icon: IRON_BARS title: challenges.gui.buttons.free-challenges.name diff --git a/src/test/java/world/bentobox/challenges/panel/user/ChallengesPanelTest.java b/src/test/java/world/bentobox/challenges/panel/user/ChallengesPanelTest.java index b985b92c..5eaf7036 100644 --- a/src/test/java/world/bentobox/challenges/panel/user/ChallengesPanelTest.java +++ b/src/test/java/world/bentobox/challenges/panel/user/ChallengesPanelTest.java @@ -251,4 +251,69 @@ void testIncompleteNonRepeatableNeverHidden() throws Exception { assertTrue(getFreeChallengeList(panel).contains(incomplete), "Incomplete challenge should always be visible"); } + + private void setShowUndeployed(ChallengesPanel panel, boolean value) throws Exception { + Field field = ChallengesPanel.class.getDeclaredField("showUndeployed"); + field.setAccessible(true); + field.setBoolean(panel, value); + } + + @Test + @DisplayName("HIDDEN mode: undeployed challenges are removed") + void testHiddenModeRemovesUndeployed() throws Exception { + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(false); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.HIDDEN); + + Challenge deployed = PanelTestHelper.createBasicChallenge("Deployed", true); + Challenge undeployed = PanelTestHelper.createBasicChallenge("Undeployed", false); + List challenges = new ArrayList<>(List.of(deployed, undeployed)); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + + ChallengesPanel panel = createPanel(); + callUpdateFreeChallengeList(panel); + + assertTrue(getFreeChallengeList(panel).contains(deployed), "Deployed challenge stays"); + assertFalse(getFreeChallengeList(panel).contains(undeployed), + "Undeployed challenge is hidden in HIDDEN mode"); + } + + @Test + @DisplayName("TOGGLEABLE mode, showing (default): undeployed challenges are visible") + void testToggleableShowingKeepsUndeployed() throws Exception { + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(false); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.TOGGLEABLE); + + Challenge deployed = PanelTestHelper.createBasicChallenge("Deployed", true); + Challenge undeployed = PanelTestHelper.createBasicChallenge("Undeployed", false); + List challenges = new ArrayList<>(List.of(deployed, undeployed)); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + + ChallengesPanel panel = createPanel(); + // Default showUndeployed == true, so undeployed challenges must remain visible. + callUpdateFreeChallengeList(panel); + + assertTrue(getFreeChallengeList(panel).contains(deployed), "Deployed challenge stays"); + assertTrue(getFreeChallengeList(panel).contains(undeployed), + "Undeployed challenge is shown in TOGGLEABLE mode when the player has not hidden them"); + } + + @Test + @DisplayName("TOGGLEABLE mode, hidden by player: undeployed challenges are removed") + void testToggleableHiddenRemovesUndeployed() throws Exception { + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(false); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.TOGGLEABLE); + + Challenge deployed = PanelTestHelper.createBasicChallenge("Deployed", true); + Challenge undeployed = PanelTestHelper.createBasicChallenge("Undeployed", false); + List challenges = new ArrayList<>(List.of(deployed, undeployed)); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + + ChallengesPanel panel = createPanel(); + setShowUndeployed(panel, false); + callUpdateFreeChallengeList(panel); + + assertTrue(getFreeChallengeList(panel).contains(deployed), "Deployed challenge stays"); + assertFalse(getFreeChallengeList(panel).contains(undeployed), + "Undeployed challenge is hidden in TOGGLEABLE mode once the player toggles them off"); + } } From 003b90b63cdcbd41bd5cb0a669b69b714b124d81 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 17:34:15 -0700 Subject: [PATCH 18/27] Add default colour for challenge description and reward text (#340) Reimplements the useful part of the closed PR #340 in a way that fits the current pipeline. Setting a default colour saves admins from prefixing every challenge's description/reward text with the same colour code. Two new gui-settings, both empty by default (no change): - description-color: colour applied to each line of a challenge's own description text. - reward-text-color: colour applied to each line of a challenge's own first-time and repeat reward text. The colour is applied per line (so multi-line text stays coloured on every lore line) before Util.translateColorCodes, so it uses the same '&' / hex codes as the challenge text itself; a colour written in the text still overrides the default. Only the challenge's own data-field text is affected, not per-challenge locale overrides. The lore-length line-wrapping half of #340 is intentionally left out: it was built on legacy ChatColor wrapping incompatible with MiniMessage, its default reintroduced the space-less-language issues it was removed for, and the panel already wraps reward text via wrapToWidth(). Adds Utils.applyDefaultColor with JUnit coverage (blank/empty handling, single/multi-line, blank-line preservation, hex colours). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .../bentobox/challenges/config/Settings.java | 58 +++++++++++++++++++ .../challenges/panel/CommonPanel.java | 15 +++-- .../bentobox/challenges/utils/Utils.java | 38 ++++++++++++ src/main/resources/config.yml | 10 ++++ .../bentobox/challenges/utils/UtilsTest.java | 36 ++++++++++++ 5 files changed, 153 insertions(+), 4 deletions(-) diff --git a/src/main/java/world/bentobox/challenges/config/Settings.java b/src/main/java/world/bentobox/challenges/config/Settings.java index 62538df6..05d530ee 100644 --- a/src/main/java/world/bentobox/challenges/config/Settings.java +++ b/src/main/java/world/bentobox/challenges/config/Settings.java @@ -121,6 +121,20 @@ public class Settings implements ConfigObject @ConfigEntry(path = "gui-settings.open-anywhere") private boolean openAnywhere = false; + @ConfigComment("") + @ConfigComment("Default colour applied to every line of a challenge's own description text, so you") + @ConfigComment("do not have to prefix each challenge with the same colour. Uses '&' colour codes or") + @ConfigComment("hex (e.g. '&b' or '7FFFF'). Leave empty for no default. A colour written in the") + @ConfigComment("description itself still overrides this. Does not affect per-challenge locale overrides.") + @ConfigEntry(path = "gui-settings.description-color") + private String descriptionColor = ""; + + @ConfigComment("") + @ConfigComment("Default colour applied to every line of a challenge's own reward text (both first-time") + @ConfigComment("and repeat reward text). Same format as description-color. Leave empty for no default.") + @ConfigEntry(path = "gui-settings.reward-text-color") + private String rewardTextColor = ""; + @ConfigComment("") @ConfigComment("This allows to change default locked level icon. This option may be") @@ -741,4 +755,48 @@ public void setOpenAnywhere(boolean openAnywhere) { this.openAnywhere = openAnywhere; } + + + /** + * Returns the default colour applied to challenge description text. + * + * @return the description colour code, or an empty string for no default. + */ + public String getDescriptionColor() + { + return descriptionColor; + } + + + /** + * Sets the default colour applied to challenge description text. + * + * @param descriptionColor the colour code (e.g. "&b" or "7FFFF"), or empty for none. + */ + public void setDescriptionColor(String descriptionColor) + { + this.descriptionColor = descriptionColor; + } + + + /** + * Returns the default colour applied to challenge reward text. + * + * @return the reward text colour code, or an empty string for no default. + */ + public String getRewardTextColor() + { + return rewardTextColor; + } + + + /** + * Sets the default colour applied to challenge reward text. + * + * @param rewardTextColor the colour code (e.g. "&b" or "7FFFF"), or empty for none. + */ + public void setRewardTextColor(String rewardTextColor) + { + this.rewardTextColor = rewardTextColor; + } } diff --git a/src/main/java/world/bentobox/challenges/panel/CommonPanel.java b/src/main/java/world/bentobox/challenges/panel/CommonPanel.java index 7ba2729c..0e2f3d68 100644 --- a/src/main/java/world/bentobox/challenges/panel/CommonPanel.java +++ b/src/main/java/world/bentobox/challenges/panel/CommonPanel.java @@ -143,8 +143,11 @@ protected List generateChallengeDescription(Challenge challenge, @Nullab String description = this.user .getTranslationOrNothing(Constants.CHALLENGES_CHALLENGES + challenge.getUniqueId() + ".description"); if (description.isEmpty()) { - // Combine the challenge description list into a single string and translate color codes - description = Util.translateColorCodes(String.join("\n", challenge.getDescription())); + // Combine the challenge description list into a single string, apply the configured + // default colour to each line, and translate color codes. + description = Util.translateColorCodes(Utils.applyDefaultColor( + String.join("\n", challenge.getDescription()), + this.addon.getChallengesSettings().getDescriptionColor())); } // Replace any [label] placeholder with the actual top label description = description.replace("[label]", this.topLabel); @@ -707,7 +710,9 @@ private String generateRepeatReward(Challenge challenge) { .getTranslationOrNothing(Constants.CHALLENGES_CHALLENGES + challenge.getUniqueId() + ".repeat-reward-text"); if (rewardText.isEmpty()) { - rewardText = wrapToWidth(Util.translateColorCodes(String.join("\n", challenge.getRepeatRewardText())), 30); + rewardText = wrapToWidth(Util.translateColorCodes(Utils.applyDefaultColor( + String.join("\n", challenge.getRepeatRewardText()), + this.addon.getChallengesSettings().getRewardTextColor())), 30); } return this.user.getTranslationOrNothing(reference + "lore", Constants.PARAMETER_TEXT, rewardText, Constants.PARAMETER_ITEMS, items, @@ -786,7 +791,9 @@ private String generateReward(Challenge challenge) { .getTranslationOrNothing(Constants.CHALLENGES_CHALLENGES + challenge.getUniqueId() + ".reward-text"); if (rewardText.isEmpty()) { - rewardText = wrapToWidth(Util.translateColorCodes(String.join("\n", challenge.getRewardText())), 30); + rewardText = wrapToWidth(Util.translateColorCodes(Utils.applyDefaultColor( + String.join("\n", challenge.getRewardText()), + this.addon.getChallengesSettings().getRewardTextColor())), 30); } return this.user.getTranslationOrNothing(reference + "lore", Constants.PARAMETER_TEXT, rewardText, Constants.PARAMETER_ITEMS, items, diff --git a/src/main/java/world/bentobox/challenges/utils/Utils.java b/src/main/java/world/bentobox/challenges/utils/Utils.java index c9b8f525..fc893aed 100644 --- a/src/main/java/world/bentobox/challenges/utils/Utils.java +++ b/src/main/java/world/bentobox/challenges/utils/Utils.java @@ -1047,4 +1047,42 @@ public static String parseDuration(Duration duration, User user) return returnString; } + + + /** + * Prefixes every line of the given text with a default colour code so the colour applies + * to each rendered lore line, not only the first (each lore line is rendered + * independently). A colour written at the start of a line still overrides the default, + * because a later colour code wins over an earlier one. The text is returned unchanged + * when the colour is blank or the text is empty. + * + *

The colour is applied before {@code Util.translateColorCodes}, so it uses the same + * '&' colour codes (or hex, e.g. {@code 7FFFF}) as the challenge text itself. + * + * @param text the text whose lines should be coloured (may contain '\n'). + * @param color the default colour code; blank means no change. + * @return the text with the colour prefixed to each line. + */ + public static String applyDefaultColor(String text, String color) + { + if (color == null || color.isBlank() || text == null || text.isEmpty()) + { + return text; + } + + String[] lines = text.split("\n", -1); + StringBuilder builder = new StringBuilder(text.length() + lines.length * color.length()); + + for (int i = 0; i < lines.length; i++) + { + if (i > 0) + { + builder.append('\n'); + } + + builder.append(color).append(lines[i]); + } + + return builder.toString(); + } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 7ab5cd69..4a38343f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -80,6 +80,16 @@ gui-settings: # Note: Challenges completion still requires being on the island when world protection is enabled. open-anywhere: false # + # Default colour applied to every line of a challenge's own description text, so you + # do not have to prefix each challenge with the same colour. Uses '&' colour codes or + # hex (e.g. '&b' or '7FFFF'). Leave empty for no default. A colour written in the + # description itself still overrides this. Does not affect per-challenge locale overrides. + description-color: '' + # + # Default colour applied to every line of a challenge's own reward text (both first-time + # and repeat reward text). Same format as description-color. Leave empty for no default. + reward-text-color: '' + # # This allows to change default locked level icon. This option may be # overwritten by each challenge level. If challenge level has specified # their locked level icon, then it will be used, instead of this one. diff --git a/src/test/java/world/bentobox/challenges/utils/UtilsTest.java b/src/test/java/world/bentobox/challenges/utils/UtilsTest.java index 45aae7df..4dcbf9dc 100644 --- a/src/test/java/world/bentobox/challenges/utils/UtilsTest.java +++ b/src/test/java/world/bentobox/challenges/utils/UtilsTest.java @@ -156,4 +156,40 @@ void testGetPreviousValue() { assertEquals(VisibilityMode.VISIBLE, Utils.getPreviousValue(VisibilityMode.values(), VisibilityMode.HIDDEN)); assertEquals(VisibilityMode.HIDDEN, Utils.getPreviousValue(VisibilityMode.values(), VisibilityMode.TOGGLEABLE)); } + + @Test + void testApplyDefaultColorBlankColorReturnsTextUnchanged() { + assertEquals("Hello", Utils.applyDefaultColor("Hello", "")); + assertEquals("Hello", Utils.applyDefaultColor("Hello", " ")); + assertEquals("Hello", Utils.applyDefaultColor("Hello", null)); + } + + @Test + void testApplyDefaultColorEmptyOrNullTextReturnedUnchanged() { + assertEquals("", Utils.applyDefaultColor("", "&b")); + assertNull(Utils.applyDefaultColor(null, "&b")); + } + + @Test + void testApplyDefaultColorSingleLinePrefixed() { + assertEquals("&bHello", Utils.applyDefaultColor("Hello", "&b")); + } + + @Test + void testApplyDefaultColorPrefixesEveryLine() { + assertEquals("&bLine1\n&bLine2\n&bLine3", + Utils.applyDefaultColor("Line1\nLine2\nLine3", "&b")); + } + + @Test + void testApplyDefaultColorPreservesBlankLines() { + // Blank lines still get the prefix, and no trailing/leading lines are dropped. + assertEquals("&bLine1\n&b\n&bLine2", + Utils.applyDefaultColor("Line1\n\nLine2", "&b")); + } + + @Test + void testApplyDefaultColorSupportsHexColor() { + assertEquals("7FFFFHello", Utils.applyDefaultColor("Hello", "7FFFF")); + } } From 76a35d1db424748ddcd881ba0e15dab31d3ea2ed Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 19:41:36 -0700 Subject: [PATCH 19/27] Add biome requirement to island challenges (#335) Island challenges can now require the player to be standing in one of a set of biomes when they complete the challenge, so admins can build exploration-style challenges that reward changing biomes. - IslandRequirements: new requiredBiomes set, stored as namespaced key strings (e.g. "minecraft:plains") so serialization is version-robust (Biome is a registry-backed type now) and unknown biomes simply never match. Included in copy(); no adapter or migration needed. - TryToComplete.checkSurrounding: gates completion on the player's current biome when requiredBiomes is set, with a wrong-biome message. - MultiBiomeSelector: a paged biome picker built on UnifiedMultiSelector, enumerating Registry.BIOME with rough per-biome icons. - EditChallengePanel: REQUIRED_BIOMES button in the island requirements menu (left-click to add, right-click to clear). - CommonPanel: shows required biomes in the challenge lore. - Utils.prettifyBiome turns "minecraft:snowy_taiga" into "Snowy Taiga". - Locale keys + tests (TryToComplete biome gate, prettifyBiome). Implemented as an option on island challenges (as the issue requested) rather than a whole new challenge type, keeping it simple. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .../requirements/IslandRequirements.java | 30 ++++ .../challenges/panel/CommonPanel.java | 13 +- .../panel/admin/EditChallengePanel.java | 52 ++++++- .../panel/util/MultiBiomeSelector.java | 142 ++++++++++++++++++ .../challenges/tasks/TryToComplete.java | 15 ++ .../bentobox/challenges/utils/Utils.java | 37 +++++ src/main/resources/locales/en-US.yml | 24 ++- .../challenges/tasks/TryToCompleteTest.java | 45 ++++++ .../bentobox/challenges/utils/UtilsTest.java | 19 +++ 9 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 src/main/java/world/bentobox/challenges/panel/util/MultiBiomeSelector.java diff --git a/src/main/java/world/bentobox/challenges/database/object/requirements/IslandRequirements.java b/src/main/java/world/bentobox/challenges/database/object/requirements/IslandRequirements.java index 722c2acb..9fab15b1 100644 --- a/src/main/java/world/bentobox/challenges/database/object/requirements/IslandRequirements.java +++ b/src/main/java/world/bentobox/challenges/database/object/requirements/IslandRequirements.java @@ -12,6 +12,7 @@ import java.util.HashSet; import java.util.Map; import java.util.Objects; +import java.util.Set; import org.bukkit.Fluid; import org.bukkit.Material; @@ -79,6 +80,15 @@ public IslandRequirements() { @Expose private int searchRadius = 10; + /** + * Namespaced keys (e.g. "minecraft:plains") of biomes the player must be standing in to + * complete the challenge. Stored as key strings rather than Biome objects so serialization + * is version-robust (Biome is a registry-backed type) and unknown biomes simply never match. + * Empty means no biome restriction. + */ + @Expose + private Set requiredBiomes = new HashSet<>(); + // --------------------------------------------------------------------- // Section: Getters and Setters // --------------------------------------------------------------------- @@ -182,6 +192,25 @@ public void setSearchRadius(int searchRadius) { this.searchRadius = searchRadius; } + + /** + * @return the namespaced keys of biomes the player must stand in (empty for no restriction). + */ + public Set getRequiredBiomes() { + if (this.requiredBiomes == null) { + this.requiredBiomes = new HashSet<>(); + } + return requiredBiomes; + } + + + /** + * @param requiredBiomes the required biome keys to set. + */ + public void setRequiredBiomes(Set requiredBiomes) { + this.requiredBiomes = requiredBiomes; + } + /** * Method isValid returns if given requirement data is valid or not. * @@ -215,6 +244,7 @@ public Requirements copy() { clone.setRemoveEntities(this.removeEntities); clone.setSearchRadius(this.searchRadius); + clone.setRequiredBiomes(new HashSet<>(this.getRequiredBiomes())); return clone; } diff --git a/src/main/java/world/bentobox/challenges/panel/CommonPanel.java b/src/main/java/world/bentobox/challenges/panel/CommonPanel.java index 0e2f3d68..4d8fc259 100644 --- a/src/main/java/world/bentobox/challenges/panel/CommonPanel.java +++ b/src/main/java/world/bentobox/challenges/panel/CommonPanel.java @@ -398,6 +398,17 @@ private String generateIslandChallenge(IslandRequirements requirement) { entities.insert(0, this.user.getTranslationOrNothing(reference + "entities-title")); } + // Required biomes + StringBuilder biomes = new StringBuilder(); + if (!requirement.getRequiredBiomes().isEmpty()) { + biomes.append(this.user.getTranslationOrNothing(reference + "biomes-title")); + requirement.getRequiredBiomes().stream().sorted().forEach(biomeKey -> { + biomes.append("\n"); + biomes.append(this.user.getTranslationOrNothing(reference + "biome-value", "[biome]", + Utils.prettifyBiome(biomeKey))); + }); + } + String searchRadius = this.user.getTranslationOrNothing(reference + "search-radius", Constants.PARAMETER_NUMBER, String.valueOf(requirement.getSearchRadius())); @@ -409,7 +420,7 @@ private String generateIslandChallenge(IslandRequirements requirement) { : ""; return this.user.getTranslationOrNothing(reference + "lore", "[blocks]", blocks.toString(), "[entities]", - entities.toString(), + entities.toString(), "[biomes]", biomes.toString(), "[warning-block]", warningBlocks, "[warning-entity]", warningEntities, "[search-radius]", searchRadius); } diff --git a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java index 0a82c00c..d662cf34 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java @@ -7,12 +7,16 @@ import java.util.Comparator; import java.util.HashSet; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.function.Consumer; import java.util.stream.Collectors; import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; import org.bukkit.World; +import org.bukkit.block.Biome; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.inventory.ItemStack; @@ -35,6 +39,7 @@ import world.bentobox.challenges.panel.ConversationUtils; import world.bentobox.challenges.panel.util.EnvironmentSelector; import world.bentobox.challenges.panel.util.ItemSelector; +import world.bentobox.challenges.panel.util.MultiBiomeSelector; import world.bentobox.challenges.panel.util.MultiBlockSelector; import world.bentobox.challenges.utils.Constants; import world.bentobox.challenges.utils.Utils; @@ -190,6 +195,7 @@ private void buildIslandRequirementsPanel(PanelBuilder panelBuilder) { panelBuilder.item(23, this.createRequirementButton(RequirementButton.SEARCH_RADIUS)); + panelBuilder.item(24, this.createRequirementButton(RequirementButton.REQUIRED_BIOMES)); panelBuilder.item(25, this.createRequirementButton(RequirementButton.REQUIRED_PERMISSIONS)); } @@ -737,7 +743,7 @@ private PanelItem createRequirementButton(RequirementButton button) { } // Buttons for Island Requirements case REQUIRED_ENTITIES, REMOVE_ENTITIES, REQUIRED_BLOCKS, REMOVE_BLOCKS, SEARCH_RADIUS, - REQUIRED_MATERIALTAGS, REQUIRED_ENTITYTAGS -> { + REQUIRED_MATERIALTAGS, REQUIRED_ENTITYTAGS, REQUIRED_BIOMES -> { return this.createIslandRequirementButton(button); } // Buttons for Inventory Requirements @@ -923,6 +929,48 @@ private PanelItem createIslandRequirementButton(RequirementButton button) { description.add(""); description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); } + case REQUIRED_BIOMES -> { + if (requirements.getRequiredBiomes().isEmpty()) { + description.add(this.user.getTranslation(reference + "none")); + } else { + description.add(this.user.getTranslation(reference + Constants.TITLE_KEY)); + requirements.getRequiredBiomes() + .forEach(biomeKey -> description.add(this.user.getTranslation(reference + "list", "[biome]", + Utils.prettifyBiome(biomeKey)))); + } + + icon = new ItemStack(Material.GRASS_BLOCK); + clickHandler = (panel, user, clickType, slot) -> { + if (clickType.isRightClick()) { + // Right click clears the biome list. + requirements.getRequiredBiomes().clear(); + this.build(); + } else { + // Left click opens the selector, hiding already-chosen biomes. + Set excluded = requirements.getRequiredBiomes().stream() + .map(key -> Registry.BIOME.get(NamespacedKey.fromString(key))) + .filter(Objects::nonNull).collect(Collectors.toSet()); + + MultiBiomeSelector.open(this.user, excluded, (status, biomes) -> { + if (Boolean.TRUE.equals(status) && biomes != null) { + biomes.forEach(biome -> requirements.getRequiredBiomes() + .add(MultiBiomeSelector.biomeKey(biome))); + } + + this.build(); + }); + } + return true; + }; + glow = false; + + description.add(""); + description.add(this.user.getTranslation(Constants.TIPS + "click-to-add")); + + if (!requirements.getRequiredBiomes().isEmpty()) { + description.add(this.user.getTranslation(Constants.TIPS + "right-click-to-clear")); + } + } default -> { icon = new ItemStack(Material.PAPER); clickHandler = null; @@ -1926,7 +1974,7 @@ public enum RequirementButton { REQUIRED_LEVEL, REQUIRED_MONEY, REMOVE_MONEY, STATISTIC, STATISTIC_BLOCKS, STATISTIC_ITEMS, STATISTIC_ENTITIES, STATISTIC_AMOUNT, REMOVE_STATISTIC, REQUIRED_MATERIALTAGS, REQUIRED_ENTITYTAGS, REQUIRED_STATISTICS, - REMOVE_STATISTICS, REQUIRED_PAPI, REQUIRED_ADVANCEMENTS, + REMOVE_STATISTICS, REQUIRED_PAPI, REQUIRED_ADVANCEMENTS, REQUIRED_BIOMES, } // --------------------------------------------------------------------- diff --git a/src/main/java/world/bentobox/challenges/panel/util/MultiBiomeSelector.java b/src/main/java/world/bentobox/challenges/panel/util/MultiBiomeSelector.java new file mode 100644 index 00000000..7a7dcf2e --- /dev/null +++ b/src/main/java/world/bentobox/challenges/panel/util/MultiBiomeSelector.java @@ -0,0 +1,142 @@ +package world.bentobox.challenges.panel.util; + +import java.util.Collection; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +import org.bukkit.Material; +import org.bukkit.Registry; +import org.bukkit.block.Biome; +import org.bukkit.inventory.ItemStack; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.challenges.utils.Utils; + +/** + * A multi-selector GUI for choosing biomes. Extends the unified multi-selector base and + * supplies the biome-specific details (element list, icons, display names). + * + *

Biomes are a registry-backed type in modern Minecraft, so they are handled by their + * namespaced key rather than as an enum. + */ +public class MultiBiomeSelector extends UnifiedMultiSelector { + + private final Set excluded; + + private MultiBiomeSelector(User user, Set excluded, + BiConsumer> consumer) { + super(user, Mode.ANY, consumer); + this.excluded = excluded; + } + + /** + * Opens the biome selector. + * + * @param user the user who opens the GUI. + * @param excluded biomes to hide from the list (e.g. ones already selected). + * @param consumer callback receiving the confirmation flag and the chosen biomes. + */ + public static void open(User user, Set excluded, + BiConsumer> consumer) { + new MultiBiomeSelector(user, excluded, consumer).build(); + } + + /** + * Opens the biome selector with no exclusions. + * + * @param user the user who opens the GUI. + * @param consumer callback receiving the confirmation flag and the chosen biomes. + */ + public static void open(User user, BiConsumer> consumer) { + new MultiBiomeSelector(user, new HashSet<>(), consumer).build(); + } + + @Override + protected List getElements() { + return StreamSupport.stream(Registry.BIOME.spliterator(), false) + .filter(biome -> excluded == null || !excluded.contains(biome)) + .sorted(Comparator.comparing(MultiBiomeSelector::biomeKey)) + .collect(Collectors.toList()); + } + + @Override + protected String getTitleKey() { + return "biome-selector"; + } + + @Override + protected String getElementKeyPrefix() { + return "biome."; + } + + @Override + protected String getElementPlaceholder() { + return "[biome]"; + } + + @Override + protected ItemStack getIcon(Biome element) { + return new ItemStack(iconMaterial(biomeKey(element))); + } + + @Override + protected String getElementDisplayName(Biome element) { + return Utils.prettifyBiome(biomeKey(element)); + } + + @Override + protected String elementToString(Biome element) { + return biomeKey(element); + } + + /** + * Returns the namespaced key string (e.g. "minecraft:plains") for a biome. + * + * @param biome the biome. + * @return its namespaced key. + */ + public static String biomeKey(Biome biome) { + return biome.getKey().toString(); + } + + /** + * Picks a rough representative icon for a biome key. Biomes have no natural item, so this + * is only a visual hint; the biome name in the button title is what identifies it. + * + * @param key the biome key. + * @return a Material to use as the button icon. + */ + private static Material iconMaterial(String key) { + String path = key.contains(":") ? key.substring(key.indexOf(':') + 1) : key; + + if (path.contains("nether") || path.contains("basalt") || path.contains("soul") || path.contains("crimson") + || path.contains("warped")) { + return Material.NETHERRACK; + } + if (path.contains("end")) { + return Material.END_STONE; + } + if (path.contains("ocean") || path.contains("river")) { + return Material.WATER_BUCKET; + } + if (path.contains("desert") || path.contains("beach") || path.contains("badlands")) { + return Material.SAND; + } + if (path.contains("snow") || path.contains("frozen") || path.contains("ice") || path.contains("cold")) { + return Material.SNOW_BLOCK; + } + if (path.contains("cave") || path.contains("deep") || path.contains("lush")) { + return Material.STONE; + } + if (path.contains("mushroom")) { + return Material.RED_MUSHROOM_BLOCK; + } + + return Material.GRASS_BLOCK; + } +} diff --git a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java index b71df214..778df469 100644 --- a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java +++ b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java @@ -1251,6 +1251,21 @@ private ChallengeResult checkSurrounding(int factor) return emptyResult; } + // Check biome requirement: the player must be standing in one of the required biomes. + Set requiredBiomes = this.getIslandRequirements().getRequiredBiomes(); + + if (!requiredBiomes.isEmpty()) + { + String currentBiome = this.user.getLocation().getBlock().getBiome().getKey().toString(); + + if (!requiredBiomes.contains(currentBiome)) + { + Utils.sendMessage(this.user, this.world, Constants.ERRORS + "wrong-biome", + "[biome]", Utils.prettifyBiome(currentBiome)); + return emptyResult; + } + } + // Init location in player position. BoundingBox boundingBox = this.user.getPlayer().getBoundingBox().clone(); diff --git a/src/main/java/world/bentobox/challenges/utils/Utils.java b/src/main/java/world/bentobox/challenges/utils/Utils.java index fc893aed..a9307c45 100644 --- a/src/main/java/world/bentobox/challenges/utils/Utils.java +++ b/src/main/java/world/bentobox/challenges/utils/Utils.java @@ -256,6 +256,43 @@ public static T getPreviousValue(T[] values, T currentValue) } + /** + * Turns a biome key such as "minecraft:snowy_taiga" into a readable "Snowy Taiga". + * The namespace is dropped and snake_case becomes Title Case. + * + * @param key the namespaced (or plain) biome key. + * @return a human-readable name, or an empty string for a blank key. + */ + public static String prettifyBiome(String key) + { + if (key == null || key.isBlank()) + { + return ""; + } + + String path = key.contains(":") ? key.substring(key.indexOf(':') + 1) : key; + StringBuilder builder = new StringBuilder(path.length()); + + for (String word : path.split("_")) + { + if (word.isEmpty()) + { + continue; + } + + if (builder.length() > 0) + { + builder.append(' '); + } + + builder.append(Character.toUpperCase(word.charAt(0))). + append(word.substring(1).toLowerCase(Locale.ENGLISH)); + } + + return builder.toString(); + } + + /** * Sanitizes the provided input. It replaces spaces and hyphens with underscores and lower cases the input. * This code also removes all color codes from the input. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 40fcafe8..a6d6a786 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -73,6 +73,7 @@ challenges: challenge-selector: "Challenge Selector" statistic-selector: "Statistic Selector" environment-selector: "Environment Selector" + biome-selector: "Biome Selector" buttons: # Button in the Challenges GUI that allows to select free challenges. free-challenges: @@ -362,11 +363,20 @@ challenges: name: "Required Blocks" description: |- Allows you to change the required - blocks for this challenge to be + blocks for this challenge to be completed. title: "Blocks: " list: " - [number] x [block]" none: "No blocks have been added." + required_biomes: + name: "Required Biomes" + description: |- + The player must be standing in one + of these biomes to complete this + island challenge. + title: "Biomes: " + list: " - [biome]" + none: "No biomes required (any biome)." required_statistics: name: "Required Statistics" description: |- @@ -913,6 +923,11 @@ challenges: description: |- Entity ID: [id] selected: "Selected" + biome: + name: "[biome]" + description: |- + Biome ID: [id] + selected: "Selected" entity-group: name: "[id]" description: "" @@ -1005,6 +1020,7 @@ challenges: click-to-change: "Click to change." shift-click-to-reset: "Shift Click to reset." click-to-add: "Click to add." + right-click-to-clear: "Right Click to clear." click-to-remove: "Click to remove." left-click-to-cycle: "Left Click to cycle down." right-click-to-cycle: "Right Click to cycle up." @@ -1092,9 +1108,14 @@ challenges: lore: |- [blocks] [entities] + [biomes] [search-radius] [warning-block] [warning-entity] + # Title that will be used if there are defined biomes in an island challenge + biomes-title: "Required Biome(s):" + # Listing of biomes the player must stand in. + biome-value: " - [biome]" # Title that will be used if there are defined blocks in an island challenge blocks-title: "Required Blocks:" # Listing of blocks that are required on the island. @@ -1305,6 +1326,7 @@ challenges: challenge-level-not-available: "You have not unlocked the required level to complete this challenge." not-repeatable: "This challenge is not repeatable!" wrong-environment: "You are in the wrong environment!" + wrong-biome: "You must be standing in the [biome] biome to complete this challenge!" not-enough-items: "You do not have enough [items] to complete this challenge!" not-close-enough: "You must be standing within [number] blocks of all required items." you-still-need: "You still need [amount] x [item]" diff --git a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java index e4f2f7e4..2311ed4e 100644 --- a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java +++ b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java @@ -31,6 +31,8 @@ import org.bukkit.Statistic; import org.bukkit.World; import org.bukkit.World.Environment; +import org.bukkit.NamespacedKey; +import org.bukkit.block.Biome; import org.bukkit.block.Block; import org.bukkit.entity.Entity; import org.bukkit.entity.EntityType; @@ -378,6 +380,49 @@ void testCompleteChallengesAddonUserChallengeWorldStringStringIslandFailMultiple eq("[item]"), eq("challenges.entities.chicken.name")); } + /** + * Mocks the biome at the player's location and returns the requirements for further setup. + */ + private IslandRequirements setupBiomeChallenge(String currentBiomeKey, Set requiredBiomes) { + challenge.setChallengeType(ChallengeType.ISLAND_TYPE); + IslandRequirements req = new IslandRequirements(); + req.setSearchRadius(1); + req.setRequiredBiomes(requiredBiomes); + challenge.setRequirements(req); + + Block block = mock(Block.class); + Biome biome = mock(Biome.class); + String[] parts = currentBiomeKey.split(":"); + when(biome.getKey()).thenReturn(NamespacedKey.minecraft(parts[parts.length - 1])); + when(block.getBiome()).thenReturn(biome); + when(user.getLocation().getBlock()).thenReturn(block); + return req; + } + + @Test + void testIslandChallengeSucceedsWhenInRequiredBiome() { + setupBiomeChallenge("minecraft:plains", new HashSet<>(Set.of("minecraft:plains"))); + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + } + + @Test + void testIslandChallengeFailsWhenNotInRequiredBiome() { + setupBiomeChallenge("minecraft:plains", new HashSet<>(Set.of("minecraft:desert"))); + assertFalse(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + verify(user).getTranslation(any(World.class), eq("challenges.errors.wrong-biome"), eq("[biome]"), + eq("Plains")); + } + + @Test + void testIslandChallengeIgnoresBiomeWhenNoneRequired() { + // No required biomes: the biome gate is skipped and the (empty) island challenge completes. + challenge.setChallengeType(ChallengeType.ISLAND_TYPE); + IslandRequirements req = new IslandRequirements(); + req.setSearchRadius(1); + challenge.setRequirements(req); + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + } + @Test void testCompleteChallengesAddonUserChallengeWorldStringStringIslandFailPartialMultipleEntities() { challenge.setChallengeType(ChallengeType.ISLAND_TYPE); diff --git a/src/test/java/world/bentobox/challenges/utils/UtilsTest.java b/src/test/java/world/bentobox/challenges/utils/UtilsTest.java index 4dcbf9dc..d3c2a760 100644 --- a/src/test/java/world/bentobox/challenges/utils/UtilsTest.java +++ b/src/test/java/world/bentobox/challenges/utils/UtilsTest.java @@ -192,4 +192,23 @@ void testApplyDefaultColorPreservesBlankLines() { void testApplyDefaultColorSupportsHexColor() { assertEquals("7FFFFHello", Utils.applyDefaultColor("Hello", "7FFFF")); } + + @Test + void testPrettifyBiomeStripsNamespaceAndTitleCases() { + assertEquals("Plains", Utils.prettifyBiome("minecraft:plains")); + assertEquals("Snowy Taiga", Utils.prettifyBiome("minecraft:snowy_taiga")); + assertEquals("Mangrove Swamp", Utils.prettifyBiome("minecraft:mangrove_swamp")); + } + + @Test + void testPrettifyBiomeWithoutNamespace() { + assertEquals("Desert", Utils.prettifyBiome("desert")); + } + + @Test + void testPrettifyBiomeBlankInput() { + assertEquals("", Utils.prettifyBiome(null)); + assertEquals("", Utils.prettifyBiome("")); + assertEquals("", Utils.prettifyBiome(" ")); + } } From 2f74680aed3c68e1712ff12261b8eba2e275056d Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 20:14:34 -0700 Subject: [PATCH 20/27] Address SonarCloud findings on #419 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - S1192: extract the duplicated "click-to-add" literal into Constants.CLICK_TO_ADD and use it in all three spots. - S6204: MultiBiomeSelector.getElements collects with toCollection(ArrayList::new) — the list must stay mutable because UnifiedMultiSelector's constructor sorts it in place (Stream.toList() would be immutable and throw at runtime). - S7158: use StringBuilder.isEmpty() in Utils.prettifyBiome. - S6541: keep the biome button out of the already-oversized createIslandRequirementButton "brain method" entirely — dispatch REQUIRED_BIOMES straight to a self-contained createBiomeRequirementButton from the outer switch, leaving the island method unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .../panel/admin/EditChallengePanel.java | 115 +++++++++++------- .../panel/util/MultiBiomeSelector.java | 4 +- .../bentobox/challenges/utils/Constants.java | 2 + .../bentobox/challenges/utils/Utils.java | 2 +- 4 files changed, 76 insertions(+), 47 deletions(-) diff --git a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java index d662cf34..0a624e52 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java @@ -743,9 +743,14 @@ private PanelItem createRequirementButton(RequirementButton button) { } // Buttons for Island Requirements case REQUIRED_ENTITIES, REMOVE_ENTITIES, REQUIRED_BLOCKS, REMOVE_BLOCKS, SEARCH_RADIUS, - REQUIRED_MATERIALTAGS, REQUIRED_ENTITYTAGS, REQUIRED_BIOMES -> { + REQUIRED_MATERIALTAGS, REQUIRED_ENTITYTAGS -> { return this.createIslandRequirementButton(button); } + // Biome requirement is a self-contained island requirement, kept out of the large + // createIslandRequirementButton switch. + case REQUIRED_BIOMES -> { + return this.createBiomeRequirementButton(); + } // Buttons for Inventory Requirements case REQUIRED_ITEMS, REMOVE_ITEMS, ADD_IGNORED_META, REMOVE_IGNORED_META -> { return this.createInventoryRequirementButton(button); @@ -929,48 +934,6 @@ private PanelItem createIslandRequirementButton(RequirementButton button) { description.add(""); description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); } - case REQUIRED_BIOMES -> { - if (requirements.getRequiredBiomes().isEmpty()) { - description.add(this.user.getTranslation(reference + "none")); - } else { - description.add(this.user.getTranslation(reference + Constants.TITLE_KEY)); - requirements.getRequiredBiomes() - .forEach(biomeKey -> description.add(this.user.getTranslation(reference + "list", "[biome]", - Utils.prettifyBiome(biomeKey)))); - } - - icon = new ItemStack(Material.GRASS_BLOCK); - clickHandler = (panel, user, clickType, slot) -> { - if (clickType.isRightClick()) { - // Right click clears the biome list. - requirements.getRequiredBiomes().clear(); - this.build(); - } else { - // Left click opens the selector, hiding already-chosen biomes. - Set excluded = requirements.getRequiredBiomes().stream() - .map(key -> Registry.BIOME.get(NamespacedKey.fromString(key))) - .filter(Objects::nonNull).collect(Collectors.toSet()); - - MultiBiomeSelector.open(this.user, excluded, (status, biomes) -> { - if (Boolean.TRUE.equals(status) && biomes != null) { - biomes.forEach(biome -> requirements.getRequiredBiomes() - .add(MultiBiomeSelector.biomeKey(biome))); - } - - this.build(); - }); - } - return true; - }; - glow = false; - - description.add(""); - description.add(this.user.getTranslation(Constants.TIPS + "click-to-add")); - - if (!requirements.getRequiredBiomes().isEmpty()) { - description.add(this.user.getTranslation(Constants.TIPS + "right-click-to-clear")); - } - } default -> { icon = new ItemStack(Material.PAPER); clickHandler = null; @@ -981,6 +944,68 @@ private PanelItem createIslandRequirementButton(RequirementButton button) { .clickHandler(clickHandler).build(); } + + /** + * Creates the "Required Biomes" button for island challenges. Left-click opens the biome + * selector (hiding already-chosen biomes); right-click clears the list. + * + * @return the PanelItem for the biome requirement button. + */ + private PanelItem createBiomeRequirementButton() { + final String reference = Constants.BUTTON + RequirementButton.REQUIRED_BIOMES.name().toLowerCase() + "."; + final String name = this.user.getTranslation(reference + "name"); + final IslandRequirements requirements = this.challenge.getRequirements(); + final List description = new ArrayList<>(); + description.add(this.user.getTranslation(reference + Constants.DESCRIPTION_KEY)); + + if (requirements.getRequiredBiomes().isEmpty()) { + description.add(this.user.getTranslation(reference + "none")); + } else { + description.add(this.user.getTranslation(reference + Constants.TITLE_KEY)); + requirements.getRequiredBiomes().forEach(biomeKey -> description.add( + this.user.getTranslation(reference + "list", "[biome]", Utils.prettifyBiome(biomeKey)))); + } + + description.add(""); + description.add(this.user.getTranslation(Constants.CLICK_TO_ADD)); + + if (!requirements.getRequiredBiomes().isEmpty()) { + description.add(this.user.getTranslation(Constants.TIPS + "right-click-to-clear")); + } + + return new PanelItemBuilder().icon(new ItemStack(Material.GRASS_BLOCK)).name(name).description(description) + .glow(false).clickHandler((panel, user, clickType, slot) -> { + if (clickType.isRightClick()) { + requirements.getRequiredBiomes().clear(); + this.build(); + } else { + this.openBiomeSelector(requirements); + } + return true; + }).build(); + } + + + /** + * Opens the biome selector for the given island requirements, excluding already-chosen + * biomes, and adds the chosen biomes back to the requirement before rebuilding the panel. + * + * @param requirements the island requirements being edited. + */ + private void openBiomeSelector(IslandRequirements requirements) { + Set excluded = requirements.getRequiredBiomes().stream() + .map(key -> Registry.BIOME.get(NamespacedKey.fromString(key))) + .filter(Objects::nonNull).collect(Collectors.toSet()); + + MultiBiomeSelector.open(this.user, excluded, (status, biomes) -> { + if (Boolean.TRUE.equals(status) && biomes != null) { + biomes.forEach(biome -> requirements.getRequiredBiomes().add(MultiBiomeSelector.biomeKey(biome))); + } + + this.build(); + }); + } + /** * This method creates buttons for inventory requirements menu. * @@ -1138,7 +1163,7 @@ private PanelItem createInventoryRequirementButton(RequirementButton button) { glow = false; description.add(""); - description.add(this.user.getTranslation(Constants.TIPS + "click-to-add")); + description.add(this.user.getTranslation(Constants.CLICK_TO_ADD)); } case REMOVE_IGNORED_META -> { icon = new ItemStack(Material.RED_SHULKER_BOX); @@ -1843,7 +1868,7 @@ private PanelItem createRewardButton(RewardButton button) { glow = false; description.add(""); - description.add(this.user.getTranslation(Constants.TIPS + "click-to-add")); + description.add(this.user.getTranslation(Constants.CLICK_TO_ADD)); } case REMOVE_IGNORED_META -> { icon = new ItemStack(Material.RED_SHULKER_BOX); diff --git a/src/main/java/world/bentobox/challenges/panel/util/MultiBiomeSelector.java b/src/main/java/world/bentobox/challenges/panel/util/MultiBiomeSelector.java index 7a7dcf2e..6ba1a2e6 100644 --- a/src/main/java/world/bentobox/challenges/panel/util/MultiBiomeSelector.java +++ b/src/main/java/world/bentobox/challenges/panel/util/MultiBiomeSelector.java @@ -1,5 +1,6 @@ package world.bentobox.challenges.panel.util; +import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; import java.util.HashSet; @@ -58,10 +59,11 @@ public static void open(User user, BiConsumer> consum @Override protected List getElements() { + // A mutable list is required: UnifiedMultiSelector's constructor sorts it in place. return StreamSupport.stream(Registry.BIOME.spliterator(), false) .filter(biome -> excluded == null || !excluded.contains(biome)) .sorted(Comparator.comparing(MultiBiomeSelector::biomeKey)) - .collect(Collectors.toList()); + .collect(Collectors.toCollection(ArrayList::new)); } @Override diff --git a/src/main/java/world/bentobox/challenges/utils/Constants.java b/src/main/java/world/bentobox/challenges/utils/Constants.java index a61228ca..8e6280df 100644 --- a/src/main/java/world/bentobox/challenges/utils/Constants.java +++ b/src/main/java/world/bentobox/challenges/utils/Constants.java @@ -249,6 +249,8 @@ public class Constants public static final String CLICK_TO_CHANGE = TIPS + "click-to-change"; + public static final String CLICK_TO_ADD = TIPS + "click-to-add"; + public static final String CLICK_TO_TOGGLE = TIPS + "click-to-toggle"; public static final String CLICK_TO_OPEN = TIPS + "click-to-open"; diff --git a/src/main/java/world/bentobox/challenges/utils/Utils.java b/src/main/java/world/bentobox/challenges/utils/Utils.java index a9307c45..35a642e0 100644 --- a/src/main/java/world/bentobox/challenges/utils/Utils.java +++ b/src/main/java/world/bentobox/challenges/utils/Utils.java @@ -280,7 +280,7 @@ public static String prettifyBiome(String key) continue; } - if (builder.length() > 0) + if (!builder.isEmpty()) { builder.append(' '); } From 84db79539d1dc3e0f2e19eab7aa1b78dc3cb550b Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 22:35:06 -0700 Subject: [PATCH 21/27] Prepare 1.8.0 release: bump version, refresh CLAUDE.md deps - Bump build.version 1.7.0 -> 1.8.0 (1.7.0 is already released). - CLAUDE.md: correct stale dependency facts (Paper 1.21.11-R0.1-SNAPSHOT, BentoBox 3.14.0; note addon.yml api-version 3.12.0). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- CLAUDE.md | 4 ++-- pom.xml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 29db8f55..d6349d7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Challenges is a BentoBox addon (Bukkit/Spigot plugin module) that adds player challenges to any BentoBox GameMode addon (BSkyBlock, AcidIsland, SkyGrid, CaveBlock). It is not a standalone plugin — it is loaded by BentoBox at runtime. -- Java 21, Maven, Spigot `1.21.3-R0.1-SNAPSHOT` -- Depends on BentoBox `3.4.0`, optionally Level `2.6.3` and Vault `1.7` +- Java 21, Maven, Paper `1.21.11-R0.1-SNAPSHOT` (server API dep is `paper.version` in `pom.xml`; `addon.yml` declares `api-version: 3.12.0`) +- Depends on BentoBox `3.14.0`, optionally Level `2.6.3` and Vault `1.7` - Version is set via `` in `pom.xml` (uses `${revision}` / CI `-bNNN` suffix) ## Commands diff --git a/pom.xml b/pom.xml index 79462c68..980f2292 100644 --- a/pom.xml +++ b/pom.xml @@ -53,7 +53,7 @@ ${build.version}-SNAPSHOT - 1.7.0 + 1.8.0 -LOCAL BentoBoxWorld_Challenges From 4fbd7be6ce8055dbdb0f1e73b9735ff3d33cd979 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Jul 2026 11:28:35 -0700 Subject: [PATCH 22/27] Add plugwright E2E in-game test spike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boots a real Paper 1.21.11 server with BentoBox 3.14.0 + BSkyBlock 1.20.0 + the Maven-built Challenges jar, and drives a headless Mineflayer bot to verify in-game behaviour the MockBukkit unit tests can't. A separate Gradle sidecar under e2e/ (does not touch the Maven build). Uses useExternalPluginsOnly + writeFiles to deploy prebuilt jars; BentoBox addons are staged into plugins/BentoBox/addons/ so they register as gamemodes (the non-obvious part — dropping them in plugins/ leaves the gamemode world uncreated and every gamemode command "Unknown"). Tests (src/test/e2e/challenges.spec.ts): - smoke: bot joins 1.21.11, ops, runs a command, receives the reply. - #329: opens the Challenges admin GUI, clicks Challenge Wipe, and asserts the confirmation instruction line added in #415 ("Type 'confirm' ... or 'cancel' ...") — an end-to-end validation of a 1.8.0 feature in-game. CI: .github/workflows/e2e.yml runs it on demand (workflow_dispatch) — advisory/opt-in, not wired to every PR yet. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .github/workflows/e2e.yml | 33 +++ e2e/.gitignore | 11 + e2e/README.md | 48 ++++ e2e/build.gradle.kts | 57 +++++ e2e/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45633 bytes e2e/gradle/wrapper/gradle-wrapper.properties | 7 + e2e/gradlew | 248 +++++++++++++++++++ e2e/gradlew.bat | 93 +++++++ e2e/settings.gradle.kts | 1 + e2e/src/test/e2e/challenges.spec.ts | 31 +++ e2e/src/test/e2e/package.json | 13 + e2e/src/test/e2e/tsconfig.json | 14 ++ 12 files changed, 556 insertions(+) create mode 100644 .github/workflows/e2e.yml create mode 100644 e2e/.gitignore create mode 100644 e2e/README.md create mode 100644 e2e/build.gradle.kts create mode 100644 e2e/gradle/wrapper/gradle-wrapper.jar create mode 100644 e2e/gradle/wrapper/gradle-wrapper.properties create mode 100755 e2e/gradlew create mode 100644 e2e/gradlew.bat create mode 100644 e2e/settings.gradle.kts create mode 100644 e2e/src/test/e2e/challenges.spec.ts create mode 100644 e2e/src/test/e2e/package.json create mode 100644 e2e/src/test/e2e/tsconfig.json diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..1bbf8feb --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,33 @@ +name: E2E (plugwright) + +# In-game end-to-end tests via plugwright (boots a real Paper server + Mineflayer bot). +# Advisory / opt-in for now: run manually from the Actions tab. It is intentionally NOT wired +# to every push/PR yet (a full run boots a server + generates worlds, ~1-2 min, and is heavier +# and flakier than the Maven unit tests). Add a `pull_request` trigger once it's proven stable. +on: + workflow_dispatch: + +jobs: + e2e: + name: E2E tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Build Challenges jar (Maven) + run: mvn -B -DskipTests package + + - name: Run E2E tests + working-directory: e2e + run: ./gradlew plugwrightTest --no-daemon diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 00000000..87429bd7 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,11 @@ +# Plugwright server run directory (Paper jar, worlds, downloaded plugins, logs) +run/ +# Cached dependency jars (BentoBox, BSkyBlock) downloaded by build.gradle.kts +.deps/ +# Gradle +.gradle/ +build/ +# Node / TypeScript test artifacts +src/test/e2e/node_modules/ +src/test/e2e/dist/ +src/test/e2e/package-lock.json diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..f31ce74e --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,48 @@ +# End-to-end (in-game) tests — plugwright spike + +This is a spike using [plugwright](https://github.com/Drownek/plugwright) to verify Challenges +**in a real running server** — something the JUnit/MockBukkit unit tests can't do. Plugwright boots +a real Paper server, loads the plugins, and drives a headless [Mineflayer](https://github.com/PrismarineJS/mineflayer) +bot that runs commands and clicks GUIs, then asserts on what the bot sees. + +It is a **separate Gradle sidecar** — it does not touch the Maven build. It deploys *prebuilt* jars: + +- **BentoBox 3.14.0** and **BSkyBlock 1.20.0** are downloaded (cached in `.deps/`). +- The **Challenges** jar is taken from `../target/` (build it first with `mvn -DskipTests package`). + +> **Gotcha that cost most of the spike:** BentoBox *addons* (BSkyBlock, Challenges) must be staged +> into `plugins/BentoBox/addons/`, **not** `plugins/`. In `plugins/` they load as bare Paper plugins +> and never register as gamemodes, so no `bskyblock_world` is created and every `/island`, +> `/bsbadmin`, `/challenges` command comes back "Unknown". See `build.gradle.kts`. + +## Running locally + +```bash +# 1. Build the Challenges jar (Maven) +mvn -DskipTests package + +# 2. Run the E2E tests (needs Java 21, Node 18+) +cd e2e +JAVA_HOME=/path/to/jdk-21 ./gradlew plugwrightTest + +# add PLUGWRIGHT_DEBUG=1 to see every message the bot receives +``` + +First run downloads Paper 1.21.11 (~50 MB) and the plugin jars; later runs reuse them +(`run/server.jar`, `.deps/`). A full run is ~30–40s (server boot + world gen dominate). + +## What the tests cover (`src/test/e2e/challenges.spec.ts`) + +- **`bot can interact with the server`** — smoke test: bot joins Paper 1.21.11, ops, runs a command, + receives the reply. Proves the whole stack (BentoBox 3.14.0 + BSkyBlock + Challenges 1.8.0) loads + and is drivable. +- **`confirmation prompts tell the player how to answer (#329)`** — opens the Challenges admin GUI, + clicks "Challenge Wipe", and asserts the bot receives the confirmation instruction line + ("Type 'confirm' ... or 'cancel' ...") added in #415. A real in-game validation of a 1.8.0 feature. + +## Notes / next steps + +- Tests requiring an **island** (block/biome/completion flows) still need island bootstrap — either + a bot-driven `/island create` (blueprint GUI) or a pre-staged world via `writeFiles`. +- In CI this runs via `.github/workflows/e2e.yml` (manual `workflow_dispatch` for now — advisory, + not a required check). diff --git a/e2e/build.gradle.kts b/e2e/build.gradle.kts new file mode 100644 index 00000000..add50300 --- /dev/null +++ b/e2e/build.gradle.kts @@ -0,0 +1,57 @@ +import java.net.URI + +plugins { + // Plugwright: boots a real Paper server, deploys plugins, drives Mineflayer bots. + id("io.github.drownek.plugwright") version "2.0.2" +} + +// --- Dependency jars ------------------------------------------------------------------------- +// BentoBox is a Paper plugin (goes in plugins/). BSkyBlock and Challenges are BentoBox *addons* +// and MUST live in plugins/BentoBox/addons/ so BentoBox loads them as gamemode/addon (creating +// the gamemode world and registering commands) — dropping them straight into plugins/ makes them +// load as bare Paper plugins that never register with BentoBox. + +val depsDir = layout.projectDirectory.dir(".deps").asFile.apply { mkdirs() } + +fun cachedJar(name: String, url: String): File { + val f = File(depsDir, name) + if (!f.exists()) { + logger.lifecycle("plugwright: downloading $name ...") + URI(url).toURL().openStream().use { input -> f.outputStream().use { input.copyTo(it) } } + } + return f +} + +val bentoboxJar = cachedJar( + "BentoBox-3.14.0.jar", + "https://github.com/BentoBoxWorld/BentoBox/releases/download/3.14.0/BentoBox-3.14.0.jar" +) +val bskyblockJar = cachedJar( + "BSkyBlock-1.20.0.jar", + "https://github.com/BentoBoxWorld/BSkyBlock/releases/download/1.20.0/BSkyBlock-1.20.0.jar" +) + +// Maven-built Challenges jar (version/profile suffix varies: -SNAPSHOT-LOCAL locally, plain on CI). +val challengesJar: File = (file("../target").listFiles()?.toList() ?: emptyList()) + .firstOrNull { + it.name.startsWith("Challenges-") && it.name.endsWith(".jar") && + !it.name.contains("sources") && !it.name.contains("javadoc") + } + ?: throw GradleException( + "Challenges jar not found in ../target — run 'mvn -q -DskipTests package' first." + ) + +plugwright { + minecraftVersion.set("1.21.11") + acceptEula.set(true) + // We deploy prebuilt jars (this is a Maven project, not a Gradle plugin build). + useExternalPluginsOnly.set(true) + testsDir.set(file("src/test/e2e")) + jvmArgs.set(listOf("-Xmx3G")) + + writeFiles { + file("plugins/BentoBox.jar", bentoboxJar) + file("plugins/BentoBox/addons/BSkyBlock.jar", bskyblockJar) + file("plugins/BentoBox/addons/Challenges.jar", challengesJar) + } +} diff --git a/e2e/gradle/wrapper/gradle-wrapper.jar b/e2e/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f8e1ee3125fe0768e9a76ee977ac089eb657005e GIT binary patch literal 45633 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�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 literal 0 HcmV?d00001 diff --git a/e2e/gradle/wrapper/gradle-wrapper.properties b/e2e/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..9355b415 --- /dev/null +++ b/e2e/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/e2e/gradlew b/e2e/gradlew new file mode 100755 index 00000000..adff685a --- /dev/null +++ b/e2e/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/e2e/gradlew.bat b/e2e/gradlew.bat new file mode 100644 index 00000000..c4bdd3ab --- /dev/null +++ b/e2e/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/e2e/settings.gradle.kts b/e2e/settings.gradle.kts new file mode 100644 index 00000000..25e524e9 --- /dev/null +++ b/e2e/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "challenges-e2e" diff --git a/e2e/src/test/e2e/challenges.spec.ts b/e2e/src/test/e2e/challenges.spec.ts new file mode 100644 index 00000000..79e7a1c2 --- /dev/null +++ b/e2e/src/test/e2e/challenges.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from '@drownek/plugwright'; + +/** + * Baseline: bot joins the real Paper 1.21.11 server (BentoBox 3.14.0 + BSkyBlock + the staged + * Challenges 1.8.0) and can run a command and receive its reply — proves the toolchain. + */ +test('bot can interact with the server', async ({ player }) => { + await player.makeOp(); + player.chat('/help'); + await expect(player).toHaveReceivedMessage('Help'); +}); + +/** + * Feature validation for 1.8.0 (#329): confirmation prompts now append an instruction line + * telling the player to type confirm/cancel. Open the Challenges admin GUI, left-click the + * "Challenge Wipe" button (which starts a confirmation conversation) and assert the bot receives + * the new instruction text in chat. + */ +test('confirmation prompts tell the player how to answer (#329)', async ({ player }) => { + await player.makeOp(); + + // Open the Challenges admin GUI (registered under the BSkyBlock admin command). + player.chat('/bsbadmin challenges'); + const gui = await player.gui({ title: /Challenges Admin/i }); + + // Left-click "Challenge Wipe" -> starts the confirmation conversation. + await gui.locator(item => item.getDisplayName().includes('Challenge Wipe')).click(); + + // The #329 change appends this instruction to every confirmation prompt. + await expect(player).toHaveReceivedMessage('to proceed, or'); +}); diff --git a/e2e/src/test/e2e/package.json b/e2e/src/test/e2e/package.json new file mode 100644 index 00000000..345ba604 --- /dev/null +++ b/e2e/src/test/e2e/package.json @@ -0,0 +1,13 @@ +{ + "type": "module", + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@drownek/plugwright": "^2.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.3" + } +} diff --git a/e2e/src/test/e2e/tsconfig.json b/e2e/src/test/e2e/tsconfig.json new file mode 100644 index 00000000..af96c496 --- /dev/null +++ b/e2e/src/test/e2e/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "node", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true, + "inlineSources": true + }, + "include": ["*.spec.ts"] +} From b58147b03da74e4c76a53befec839025b59fcec4 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Jul 2026 11:40:14 -0700 Subject: [PATCH 23/27] Make e2e workflow a proper on-demand regression harness - workflow_dispatch input to filter by test name (-PtestNames) - 25-min job timeout - cache Paper jar + libraries + BentoBox/BSkyBlock deps + npm modules - always upload server log + plugwright report as an artifact (so a regression failure is debuggable) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .github/workflows/e2e.yml | 63 +++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1bbf8feb..105e7acb 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,16 +1,28 @@ name: E2E (plugwright) -# In-game end-to-end tests via plugwright (boots a real Paper server + Mineflayer bot). -# Advisory / opt-in for now: run manually from the Actions tab. It is intentionally NOT wired -# to every push/PR yet (a full run boots a server + generates worlds, ~1-2 min, and is heavier -# and flakier than the Maven unit tests). Add a `pull_request` trigger once it's proven stable. +# In-game end-to-end tests via plugwright (boots a real Paper server + Mineflayer bot) — +# the layer the Maven/MockBukkit unit tests can't reach. Run ON DEMAND as a regression check. +# +# Trigger it from the GitHub UI (Actions tab -> "E2E (plugwright)" -> "Run workflow", pick the +# branch/tag) or from the CLI: gh workflow run e2e.yml --ref +# +# It is deliberately NOT wired to every push/PR: a run boots a server and generates worlds +# (~1-2 min) and is heavier/flakier than the unit tests. For a nightly regression, add: +# schedule: +# - cron: "0 3 * * *" on: workflow_dispatch: + inputs: + test_names: + description: "Only run tests whose name contains this (comma-separated substrings). Blank = all." + required: false + default: "" jobs: e2e: name: E2E tests runs-on: ubuntu-latest + timeout-minutes: 25 steps: - uses: actions/checkout@v4 @@ -25,9 +37,50 @@ jobs: with: node-version: 20 + - name: Set up Gradle (with caching) + uses: gradle/actions/setup-gradle@v4 + + # Reuse the Paper jar + downloaded libraries and the BentoBox/BSkyBlock jars across runs. + # plugwright's clean step preserves server.jar / cache / libraries, and build.gradle.kts + # caches the dependency jars in .deps, so this avoids re-downloading ~60 MB every run. + - name: Cache Paper + dependency jars + uses: actions/cache@v4 + with: + path: | + e2e/.deps + e2e/run/server.jar + e2e/run/cache + e2e/run/libraries + key: plugwright-${{ hashFiles('e2e/build.gradle.kts') }} + + - name: Cache npm modules + uses: actions/cache@v4 + with: + path: e2e/src/test/e2e/node_modules + key: e2e-npm-${{ hashFiles('e2e/src/test/e2e/package.json') }} + - name: Build Challenges jar (Maven) run: mvn -B -DskipTests package - name: Run E2E tests working-directory: e2e - run: ./gradlew plugwrightTest --no-daemon + env: + PLUGWRIGHT_DEBUG: "1" + run: | + ARGS="" + if [ -n "${{ inputs.test_names }}" ]; then + ARGS="-PtestNames=${{ inputs.test_names }}" + fi + ./gradlew plugwrightTest $ARGS --no-daemon + + # Always upload the server log + any plugwright report so a regression is debuggable. + - name: Upload server log & report + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-logs + path: | + e2e/run/logs/** + e2e/run/plugwright-report/** + if-no-files-found: ignore + retention-days: 14 From 704531e210e6ce359227f4f30af3bdd5a10b5c16 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Jul 2026 11:46:06 -0700 Subject: [PATCH 24/27] Fix GitHub Actions security findings in e2e workflow - S7630 (BLOCKER, script injection): bind inputs.test_names to a TEST_NAMES env var and reference it as a quoted shell variable instead of interpolating ${{ inputs.* }} directly into the run block. - S7637 (MAJOR, unpinned action): pin gradle/actions/setup-gradle to the v4.4.4 commit SHA. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .github/workflows/e2e.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 105e7acb..282b6eaa 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -38,7 +38,7 @@ jobs: node-version: 20 - name: Set up Gradle (with caching) - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4 # Reuse the Paper jar + downloaded libraries and the BentoBox/BSkyBlock jars across runs. # plugwright's clean step preserves server.jar / cache / libraries, and build.gradle.kts @@ -66,12 +66,15 @@ jobs: working-directory: e2e env: PLUGWRIGHT_DEBUG: "1" + # Bind the user-controlled input to an env var and reference it as a shell variable + # below — never interpolate ${{ inputs.* }} directly into a run block (script injection). + TEST_NAMES: ${{ inputs.test_names }} run: | - ARGS="" - if [ -n "${{ inputs.test_names }}" ]; then - ARGS="-PtestNames=${{ inputs.test_names }}" + if [ -n "$TEST_NAMES" ]; then + ./gradlew plugwrightTest "-PtestNames=$TEST_NAMES" --no-daemon + else + ./gradlew plugwrightTest --no-daemon fi - ./gradlew plugwrightTest $ARGS --no-daemon # Always upload the server log + any plugwright report so a regression is debuggable. - name: Upload server log & report From b2740f26ed8f75603949d47ef8c8016a09d3dd0f Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Jul 2026 12:00:30 -0700 Subject: [PATCH 25/27] Pin E2E npm deps via committed lockfile (fixes CI 1.21.11 support) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on-demand E2E run failed in CI with "Server version '1.21.11' is not supported. Latest supported version is '1.21.4'." — without a committed package-lock.json, CI's fresh npm install resolved an older mineflayer/minecraft-data than the local run, and it didn't know 1.21.11. Commit the lockfile (mineflayer 4.37.1 / minecraft-data 3.111.0, which support 1.21.11) so installs are reproducible, and key the npm cache on the lockfile (also invalidates the stale node_modules the failed run cached under the package.json hash). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NKxodNE4h3TsSHMqDEeC8v --- .github/workflows/e2e.yml | 4 +- e2e/.gitignore | 3 +- e2e/src/test/e2e/package-lock.json | 1043 ++++++++++++++++++++++++++++ 3 files changed, 1048 insertions(+), 2 deletions(-) create mode 100644 e2e/src/test/e2e/package-lock.json diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 282b6eaa..6a9787c7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -57,7 +57,9 @@ jobs: uses: actions/cache@v4 with: path: e2e/src/test/e2e/node_modules - key: e2e-npm-${{ hashFiles('e2e/src/test/e2e/package.json') }} + # Key on the committed lockfile so the cache tracks the exact resolved versions + # (mineflayer/minecraft-data must support the target Minecraft version). + key: e2e-npm-${{ hashFiles('e2e/src/test/e2e/package-lock.json') }} - name: Build Challenges jar (Maven) run: mvn -B -DskipTests package diff --git a/e2e/.gitignore b/e2e/.gitignore index 87429bd7..67248143 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -8,4 +8,5 @@ build/ # Node / TypeScript test artifacts src/test/e2e/node_modules/ src/test/e2e/dist/ -src/test/e2e/package-lock.json +# package-lock.json IS committed — it pins mineflayer/minecraft-data to versions that +# support the target Minecraft version, so CI installs are reproducible. diff --git a/e2e/src/test/e2e/package-lock.json b/e2e/src/test/e2e/package-lock.json new file mode 100644 index 00000000..9a8a052f --- /dev/null +++ b/e2e/src/test/e2e/package-lock.json @@ -0,0 +1,1043 @@ +{ + "name": "e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@drownek/plugwright": "^2.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.3" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.16.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz", + "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.3.tgz", + "integrity": "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@drownek/plugwright": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@drownek/plugwright/-/plugwright-2.0.2.tgz", + "integrity": "sha512-VopUAnSH7qVVNdLDKa64Rpae7vepiR3cy0Z0y9FHEDAi5jHFXGVGFPL+OClAA5ZD/p2WI5Er3uROmk4K69z/xw==", + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0", + "mineflayer": "^4.0.0", + "picocolors": "^1.1.1", + "source-map-support": "^0.5.21" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-rsa": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/node-rsa/-/node-rsa-1.1.4.tgz", + "integrity": "sha512-dB0ECel6JpMnq5ULvpUTunx3yNm8e/dIkv8Zu9p2c8me70xIRUUG3q+qXRwcSf9rN3oqamv4116iHy90dJGRpA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz", + "integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xboxreplay/xboxlive-auth": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@xboxreplay/xboxlive-auth/-/xboxlive-auth-5.1.0.tgz", + "integrity": "sha512-UngHHsehZbiTjyyNmo8HvdoUDKMID1U9uVfrpFWUK/2UxPuVTKy5n+CzZQ3S488sW5vOhgh0lHqqynT8ouwgvw==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/aes-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz", + "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asn1": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha512-6i37w/+EhlWlGUJff3T/Q8u1RGmP5wgbiwYnOnbOqvtrPxT63/sYFyP9RcpxtxGymtfA075IvmOnL7ycNOWl3w==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz", + "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", + "license": "MIT", + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/endian-toggle": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/endian-toggle/-/endian-toggle-0.0.0.tgz", + "integrity": "sha512-ShfqhXeHRE4TmggSlHXG8CMGIcsOsqDw/GcoPcosToE59Rm9e4aXaMhEQf2kPBsBRrKem1bbOAv5gOKnkliMFQ==", + "license": "MIT" + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.reduce": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz", + "integrity": "sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==", + "license": "MIT" + }, + "node_modules/macaddress": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/macaddress/-/macaddress-0.5.4.tgz", + "integrity": "sha512-i8xVWoUjj2woYU8kbpQby86Kq7uF7xl2brtKREXUBWpfgqx1fKXEeYzDiVMVxA/IufC1d3xxwJRHtFCX+9IspA==", + "license": "MIT" + }, + "node_modules/minecraft-data": { + "version": "3.111.0", + "resolved": "https://registry.npmjs.org/minecraft-data/-/minecraft-data-3.111.0.tgz", + "integrity": "sha512-0kKHqNZL/D1IbH7tJXBJ3z7YSn1/ylnWWR7X2zFwtEV+yr23nimItpGjP3o8UaLYrC+OV1gKLFQoew03XvJyoA==", + "license": "MIT" + }, + "node_modules/minecraft-folder-path": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minecraft-folder-path/-/minecraft-folder-path-1.2.0.tgz", + "integrity": "sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==", + "license": "MIT" + }, + "node_modules/minecraft-protocol": { + "version": "1.66.2", + "resolved": "https://registry.npmjs.org/minecraft-protocol/-/minecraft-protocol-1.66.2.tgz", + "integrity": "sha512-keY1IY1E2AeurcekCfcXrg0TDbykGVFiMe1E4wR8QkNtQRieNwfr2xaF3g3vT9ChkwzvENqp3jxgmtFCKSUKPg==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/node-rsa": "^1.1.4", + "@types/readable-stream": "^4.0.0", + "aes-js": "^3.1.2", + "buffer-equal": "^1.0.0", + "debug": "^4.3.2", + "endian-toggle": "^0.0.0", + "lodash.merge": "^4.3.0", + "minecraft-data": "^3.78.0", + "minecraft-folder-path": "^1.2.0", + "node-fetch": "^2.6.1", + "node-rsa": "^0.4.2", + "prismarine-auth": "^3.1.1", + "prismarine-chat": "^1.10.0", + "prismarine-nbt": "^2.5.0", + "prismarine-realms": "^1.2.0", + "protodef": "^1.17.0", + "readable-stream": "^4.1.0", + "uuid-1345": "^1.0.1", + "yggdrasil": "^1.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/mineflayer": { + "version": "4.37.1", + "resolved": "https://registry.npmjs.org/mineflayer/-/mineflayer-4.37.1.tgz", + "integrity": "sha512-kchZCJb1znzz8ZhE0+gLQ3e2t/9xUsqUy/IM/sGfceINxi3h6KXKY9luaUEa59vnD/x0OKwYdERY4sscm0ErNQ==", + "license": "MIT", + "dependencies": { + "minecraft-data": "^3.108.0", + "minecraft-protocol": "^1.66.0", + "mojangson": "^2.0.4", + "prismarine-biome": "^1.1.1", + "prismarine-block": "^1.22.0", + "prismarine-chat": "^1.7.1", + "prismarine-chunk": "^1.39.0", + "prismarine-entity": "^2.5.0", + "prismarine-item": "^1.17.0", + "prismarine-nbt": "^2.0.0", + "prismarine-physics": "^1.9.0", + "prismarine-recipe": "^1.5.0", + "prismarine-registry": "^1.10.0", + "prismarine-windows": "^2.9.0", + "prismarine-world": "^3.6.0", + "protodef": "^1.18.0", + "typed-emitter": "^1.0.0", + "uuid-1345": "^1.0.2", + "vec3": "^0.1.7" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/mojangson": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/mojangson/-/mojangson-2.0.4.tgz", + "integrity": "sha512-HYmhgDjr1gzF7trGgvcC/huIg2L8FsVbi/KacRe6r1AswbboGVZDS47SOZlomPuMWvZLas8m9vuHHucdZMwTmQ==", + "license": "MIT", + "dependencies": { + "nearley": "^2.19.5" + } + }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "license": "BSD-3-Clause" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-rsa": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-0.4.2.tgz", + "integrity": "sha512-Bvso6Zi9LY4otIZefYrscsUpo2mUpiAVIEmSZV2q41sP8tHZoert3Yu6zv4f/RXJqMNZQKCtnhDugIuCma23YA==", + "license": "MIT", + "dependencies": { + "asn1": "0.2.3" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/prismarine-auth": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/prismarine-auth/-/prismarine-auth-3.1.1.tgz", + "integrity": "sha512-NuNrMGZdoigFKsvi1ZZgAEvNYNuE5qe6lo/tw+bqeNbkhpjHC0u1JNxLEujnfqduXI18e19PvUtWNMDl/gH7yw==", + "license": "MIT", + "dependencies": { + "@azure/msal-node": "^2.0.2", + "@xboxreplay/xboxlive-auth": "^5.1.0", + "debug": "^4.3.3", + "smart-buffer": "^4.1.0", + "uuid-1345": "^1.0.2" + } + }, + "node_modules/prismarine-biome": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/prismarine-biome/-/prismarine-biome-1.4.0.tgz", + "integrity": "sha512-fD2WmjN8Zr/xA/jeMInReLgaDlznwA5xlaK529PzWuGzgjpc5ijVu1Lp1oqHyZn3WxOG/bRVtW1bU+tmgCurWA==", + "license": "MIT", + "peerDependencies": { + "prismarine-registry": "^1.1.0" + } + }, + "node_modules/prismarine-block": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/prismarine-block/-/prismarine-block-1.23.0.tgz", + "integrity": "sha512-j2UoU4KbXMvNlBw+aLkMOnEuMayYefznUfbrfv1VIbckG3RA9LpNWltOMHXuOR5YkHp8uIZPOclj95XC88jgGw==", + "license": "MIT", + "dependencies": { + "minecraft-data": "^3.38.0", + "prismarine-biome": "^1.1.0", + "prismarine-chat": "^1.5.0", + "prismarine-item": "^1.10.1", + "prismarine-nbt": "^2.0.0", + "prismarine-registry": "^1.1.0" + } + }, + "node_modules/prismarine-chat": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/prismarine-chat/-/prismarine-chat-1.13.0.tgz", + "integrity": "sha512-tvDbrQmJEoy09yLE5nnedGhQYxnRDaPRePMv7W39dFaHr2LGcA2JfCmH0vG5193+BsEFz3a5+0EpQSK8OW7YmA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.2", + "mojangson": "^2.0.1", + "prismarine-nbt": "^2.0.0", + "prismarine-registry": "^1.4.0" + } + }, + "node_modules/prismarine-chunk": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/prismarine-chunk/-/prismarine-chunk-1.40.0.tgz", + "integrity": "sha512-TtT84Bys7+aGA94HwcK0QDp+jkWcLOLErKYtaWWl+EJya28NqPoBASr5L/lPZ8ZWLQUugg/aFIefZI/rEhEQWw==", + "license": "MIT", + "dependencies": { + "prismarine-biome": "^1.2.0", + "prismarine-block": "^1.14.1", + "prismarine-nbt": "^2.2.1", + "prismarine-registry": "^1.1.0", + "smart-buffer": "^4.1.0", + "uint4": "^0.1.2", + "vec3": "^0.1.3", + "xxhash-wasm": "^0.4.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/prismarine-entity": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/prismarine-entity/-/prismarine-entity-2.6.0.tgz", + "integrity": "sha512-/LlZRLOpACiXk+GqoaKi0XPBFnNMjb1d4OIzuSCSEgNMK6FUo3Wnin5yeSZ7ff3Ztt7yagN9lX2jSOafn6IIzg==", + "license": "MIT", + "dependencies": { + "prismarine-chat": "^1.4.1", + "prismarine-item": "^1.11.2", + "prismarine-registry": "^1.4.0", + "vec3": "^0.1.4" + } + }, + "node_modules/prismarine-item": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/prismarine-item/-/prismarine-item-1.18.0.tgz", + "integrity": "sha512-8pEq6YfcneVvarvUFnex09a3+MR8/4NCQVyawIKAa3kh/g9dHLexoEcpQEgM3cmpg4gbLmspSiARGwed5uGhlg==", + "license": "MIT", + "dependencies": { + "prismarine-nbt": "^2.0.0", + "prismarine-registry": "^1.4.0" + } + }, + "node_modules/prismarine-nbt": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/prismarine-nbt/-/prismarine-nbt-2.8.0.tgz", + "integrity": "sha512-5D6FUZq0PNtf3v/41ImDlwThVesOv5adyqCRMZLzmkUGEmRJNNh5C6AsnvrClBftXs+IF0yqPnZoj8kcNPiMGg==", + "license": "MIT", + "dependencies": { + "protodef": "^1.18.0" + } + }, + "node_modules/prismarine-physics": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/prismarine-physics/-/prismarine-physics-1.11.0.tgz", + "integrity": "sha512-P25VSDi3kJHQAb/AJBiJCQuxyRCVXRSdEiDjx56ywocgt65N/exatVTiJjOK5HgEKHJSfw0sXSAohQhvutnGAA==", + "license": "MIT", + "dependencies": { + "minecraft-data": "^3.0.0", + "prismarine-nbt": "^2.0.0", + "vec3": "^0.1.7" + } + }, + "node_modules/prismarine-realms": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/prismarine-realms/-/prismarine-realms-1.6.0.tgz", + "integrity": "sha512-AwemW0vwxG9hQaFtg1twSV7eymB6QtYxGK0jjpxfdA2sdK15kU8jh8uD1o5XF0oxSMU+BbpzZMCmXtXq4QE6bw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.3", + "node-fetch": "^2.6.1" + } + }, + "node_modules/prismarine-recipe": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prismarine-recipe/-/prismarine-recipe-1.5.0.tgz", + "integrity": "sha512-GRZHbsyBIUgVNF10vFRv2YWZj86vokCT5EWX6iK6gfx6h4FapgZT29V2DNkjv5+hmdzBCLZvfx1/RYr8VPeoGQ==", + "license": "MIT", + "peerDependencies": { + "prismarine-registry": "^1.4.0" + } + }, + "node_modules/prismarine-registry": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prismarine-registry/-/prismarine-registry-1.12.0.tgz", + "integrity": "sha512-OC5U6YrflY6OcAWRZEqe2HGZuNp0bIuP7H+oKEHD6rLfKNDxo8Ymx5eh2VvrZWnMVugpwID1Qj/UjA4MoCzNDw==", + "license": "MIT", + "dependencies": { + "minecraft-data": "^3.70.0", + "prismarine-block": "^1.17.1", + "prismarine-nbt": "^2.0.0" + } + }, + "node_modules/prismarine-windows": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/prismarine-windows/-/prismarine-windows-2.10.0.tgz", + "integrity": "sha512-ssXLGAr7W9JLvvLjYMoo1j4j6AdJaoIb0/HlqkWMWlQqvZJeiS4zyBjJY6+GtR4OzpjkEf6IvF5cNXhHFpbcZQ==", + "license": "MIT", + "dependencies": { + "prismarine-item": "^1.12.2", + "prismarine-registry": "^1.7.0", + "typed-emitter": "^2.1.0" + } + }, + "node_modules/prismarine-windows/node_modules/typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", + "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", + "license": "MIT", + "optionalDependencies": { + "rxjs": "*" + } + }, + "node_modules/prismarine-world": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/prismarine-world/-/prismarine-world-3.7.0.tgz", + "integrity": "sha512-M5euvNjQ3vIk689BSa0YC6PBwpVY35Oc6q6KyZ0IqyFtI+cQ9em+8l5OTAK/uu9/gzDDhR7cmm9L2WXgTXBQCw==", + "license": "MIT", + "dependencies": { + "vec3": "^0.1.7" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/protodef": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/protodef/-/protodef-1.19.0.tgz", + "integrity": "sha512-94f3GR7pk4Qi5YVLaLvWBfTGUIzzO8hyo7vFVICQuu5f5nwKtgGDaeC1uXIu49s5to/49QQhEYeL0aigu1jEGA==", + "license": "MIT", + "dependencies": { + "lodash.reduce": "^4.6.0", + "protodef-validator": "^1.3.0", + "readable-stream": "^4.4.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/protodef-validator": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/protodef-validator/-/protodef-validator-1.4.0.tgz", + "integrity": "sha512-2y2coBolqCEuk5Kc3QwO7ThR+/7TZiOit4FrpAgl+vFMvq8w76nDhh09z08e2NQOdrgPLsN2yzXsvRvtADgUZQ==", + "license": "MIT", + "dependencies": { + "ajv": "^6.5.4" + }, + "bin": { + "protodef-validator": "cli.js" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "license": "CC0-1.0" + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "license": "MIT", + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typed-emitter": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.4.0.tgz", + "integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint4": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/uint4/-/uint4-0.1.2.tgz", + "integrity": "sha512-lhEx78gdTwFWG+mt6cWAZD/R6qrIj0TTBeH5xwyuDJyswLNlGe+KVlUPQ6+mx5Ld332pS0AMUTo9hIly7YsWxQ==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/uuid-1345": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uuid-1345/-/uuid-1345-1.0.2.tgz", + "integrity": "sha512-bA5zYZui+3nwAc0s3VdGQGBfbVsJLVX7Np7ch2aqcEWFi5lsAEcmO3+lx3djM1npgpZI8KY2FITZ2uYTnYUYyw==", + "license": "MIT", + "dependencies": { + "macaddress": "^0.5.1" + } + }, + "node_modules/vec3": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz", + "integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==", + "license": "BSD" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/xxhash-wasm": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-0.4.2.tgz", + "integrity": "sha512-/eyHVRJQCirEkSZ1agRSCwriMhwlyUcFkXD5TPVSLP+IPzjsqMVzZwdoczLp1SoQU0R3dxz1RpIK+4YNQbCVOA==", + "license": "MIT" + }, + "node_modules/yggdrasil": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/yggdrasil/-/yggdrasil-1.8.0.tgz", + "integrity": "sha512-r5bKOhkZ52DJ6q034uSkdsdZLoFVhOmfDOagRs6h/JX5W7+XIPOMb+peCbElhLEoIckwt43NCUoNQbydOzuPcQ==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "uuid": "^10.0.0" + } + }, + "node_modules/yggdrasil/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + } + } +} From 4ffa54ece7ae1ebe231c6211a9093823c1c4aae1 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Jul 2026 15:46:46 -0700 Subject: [PATCH 26/27] Add E2E toggle tests for 1.8.0 settings (#349, #179) Extends the plugwright in-game suite with two tests that drive the admin Settings GUI on a live Paper 1.21.11 server: - open-anywhere (#349): the new "Open GUI Anywhere" Elytra toggle renders and its click-handler flips + persists the setting. - include-undeployed (#179): the "Include Undeployed Challenges" barrel toggle now shipped in config.yml behaves the same way. Both are self-restoring (read state, flip, assert, flip back) so they are independent of the persisted run-dir config and of test ordering. Shared openSettingsPanel()/expectToggleFlips() helpers; LiveGuiHandle/ GuiItemLocator types derived from PlayerWrapper since the package does not re-export them. README coverage list updated. Full suite: 4/4 pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DfEq1X2sy57G8nq2Cd23ba --- e2e/README.md | 9 ++++ e2e/src/test/e2e/challenges.spec.ts | 68 ++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/e2e/README.md b/e2e/README.md index f31ce74e..d040a993 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -39,6 +39,15 @@ First run downloads Paper 1.21.11 (~50 MB) and the plugin jars; later runs reuse - **`confirmation prompts tell the player how to answer (#329)`** — opens the Challenges admin GUI, clicks "Challenge Wipe", and asserts the bot receives the confirmation instruction line ("Type 'confirm' ... or 'cancel' ...") added in #415. A real in-game validation of a 1.8.0 feature. +- **`open-anywhere setting toggles in the admin settings GUI (#349)`** — opens admin → Settings, + finds the new "Open GUI Anywhere" (Elytra) toggle, clicks it and asserts the Enabled/Disabled + lore flips, then clicks again to restore it. Proves the full config → Settings → panel button → + click-handler → saveSettings chain works on a live server. +- **`include-undeployed setting toggles in the admin settings GUI (#179)`** — same live toggle check + for the "Include Undeployed Challenges" (Barrel) button that now ships in config.yml. + +Both toggle tests are self-restoring (they read the current state, flip it, assert, then flip back), +so they don't depend on the persisted run-dir config or the order the tests run in. ## Notes / next steps diff --git a/e2e/src/test/e2e/challenges.spec.ts b/e2e/src/test/e2e/challenges.spec.ts index 79e7a1c2..c78d3249 100644 --- a/e2e/src/test/e2e/challenges.spec.ts +++ b/e2e/src/test/e2e/challenges.spec.ts @@ -1,4 +1,9 @@ -import { expect, test } from '@drownek/plugwright'; +import { expect, test, PlayerWrapper } from '@drownek/plugwright'; + +// LiveGuiHandle / GuiItemLocator are not re-exported from the package entry point, so derive them +// from PlayerWrapper's public API instead of importing them directly. +type LiveGuiHandle = Awaited>; +type GuiItemLocator = ReturnType; /** * Baseline: bot joins the real Paper 1.21.11 server (BentoBox 3.14.0 + BSkyBlock + the staged @@ -29,3 +34,64 @@ test('confirmation prompts tell the player how to answer (#329)', async ({ playe // The #329 change appends this instruction to every confirmation prompt. await expect(player).toHaveReceivedMessage('to proceed, or'); }); + +/** + * Feature validation for 1.8.0 (#349): a new "Open GUI Anywhere" setting lets players open the + * challenges GUI while off their island. It surfaces as an Elytra toggle in the admin settings + * GUI. Confirm the button renders in a real server and that clicking it flips the Enabled/Disabled + * state — proving the whole config -> Settings -> panel button -> click-handler -> saveSettings + * chain is wired up, not just present in the JUnit tests. + */ +test('open-anywhere setting toggles in the admin settings GUI (#349)', async ({ player }) => { + await player.makeOp(); + + const settings = await openSettingsPanel(player); + const openAnywhere = settings.locator(item => item.getDisplayName().includes('Open GUI Anywhere')); + + await expectToggleFlips(openAnywhere); +}); + +/** + * Feature validation for 1.8.0 (#179): the include-undeployed option now ships in config.yml and + * is editable from the admin settings GUI as an "Include Undeployed Challenges" barrel toggle + * (it controls whether undeployed challenges count toward level completion). Same live wiring + * check as above, on a different setting. + */ +test('include-undeployed setting toggles in the admin settings GUI (#179)', async ({ player }) => { + await player.makeOp(); + + const settings = await openSettingsPanel(player); + const includeUndeployed = settings.locator(item => item.getDisplayName().includes('Include Undeployed')); + + await expectToggleFlips(includeUndeployed); +}); + +/** + * Open the Challenges admin GUI and click through to the Settings sub-panel, returning a live + * handle to it. The admin menu is registered under the BSkyBlock admin command; the "Settings" + * button opens EditSettingsPanel (window title "Settings"). + */ +async function openSettingsPanel(player: PlayerWrapper): Promise { + player.chat('/bsbadmin challenges'); + const admin = await player.gui({ title: /Challenges Admin/i }); + await admin.locator(item => item.getDisplayName().includes('Settings')).click(); + return player.gui({ title: /Settings/i }); +} + +/** + * Assert a settings toggle button really toggles on a live server: read its current Enabled/Disabled + * state, click it and assert the state flipped, then click again to restore it. Restoring keeps the + * test independent of the persisted run-dir config and of the order tests run in. + */ +async function expectToggleFlips(button: GuiItemLocator): Promise { + // Wait for the panel to finish drawing this button with a known toggle state. + await expect.poll(() => button.loreText()).toMatch(/Enabled|Disabled/); + const wasEnabled = button.loreText().includes('Enabled'); + + await button.click(); + await expect(button).toHaveLore(wasEnabled ? 'Disabled' : 'Enabled'); + + // Put it back the way we found it. + await button.click(); + await expect(button).toHaveLore(wasEnabled ? 'Enabled' : 'Disabled'); +} From 4399087824bfd1069b3478d7a4483f90bb719836 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 12 Jul 2026 09:04:15 -0700 Subject: [PATCH 27/27] Address Copilot review on the 1.8.0 release PR (#421) - ChallengesAddon.allLoaded(): reset levelProvided=false when the Level add-on is absent, so isLevelProvided() can't be stale-true and TryToComplete.rewardIslandLevel() can't NPE on a null level add-on. - EditChallengePanel: sort the required-biome list before rendering so the admin GUI description is stable (the backing set is unordered). - ChallengesPanel.createToggleUndeployedButton(): guard the template description with !isBlank() to avoid inserting empty lore lines, matching the other button builders. - ChallengesAddonTest: verify placeholder registration with atLeastOnce() instead of times(13) so the tests don't break whenever a placeholder is added or removed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DfEq1X2sy57G8nq2Cd23ba --- .../world/bentobox/challenges/ChallengesAddon.java | 1 + .../challenges/panel/admin/EditChallengePanel.java | 7 +++++-- .../challenges/panel/user/ChallengesPanel.java | 2 +- .../world/bentobox/challenges/ChallengesAddonTest.java | 10 ++++++---- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/main/java/world/bentobox/challenges/ChallengesAddon.java b/src/main/java/world/bentobox/challenges/ChallengesAddon.java index 5c7d6723..39c9c530 100644 --- a/src/main/java/world/bentobox/challenges/ChallengesAddon.java +++ b/src/main/java/world/bentobox/challenges/ChallengesAddon.java @@ -234,6 +234,7 @@ public void allLoaded() this.log("Challenges Addon hooked into Level addon."); }, () -> { this.levelAddon = null; + this.levelProvided = false; this.logWarning("Level add-on not found so level challenges will not work!"); }); diff --git a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java index 0a624e52..aaa32a78 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java @@ -962,8 +962,11 @@ private PanelItem createBiomeRequirementButton() { description.add(this.user.getTranslation(reference + "none")); } else { description.add(this.user.getTranslation(reference + Constants.TITLE_KEY)); - requirements.getRequiredBiomes().forEach(biomeKey -> description.add( - this.user.getTranslation(reference + "list", "[biome]", Utils.prettifyBiome(biomeKey)))); + // Sort so the biome list renders in a stable order (the underlying set is unordered). + requirements.getRequiredBiomes().stream() + .sorted(Comparator.comparing(Utils::prettifyBiome)) + .forEach(biomeKey -> description.add( + this.user.getTranslation(reference + "list", "[biome]", Utils.prettifyBiome(biomeKey)))); } description.add(""); diff --git a/src/main/java/world/bentobox/challenges/panel/user/ChallengesPanel.java b/src/main/java/world/bentobox/challenges/panel/user/ChallengesPanel.java index 93493efe..597bbc02 100644 --- a/src/main/java/world/bentobox/challenges/panel/user/ChallengesPanel.java +++ b/src/main/java/world/bentobox/challenges/panel/user/ChallengesPanel.java @@ -714,7 +714,7 @@ private PanelItem createToggleUndeployedButton(@NonNull ItemTemplateRecord templ builder.name(this.user.getTranslation(this.world, template.title())); } - if (template.description() != null) + if (template.description() != null && !template.description().isBlank()) { builder.description(this.user.getTranslation(this.world, template.description())); } diff --git a/src/test/java/world/bentobox/challenges/ChallengesAddonTest.java b/src/test/java/world/bentobox/challenges/ChallengesAddonTest.java index 3b22e472..2a5e9a2d 100644 --- a/src/test/java/world/bentobox/challenges/ChallengesAddonTest.java +++ b/src/test/java/world/bentobox/challenges/ChallengesAddonTest.java @@ -377,9 +377,10 @@ void testPlaceholderCompletedPercentRegistered() { addon.onEnable(); - // Verify that the completed_percent placeholder was registered + // Capture every registered placeholder name; assert the one under test is present without + // pinning the exact total (which changes whenever any placeholder is added or removed). ArgumentCaptor nameCaptor = ArgumentCaptor.forClass(String.class); - verify(phm, org.mockito.Mockito.times(13)).registerPlaceholder(any(GameModeAddon.class), nameCaptor.capture(), any()); + verify(phm, org.mockito.Mockito.atLeastOnce()).registerPlaceholder(any(GameModeAddon.class), nameCaptor.capture(), any()); List names = nameCaptor.getAllValues(); assertTrue(names.contains("challenges_completed_percent"), @@ -398,9 +399,10 @@ void testPlaceholderLatestLevelCompletedPercentRegistered() { addon.onEnable(); - // Verify that the latest_level_completed_percent placeholder was registered + // Capture every registered placeholder name; assert the one under test is present without + // pinning the exact total (which changes whenever any placeholder is added or removed). ArgumentCaptor nameCaptor = ArgumentCaptor.forClass(String.class); - verify(phm, org.mockito.Mockito.times(13)).registerPlaceholder(any(GameModeAddon.class), nameCaptor.capture(), any()); + verify(phm, org.mockito.Mockito.atLeastOnce()).registerPlaceholder(any(GameModeAddon.class), nameCaptor.capture(), any()); List names = nameCaptor.getAllValues(); assertTrue(names.contains("challenges_latest_level_completed_percent"),