From a61d0975998fa01d48c92d1532aecd2dc9f715bc Mon Sep 17 00:00:00 2001 From: tastybento Date: Tue, 30 Jun 2026 23:15:17 -0700 Subject: [PATCH 01/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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"), From f8b56e60d2569ff697105a6af964f922a07439c2 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 18 Jul 2026 18:49:29 -0700 Subject: [PATCH 28/37] Default repeatable challenges to unlimited repeats and clarify 0 = infinite New challenges created in the GUI defaulted to maxTimes = 1, so toggling repeatable had no effect: the completion check blocks once the count reaches maxTimes. Default maxTimes to 0 (unlimited), matching the template importer's default. Also make the Repeat Count button explain that 0 means unlimited: the description says so, the current-value lore shows "(infinite)" when the value is 0, and the chat input prompt mentions it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016uXwirQRfvEkRThWELRcM2 --- .../world/bentobox/challenges/database/object/Challenge.java | 2 +- .../bentobox/challenges/panel/admin/EditChallengePanel.java | 5 +++-- src/main/resources/locales/en-US.yml | 3 +++ 3 files changed, 7 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 1b402826..bfbd9733 100644 --- a/src/main/java/world/bentobox/challenges/database/object/Challenge.java +++ b/src/main/java/world/bentobox/challenges/database/object/Challenge.java @@ -277,7 +277,7 @@ public enum ChallengeType * Maximum number of times the challenge can be repeated. 0 or less will mean infinite times. */ @Expose - private int maxTimes = 1; + private int maxTimes = 0; /** * Repeat experience reward 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 aaa32a78..9db6d423 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java @@ -1609,7 +1609,8 @@ private PanelItem createRewardButton(RewardButton button) { description.add(this.user.getTranslation(Constants.CLICK_TO_TOGGLE)); } case REPEAT_COUNT -> { - description.add(this.user.getTranslation(reference + Constants.VALUE_KEY, Constants.PARAMETER_NUMBER, + String valueKey = this.challenge.getMaxTimes() > 0 ? Constants.VALUE_KEY : "value-infinite"; + description.add(this.user.getTranslation(reference + valueKey, Constants.PARAMETER_NUMBER, String.valueOf(this.challenge.getMaxTimes()))); icon = new ItemStack(Material.COBBLESTONE_WALL); clickHandler = (panel, user, clickType, i) -> { @@ -1622,7 +1623,7 @@ private PanelItem createRewardButton(RewardButton button) { this.build(); }; ConversationUtils.createNumericInput(numberConsumer, this.user, - this.user.getTranslation(Constants.INPUT_NUMBER), 0, Integer.MAX_VALUE); + this.user.getTranslation(Constants.CONVERSATIONS + "input-repeat-count"), 0, Integer.MAX_VALUE); return true; }; diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index a6d6a786..14fbd539 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -665,7 +665,9 @@ challenges: description: |- Allows you to change the number of repeats for the challenge. + Set to 0 for unlimited repeats. value: "Current value: [number]" + value-infinite: "Current value: [number] (infinite)" cool_down: name: "Cool Down" description: |- @@ -1251,6 +1253,7 @@ challenges: 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-repeat-count: "Please enter the number of repeats in the chat. Enter 0 for unlimited repeats." input-seconds: "Please enter a number of seconds in the chat." numeric-only: "The given [value] is not a number!" not-valid-value: "The given number [value] is not valid. It must be greater than [min] and less than [max]!" From 62601f5133646d46750783050d98c9e0c77bf76a Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 18 Jul 2026 18:49:34 -0700 Subject: [PATCH 29/37] Sync locale files with en-US Add translations for the 28 keys missing from all 14 locale files: the new repeat-count strings (value-infinite, input-repeat-count, the 0-means-unlimited description line) plus the 1.8.0 keys that were never translated (biome requirements, open-anywhere, island level rewards, undeployed challenges toggle, confirm-instruction, wrong-biome). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016uXwirQRfvEkRThWELRcM2 --- src/main/resources/locales/cs.yml | 50 ++++++++++++++++++++++++++ src/main/resources/locales/de.yml | 50 ++++++++++++++++++++++++++ src/main/resources/locales/es.yml | 50 ++++++++++++++++++++++++++ src/main/resources/locales/fr.yml | 50 ++++++++++++++++++++++++++ src/main/resources/locales/hu.yml | 50 ++++++++++++++++++++++++++ src/main/resources/locales/ja.yml | 50 ++++++++++++++++++++++++++ src/main/resources/locales/lv.yml | 50 ++++++++++++++++++++++++++ src/main/resources/locales/pl.yml | 50 ++++++++++++++++++++++++++ src/main/resources/locales/pt.yml | 50 ++++++++++++++++++++++++++ src/main/resources/locales/ru.yml | 54 +++++++++++++++++++++++++++- src/main/resources/locales/uk.yml | 50 ++++++++++++++++++++++++++ src/main/resources/locales/zh-CN.yml | 53 ++++++++++++++++++++++++++- src/main/resources/locales/zh-HK.yml | 50 ++++++++++++++++++++++++++ src/main/resources/locales/zh-TW.yml | 54 +++++++++++++++++++++++++++- 14 files changed, 708 insertions(+), 3 deletions(-) diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index 5580f728..306f8f3c 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -57,6 +57,7 @@ challenges: manage-statistics: Správa statistik manage-advancements: Správa pokroků advancement-selector: Výběr pokroku + biome-selector: "Výběr biomu" buttons: free-challenges: name: "Výzvy zdarma" @@ -526,7 +527,9 @@ challenges: Umožňuje změnit počet opakování za výzvu. + Nastavte 0 pro neomezené opakování. value: "Aktuální hodnota: [number]" + value-infinite: "Aktuální hodnota: [number] (neomezeno)" cool_down: name: "Vychladnout" description: |- @@ -911,6 +914,48 @@ challenges: name: [name] description: '[description]' selected: Vybráno + toggle-undeployed: + name: "Nenasazené výzvy" + shown: |- + Nenasazené výzvy jsou + nyní zobrazeny. + hidden: |- + Nenasazené výzvy jsou + nyní skryty. + required_biomes: + name: "Požadované biomy" + description: |- + Hráč musí stát v jednom + z těchto biomů pro splnění této + ostrovní výzvy. + title: "Biomy: " + list: " - [biome]" + none: "Žádné biomy nejsou vyžadovány (jakýkoli biom)." + reward_island_level: + name: "Úroveň ostrova jako odměna" + description: |- + Umožňuje změnit odměnu úrovně ostrova. + Vyžaduje addon Level. + value: "Aktuální hodnota: [number]" + repeat_reward_island_level: + name: "Úroveň ostrova za opakování" + description: |- + Umožňuje změnit odměnu úrovně + ostrova za opakování výzvy. + Vyžaduje addon Level. + value: "Aktuální hodnota: [number]" + open_anywhere: + name: "Otevřít GUI kdekoli" + description: |- + Umožňuje hráčům otevřít GUI výzev + odkudkoli. Splnění stále vyžaduje + být na ostrově, pokud je chráněno. + enabled: "Povoleno" + disabled: "Zakázáno" + biome: + name: "[biome]" + description: "ID biomu: [id]" + selected: "Vybráno" tips: click-to-select: "Klepnutím na vyberte." click-to-choose: "Klepnutím na vyberte." @@ -1007,6 +1052,8 @@ challenges: search-radius: "Ne dále než [number] metrů" warning-block: "Bloky budou odstraněny " warning-entity: "Entity budou odstraněny" + biomes-title: "Požadované biomy:" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1138,6 +1185,8 @@ challenges: write-search: "Napište prosím hodnotu hledání. (pro ukončení napiš 'cancel')" search-updated: "Hodnota vyhledávání byla aktualizována." enter-formula: "Zadejte vzorec, který používá zástupné symboly PAPI a symboly =,<>,<+,>=, ==, !=, AND, OR pouze.\nPříklad: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Napište 'confirm' do chatu pro pokračování, nebo 'cancel' pro zrušení." + input-repeat-count: "Zadejte do chatu počet opakování. Zadejte 0 pro neomezené opakování." titles: challenge-title: Úspěšně dokončeno challenge-subtitle: "[friendlyName]" @@ -1200,6 +1249,7 @@ challenges: no-team: Toto je týmová výzva. Chcete-li ji splnit, musíte mít tým! insufficient-team: Tato týmová výzva potřebuje [number] členů týmu online. Online je pouze [online]. member-missing-share: [name] nemá svůj podíl ([amount] x [item]) pro tuto týmovou výzvu. + wrong-biome: "Pro splnění této výzvy musíte stát v biomu [biome]!" materials: any: 'Jakýkoli ' all_hanging_signs: diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index 1078f69c..4e6ffece 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -59,6 +59,7 @@ challenges: manage-statistics: Statistiken verwalten manage-advancements: Fortschritte verwalten advancement-selector: Fortschritt-Auswahl + biome-selector: "Biom wählen" buttons: free-challenges: name: "Kostenlose Herausforderungen" @@ -517,7 +518,9 @@ challenges: Ermöglicht die Änderung der Anzahl der Wiederholungen für die Herausforderung. + Setze 0 für unbegrenzte Wiederholungen. value: "Aktueller Wert: [number]" + value-infinite: "Aktueller Wert: [number] (unbegrenzt)" cool_down: name: "Abkühlen" description: |- @@ -902,6 +905,48 @@ challenges: name: [name] description: '[description]' selected: Ausgewählt + toggle-undeployed: + name: "Nicht veröffentlichte Herausforderungen" + shown: |- + Nicht veröffentlichte Herausforderungen werden + derzeit angezeigt. + hidden: |- + Nicht veröffentlichte Herausforderungen sind + derzeit ausgeblendet. + required_biomes: + name: "Benötigte Biome" + description: |- + Der Spieler muss in einem dieser + Biome stehen, um diese Insel- + Herausforderung abzuschließen. + title: "Biome: " + list: " - [biome]" + none: "Keine Biome erforderlich (beliebiges Biom)." + reward_island_level: + name: "Belohnung Insellevel" + description: |- + Ermöglicht die Änderung des Insellevels als Belohnung. + Benötigt das Level-Addon. + value: "Aktueller Wert: [number]" + repeat_reward_island_level: + name: "Wiederholungsbelohnung Insellevel" + description: |- + Ermöglicht die Änderung der Insellevel- + Wiederholungsbelohnung für die Herausforderung. + Benötigt das Level-Addon. + value: "Aktueller Wert: [number]" + open_anywhere: + name: "GUI überall öffnen" + description: |- + Erlaubt Spielern, das Herausforderungs-GUI + von überall zu öffnen. Zum Abschließen muss + man weiterhin auf der Insel sein, wenn geschützt. + enabled: "Aktiviert" + disabled: "Deaktiviert" + biome: + name: "[biome]" + description: "Biom-ID: [id]" + selected: "Ausgewählt" tips: click-to-select: "Klick zu markieren." click-to-choose: "Klick zum wählen." @@ -998,6 +1043,8 @@ challenges: search-radius: "Nicht weiter als [number] Meter" warning-block: "Blöcke werden entfernt" warning-entity: "Mobs werden entfernt" + biomes-title: "Benötigte(s) Biom(e):" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1127,6 +1174,8 @@ challenges: write-search: "Bitte geben Sie einen Suchwert ein. (zum Beenden 'cancel' schreiben)" search-updated: "Suchwert aktualisiert." enter-formula: "Gib eine Formel ein, die PAPI-Platzhalter und die Symbole =,<>,<=,>=, ==, !=, AND, OR verwendet.\nBeispiel: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Gib 'confirm' im Chat ein, um fortzufahren, oder 'cancel' zum Abbrechen." + input-repeat-count: "Bitte gib die Anzahl der Wiederholungen im Chat ein. Gib 0 für unbegrenzte Wiederholungen ein." titles: challenge-title: Erfolgreich abgeschlossen challenge-subtitle: "[friendlyName]" @@ -1191,6 +1240,7 @@ challenges: no-team: Dies ist eine Team-Herausforderung. Du musst ein Team haben, um sie abzuschließen! insufficient-team: Diese Team-Herausforderung benötigt [number] Teammitglieder online. Nur [online] sind online. member-missing-share: [name] hat seinen Anteil ([amount] x [item]) nicht für diese Team-Herausforderung. + wrong-biome: "Du musst im Biom [biome] stehen, um diese Herausforderung abzuschließen!" materials: any: 'Beliebig ' all_hanging_signs: diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index 9f6c4764..b25494fd 100755 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -60,6 +60,7 @@ challenges: manage-statistics: Gestionar Estadísticas manage-advancements: Gestionar Avances advancement-selector: Selector de Avances + biome-selector: "Selector de bioma" buttons: free-challenges: name: "desafíos gratuitos" @@ -529,7 +530,9 @@ challenges: Permite cambiar el número de repeticiones para el desafío. + Establece 0 para repeticiones ilimitadas. value: "Valor actual: [number]" + value-infinite: "Valor actual: [number] (ilimitado)" cool_down: name: "enfriamiento" description: |- @@ -918,6 +921,48 @@ challenges: name: [name] description: '[description]' selected: Seleccionado + toggle-undeployed: + name: "Desafíos no desplegados" + shown: |- + Los desafíos no desplegados están + actualmente visibles. + hidden: |- + Los desafíos no desplegados están + actualmente ocultos. + required_biomes: + name: "Biomas requeridos" + description: |- + El jugador debe estar en uno + de estos biomas para completar este + desafío de isla. + title: "Biomas: " + list: " - [biome]" + none: "No se requieren biomas (cualquier bioma)." + reward_island_level: + name: "Nivel de isla de recompensa" + description: |- + Permite cambiar el nivel de isla de recompensa. + Requiere el addon Level. + value: "Valor actual: [number]" + repeat_reward_island_level: + name: "Nivel de isla por repetición" + description: |- + Permite cambiar la recompensa de nivel + de isla por repetir el desafío. + Requiere el addon Level. + value: "Valor actual: [number]" + open_anywhere: + name: "Abrir GUI en cualquier lugar" + description: |- + Permite a los jugadores abrir el GUI de desafíos + desde cualquier lugar. Completarlos aún requiere + estar en la isla cuando está protegida. + enabled: "Habilitado" + disabled: "Deshabilitado" + biome: + name: "[biome]" + description: "ID del bioma: [id]" + selected: "seleccionado" tips: click-to-select: "Haga clic en para seleccionar." click-to-choose: "Haga clic en para elegir." @@ -1014,6 +1059,8 @@ challenges: search-radius: "No más allá de [number] metros" warning-block: "Los bloques serán eliminados" warning-entity: "Entidades serán eliminadas" + biomes-title: "Bioma(s) requerido(s):" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1145,6 +1192,8 @@ challenges: write-search: "Por favor escriba un valor de búsqueda. (escriba 'cancelar' para salir)" search-updated: "&un valor de búsqueda actualizado." enter-formula: "Introduce una fórmula que utilice marcadores de posición PAPI y símbolos =,<>,<+,>=, ==, !=, AND, OR solamente.\nEjemplo: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Escribe 'confirm' en el chat para continuar, o 'cancel' para cancelar." + input-repeat-count: "Por favor ingresa el número de repeticiones en el chat. Ingresa 0 para repeticiones ilimitadas." titles: challenge-title: Completado con éxito challenge-subtitle: "[friendlyName]" @@ -1209,6 +1258,7 @@ challenges: no-team: Este es un desafío de equipo. ¡Debes tener un equipo para completarlo! insufficient-team: Este desafío de equipo necesita [number] miembros del equipo en línea. Solo [online] están en línea. member-missing-share: [name] no tiene su parte ([amount] x [item]) para este desafío de equipo. + wrong-biome: "¡Debes estar en el bioma [biome] para completar este desafío!" materials: any: 'Cualquiera ' all_hanging_signs: diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index 540839fc..4d5dfcb5 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -57,6 +57,7 @@ challenges: manage-statistics: Gérer les Statistiques manage-advancements: Gérer les Avancées advancement-selector: Sélecteur d'Avancées + biome-selector: "Sélecteur de biome" buttons: free-challenges: name: "Challenges gratuits" @@ -521,7 +522,9 @@ challenges: Permet de modifier le nombre de répétitions pour le défi. + Mettez 0 pour des répétitions illimitées. value: "Valeur actuelle : [number]" + value-infinite: "Valeur actuelle : [number] (illimité)" cool_down: name: "Refroidissement" description: |- @@ -910,6 +913,48 @@ challenges: name: [name] description: '[description]' selected: Sélectionné + toggle-undeployed: + name: "Défis non déployés" + shown: |- + Les défis non déployés sont + actuellement affichés. + hidden: |- + Les défis non déployés sont + actuellement masqués. + required_biomes: + name: "Biomes requis" + description: |- + Le joueur doit se trouver dans l'un + de ces biomes pour terminer ce + défi d'île. + title: "Biomes : " + list: " - [biome]" + none: "Aucun biome requis (n'importe quel biome)." + reward_island_level: + name: "Niveau d'île en récompense" + description: |- + Permet de modifier le niveau d'île en récompense. + Nécessite l'addon Level. + value: "Valeur actuelle : [number]" + repeat_reward_island_level: + name: "Niveau d'île en récompense répétée" + description: |- + Permet de modifier la récompense de niveau + d'île pour la répétition du défi. + Nécessite l'addon Level. + value: "Valeur actuelle : [number]" + open_anywhere: + name: "Ouvrir le GUI n'importe où" + description: |- + Permet aux joueurs d'ouvrir le GUI des défis + depuis n'importe où. Terminer un défi nécessite + toujours d'être sur l'île si protégée. + enabled: "Activé" + disabled: "Désactivé" + biome: + name: "[biome]" + description: "ID du biome : [id]" + selected: "sélectionné" tips: click-to-select: "Cliquez pour sélectionner." click-to-choose: "Cliquez sur pour choisir." @@ -1006,6 +1051,8 @@ challenges: search-radius: "Pas plus loin que [number] mètres" warning-block: "Les blocs seront supprimés" warning-entity: "Les entités seront supprimées" + biomes-title: "Biome(s) requis :" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1137,6 +1184,8 @@ challenges: write-search: "Veuillez saisir une valeur de recherche. (écrire 'cancel' pour quitter)" search-updated: "Valeur de recherche mise à jour." enter-formula: "Entrez une formule qui utilise uniquement des espaces réservés PAPI et des symboles =,<>,<+,>=, ==, !=, AND, OR.\nExemple : %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Tapez 'confirm' dans le chat pour continuer, ou 'cancel' pour annuler." + input-repeat-count: "Veuillez saisir le nombre de répétitions dans le chat. Saisissez 0 pour des répétitions illimitées." titles: challenge-title: Complété avec succès challenge-subtitle: "[friendlyName]" @@ -1201,6 +1250,7 @@ challenges: no-team: C'est un défi d'équipe. Vous devez avoir une équipe pour le compléter! insufficient-team: Ce défi d'équipe nécessite [number] membres de l'équipe en ligne. Seul [online] est en ligne. member-missing-share: [name] n'a pas sa part ([amount] x [item]) pour ce défi d'équipe. + wrong-biome: "Vous devez vous trouver dans le biome [biome] pour terminer ce défi !" materials: any: 'Quelconque ' all_hanging_signs: diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index bae616a7..159e5c95 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -57,6 +57,7 @@ challenges: manage-statistics: Statisztikák kezelése manage-advancements: Haladások kezelése advancement-selector: Haladás választó + biome-selector: "Biomválasztó" buttons: free-challenges: name: "Ingyenes kihívások" @@ -526,7 +527,9 @@ challenges: Lehetővé teszi a ismétlésszám a kihíváshoz. + Állítsd 0-ra a korlátlan ismétléshez. value: "Jelenlegi érték: [number]" + value-infinite: "Jelenlegi érték: [number] (korlátlan)" cool_down: name: "Lehűlni" description: |- @@ -911,6 +914,48 @@ challenges: name: [name] description: '[description]' selected: Kiválasztva + toggle-undeployed: + name: "Nem aktivált kihívások" + shown: |- + A nem aktivált kihívások jelenleg + láthatók. + hidden: |- + A nem aktivált kihívások jelenleg + rejtettek. + required_biomes: + name: "Szükséges biomok" + description: |- + A játékosnak ezen biomok egyikében + kell állnia a szigetkihívás + teljesítéséhez. + title: "Biomok: " + list: " - [biome]" + none: "Nincs szükséges biom (bármelyik biom)." + reward_island_level: + name: "Jutalom szigetszint" + description: |- + Lehetővé teszi a jutalom szigetszint módosítását. + A Level addon szükséges hozzá. + value: "Jelenlegi érték: [number]" + repeat_reward_island_level: + name: "Ismétlési jutalom szigetszint" + description: |- + Lehetővé teszi az ismétlési jutalom + szigetszint módosítását a kihíváshoz. + A Level addon szükséges hozzá. + value: "Jelenlegi érték: [number]" + open_anywhere: + name: "GUI megnyitása bárhonnan" + description: |- + Lehetővé teszi a játékosoknak a kihívások GUI + megnyitását bárhonnan. A teljesítéshez továbbra is + a szigeten kell lenni, ha védett. + enabled: "Engedélyezve" + disabled: "Letiltva" + biome: + name: "[biome]" + description: "Biom azonosító: [id]" + selected: "Kiválasztva" tips: click-to-select: "Kattintson a gombra a kiválasztáshoz." click-to-choose: "Kattintson a gombra a kiválasztáshoz." @@ -1007,6 +1052,8 @@ challenges: search-radius: "Legfeljebb [number] méternél" warning-block: "Blokkok eltávolításra kerülnek" warning-entity: "Az entitások eltávolításra kerülnek" + biomes-title: "Szükséges biom(ok):" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1138,6 +1185,8 @@ challenges: write-search: "Kérjük, írjon be egy keresési értéket. (a kilépéshez írja be: „Mégse”)" search-updated: "keresési érték frissítve." enter-formula: "Írj be egy képletet, amely csak PAPI helyőrzőket és szimbólumokat =,<>,<+,>=, ==, !=, AND, OR használ.\nPélda: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Írd be a chatbe: 'confirm' a folytatáshoz, vagy 'cancel' a megszakításhoz." + input-repeat-count: "Kérjük, add meg az ismétlések számát a chatben. Írj 0-t a korlátlan ismétléshez." titles: challenge-title: Sikeresen teljesítve challenge-subtitle: "[friendlyName]" @@ -1202,6 +1251,7 @@ challenges: no-team: Ez egy csapatkihívás. Csapatodnak kell lennie a befejezéshez! insufficient-team: Ez a csapatkihívás [number] csapattag jelenlétét igényli. Csak [online] van online. member-missing-share: [name] nem rendelkezik a saját részével ([amount] x [item]) ehhez a csapatkihíváshoz. + wrong-biome: "A(z) [biome] biomban kell állnod a kihívás teljesítéséhez!" materials: any: 'Bármilyen ' all_hanging_signs: diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml index 2b92b64d..56d7bbef 100644 --- a/src/main/resources/locales/ja.yml +++ b/src/main/resources/locales/ja.yml @@ -65,6 +65,7 @@ challenges: challenge-selector: チャレンジセレクター statistic-selector: 統計セレクター environment-selector: 環境セレクター + biome-selector: "バイオームセレクター" buttons: free-challenges: name: 無料の課題 @@ -563,7 +564,9 @@ challenges: description: |- リピート数を変更できます 挑戦のために。 + 0 に設定すると無制限に繰り返せます。 value: 現在の値:[number] + value-infinite: "現在の値:[number](無制限)" cool_down: name: クールダウン description: |- @@ -897,6 +900,48 @@ challenges: description: "チャレンジライブラリを\n言語でフィルタリングします。" current: '表示中: [lang]' all: すべて + toggle-undeployed: + name: "未デプロイのチャレンジ" + shown: |- + 未デプロイのチャレンジは + 現在表示されています。 + hidden: |- + 未デプロイのチャレンジは + 現在非表示です。 + required_biomes: + name: "必要なバイオーム" + description: |- + この島チャレンジを完了するには、 + プレイヤーがこれらのバイオームの + いずれかにいる必要があります。 + title: "バイオーム:" + list: " - [biome]" + none: "必要なバイオームはありません(任意のバイオーム)。" + reward_island_level: + name: "報酬の島レベル" + description: |- + 報酬の島レベルを変更できます。 + Levelアドオンが必要です。 + value: "現在の値:[number]" + repeat_reward_island_level: + name: "リピート報酬の島レベル" + description: |- + チャレンジのリピート報酬の + 島レベルを変更できます。 + Levelアドオンが必要です。 + value: "現在の値:[number]" + open_anywhere: + name: "どこでもGUIを開く" + description: |- + プレイヤーがどこからでもチャレンジGUIを + 開けるようにします。保護されている場合、 + 完了には島にいる必要があります。 + enabled: "有効" + disabled: "無効" + biome: + name: "[biome]" + description: "バイオームID:[id]" + selected: "選択" tips: click-to-select: クリックして選択します。 click-to-choose: クリックして選択します。 @@ -993,6 +1038,8 @@ challenges: search-radius: '[number]メートル以下' warning-block: ブロックが削除されます warning-entity: エンティティは削除されます + biomes-title: "必要なバイオーム:" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1125,6 +1172,8 @@ challenges: enter-formula: |- Papiプレースホルダーとシンボルを使用する式を入力します=、<>、<+、> =、==、!=、および、またはのみ。 例:%my_lifetime_count%> = 1000および%Island_Level%> = 100 + confirm-instruction: "続行するにはチャットに 'confirm' と入力し、中止するには 'cancel' と入力してください。" + input-repeat-count: "チャットに繰り返し回数を入力してください。0 を入力すると無制限になります。" titles: challenge-title: 正常に完了しました challenge-subtitle: '[friendlyName]' @@ -1191,6 +1240,7 @@ challenges: no-team: これはチームチャレンジです。完了するにはチームが必要です! insufficient-team: このチームチャレンジは[number]人のチームメンバーがオンラインである必要があります。[online]人だけがオンラインです。 member-missing-share: [name]はこのチームチャレンジのために自分のシェア([amount] x [item])を持っていません。 + wrong-biome: "このチャレンジを完了するには [biome] バイオームにいる必要があります!" protection: flags: CHALLENGES_ISLAND_PROTECTION: diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml index 910fa1f5..e1dad597 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -67,6 +67,7 @@ challenges: manage-statistics: Pārvaldīt Statistiku manage-advancements: Pārvaldīt Sasniegumus advancement-selector: Sasniegumu Atlasītājs + biome-selector: "Biomu Izvēlne" buttons: # Button in the Challenges GUI that allows to select free challenges. free-challenges: @@ -542,7 +543,9 @@ challenges: Ļauj uzstādīt cik daudz reižu uzdevumu varēs izpildīt. + Iestati 0, lai atkārtojumi būtu neierobežoti. value: "Vērtība: [number]" + value-infinite: "Vērtība: [number] (neierobežoti)" cool_down: name: "Taimouts" description: |- @@ -919,6 +922,48 @@ challenges: name: [name] description: '[description]' selected: Atlasīts + toggle-undeployed: + name: "Neaktivizētie uzdevumi" + shown: |- + Neaktivizētie uzdevumi pašlaik + ir redzami. + hidden: |- + Neaktivizētie uzdevumi pašlaik + ir paslēpti. + required_biomes: + name: "Nepieciešamie biomi" + description: |- + Spēlētājam jāstāv vienā + no šiem biomiem, lai izpildītu + šo salas uzdevumu. + title: "Biomi: " + list: " - [biome]" + none: "Biomi nav nepieciešami (jebkurš bioms)." + reward_island_level: + name: "Balvas salas līmenis" + description: |- + Ļauj mainīt balvas salas līmeni. + Nepieciešams Level papildinājums. + value: "Vērtība: [number]" + repeat_reward_island_level: + name: "Atkārtojuma balvas salas līmenis" + description: |- + Ļauj mainīt salas līmeņa balvu + par uzdevuma atkārtošanu. + Nepieciešams Level papildinājums. + value: "Vērtība: [number]" + open_anywhere: + name: "Atvērt GUI jebkur" + description: |- + Ļauj spēlētājiem atvērt uzdevumu GUI + no jebkuras vietas. Izpildei joprojām + jāatrodas uz salas, ja tā ir aizsargāta. + enabled: "Ieslēgts" + disabled: "Izslēgts" + biome: + name: "[biome]" + description: "Bioma ID: [id]" + selected: "Iezīmēts" tips: click-to-select: "Klikšķini, lai atlasītu." click-to-choose: "Klikšķini, lai izvēlētos." @@ -1088,6 +1133,8 @@ challenges: # Waning about block/entity removing warning-block: "Bloks(-i) tiks dzēsti" warning-entity: "Radība(-as) tiks dzēstas" + biomes-title: "Nepieciešamie biomi:" + biome-value: " - [biome]" # Message that will generate for inventory type requirements and replace [type-requirements] inventory: lore: |- @@ -1306,6 +1353,8 @@ challenges: search-updated: "Meklēšanas vērtība atjaunota." enter-formula: "Ievadiet formulu, kas izmanto tikai PAPI vietturus un simbolus =,<>,<+,>=, ==, !=, AND, OR.\nPiemērs: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Ieraksti čatā 'confirm', lai turpinātu, vai 'cancel', lai atceltu." + input-repeat-count: "Lūdzu ieraksti čatā atkārtojumu skaitu. Ieraksti 0, lai atkārtojumi būtu neierobežoti." titles: # Title and subtitle may contain variables in [] that will be replaced with a proper message from the challenge object. # [friendlyName] will be replaced with challenge friendly name. @@ -1370,6 +1419,7 @@ challenges: no-library-entries: "Nevar atrast bibliotēkas ierakstus. Nav ko rādīt." not-hooked: "Uzdevumu Papildinājumam neizdevās atrast Spēles Režīmu." timeout: "Šim uzdevumam ir uzstādīts [timeout] taimauts starp izpildēm. Tev vēl ir jāgaida [wait-time], lai varētu pildīt uzdevumu." + wrong-biome: "Lai izpildītu šo uzdevumu, tev jāstāv [biome] biomā!" # # Showcase for manual material translation # materials: # # Names should be lowercase. diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index 0232b3b9..fb6d421f 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -57,6 +57,7 @@ challenges: manage-statistics: Zarządzaj Statystykami manage-advancements: Zarządzaj Osiągnięciami advancement-selector: Selektor Osiągnięć + biome-selector: "Selektor biomów" buttons: free-challenges: name: "Wyzwania bez kategorii" @@ -467,7 +468,9 @@ challenges: Pozwala na zmianę liczby powtórzeń dla zadania. + Ustaw 0 dla nieograniczonych powtórzeń. value: "Aktualna wartość: [number]" + value-infinite: "Aktualna wartość: [number] (nieograniczone)" cool_down: name: "Opóźnienie" description: |- @@ -841,6 +844,48 @@ challenges: name: [name] description: '[description]' selected: Wybrane + toggle-undeployed: + name: "Niewdrożone zadania" + shown: |- + Niewdrożone zadania są + obecnie widoczne. + hidden: |- + Niewdrożone zadania są + obecnie ukryte. + required_biomes: + name: "Wymagane biomy" + description: |- + Gracz musi stać w jednym + z tych biomów, aby ukończyć to + zadanie wyspy. + title: "Biomy: " + list: " - [biome]" + none: "Żadne biomy nie są wymagane (dowolny biom)." + reward_island_level: + name: "Poziom wyspy jako nagroda" + description: |- + Pozwala na zmianę nagrody poziomu wyspy. + Wymaga dodatku Level. + value: "Aktualna wartość: [number]" + repeat_reward_island_level: + name: "Poziom wyspy za powtórzenie" + description: |- + Pozwala na zmianę nagrody poziomu + wyspy za powtórzenie zadania. + Wymaga dodatku Level. + value: "Aktualna wartość: [number]" + open_anywhere: + name: "Otwieraj GUI wszędzie" + description: |- + Pozwala graczom otwierać GUI zadań + z dowolnego miejsca. Ukończenie nadal wymaga + przebywania na wyspie, gdy jest chroniona. + enabled: "Włączone" + disabled: "Wyłączone" + biome: + name: "[biome]" + description: "ID biomu: [id]" + selected: "wybrane" tips: click-to-select: "Kliknij , aby wybrać." click-to-choose: "Kliknij , aby wybrać." @@ -937,6 +982,8 @@ challenges: search-radius: "Nie dalej niż [number] metrów" warning-block: "Bloki zostaną usunięte" warning-entity: "Jednostki zostaną usunięte" + biomes-title: "Wymagane biomy:" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1068,6 +1115,8 @@ challenges: write-search: "Proszę wpisać wartość wyszukiwania. (napisz „anuluj”, aby wyjść)" search-updated: "Zaktualizowano wartość wyszukiwania." enter-formula: "Wpisz formułę, która używa symboli zastępczych PAPI i symboli =,<>,<+,>=, ==, !=, AND, OR tylko.\nPrzykład: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Wpisz 'confirm' na czacie, aby kontynuować, lub 'cancel', aby anulować." + input-repeat-count: "Proszę podać liczbę powtórzeń na czacie. Wpisz 0 dla nieograniczonych powtórzeń." titles: challenge-title: Zakończone sukcesem challenge-subtitle: "[friendlyName]" @@ -1132,6 +1181,7 @@ challenges: no-team: To jest wyzwanie drużyny. Musisz mieć drużynę aby je ukończyć! insufficient-team: To wyzwanie drużyny wymaga [number] członków drużyny online. Tylko [online] są online. member-missing-share: [name] nie ma swojego udziału ([amount] x [item]) dla tego wyzwania drużyny. + wrong-biome: "Musisz stać w biomie [biome], aby ukończyć to zadanie!" materials: any: 'Dowolny ' all_hanging_signs: diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index 4b053e9f..629e78b3 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -59,6 +59,7 @@ challenges: manage-statistics: Gerenciar Estatísticas manage-advancements: Gerenciar Avanços advancement-selector: Seletor de Avanços + biome-selector: "Seletor de Bioma" buttons: free-challenges: name: "Desafios Gratuitos" @@ -528,7 +529,9 @@ challenges: Permite alterar o número de repetições para o desafio. + Defina 0 para repetições ilimitadas. value: "Valor atual: [number]" + value-infinite: "Valor atual: [number] (ilimitado)" cool_down: name: "Resfriamento" description: |- @@ -913,6 +916,48 @@ challenges: name: [name] description: '[description]' selected: Selecionado + toggle-undeployed: + name: "Desafios não implantados" + shown: |- + Os desafios não implantados estão + atualmente visíveis. + hidden: |- + Os desafios não implantados estão + atualmente ocultos. + required_biomes: + name: "Biomas necessários" + description: |- + O jogador deve estar em um + destes biomas para completar este + desafio de ilha. + title: "Biomas: " + list: " - [biome]" + none: "Nenhum bioma necessário (qualquer bioma)." + reward_island_level: + name: "Nível da ilha de recompensa" + description: |- + Permite alterar o nível da ilha de recompensa. + Requer o addon Level. + value: "Valor atual: [number]" + repeat_reward_island_level: + name: "Nível da ilha por repetição" + description: |- + Permite alterar a recompensa de nível + da ilha por repetir o desafio. + Requer o addon Level. + value: "Valor atual: [number]" + open_anywhere: + name: "Abrir GUI em qualquer lugar" + description: |- + Permite que os jogadores abram o GUI de desafios + de qualquer lugar. Completar ainda requer + estar na ilha quando protegida. + enabled: "Ativado" + disabled: "Desativado" + biome: + name: "[biome]" + description: "ID do bioma: [id]" + selected: "Selecionado" tips: click-to-select: "Clique em para selecionar." click-to-choose: "Clique em para escolher." @@ -1009,6 +1054,8 @@ challenges: search-radius: "Não além de [number] metros" warning-block: "Os blocos serão removidos" warning-entity: "As entidades serão removidas" + biomes-title: "Bioma(s) necessário(s):" + biome-value: " - [biome]" inventory: lore: |- [Items] @@ -1140,6 +1187,8 @@ challenges: write-search: "Por favor, escreva um valor de pesquisa. (escreva 'cancel' para sair)" search-updated: "Valor de pesquisa atualizado." enter-formula: "Digite uma fórmula que usa placeholders PAPI e símbolos =,<>,<+,>=, ==, !=, AND, OR apenas.\nExemplo: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Digite 'confirm' no chat para continuar, ou 'cancel' para cancelar." + input-repeat-count: "Digite o número de repetições no chat. Digite 0 para repetições ilimitadas." titles: challenge-title: Completado com sucesso challenge-subtitle: "[friendlyName]" @@ -1206,6 +1255,7 @@ challenges: no-team: Este é um desafio em equipe. Você deve ter uma equipe para completá-lo! insufficient-team: Este desafio em equipe precisa de [number] membros da equipe online. Apenas [online] estão online. member-missing-share: [name] não tem sua parte ([amount] x [item]) para este desafio em equipe. + wrong-biome: "Você precisa estar no bioma [biome] para completar este desafio!" materials: any: 'Qualquer ' all_hanging_signs: diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index dea0a820..d2840ecb 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -513,8 +513,12 @@ challenges: disabled: Отключено repeat_count: name: Количество повторений - description: "Позволяет изменить количество повторений\nдля испытания." + description: |- + Позволяет изменить количество повторений + для испытания. + Установите 0 для неограниченных повторений. value: 'Текущее значение: [number]' + value-infinite: "Текущее значение: [number] (неограниченно)" cool_down: name: Перезагрузка description: "Позволяет изменить период перезагрузки\n(в секундах) который должен пройти\nмежду завершениями повторяемого испытания." @@ -728,6 +732,48 @@ challenges: name: Поиск description: "Позволяет искать элемент\nиспользуя значение ввода текста." search: 'Значение: [value]' + toggle-undeployed: + name: "Неактивированные испытания" + shown: |- + Неактивированные испытания сейчас + показаны. + hidden: |- + Неактивированные испытания сейчас + скрыты. + required_biomes: + name: "Требуемые биомы" + description: |- + Игрок должен находиться в одном + из этих биомов, чтобы выполнить это + островное испытание. + title: "Биомы: " + list: " - [biome]" + none: "Биомы не требуются (любой биом)." + reward_island_level: + name: "Уровень острова в награду" + description: |- + Позволяет изменить награду уровнем острова. + Требуется аддон Level. + value: "Текущее значение: [number]" + repeat_reward_island_level: + name: "Уровень острова за повторение" + description: |- + Позволяет изменить награду уровнем + острова за повторение испытания. + Требуется аддон Level. + value: "Текущее значение: [number]" + open_anywhere: + name: "Открывать GUI где угодно" + description: |- + Позволяет игрокам открывать GUI испытаний + откуда угодно. Для выполнения по-прежнему нужно + находиться на острове, если включена защита. + enabled: "Включено" + disabled: "Отключено" + biome: + name: "[biome]" + description: "ID биома: [id]" + selected: "Выбрано" descriptions: admin: input: Открыть текстовое поле ввода. @@ -1013,6 +1059,8 @@ challenges: search-radius: Не дальше [number] метров warning-block: Блоки будут удалены warning-entity: Сущности будут удалены + biomes-title: "Требуемые биомы:" + biome-value: " - [biome]" inventory: lore: "[items]\n[warning]" item-title: 'Требуемые предметы:' @@ -1144,6 +1192,7 @@ challenges: challenge-selector: Выбор испытания statistic-selector: Выбор статистики environment-selector: Выбор измерения + biome-selector: "Выбор биома" tips: click-to-select: Нажмите чтобы выбрать. click-to-choose: Нажмите чтобы выбрать. @@ -1281,6 +1330,7 @@ challenges: no-team: Это командное испытание. Вам нужно иметь команду чтобы завершить его! insufficient-team: Это командное испытание требует [number] членов команды онлайн. Только [online] онлайн. member-missing-share: [name] не имеет их доли ([amount] x [item]) для этого командного испытания. + wrong-biome: "Вы должны находиться в биоме [biome], чтобы выполнить это испытание!" materials: any: 'Любой ' all_hanging_signs: @@ -1335,6 +1385,8 @@ challenges: write-search: Пожалуйста введите значение поиска. (Введите 'cancel' чтобы выйти) search-updated: Значение поиска обновлено. enter-formula: "Введите формулу которая использует только заполнители PAPI и символы =,<>,<+,>=, ==, !=, AND, OR.\nПример: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Введите 'confirm' в чат для продолжения или 'cancel' для отмены." + input-repeat-count: "Пожалуйста, введите количество повторений в чат. Введите 0 для неограниченных повторений." protection: flags: CHALLENGES_ISLAND_PROTECTION: diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index 54ac8a8f..42d3c3cf 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -57,6 +57,7 @@ challenges: manage-statistics: Керування статистикою manage-advancements: Керування досягненнями advancement-selector: Селектор досягнень + biome-selector: "Вибір біому" buttons: free-challenges: name: "Безкоштовні виклики" @@ -526,7 +527,9 @@ challenges: Дозволяє змінювати кількість повторень за виклик. + Встановіть 0 для необмежених повторень. value: "Поточне значення: [number]" + value-infinite: "Поточне значення: [number] (необмежено)" cool_down: name: "Охолодження" description: |- @@ -915,6 +918,48 @@ challenges: name: [name] description: '[description]' selected: Вибрано + toggle-undeployed: + name: "Неактивовані випробування" + shown: |- + Неактивовані випробування зараз + показані. + hidden: |- + Неактивовані випробування зараз + приховані. + required_biomes: + name: "Потрібні біоми" + description: |- + Гравець має перебувати в одному + з цих біомів, щоб виконати це + острівне випробування. + title: "Біоми: " + list: " - [biome]" + none: "Біоми не потрібні (будь-який біом)." + reward_island_level: + name: "Рівень острова в нагороду" + description: |- + Дозволяє змінити нагороду рівнем острова. + Потрібен аддон Level. + value: "Поточне значення: [number]" + repeat_reward_island_level: + name: "Рівень острова за повторення" + description: |- + Дозволяє змінити нагороду рівнем + острова за повторення випробування. + Потрібен аддон Level. + value: "Поточне значення: [number]" + open_anywhere: + name: "Відкривати GUI будь-де" + description: |- + Дозволяє гравцям відкривати GUI випробувань + звідусіль. Для виконання все одно потрібно + перебувати на острові, якщо він захищений. + enabled: "Увімкнено" + disabled: "Вимкнено" + biome: + name: "[biome]" + description: "ID біому: [id]" + selected: "Вибрано" tips: click-to-select: "Натисніть , щоб вибрати." click-to-choose: "Натисніть , щоб вибрати." @@ -1011,6 +1056,8 @@ challenges: search-radius: "Не далі [number] метрів" warning-block: "Блоки будуть видалені" warning-entity: "Сутності будуть видалені" + biomes-title: "Потрібні біоми:" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1142,6 +1189,8 @@ challenges: write-search: "Введіть пошукове значення. (напишіть 'cancel', щоб вийти)" search-updated: "Значення пошуку оновлено." enter-formula: "Введіть формулу, яка використовує тільки заповнювачі PAPI та символи =,<>,<+,>=, ==, !=, AND, OR.\nПриклад: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Введіть 'confirm' у чат, щоб продовжити, або 'cancel', щоб скасувати." + input-repeat-count: "Будь ласка, введіть кількість повторень у чат. Введіть 0 для необмежених повторень." titles: challenge-title: Успішно завершено challenge-subtitle: "[friendlyName]" @@ -1206,6 +1255,7 @@ challenges: no-team: Це командний виклик. У вас повинна бути команда, щоб його завершити! insufficient-team: Цей командний виклик потребує [number] членів команди в мережі. Лише [online] в мережі. member-missing-share: [name] не має свою частину ([amount] x [item]) для цього командного виклику. + wrong-biome: "Ви повинні перебувати в біомі [biome], щоб виконати це випробування!" materials: any: 'Будь-який ' all_hanging_signs: diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index d8630bfa..e8616ae8 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -57,6 +57,7 @@ challenges: manage-statistics: 管理统计 manage-advancements: 管理进度 advancement-selector: 进度选择器 + biome-selector: "选择生物群系" buttons: free-challenges: name: "自由挑战" @@ -403,8 +404,11 @@ challenges: disabled: "已禁用" repeat_count: name: "重复次数" - description: "允许更改挑战的重复次数" + description: |- + 允许更改挑战的重复次数 + 设为 0 表示无限次重复。 value: "当前值:[number]" + value-infinite: "当前值:[number] (无限)" cool_down: name: "冷却" description: "允许更改重复挑战的冷却时间" @@ -706,6 +710,48 @@ challenges: name: [name] description: '[description]' selected: 已选中 + toggle-undeployed: + name: "未部署的挑战" + shown: |- + 未部署的挑战当前 + 显示。 + hidden: |- + 未部署的挑战当前 + 隐藏。 + required_biomes: + name: "所需生物群系" + description: |- + 玩家必须站在这些生物群系 + 之一中才能完成此 + 岛屿挑战。 + title: "生物群系:" + list: " - [biome]" + none: "无需特定生物群系(任意生物群系)。" + reward_island_level: + name: "奖励岛屿等级" + description: |- + 允许更改奖励的岛屿等级。 + 需要 Level 插件。 + value: "当前值:[number]" + repeat_reward_island_level: + name: "重复奖励岛屿等级" + description: |- + 允许更改挑战重复完成时 + 奖励的岛屿等级。 + 需要 Level 插件。 + value: "当前值:[number]" + open_anywhere: + name: "随处打开 GUI" + description: |- + 允许玩家在任何地方打开挑战 GUI。 + 受保护时,完成挑战仍需 + 身处岛屿上。 + enabled: "已启用" + disabled: "已禁用" + biome: + name: "[biome]" + description: "生物群系 ID:[id]" + selected: "已选择" tips: click-to-select: "单击 进行选择。" click-to-choose: "单击 进行选择。" @@ -802,6 +848,8 @@ challenges: search-radius: "必须在在[number] 米范围内" warning-block: "所需方块将在完成挑战时移除" warning-entity: "所需实体将在完成挑战时移除" + biomes-title: "所需生物群系:" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -933,6 +981,8 @@ challenges: write-search: "请写一个搜索值。(输入 'cancel' 取消)" search-updated: "搜索值已更新。" enter-formula: "输入仅使用PAPI占位符和符号=,<>,<+,>=, ==, !=, AND, OR的公式。\n示例: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "在聊天中输入'confirm'以继续,或输入'cancel'以取消。" + input-repeat-count: "请在聊天中输入重复次数。输入 0 表示无限次。" titles: challenge-title: "已完成" challenge-subtitle: "[friendlyName]" @@ -995,6 +1045,7 @@ challenges: no-team: 这是一个团队挑战。您必须拥有一个团队才能完成它! insufficient-team: 此团队挑战需要[number]名团队成员在线。只有[online]名在线。 member-missing-share: [name]对于此团队挑战没有他们的份额([amount] x [item])。 + wrong-biome: "你必须站在 [biome] 生物群系中才能完成此挑战!" materials: any: '任意 ' all_hanging_signs: diff --git a/src/main/resources/locales/zh-HK.yml b/src/main/resources/locales/zh-HK.yml index 0bd64678..de0af029 100644 --- a/src/main/resources/locales/zh-HK.yml +++ b/src/main/resources/locales/zh-HK.yml @@ -69,6 +69,7 @@ challenges: manage-statistics: 管理統計資料 manage-advancements: 管理進度 advancement-selector: 進度選擇器 + biome-selector: "選擇生態域" buttons: free-challenges: name: '自由挑戰' @@ -436,7 +437,9 @@ challenges: description: |- 修改 挑戰可重覆完成 的最大次數 + 設為 0 代表無限次重複。 value: '目前數: [number]次' + value-infinite: "目前數: [number]次 (無限)" cool_down: name: '冷卻時間' description: |- @@ -775,6 +778,48 @@ challenges: name: [id] description: '' selected: 已選擇 + toggle-undeployed: + name: "未部署的挑戰" + shown: |- + 未部署的挑戰目前 + 顯示。 + hidden: |- + 未部署的挑戰目前 + 隱藏。 + required_biomes: + name: "所需生態域" + description: |- + 玩家必須站在這些生態域 + 之一中才能完成此 + 島嶼挑戰。 + title: "生態域: " + list: " - [biome]" + none: "無需特定生態域(任何生態域)。" + reward_island_level: + name: "獎勵島嶼等級" + description: |- + 允許更改獎勵的島嶼等級。 + 需要 Level 插件。 + value: "目前數: [number]次" + repeat_reward_island_level: + name: "重複獎勵島嶼等級" + description: |- + 允許更改挑戰重複完成時 + 獎勵的島嶼等級。 + 需要 Level 插件。 + value: "目前數: [number]次" + open_anywhere: + name: "隨處開啟 GUI" + description: |- + 允許玩家在任何地方開啟挑戰 GUI。 + 受保護時,完成挑戰仍需 + 身處島嶼上。 + enabled: "已啟用" + disabled: "已禁用" + biome: + name: "[biome]" + description: "生態域ID: [id]" + selected: "已選" tips: click-to-select: "點擊選擇" click-to-choose: "點擊選擇" @@ -871,6 +916,8 @@ challenges: search-radius: '不可超過[number]米' warning-block: '方塊會被收取不退還' warning-entity: '實體會被收取不退還' + biomes-title: "所需生態域:" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1008,6 +1055,8 @@ challenges: write-search: '請輸入搜索字條 (輸入''cancel''退出)' search-updated: '搜索完成' enter-formula: "輸入僅使用 PAPI 占位符和符號 =,<>,<+,>=, ==, !=, AND, OR 的公式。\n示例:%my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "在聊天中輸入'confirm'以繼續,或輸入'cancel'以取消。" + input-repeat-count: "請在聊天中輸入重複次數。輸入 0 代表無限次。" titles: # Title and subtitle may contain variables in [] that will be replaced with a proper message from the challenge object. # [friendlyName] will be replaced with challenge friendly name. @@ -1073,6 +1122,7 @@ challenges: not-hooked: '挑戰附加插件沒有發現任何遊戲模式。' timeout: '每次完成這挑戰要求最少等待[timeout]秒。 你必須等待[wait-time]秒才可以再次完成。' requirement-not-met: '這挑戰要求[statistic]達到[number]。 你只有[value]。' + wrong-biome: "你必須站在 [biome] 生態域中才能完成此挑戰!" # # Showcase for manual material translation # materials: # # Names should be lowercase. diff --git a/src/main/resources/locales/zh-TW.yml b/src/main/resources/locales/zh-TW.yml index 9065b3cf..ff35f0f7 100644 --- a/src/main/resources/locales/zh-TW.yml +++ b/src/main/resources/locales/zh-TW.yml @@ -504,8 +504,12 @@ challenges: disabled: 已禁用 repeat_count: name: 重複計數 - description: "允許你變更挑戰的\n重複次數。" + description: |- + 允許你變更挑戰的 + 重複次數。 + 設為 0 表示無限次重複。 value: 目前值:[number] + value-infinite: "目前值:[number] (無限)" cool_down: name: 冷卻 description: "允許你變更冷卻\n期間(以秒為單位)必須經過\n可重複挑戰完成之間。" @@ -719,6 +723,48 @@ challenges: name: 搜尋 description: "允許你搜尋\n使用文字輸入值的元素。" search: 值:[value] + toggle-undeployed: + name: "未部署的挑戰" + shown: |- + 未部署的挑戰目前 + 顯示。 + hidden: |- + 未部署的挑戰目前 + 隱藏。 + required_biomes: + name: "所需生態域" + description: |- + 玩家必須站在這些生態域 + 之一中才能完成此 + 島嶼挑戰。 + title: "生態域:" + list: " - [biome]" + none: "無需特定生態域(任何生態域)。" + reward_island_level: + name: "獎勵島嶼等級" + description: |- + 允許你變更獎勵的島嶼等級。 + 需要 Level 插件。 + value: "目前值:[number]" + repeat_reward_island_level: + name: "重複獎勵島嶼等級" + description: |- + 允許你變更挑戰重複完成時 + 獎勵的島嶼等級。 + 需要 Level 插件。 + value: "目前值:[number]" + open_anywhere: + name: "隨處開啟 GUI" + description: |- + 允許玩家在任何地方開啟挑戰 GUI。 + 受保護時,完成挑戰仍需 + 身處島嶼上。 + enabled: "已啟用" + disabled: "已禁用" + biome: + name: "[biome]" + description: "生態域 ID:[id]" + selected: "已選擇" descriptions: admin: save: '保存幷返回上一級菜單' @@ -867,6 +913,8 @@ challenges: search-radius: 不超過 [number] 米 warning-block: 方塊將被 移除 warning-entity: 實體將被 移除 + biomes-title: "所需生態域:" + biome-value: " - [biome]" inventory: lore: "[items]\n[warning]" item-title: 所需物品: @@ -1000,6 +1048,7 @@ challenges: challenge-selector: 挑戰選擇器 statistic-selector: 統計資料選擇器 environment-selector: 環境選擇器 + biome-selector: "生態域選擇器" tips: click-to-select: 按一下 以選擇。 click-to-choose: 按一下 以選擇。 @@ -1133,6 +1182,7 @@ challenges: no-team: 這是團隊挑戰。你必須有團隊才能完成它! insufficient-team: 此團隊挑戰需要 [number] 個團隊成員在線。只有 [online] 在線。 member-missing-share: [name] 沒有他們的份額([amount] x [item])用於此團隊挑戰。 + wrong-biome: "你必須站在 [biome] 生態域中才能完成此挑戰!" materials: any: '任何 ' all_hanging_signs: @@ -1187,6 +1237,8 @@ challenges: write-search: 請輸入搜尋值。(輸入「取消」退出) search-updated: 搜尋值已更新。 enter-formula: "輸入使用 PAPI 佔位符和符號 =,<>,<+,>=, ==, !=, AND, OR 的公式。\n範例:%my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "在聊天中輸入'confirm'以繼續,或輸入'cancel'以取消。" + input-repeat-count: "請在聊天中輸入重複次數。輸入 0 表示無限次。" protection: flags: CHALLENGES_ISLAND_PROTECTION: From dbcece3f19c3995acaa575d59fd7ac1e6aba5bb6 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 18 Jul 2026 20:16:13 -0700 Subject: [PATCH 30/37] Fix Repeat Reward Items selector opening with the first-time reward list The REPEAT_REWARD_ITEMS click handler passed getRewardItems() to ItemSelector, so the selector displayed the first-time rewards instead of the repeat rewards, and saving silently copied them into repeatItemReward. Pass getRepeatItemReward() instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016uXwirQRfvEkRThWELRcM2 --- .../bentobox/challenges/panel/admin/EditChallengePanel.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9db6d423..273617af 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java @@ -1708,7 +1708,7 @@ private PanelItem createRewardButton(RewardButton button) { icon = new ItemStack(Material.CHEST); clickHandler = (panel, user, clickType, slot) -> { - ItemSelector.open(this.user, this.challenge.getRewardItems(), (status, value) -> { + ItemSelector.open(this.user, this.challenge.getRepeatItemReward(), (status, value) -> { if (Boolean.TRUE.equals(status)) { this.challenge.setRepeatItemReward(value); } From d5f4c9d1ec1be6c007dc624e19ab38e3ac422703 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 18 Jul 2026 20:27:02 -0700 Subject: [PATCH 31/37] Tell players when the reward chance roll fails A failed reward chance roll was silent, making it indistinguishable from a bug. Send a "no reward this time" message when the roll fails on first completion, or when every roll fails on a repeat, but only if the challenge actually has chance-gated rewards (items, money or experience) configured. Add the locale key to en-US and all translations. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016uXwirQRfvEkRThWELRcM2 --- .../challenges/tasks/TryToComplete.java | 41 +++++++++++++++- src/main/resources/locales/cs.yml | 1 + src/main/resources/locales/de.yml | 1 + src/main/resources/locales/en-US.yml | 1 + src/main/resources/locales/es.yml | 1 + src/main/resources/locales/fr.yml | 1 + src/main/resources/locales/hu.yml | 1 + src/main/resources/locales/ja.yml | 1 + src/main/resources/locales/lv.yml | 1 + src/main/resources/locales/pl.yml | 1 + src/main/resources/locales/pt.yml | 1 + src/main/resources/locales/ru.yml | 1 + src/main/resources/locales/uk.yml | 1 + src/main/resources/locales/zh-CN.yml | 1 + src/main/resources/locales/zh-HK.yml | 1 + src/main/resources/locales/zh-TW.yml | 1 + .../challenges/tasks/TryToCompleteTest.java | 48 +++++++++++++++++++ 17 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java index 778df469..15fa3da2 100644 --- a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java +++ b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java @@ -297,11 +297,18 @@ ChallengeResult build(int maxTimes) // Send message about first completion only if it is completed only once. if (result.getFactor() == 1) { - Utils.sendMessage(this.user, + Utils.sendMessage(this.user, this.world, Constants.MESSAGES + "you-completed-challenge", Constants.PARAMETER_VALUE, this.challenge.getFriendlyName()); } + // Tell recipients the chance roll failed, so silence is not mistaken for a missing reward. + if (!rewardSuccess && this.hasFirstTimeRewards()) + { + this.getRewardRecipients().forEach(recipient -> Utils.sendMessage(recipient, + this.world, Constants.MESSAGES + "no-reward-this-time")); + } + if (this.addon.getChallengesSettings().isBroadcastMessages()) { Bukkit.getOnlinePlayers().stream(). @@ -328,6 +335,8 @@ ChallengeResult build(int maxTimes) { int rewardFactor = result.getFactor() - (result.wasCompleted() ? 0 : 1); + List missedRecipients = new ArrayList<>(); + // Reward every recipient (all present members for a team challenge, else just the user). for (User recipient : this.getRewardRecipients()) { @@ -368,6 +377,10 @@ ChallengeResult build(int maxTimes) recipient.getPlayer().giveExp( this.challenge.getRepeatExperienceReward() * rewardedCount); } + else if (this.hasRepeatRewards()) + { + missedRecipients.add(recipient); + } // Run commands (not gated by reward chance) for (int i = 0; i < rewardFactor; i++) @@ -392,6 +405,10 @@ ChallengeResult build(int maxTimes) this.world, Constants.MESSAGES + "you-repeated-challenge", Constants.PARAMETER_VALUE, this.challenge.getFriendlyName()); } + + // Tell recipients whose every roll failed, so silence is not mistaken for a missing reward. + missedRecipients.forEach(recipient -> Utils.sendMessage(recipient, + this.world, Constants.MESSAGES + "no-reward-this-time")); } // Mark as complete @@ -477,6 +494,28 @@ protected boolean shouldRewardItems() } + /** + * @return true if the challenge has any first-time rewards that are gated by the reward chance. + */ + private boolean hasFirstTimeRewards() + { + return !this.challenge.getRewardItems().isEmpty() || + this.challenge.getRewardExperience() > 0 || + this.challenge.getRewardMoney() > 0; + } + + + /** + * @return true if the challenge has any repeat rewards that are gated by the reward chance. + */ + private boolean hasRepeatRewards() + { + return !this.challenge.getRepeatItemReward().isEmpty() || + this.challenge.getRepeatExperienceReward() > 0 || + this.challenge.getRepeatMoneyReward() > 0; + } + + /** * 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. diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index 306f8f3c..6e3ae34b 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -1205,6 +1205,7 @@ challenges: you-completed-challenge: "Dokončil jsi výzvu [value]!" you-repeated-challenge: "Zopakoval jsi výzvu [value]!" you-repeated-challenge-multiple: "Zopakoval jsi výzvu [value] [count]x!" + no-reward-this-time: "Štěstí ti nepřálo — tentokrát bez odměny!" you-completed-level: "Dokončil jsi úroveň [value]!" name-has-completed-challenge: "[name] dokončil výzvu [value]!" name-has-completed-level: "[name] dokončil úroveň [value]!" diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index 4e6ffece..f9269f2c 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -1194,6 +1194,7 @@ challenges: you-completed-challenge: "Du hast die [value] Herausforderungen abgeschlossen!" you-repeated-challenge: "Du hast die [value] Herausforderung wiederholt!" you-repeated-challenge-multiple: "Du hast die [value] Herausforderungen [count] mal wiederholt!" + no-reward-this-time: "Das Glück war dir nicht hold — diesmal keine Belohnung!" you-completed-level: "Du hast den [value] level abgeschlossen!" name-has-completed-challenge: "[name] hat die [value] -Herausforderung abgeschlossen!" name-has-completed-level: "[name] hat den [value] Level abgeschlossen!" diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 14fbd539..90cd4a27 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -1312,6 +1312,7 @@ challenges: you-completed-challenge: "You completed the [value] challenge!" you-repeated-challenge: "You repeated the [value] challenge!" you-repeated-challenge-multiple: "You repeated the [value] challenge [count] times!" + no-reward-this-time: "Luck was not on your side — no reward this time!" you-completed-level: "You completed the [value] level!" name-has-completed-challenge: "[name] has completed the [value] challenge!" name-has-completed-level: "[name] has completed the [value] level!" diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index b25494fd..7a54b109 100755 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -1212,6 +1212,7 @@ challenges: you-completed-challenge: "¡Has completado el desafío [value]!" you-repeated-challenge: "¡Has repetido el desafío [value]!" you-repeated-challenge-multiple: "¡Has repetido el desafío [value] [count] veces!" + no-reward-this-time: "La suerte no estuvo de tu lado — ¡sin recompensa esta vez!" you-completed-level: "¡Has completado el nivel [value]!" name-has-completed-challenge: "¡[name] ha completado el desafío [value]!" name-has-completed-level: "¡[name] ha completado el nivel [value]!" diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index 4d5dfcb5..0c75ac03 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -1204,6 +1204,7 @@ challenges: you-completed-challenge: "Vous avez complété le challenge: [value] !" you-repeated-challenge: "Vous avez répété le challenge: [value] !" you-repeated-challenge-multiple: "Vous avez répété le challenge: [value] [count] fois !" + no-reward-this-time: "La chance n'était pas de votre côté — pas de récompense cette fois !" you-completed-level: "Vous avez complété le niveau: [value] !" name-has-completed-challenge: "[name] a complété le challenge: [value] !" name-has-completed-level: "[name] a complété le niveau: [value] !" diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index 159e5c95..d6e4004a 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -1205,6 +1205,7 @@ challenges: you-completed-challenge: "Teljesítette a [value] kihívást!" you-repeated-challenge: "Megismételte a [value] kihívást!" you-repeated-challenge-multiple: "Ismételted a [value] kihívást [count]!" + no-reward-this-time: "A szerencse most nem állt melléd — ezúttal nincs jutalom!" you-completed-level: "Elérted az [value] szintet!" name-has-completed-challenge: "[name] teljesítette a [value] kihívást!" name-has-completed-level: "[name] befejezte az [value] szintet!" diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml index 56d7bbef..939e8b26 100644 --- a/src/main/resources/locales/ja.yml +++ b/src/main/resources/locales/ja.yml @@ -1192,6 +1192,7 @@ challenges: you-completed-challenge: &2 [value]チャレンジを完了しました! you-repeated-challenge: &2 [value]チャレンジを繰り返しました! you-repeated-challenge-multiple: &2 [value]チャレンジ [count]回繰り返しました! + no-reward-this-time: "運が味方しませんでした — 今回は報酬なしです!" you-completed-level: &2 [value]レベルを完了しました! name-has-completed-challenge: &5 [name]は[value]&r&5チャレンジ を完了しました! name-has-completed-level: &5 [name]は[value]&r&5レベルを完了しました! diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml index e1dad597..b1a35a90 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -1381,6 +1381,7 @@ challenges: you-completed-challenge: 'Tu izpildīji [value] uzdevumu!' you-repeated-challenge: 'Tu atkārtoti izpildīji [value] uzdevumu!' you-repeated-challenge-multiple: 'Tu atkārtoji [value] uzdevumu [count] reizes!' + no-reward-this-time: "Veiksme nebija tavā pusē — šoreiz bez balvas!" you-completed-level: 'Tu pabeidzi [value] līmeni!' name-has-completed-challenge: '[name] pabeidza [value] uzdevumu!' name-has-completed-level: '[name] pabeidza [value] līmeni!' diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index fb6d421f..e2ec7a1e 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -1135,6 +1135,7 @@ challenges: you-completed-challenge: "Ukończono [value] wyzwanie!" you-repeated-challenge: "Powtórzyłeś [value] wyzwanie!" you-repeated-challenge-multiple: " Powtórzyłeś [value] challenge [count] razy!" + no-reward-this-time: "Szczęście ci nie dopisało — tym razem bez nagrody!" you-completed-level: "Ukończono [value] poziom!" name-has-completed-challenge: " [name] zakończyła [value] wyzwanie!" name-has-completed-level: " [name] uzupełniła [value] poziom!" diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index 629e78b3..744e3617 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -1209,6 +1209,7 @@ challenges: you-repeated-challenge-multiple: |- Você repetiu o [value] desafio [count] vezes! + no-reward-this-time: "A sorte não estava do seu lado — sem recompensa desta vez!" you-completed-level: "Você completou o nível [valor] !" name-has-completed-challenge: "[name] completou [value] o desafio!" name-has-completed-level: "[name] completou o nível [value]!" diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index d2840ecb..ffc7545d 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -1262,6 +1262,7 @@ challenges: you-completed-challenge: "Вы завершили [value] челлендж!" you-repeated-challenge: "Ты перепрошел [value] челлендж!" you-repeated-challenge-multiple: "Вы перепрошли [value] челлендж [count] раз!" + no-reward-this-time: "Удача не на вашей стороне — в этот раз без награды!" you-completed-level: "Вы завершили [value] уровень!" name-has-completed-challenge: "[name] завершил [value] челлендж!" name-has-completed-level: "[name] завершил [value] уровень!" diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index 42d3c3cf..f9f34e9f 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -1209,6 +1209,7 @@ challenges: you-completed-challenge: "Ви завершили завдання [value] !" you-repeated-challenge: "Ви повторили завдання [value] !" you-repeated-challenge-multiple: "Ви повторили завдання [value] [count] разів!" + no-reward-this-time: "Удача не на вашому боці — цього разу без нагороди!" you-completed-level: "Ви завершили рівень [value] !" name-has-completed-challenge: "[name] виконав завдання [value] !" name-has-completed-level: "[name] завершив рівень [value] !" diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index e8616ae8..88e51692 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -1001,6 +1001,7 @@ challenges: you-completed-challenge: "你完成了挑战 [value] !" you-repeated-challenge: "你再次完成了挑战 [value] !" you-repeated-challenge-multiple: "你完成挑战 [value] [count] 次了!" + no-reward-this-time: "运气不佳——这次没有奖励!" you-completed-level: "恭喜,你已完成挑战等级 [value]!" name-has-completed-challenge: "恭喜! [name] 完成了挑战 [value] !" name-has-completed-level: "恭喜! [name] 已完成挑战等级 [value]!" diff --git a/src/main/resources/locales/zh-HK.yml b/src/main/resources/locales/zh-HK.yml index de0af029..4d5e92a5 100644 --- a/src/main/resources/locales/zh-HK.yml +++ b/src/main/resources/locales/zh-HK.yml @@ -1082,6 +1082,7 @@ challenges: you-completed-challenge: '你已完成了 [value]挑戰!' you-repeated-challenge: '你已重覆完成了 [value]挑戰!' you-repeated-challenge-multiple: '你已重覆完成了 [value]挑戰 [count]次!' + no-reward-this-time: "運氣欠佳——這次沒有獎勵!" you-completed-level: '你已完成了 [value]等級!' name-has-completed-challenge: '[name]已完成了 [value]挑戰!' name-has-completed-level: '[name]已完成了 [value]等級!' diff --git a/src/main/resources/locales/zh-TW.yml b/src/main/resources/locales/zh-TW.yml index ff35f0f7..8f242293 100644 --- a/src/main/resources/locales/zh-TW.yml +++ b/src/main/resources/locales/zh-TW.yml @@ -1116,6 +1116,7 @@ challenges: you-completed-challenge: '你已經完成了 [value] 挑戰!' you-repeated-challenge: '你已經重複完成了 [value] 挑戰!' you-repeated-challenge-multiple: '你重複完成了 [value] 挑戰 [count] 次!' + no-reward-this-time: "運氣不佳——這次沒有獎勵!" you-completed-level: '你完成了 [value] 級別!' name-has-completed-challenge: '[name] 已完成 [value] 挑戰!' name-has-completed-level: '[name] 已完成 [value] 挑戰級別!' diff --git a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java index 2311ed4e..10811835 100644 --- a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java +++ b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java @@ -948,6 +948,54 @@ void testRewardChance0NoItems() { verify(player, never()).giveExp(50); } + @Test + void testRewardChance0SendsNoRewardMessage() { + challenge.setRewardChance(0); + 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<>()); + + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + verify(user).getTranslation(any(World.class), eq("challenges.messages.no-reward-this-time")); + } + + @Test + void testRewardChance0NoRewardsConfiguredNoMessage() { + challenge.setRewardChance(0); + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(false); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + verify(user, never()).getTranslation(any(World.class), eq("challenges.messages.no-reward-this-time")); + } + + @Test + void testRewardChance0RepeatSendsNoRewardMessage() { + challenge.setRewardChance(0); + challenge.setRepeatable(true); + 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<>()); + + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + verify(user).getTranslation(any(World.class), eq("challenges.messages.no-reward-this-time")); + } + + @Test + void testRewardChance100NoMissMessage() { + challenge.setRewardChance(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<>()); + + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + verify(user, never()).getTranslation(any(World.class), eq("challenges.messages.no-reward-this-time")); + } + @Test void testRewardChanceRepeatRewards() { challenge.setRewardChance(100); From a860cacd2d8e7eecb9bfee655815491294c474f3 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 18 Jul 2026 20:38:01 -0700 Subject: [PATCH 32/37] Override transitive uuid to 11.1.1 in E2E harness (CVE-2026-41907) Dependabot alert #1: uuid < 11.1.1 has a missing buffer bounds check in v3/v5/v6 when buf is provided. It reaches the E2E lockfile transitively via @azure/msal-node (8.3.2) and yggdrasil (10.0.0) under mineflayer. Force 11.1.1 with an npm override; test-only dependency, nothing ships in the addon jar. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016uXwirQRfvEkRThWELRcM2 --- e2e/src/test/e2e/package-lock.json | 27 ++++++++------------------- e2e/src/test/e2e/package.json | 3 +++ 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/e2e/src/test/e2e/package-lock.json b/e2e/src/test/e2e/package-lock.json index 9a8a052f..6a38b7c2 100644 --- a/e2e/src/test/e2e/package-lock.json +++ b/e2e/src/test/e2e/package-lock.json @@ -969,13 +969,16 @@ } }, "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).", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/uuid-1345": { @@ -1024,20 +1027,6 @@ "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" - } } } } diff --git a/e2e/src/test/e2e/package.json b/e2e/src/test/e2e/package.json index 345ba604..1b037001 100644 --- a/e2e/src/test/e2e/package.json +++ b/e2e/src/test/e2e/package.json @@ -6,6 +6,9 @@ "dependencies": { "@drownek/plugwright": "^2.0.2" }, + "overrides": { + "uuid": "^11.1.1" + }, "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.3" From a9491755a5b7253d8c14f23709eeea01caf8b5e9 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 18 Jul 2026 20:44:12 -0700 Subject: [PATCH 33/37] Require reward chance to be at least 1% A reward chance of 0% would mean rewards are never given, which is never a sensible configuration. Clamp setRewardChance to 1-100, raise the GUI numeric input minimum from 0 to 1, and update the button description in all locales from (0-100) to (1-100). Hand-edited database values of 0 still load and behave as "never rewards" since Gson bypasses the setter. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016uXwirQRfvEkRThWELRcM2 --- .../challenges/database/object/Challenge.java | 7 ++-- .../panel/admin/EditChallengePanel.java | 2 +- src/main/resources/locales/cs.yml | 2 +- src/main/resources/locales/de.yml | 2 +- src/main/resources/locales/en-US.yml | 2 +- src/main/resources/locales/es.yml | 2 +- src/main/resources/locales/fr.yml | 2 +- src/main/resources/locales/hu.yml | 2 +- src/main/resources/locales/ja.yml | 2 +- src/main/resources/locales/lv.yml | 2 +- src/main/resources/locales/pl.yml | 2 +- src/main/resources/locales/pt.yml | 2 +- src/main/resources/locales/ru.yml | 2 +- src/main/resources/locales/uk.yml | 2 +- src/main/resources/locales/zh-CN.yml | 2 +- src/main/resources/locales/zh-HK.yml | 2 +- src/main/resources/locales/zh-TW.yml | 2 +- .../challenges/tasks/TryToCompleteTest.java | 39 +++++++++++++++---- 18 files changed, 51 insertions(+), 27 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 bfbd9733..3e37c20c 100644 --- a/src/main/java/world/bentobox/challenges/database/object/Challenge.java +++ b/src/main/java/world/bentobox/challenges/database/object/Challenge.java @@ -845,12 +845,13 @@ public void setRewardCommands(List rewardCommands) /** - * Sets the reward chance percentage. - * @param rewardChance the rewardChance new value (0-100). + * Sets the reward chance percentage. Values are clamped to 1-100: 0 would mean rewards + * are never given, which is not allowed. + * @param rewardChance the rewardChance new value (1-100). */ public void setRewardChance(int rewardChance) { - this.rewardChance = rewardChance; + this.rewardChance = Math.min(100, Math.max(1, rewardChance)); } 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 273617af..0bb2341c 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java @@ -1524,7 +1524,7 @@ private PanelItem createRewardButton(RewardButton button) { this.build(); }; ConversationUtils.createNumericInput(numberConsumer, this.user, - this.user.getTranslation(Constants.INPUT_NUMBER), 0, 100); + this.user.getTranslation(Constants.INPUT_NUMBER), 1, 100); return true; }; diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index 6e3ae34b..571d7ca1 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -471,7 +471,7 @@ challenges: reward_chance: name: "Šance na odměnu" description: |- - Šance v procentech (0-100), že budou + Šance v procentech (1-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. diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index f9269f2c..b43de4e2 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -462,7 +462,7 @@ challenges: reward_chance: name: "Belohnungschance" description: |- - Prozentuale Chance (0-100), dass materielle + Prozentuale Chance (1-100), dass materielle Belohnungen beim Abschluss vergeben werden. Gegenstände, Geld und Erfahrung nutzen einen Wurf. Befehle werden immer ausgeführt. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 90cd4a27..4eb87fac 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -610,7 +610,7 @@ challenges: reward_chance: name: "Reward Chance" description: |- - Chance percentage (0-100) that material + Chance percentage (1-100) that material rewards are given on completion. Items, money, and experience use one roll. Commands are always executed. diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index 7a54b109..4f069ede 100755 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -474,7 +474,7 @@ challenges: reward_chance: name: "Probabilidad de Recompensa" description: |- - Probabilidad (0-100) de que se entreguen + Probabilidad (1-100) de que se entreguen las recompensas materiales al completar. Objetos, dinero y experiencia usan una sola tirada. Los comandos siempre se ejecutan. diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index 0c75ac03..6fdd8370 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -466,7 +466,7 @@ challenges: reward_chance: name: "Chance de récompense" description: |- - Pourcentage de chance (0-100) que les récompenses + Pourcentage de chance (1-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. diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index d6e4004a..9b1f22b7 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -471,7 +471,7 @@ challenges: reward_chance: name: "Jutalom esélye" description: |- - Százalékos esély (0-100) arra, hogy a teljesítéskor + Százalékos esély (1-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. diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml index 939e8b26..fb024faa 100644 --- a/src/main/resources/locales/ja.yml +++ b/src/main/resources/locales/ja.yml @@ -524,7 +524,7 @@ challenges: name: 報酬確率 description: |- 達成時に物質的な報酬が与えられる - 確率(0-100)です。 + 確率(1-100)です。 アイテム、お金、経験値は1回の抽選で決まります。 コマンドは常に実行されます。 value: 確率:[number]% diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml index b1a35a90..a998204f 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -489,7 +489,7 @@ challenges: reward_chance: name: "Atlīdzības Iespēja" description: |- - Iespēja procentos (0-100), ka izpildot + Iespēja procentos (1-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. diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index e2ec7a1e..8288d8d2 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -441,7 +441,7 @@ challenges: reward_chance: name: "Szansa na nagrodę" description: |- - Procentowa szansa (0-100), że nagrody + Procentowa szansa (1-100), że nagrody materialne zostaną przyznane po ukończeniu. Przedmioty, pieniądze i doświadczenie używają jednego losowania. Komendy są wykonywane zawsze. diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index 744e3617..67c8672f 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -473,7 +473,7 @@ challenges: reward_chance: name: "Chance de Recompensa" description: |- - Chance percentual (0-100) de que as recompensas + Chance percentual (1-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. diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index ffc7545d..404ef304 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -493,7 +493,7 @@ challenges: reward_chance: name: Шанс награды description: |- - Шанс в процентах (0-100), что за выполнение + Шанс в процентах (1-100), что за выполнение будут выданы материальные награды. Предметы, деньги и опыт используют один бросок. Команды выполняются всегда. diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index f9f34e9f..41417389 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -471,7 +471,7 @@ challenges: reward_chance: name: "Шанс винагороди" description: |- - Шанс у відсотках (0-100), що за виконання + Шанс у відсотках (1-100), що за виконання будуть видані матеріальні винагороди. Предмети, гроші та досвід використовують один кидок. Команди виконуються завжди. diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index 88e51692..ab4b1c22 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -375,7 +375,7 @@ challenges: name: "奖励几率" description: |- 完成挑战时发放物质奖励的 - 几率百分比(0-100)。 + 几率百分比(1-100)。 物品、金钱和经验共用一次判定。 命令始终会执行。 value: "几率: [number]%" diff --git a/src/main/resources/locales/zh-HK.yml b/src/main/resources/locales/zh-HK.yml index 4d5e92a5..27d535ea 100644 --- a/src/main/resources/locales/zh-HK.yml +++ b/src/main/resources/locales/zh-HK.yml @@ -391,7 +391,7 @@ challenges: name: "獎勵機率" description: |- 完成挑戰時發放物質獎勵的 - 機率百分比(0-100)。 + 機率百分比(1-100)。 物品、金錢和經驗共用一次判定。 指令必定會執行。 value: "機率: [number]%" diff --git a/src/main/resources/locales/zh-TW.yml b/src/main/resources/locales/zh-TW.yml index 8f242293..0f1cdf64 100644 --- a/src/main/resources/locales/zh-TW.yml +++ b/src/main/resources/locales/zh-TW.yml @@ -485,7 +485,7 @@ challenges: name: 獎勵機率 description: |- 完成挑戰時發放物質獎勵的 - 機率百分比(0-100)。 + 機率百分比(1-100)。 物品、金錢和經驗共用一次判定。 指令一定會執行。 value: 機率:[number]% diff --git a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java index 10811835..a75a0ec4 100644 --- a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java +++ b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java @@ -911,6 +911,29 @@ void testRewardChanceDefaultIs100() { assertEquals(100, c.getRewardChance()); } + @Test + void testRewardChanceClampedTo1To100() { + Challenge c = new Challenge(); + c.setRewardChance(0); + assertEquals(1, c.getRewardChance()); + c.setRewardChance(-5); + assertEquals(1, c.getRewardChance()); + c.setRewardChance(150); + assertEquals(100, c.getRewardChance()); + c.setRewardChance(50); + assertEquals(50, c.getRewardChance()); + } + + /** + * Sets the rewardChance field directly, bypassing the clamping setter, to simulate + * legacy or hand-edited database data. + */ + private void setRewardChanceField(Challenge c, int value) throws Exception { + java.lang.reflect.Field field = Challenge.class.getDeclaredField("rewardChance"); + field.setAccessible(true); + field.setInt(c, value); + } + @Test void testRewardChance100GivesRewards() { challenge.setRewardChance(100); @@ -929,8 +952,8 @@ void testRewardChance100GivesRewards() { } @Test - void testRewardChance0NoItems() { - challenge.setRewardChance(0); + void testRewardChance0NoItems() throws Exception { + setRewardChanceField(challenge, 0); challenge.setRewardExperience(50); challenge.setRewardMoney(100); challenge.setRewardItems(Collections.singletonList(new ItemStack(Material.EMERALD))); @@ -949,8 +972,8 @@ void testRewardChance0NoItems() { } @Test - void testRewardChance0SendsNoRewardMessage() { - challenge.setRewardChance(0); + void testRewardChance0SendsNoRewardMessage() throws Exception { + setRewardChanceField(challenge, 0); 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<>()); @@ -961,8 +984,8 @@ void testRewardChance0SendsNoRewardMessage() { } @Test - void testRewardChance0NoRewardsConfiguredNoMessage() { - challenge.setRewardChance(0); + void testRewardChance0NoRewardsConfiguredNoMessage() throws Exception { + setRewardChanceField(challenge, 0); when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(false); when(inv.addItem(any())).thenReturn(new HashMap<>()); @@ -972,8 +995,8 @@ void testRewardChance0NoRewardsConfiguredNoMessage() { } @Test - void testRewardChance0RepeatSendsNoRewardMessage() { - challenge.setRewardChance(0); + void testRewardChance0RepeatSendsNoRewardMessage() throws Exception { + setRewardChanceField(challenge, 0); challenge.setRepeatable(true); challenge.setRepeatItemReward(Collections.singletonList(new ItemStack(Material.DIAMOND))); when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(true); From 3a3fa861beb4ba7dc5ef07a9791ab0c2f3314e9d Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 19 Jul 2026 08:05:47 -0700 Subject: [PATCH 34/37] Fix numeric range error message to say bounds are inclusive The conversation validation accepts min and max themselves, but the not-valid-value message claimed the number must be "greater than [min] and less than [max]". Reword to "between [min] and [max] inclusive" in en-US and all translations, and fix the broken [valor] placeholder in the Portuguese string. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016uXwirQRfvEkRThWELRcM2 --- src/main/resources/locales/cs.yml | 2 +- src/main/resources/locales/de.yml | 2 +- src/main/resources/locales/en-US.yml | 2 +- src/main/resources/locales/es.yml | 2 +- src/main/resources/locales/fr.yml | 2 +- src/main/resources/locales/hu.yml | 2 +- src/main/resources/locales/ja.yml | 2 +- src/main/resources/locales/lv.yml | 2 +- src/main/resources/locales/pl.yml | 2 +- src/main/resources/locales/pt.yml | 2 +- src/main/resources/locales/ru.yml | 2 +- src/main/resources/locales/uk.yml | 2 +- src/main/resources/locales/zh-CN.yml | 2 +- src/main/resources/locales/zh-HK.yml | 2 +- src/main/resources/locales/zh-TW.yml | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index 571d7ca1..66b25c80 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -1147,7 +1147,7 @@ challenges: input-number: "Zadejte prosím číslo do chatu." input-seconds: "Zadejte prosím sekundy do chatu." numeric-only: "Daná [value] není číslo!" - not-valid-value: "Dané číslo [value] není platné. Musí být větší než [min] a menší než [max]!" + not-valid-value: "Zadané číslo [value] není platné. Musí být mezi [min] a [max] včetně." user-data-removed: "Všechna uživatelská data pro [gamemode] jsou vymazána z databáze." confirm-user-data-deletion: "Potvrďte prosím, že chcete vymazat databázi uživatelů pro [gamemode]." challenge-data-removed: "Všechna data výzev pro [gamemode] jsou vymazána z databáze." diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index b43de4e2..ea0e7f47 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -1136,7 +1136,7 @@ challenges: input-number: "Bitte geben Sie im Chat eine Nummer ein." input-seconds: "Bitte geben Sie im Chat eine Sekunde ein." numeric-only: "Der angegebene [value] ist keine Zahl!" - not-valid-value: "Die angegebene Zahl [value] ist ungültig. Er muss größer als [min] und kleiner als [max] sein!" + not-valid-value: "Die angegebene Zahl [value] ist ungültig. Sie muss zwischen [min] und [max] (einschließlich) liegen." user-data-removed: "Alle Benutzerdaten für [gamemode] werden aus der Datenbank gelöscht." confirm-user-data-deletion: "Bitte bestätigen Sie, dass Sie die Benutzerdatenbank für [gamemode] löschen möchten." challenge-data-removed: "Alle Herausforderungsdaten für [gamemode] werden aus der Datenbank gelöscht." diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 4eb87fac..79eff62f 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -1256,7 +1256,7 @@ challenges: input-repeat-count: "Please enter the number of repeats in the chat. Enter 0 for unlimited repeats." input-seconds: "Please enter a number of seconds in the chat." numeric-only: "The given [value] is not a number!" - not-valid-value: "The given number [value] is not valid. It must be greater than [min] and less than [max]!" + not-valid-value: "The given number [value] is not valid. It must be between [min] and [max] inclusive." user-data-removed: "All user data for [gamemode] has been cleared from the database." confirm-user-data-deletion: "Please confirm that you want to clear the user database for [gamemode]." challenge-data-removed: "All challenge data for [gamemode] has been cleared from the database." diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index 4f069ede..ec7f8c99 100755 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -1154,7 +1154,7 @@ challenges: input-number: "Por favor ingresa un número en el chat." input-seconds: "Por favor ingrese unos segundos en el chat." numeric-only: "¡El [value] dado no es un número!" - not-valid-value: "El número dado [value] no es válido. ¡Debe ser mayor que [min] y menor que [max]!" + not-valid-value: "El número dado [value] no es válido. Debe estar entre [min] y [max], ambos inclusive." user-data-removed: "Todos los datos del usuario para [gamemode] se borran de la base de datos." confirm-user-data-deletion: "Confirme que desea borrar la base de datos de usuarios para [gamemode]." challenge-data-removed: "Todos los datos de los desafíos para [gamemode] se borran de la base de datos." diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index 6fdd8370..8ddb7594 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -1146,7 +1146,7 @@ challenges: input-number: "Veuillez saisir un numéro dans le chat." input-seconds: "Veuillez entrer une seconde dans le chat." numeric-only: "La [value] donnée n'est pas un nombre !" - not-valid-value: "Le nombre donné [value] n'est pas valide. Elle doit être supérieure à [min] et inférieure à [max] !" + not-valid-value: "Le nombre donné [value] n'est pas valide. Il doit être compris entre [min] et [max] inclus." user-data-removed: "Toutes les données utilisateur pour [gamemode] sont effacées de la base de données." confirm-user-data-deletion: "Veuillez confirmer que vous souhaitez effacer la base de données utilisateur pour [gamemode]." challenge-data-removed: "Toutes les données des défis pour [gamemode] sont effacées de la base de données." diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index 9b1f22b7..d9701a5e 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -1147,7 +1147,7 @@ challenges: input-number: "Kérjük, adjon meg egy számot a chatben." input-seconds: "Kérjük, adjon meg egy másodpercet a csevegésben." numeric-only: "A megadott [value] nem szám!" - not-valid-value: "A megadott szám [value] nem érvényes. Nagyobbnak kell lennie, mint [min] és kisebbnek, mint [max]!" + not-valid-value: "A megadott szám ([value]) érvénytelen. [min] és [max] között kell lennie (a határokat is beleértve)." user-data-removed: "A [gamemode] összes felhasználói adata törlődik az adatbázisból." confirm-user-data-deletion: "Kérjük, erősítse meg, hogy törölni szeretné a [gamemode] felhasználói adatbázisát." challenge-data-removed: "[gamemode] adatai törlődnek az adatbázisból." diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml index fb024faa..3950496f 100644 --- a/src/main/resources/locales/ja.yml +++ b/src/main/resources/locales/ja.yml @@ -1132,7 +1132,7 @@ challenges: input-number: チャットに番号を入力してください。 input-seconds: チャットに数秒を入力してください。 numeric-only: '与えられた[value]は数字ではありません!' - not-valid-value: '指定された番号[value]は無効です。 [min]よりも大きく、[max]未満でなければなりません!' + not-valid-value: "指定された数値 [value] は無効です。[min] から [max] までの範囲(両端を含む)で入力してください。" user-data-removed: '[gamemode]のすべてのユーザーデータは、データベースからクリアされています。' confirm-user-data-deletion: '[gamemode]のユーザーデータベースをクリアすることを確認してください。' challenge-data-removed: '[gamemode]のすべてのチャレンジデータは、データベースからクリアされています。' diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml index a998204f..2b7930be 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -1278,7 +1278,7 @@ challenges: # Error message that is showed if user input a value that is not a number. numeric-only: "Šis `[value]` nav skaitlis!" # Error message that is showed if user input a number that is smaller or larger that allowed. - not-valid-value: "Ierakstītais skaitlis [value] nav derīgs. Skaitlis nedrīkst būt mazāks par [min] un lielāks par [max]!" + not-valid-value: "Ierakstītais skaitlis [value] nav derīgs. Tam jābūt no [min] līdz [max] (ieskaitot)." # Message that confirms user data removing. user-data-removed: "Visi lietotāju dati priekš [gamemode] ir dzēsti." # Message that asks confirmation for user data removing. diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index 8288d8d2..1eaf25ef 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -1077,7 +1077,7 @@ challenges: input-number: "Proszę podać numer na czacie." input-seconds: "Wprowadź sekundy na czacie." numeric-only: "Podana [value] nie jest liczbą!" - not-valid-value: "Podana liczba [value] jest nieprawidłowa. Musi być większy niż [min] i mniejszy niż [max]!" + not-valid-value: "Podana liczba [value] jest nieprawidłowa. Musi być w zakresie od [min] do [max] włącznie." user-data-removed: "Wszystkie dane użytkownika dla trybu [gamemode] są usuwane z bazy danych." confirm-user-data-deletion: "Potwierdź, że chcesz wyczyścić bazę danych użytkowników dla [gamemode]." challenge-data-removed: "Wszystkie dane wyzwań dla trybu [gamemode] zostaną usunięte z bazy danych." diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index 67c8672f..660dbe6e 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -1149,7 +1149,7 @@ challenges: input-number: "Digite um número no chat." input-seconds: "Por favor, digite um segundo no chat." numeric-only: "O [value] dado não é um número!" - not-valid-value: "O número dado [valor] não é válido. Deve ser maior que [min] e menor que [max]!" + not-valid-value: "O número dado [value] não é válido. Deve estar entre [min] e [max], inclusive." user-data-removed: "Todos os dados do usuário para [gamemode] são apagados do banco de dados." confirm-user-data-deletion: "Confirme que deseja limpar o banco de dados do usuário para [gamemode]." challenge-data-removed: "Todos os dados de desafios para [gamemode] são apagados do banco de dados." diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index 404ef304..12d6ca03 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -1348,7 +1348,7 @@ challenges: input-number: Пожалуйста введите число в чат. input-seconds: Пожалуйста введите количество секунд в чат. numeric-only: Введенное [value] не является числом! - not-valid-value: Введенное число [value] не действительно. Оно должно быть больше [min] и меньше [max]! + not-valid-value: "Введённое число [value] недопустимо. Оно должно быть от [min] до [max] включительно." user-data-removed: Все данные пользователя для [gamemode] были удалены из базы данных. confirm-user-data-deletion: Пожалуйста подтвердите что вы хотите очистить базу данных пользователя для [gamemode]. challenge-data-removed: Все данные испытаний для [gamemode] были удалены из базы данных. diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index 41417389..7f9ce936 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -1151,7 +1151,7 @@ challenges: input-number: "Будь ласка, введіть номер у чаті." input-seconds: "Будь ласка, введіть секунди в чаті." numeric-only: "Дане [value] не є числом!" - not-valid-value: "Дане число [value] недійсне. Він має бути більшим за [min] і меншим за [max]!" + not-valid-value: "Введене число [value] неприпустиме. Воно має бути від [min] до [max] включно." user-data-removed: "Усі дані користувача для [gamemode] видалено з бази даних." confirm-user-data-deletion: "Підтвердьте, що ви бажаєте очистити базу даних користувачів для [gamemode]." challenge-data-removed: "Усі дані викликів для [gamemode] видалено з бази даних." diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index ab4b1c22..1b46255d 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -943,7 +943,7 @@ challenges: input-number: "请在聊天中输入一个数字。" input-seconds: "请在聊天中输入秒数。" numeric-only: "你输入的 [value] 不是数字!" - not-valid-value: "你输入的的数字 [value] 无效。它必须大于 [min] 且小于 [max]!" + not-valid-value: "输入的数字 [value] 无效。必须在 [min] 到 [max] 之间(含边界值)。" user-data-removed: "已清除 [gamemode] 的所有玩家数据。" confirm-user-data-deletion: "请确认您要清除 [gamemode] 的玩家数据。" challenge-data-removed: "已清除 [gamemode] 的所有挑战数据。" diff --git a/src/main/resources/locales/zh-HK.yml b/src/main/resources/locales/zh-HK.yml index 27d535ea..92900094 100644 --- a/src/main/resources/locales/zh-HK.yml +++ b/src/main/resources/locales/zh-HK.yml @@ -1011,7 +1011,7 @@ challenges: input-number: '請在聊天中輸入 一個數字' input-seconds: '請在聊天中輸入 一個秒數' numeric-only: '輸入的[value]不是 有效數字!' - not-valid-value: '輸入的[value]無效. 數字必須於[min]及[max]之間!' + not-valid-value: "輸入的數字 [value] 無效。必須在 [min] 至 [max] 之間(包含邊界值)。" user-data-removed: '空島模式[gamemode]中的所有玩家數據已被清除' confirm-user-data-deletion: |- 請再次 確認 要把空島模式[gamemode]中的 diff --git a/src/main/resources/locales/zh-TW.yml b/src/main/resources/locales/zh-TW.yml index 0f1cdf64..0795560a 100644 --- a/src/main/resources/locales/zh-TW.yml +++ b/src/main/resources/locales/zh-TW.yml @@ -1200,7 +1200,7 @@ challenges: input-number: 請在聊天中輸入數字。 input-seconds: 請在聊天中輸入秒數。 numeric-only: 給定的 [value] 不是數字! - not-valid-value: 給定的數字 [value] 無效。它必須大於 [min] 且小於 [max]! + not-valid-value: "輸入的數字 [value] 無效。必須在 [min] 到 [max] 之間(含邊界值)。" user-data-removed: 所有 [gamemode] 的使用者資料已從資料庫中清除。 confirm-user-data-deletion: 請確認你想清除 [gamemode] 的使用者資料庫。 challenge-data-removed: 所有 [gamemode] 的挑戰資料已從資料庫中清除。 From 2c29587eb4bc408e03bdc881ddf70a3096b13080 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 19 Jul 2026 08:51:55 -0700 Subject: [PATCH 35/37] Parse color codes in the multi-line text input echo The "Input Text:" echo in the description conversation showed raw MiniMessage tags (e.g. bread) instead of rendering them. Parse each echoed line through BentoBox's MiniMessage/legacy bridge so the player sees the colors they will actually get. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016uXwirQRfvEkRThWELRcM2 --- .../world/bentobox/challenges/panel/ConversationUtils.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/world/bentobox/challenges/panel/ConversationUtils.java b/src/main/java/world/bentobox/challenges/panel/ConversationUtils.java index 8830dcd1..44f3b642 100644 --- a/src/main/java/world/bentobox/challenges/panel/ConversationUtils.java +++ b/src/main/java/world/bentobox/challenges/panel/ConversationUtils.java @@ -27,6 +27,7 @@ import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.util.Util; import world.bentobox.challenges.utils.Constants; import world.bentobox.challenges.utils.Utils; @@ -442,7 +443,9 @@ public String getPromptText(@NotNull ConversationContext context) for (String line : ((List) context.getSessionData(SESSION_CONSTANT))) { - sb.append(line); + // Render MiniMessage tags and legacy codes so the echo shows the + // colors the player will actually get. + sb.append(Util.componentToLegacy(Util.parseMiniMessageOrLegacy(line))); sb.append(System.lineSeparator()); } From bac69a7cc8dbd57e531a73de650c8c2c168c9f1a Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 19 Jul 2026 08:59:24 -0700 Subject: [PATCH 36/37] Document description/reward colour options as MiniMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The description-color and reward-text-color config comments described only legacy '&' codes, but MiniMessage tags are the primary format now. Verified end-to-end that '', '<#55FFFF>', '', '&b' and '7FFFF' all render correctly through applyDefaultColor → translateColorCodes → parseMiniMessageOrLegacy, and updated the config comments and javadoc to match. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016uXwirQRfvEkRThWELRcM2 --- .../bentobox/challenges/config/Settings.java | 18 +++++++++++------- src/main/resources/config.yml | 8 +++++--- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/main/java/world/bentobox/challenges/config/Settings.java b/src/main/java/world/bentobox/challenges/config/Settings.java index f1d61cb0..8abb3919 100644 --- a/src/main/java/world/bentobox/challenges/config/Settings.java +++ b/src/main/java/world/bentobox/challenges/config/Settings.java @@ -125,9 +125,11 @@ public class Settings implements ConfigObject @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.") + @ConfigComment("do not have to prefix each challenge with the same colour. Uses MiniMessage tags,") + @ConfigComment("including hex (e.g. '', '<#55FFFF>' or ''). Legacy '&' codes") + @ConfigComment("(e.g. '&b' or '7FFFF') also still work. Leave empty for no default. A colour") + @ConfigComment("written in the description itself still overrides this. Does not affect") + @ConfigComment("per-challenge locale overrides.") @ConfigEntry(path = "gui-settings.description-color") private String descriptionColor = ""; @@ -762,7 +764,7 @@ public void setOpenAnywhere(boolean openAnywhere) /** * Returns the default colour applied to challenge description text. * - * @return the description colour code, or an empty string for no default. + * @return the description colour (MiniMessage tag or legacy code), or an empty string for no default. */ public String getDescriptionColor() { @@ -773,7 +775,8 @@ public String getDescriptionColor() /** * Sets the default colour applied to challenge description text. * - * @param descriptionColor the colour code (e.g. "&b" or "7FFFF"), or empty for none. + * @param descriptionColor the colour, as a MiniMessage tag (e.g. "<white>" or + * "<#55FFFF>") or legacy code (e.g. "&b"), or empty for none. */ public void setDescriptionColor(String descriptionColor) { @@ -784,7 +787,7 @@ public void setDescriptionColor(String descriptionColor) /** * Returns the default colour applied to challenge reward text. * - * @return the reward text colour code, or an empty string for no default. + * @return the reward text colour (MiniMessage tag or legacy code), or an empty string for no default. */ public String getRewardTextColor() { @@ -795,7 +798,8 @@ public String getRewardTextColor() /** * Sets the default colour applied to challenge reward text. * - * @param rewardTextColor the colour code (e.g. "&b" or "7FFFF"), or empty for none. + * @param rewardTextColor the colour, as a MiniMessage tag (e.g. "<white>" or + * "<#55FFFF>") or legacy code (e.g. "&b"), or empty for none. */ public void setRewardTextColor(String rewardTextColor) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 98a3f66f..e1593344 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -81,9 +81,11 @@ gui-settings: 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. + # do not have to prefix each challenge with the same colour. Uses MiniMessage tags, + # including hex (e.g. '', '<#55FFFF>' or ''). Legacy '&' codes + # (e.g. '&b' or '7FFFF') also still work. 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 From 06080c1de77dd7886fabb8a6998f342a64f727c5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 19 Jul 2026 16:50:22 -0700 Subject: [PATCH 37/37] Remove duplicate right-click-to-clear key from en-US tips The key appeared twice in the gui.tips section with identical values, causing a YamlConstructor duplicate-keys warning on server load. Scanned all resource YAML files for other duplicate keys; this was the only one. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016uXwirQRfvEkRThWELRcM2 --- src/main/resources/locales/en-US.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 79eff62f..5d9613b5 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -1042,7 +1042,6 @@ challenges: Click on an item in your inventory. left-click-to-edit: "Left Click to edit." - right-click-to-clear: "Right Click to clear." click-to-previous: "Click to view previous page." click-to-next: "Click to view next page." descriptions: