From e4b5f5221c48b906ba68740cdfc068e1e959eb16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 21:58:37 +0000 Subject: [PATCH 01/15] Initial plan From 444977e1b0eeecc27815c6bbba74993a5c9c569c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 22:07:27 +0000 Subject: [PATCH 02/15] Fix XP increase when teleporting between worlds with advancements When granting advancement criteria via awardCriteria(), some Minecraft advancements reward XP, causing the player's experience to increase on every world switch. Fix by saving the player's current XP before granting advancement criteria in setAdvancements() and restoring it afterward. Also add test testGetInventoryAdvancementsPreservesExperience to verify that setTotalExperience is called after advancement criteria are awarded, ensuring XP is properly restored. Agent-Logs-Url: https://github.com/BentoBoxWorld/InvSwitcher/sessions/dde552a1-224e-42d1-94cb-218e3609eaf8 Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .../6fe7981a-2e32-439e-9aaf-1de05cb43e26.json | 39 ++++++++++++++++ .../b878f9a4-d9f0-41e1-9d37-3c6e67e446a8.json | 24 ++++++++++ .../com/wasteofplastic/invswitcher/Store.java | 13 +++++- .../wasteofplastic/invswitcher/StoreTest.java | 44 +++++++++++++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 database_backup/InventoryStorage/6fe7981a-2e32-439e-9aaf-1de05cb43e26.json create mode 100644 database_backup/InventoryStorage/b878f9a4-d9f0-41e1-9d37-3c6e67e446a8.json diff --git a/database_backup/InventoryStorage/6fe7981a-2e32-439e-9aaf-1de05cb43e26.json b/database_backup/InventoryStorage/6fe7981a-2e32-439e-9aaf-1de05cb43e26.json new file mode 100644 index 0000000..b72bced --- /dev/null +++ b/database_backup/InventoryStorage/6fe7981a-2e32-439e-9aaf-1de05cb43e26.json @@ -0,0 +1,39 @@ +{ + "uniqueId": "6fe7981a-2e32-439e-9aaf-1de05cb43e26", + "inventory": { + "default": [ + "is:\n ==: org.bukkit.inventory.ItemStack\n", + null, + "is:\n ==: org.bukkit.inventory.ItemStack\n", + null, + null, + "is:\n ==: org.bukkit.inventory.ItemStack\n" + ] + }, + "health": { + "default": 0.0 + }, + "food": { + "default": 0 + }, + "exp": { + "default": 0 + }, + "location": {}, + "gameMode": {}, + "advancements": {}, + "enderChest": { + "default": [ + "is:\n ==: org.bukkit.inventory.ItemStack\n", + null, + "is:\n ==: org.bukkit.inventory.ItemStack\n", + null, + null, + "is:\n ==: org.bukkit.inventory.ItemStack\n" + ] + }, + "untypedStats": {}, + "blockStats": {}, + "itemStats": {}, + "entityStats": {} +} \ No newline at end of file diff --git a/database_backup/InventoryStorage/b878f9a4-d9f0-41e1-9d37-3c6e67e446a8.json b/database_backup/InventoryStorage/b878f9a4-d9f0-41e1-9d37-3c6e67e446a8.json new file mode 100644 index 0000000..679ba6a --- /dev/null +++ b/database_backup/InventoryStorage/b878f9a4-d9f0-41e1-9d37-3c6e67e446a8.json @@ -0,0 +1,24 @@ +{ + "uniqueId": "b878f9a4-d9f0-41e1-9d37-3c6e67e446a8", + "inventory": { + "world/island-primary": [ + "is:\n ==: org.bukkit.inventory.ItemStack\n", + null, + "is:\n ==: org.bukkit.inventory.ItemStack\n", + null, + null, + "is:\n ==: org.bukkit.inventory.ItemStack\n" + ] + }, + "health": {}, + "food": {}, + "exp": {}, + "location": {}, + "gameMode": {}, + "advancements": {}, + "enderChest": {}, + "untypedStats": {}, + "blockStats": {}, + "itemStats": {}, + "entityStats": {} +} \ No newline at end of file diff --git a/src/main/java/com/wasteofplastic/invswitcher/Store.java b/src/main/java/com/wasteofplastic/invswitcher/Store.java index 5ef1022..9454697 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/Store.java +++ b/src/main/java/com/wasteofplastic/invswitcher/Store.java @@ -320,7 +320,15 @@ private void setFood(InventoryStorage store, Player player, String overworldName private void setAdvancements(InventoryStorage store, Player player, String overworldName) { // Advancements - store.getAdvancements(overworldName).forEach((k, v) -> { + Map> advancements = store.getAdvancements(overworldName); + if (advancements.isEmpty()) { + return; + } + // Save current experience before granting advancements, because some advancements + // reward XP when their criteria are awarded, which would incorrectly increase the + // player's experience points. + int savedExp = getTotalExperience(player); + advancements.forEach((k, v) -> { Iterator it = Bukkit.advancementIterator(); while (it.hasNext()) { Advancement a = it.next(); @@ -330,7 +338,8 @@ private void setAdvancements(InventoryStorage store, Player player, String overw } } }); - + // Restore experience to prevent advancement rewards from modifying it + setTotalExperience(player, savedExp); } public void removeFromCache(Player player) { diff --git a/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java b/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java index dfe5419..9cdb000 100644 --- a/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java +++ b/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java @@ -22,6 +22,7 @@ import java.nio.file.Path; import java.util.Comparator; import java.util.HashSet; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -30,8 +31,11 @@ import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.World.Environment; +import org.bukkit.advancement.Advancement; +import org.bukkit.advancement.AdvancementProgress; import org.bukkit.attribute.AttributeInstance; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; @@ -200,6 +204,46 @@ public void testGetInventory() { verify(player).setTotalExperience(0); } + /** + * Test that advancement grants during {@link Store#getInventory} do not modify the player's + * experience points. Some advancements reward XP when their criteria are awarded; the store + * must save and restore XP around the advancement grant step. + */ + @Test + public void testGetInventoryAdvancementsPreservesExperience() { + sets.setAdvancements(true); + sets.setExperience(true); + sets.setStatistics(false); + sets.setIslandsActive(false); + + // Mock an advancement with awarded criteria + Advancement advancement = mock(Advancement.class); + NamespacedKey advKey = NamespacedKey.minecraft("story_mine_stone"); + when(advancement.getKey()).thenReturn(advKey); + + AdvancementProgress progress = mock(AdvancementProgress.class); + Set criteria = new HashSet<>(Set.of("mine_stone")); + when(progress.getAwardedCriteria()).thenReturn(criteria); + when(player.getAdvancementProgress(advancement)).thenReturn(progress); + + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class, Mockito.RETURNS_MOCKS)) { + // Return a fresh iterator each time so both storeInventory and getInventory can iterate + mockedBukkit.when(Bukkit::advancementIterator).thenAnswer(inv -> List.of(advancement).iterator()); + + // Store inventory (saves advancement data and clears player including XP reset) + s.storeInventory(player, world); + + // Load inventory — experience set, advancements granted, XP restored + s.getInventory(player, world); + } + + // setTotalExperience should be called exactly 3 times: + // 1. clearPlayer during storeInventory (XP reset to 0) + // 2. experience loading during getInventory (XP set to stored value 0) + // 3. XP restoration inside setAdvancements after granting advancement criteria + verify(player, times(3)).setTotalExperience(0); + } + /** * Test method for {@link com.wasteofplastic.invswitcher.Store#removeFromCache(org.bukkit.entity.Player)}. */ From ea50727c2261924837af2c25b68b33317eaddd0c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 22:07:48 +0000 Subject: [PATCH 03/15] Remove accidentally committed test database files and add to .gitignore Agent-Logs-Url: https://github.com/BentoBoxWorld/InvSwitcher/sessions/dde552a1-224e-42d1-94cb-218e3609eaf8 Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .gitignore | 4 ++ .../6fe7981a-2e32-439e-9aaf-1de05cb43e26.json | 39 ------------------- .../b878f9a4-d9f0-41e1-9d37-3c6e67e446a8.json | 24 ------------ 3 files changed, 4 insertions(+), 63 deletions(-) delete mode 100644 database_backup/InventoryStorage/6fe7981a-2e32-439e-9aaf-1de05cb43e26.json delete mode 100644 database_backup/InventoryStorage/b878f9a4-d9f0-41e1-9d37-3c6e67e446a8.json diff --git a/.gitignore b/.gitignore index 5e2fb46..46abb5a 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,10 @@ release.properties dependency-reduced-pom.xml buildNumber.properties + # Test artifacts +database/ +database_backup/ + # Intellij *.iml *.java___jb_tmp___ diff --git a/database_backup/InventoryStorage/6fe7981a-2e32-439e-9aaf-1de05cb43e26.json b/database_backup/InventoryStorage/6fe7981a-2e32-439e-9aaf-1de05cb43e26.json deleted file mode 100644 index b72bced..0000000 --- a/database_backup/InventoryStorage/6fe7981a-2e32-439e-9aaf-1de05cb43e26.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "uniqueId": "6fe7981a-2e32-439e-9aaf-1de05cb43e26", - "inventory": { - "default": [ - "is:\n ==: org.bukkit.inventory.ItemStack\n", - null, - "is:\n ==: org.bukkit.inventory.ItemStack\n", - null, - null, - "is:\n ==: org.bukkit.inventory.ItemStack\n" - ] - }, - "health": { - "default": 0.0 - }, - "food": { - "default": 0 - }, - "exp": { - "default": 0 - }, - "location": {}, - "gameMode": {}, - "advancements": {}, - "enderChest": { - "default": [ - "is:\n ==: org.bukkit.inventory.ItemStack\n", - null, - "is:\n ==: org.bukkit.inventory.ItemStack\n", - null, - null, - "is:\n ==: org.bukkit.inventory.ItemStack\n" - ] - }, - "untypedStats": {}, - "blockStats": {}, - "itemStats": {}, - "entityStats": {} -} \ No newline at end of file diff --git a/database_backup/InventoryStorage/b878f9a4-d9f0-41e1-9d37-3c6e67e446a8.json b/database_backup/InventoryStorage/b878f9a4-d9f0-41e1-9d37-3c6e67e446a8.json deleted file mode 100644 index 679ba6a..0000000 --- a/database_backup/InventoryStorage/b878f9a4-d9f0-41e1-9d37-3c6e67e446a8.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "uniqueId": "b878f9a4-d9f0-41e1-9d37-3c6e67e446a8", - "inventory": { - "world/island-primary": [ - "is:\n ==: org.bukkit.inventory.ItemStack\n", - null, - "is:\n ==: org.bukkit.inventory.ItemStack\n", - null, - null, - "is:\n ==: org.bukkit.inventory.ItemStack\n" - ] - }, - "health": {}, - "food": {}, - "exp": {}, - "location": {}, - "gameMode": {}, - "advancements": {}, - "enderChest": {}, - "untypedStats": {}, - "blockStats": {}, - "itemStats": {}, - "entityStats": {} -} \ No newline at end of file From ded731361036c1c623bbef15c661848001c17154 Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 20 May 2026 15:22:00 -0700 Subject: [PATCH 04/15] Update build version from 1.17.1 to 1.17.2 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c6baa7e..6964411 100644 --- a/pom.xml +++ b/pom.xml @@ -64,7 +64,7 @@ -LOCAL - 1.17.1 + 1.17.2 BentoBoxWorld_addon-invSwitcher bentobox-world From 109a26eafc5cf70545e74092b33ec79272ba607b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 01:48:02 +0000 Subject: [PATCH 05/15] Initial plan From b6ae2ed3bd21ab915095be93ab465618a921a099 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 02:09:23 +0000 Subject: [PATCH 06/15] feat: intercept BentoBox player reset events when player is in non-BentoBox world When BentoBox fires PlayerResetInventoryEvent, PlayerResetEnderChestEvent, PlayerResetExpEvent, PlayerResetHealthEvent, or PlayerResetHungerEvent while the player is in a non-BentoBox world, InvSwitcher now: - Cancels the event (to protect the player's current world inventory) - Clears/resets the stored data for the BentoBox world instead Also updates: - BentoBox dependency to 3.17.0-SNAPSHOT (which includes the new event classes) - MockBukkit dependency from JitPack to Maven Central (org.mockbukkit 4.110.0) - New tests in PlayerListenerTest and StoreTest covering the new behavior Agent-Logs-Url: https://github.com/BentoBoxWorld/InvSwitcher/sessions/5189c153-b6ad-472a-b4a2-ccc1ae6c6b30 Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- pom.xml | 16 +- .../com/wasteofplastic/invswitcher/Store.java | 130 +++++++++++++ .../invswitcher/listeners/PlayerListener.java | 101 ++++++++++ .../wasteofplastic/invswitcher/StoreTest.java | 176 ++++++++++++++++++ .../listeners/PlayerListenerTest.java | 164 ++++++++++++++++ 5 files changed, 575 insertions(+), 12 deletions(-) diff --git a/pom.xml b/pom.xml index 6964411..cf83be3 100644 --- a/pom.xml +++ b/pom.xml @@ -58,7 +58,7 @@ 1.17.5 1.21.11-R0.1-SNAPSHOT - 2.7.1-SNAPSHOT + 3.17.0-SNAPSHOT ${build.version}-SNAPSHOT @@ -114,14 +114,6 @@ - - - jitpack.io - https://jitpack.io - - true - - spigot-repo https://hub.spigotmc.org/nexus/content/repositories/snapshots @@ -146,9 +138,9 @@ - com.github.MockBukkit - MockBukkit - v1.21-SNAPSHOT + org.mockbukkit.mockbukkit + mockbukkit-v1.21 + 4.110.0 test diff --git a/src/main/java/com/wasteofplastic/invswitcher/Store.java b/src/main/java/com/wasteofplastic/invswitcher/Store.java index 9454697..89c0c37 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/Store.java +++ b/src/main/java/com/wasteofplastic/invswitcher/Store.java @@ -676,4 +676,134 @@ private static void setTotalExperience(final Player player, final int exp) public void saveOnShutdown() { Bukkit.getOnlinePlayers().forEach(p -> this.storeAndSave(p, p.getWorld(), true)); } + + /** + * Compute the storage key for a player and island event world, without using + * the player's current location. Used when the player is not in the target world. + * @param player - player + * @param world - the BentoBox event world + * @param island - the island involved in the event (may be null) + * @return storage key for this world/island combination + */ + String getStorageKeyForEvent(Player player, World world, Island island) { + String overworldName = getOverworldName(world); + if (!addon.getSettings().isIslandsActive()) { + return overworldName; + } + World overworld = Util.getWorld(world); + if (overworld == null) { + return overworldName; + } + int count = addon.getIslands().getNumberOfConcurrentIslands(player.getUniqueId(), overworld); + if (count <= 1) { + return overworldName; + } + // Only use island-specific key if the player owns the island + if (island != null && island.getOwner() != null && island.getOwner().equals(player.getUniqueId())) { + return overworldName + "/" + island.getUniqueId(); + } + return overworldName; + } + + /** + * Clears the stored inventory for a BentoBox world when the player is not currently in + * that world. Called when BentoBox fires a {@code PlayerResetInventoryEvent} while the + * player is in a non-BentoBox world so the player's current inventory is not affected. + * @param player - online player + * @param world - the BentoBox world whose stored inventory should be cleared + * @param island - the island involved in the reset (may be null) + */ + public void clearStoredInventoryForWorld(Player player, World world, Island island) { + InventoryStorage store = getInv(player); + String key = getStorageKeyForEvent(player, world, island); + String worldKey = getOverworldName(world); + Settings settings = addon.getSettings(); + if (settings.isInventory()) { + String k = settings.isIslandsInventory() ? key : worldKey; + store.setInventory(k, Collections.emptyList()); + } + database.saveObjectAsync(store); + } + + /** + * Clears the stored ender chest for a BentoBox world when the player is not currently in + * that world. Called when BentoBox fires a {@code PlayerResetEnderChestEvent} while the + * player is in a non-BentoBox world. + * @param player - online player + * @param world - the BentoBox world whose stored ender chest should be cleared + * @param island - the island involved in the reset (may be null) + */ + public void clearStoredEnderChestForWorld(Player player, World world, Island island) { + InventoryStorage store = getInv(player); + String key = getStorageKeyForEvent(player, world, island); + String worldKey = getOverworldName(world); + Settings settings = addon.getSettings(); + if (settings.isEnderChest()) { + String k = settings.isIslandsEnderChest() ? key : worldKey; + store.setEnderChest(k, Collections.emptyList()); + } + database.saveObjectAsync(store); + } + + /** + * Zeroes the stored experience for a BentoBox world when the player is not currently in + * that world. Called when BentoBox fires a {@code PlayerResetExpEvent} while the + * player is in a non-BentoBox world. + * @param player - online player + * @param world - the BentoBox world whose stored experience should be zeroed + * @param island - the island involved in the reset (may be null) + */ + public void clearStoredExpForWorld(Player player, World world, Island island) { + InventoryStorage store = getInv(player); + String key = getStorageKeyForEvent(player, world, island); + String worldKey = getOverworldName(world); + Settings settings = addon.getSettings(); + if (settings.isExperience()) { + String k = settings.isIslandsExperience() ? key : worldKey; + store.setExp(k, 0); + } + database.saveObjectAsync(store); + } + + /** + * Removes the stored health for a BentoBox world when the player is not currently in + * that world. Called when BentoBox fires a {@code PlayerResetHealthEvent} while the + * player is in a non-BentoBox world. Removing the entry means the player will receive + * maximum health the next time they enter the world. + * @param player - online player + * @param world - the BentoBox world whose stored health should be removed + * @param island - the island involved in the reset (may be null) + */ + public void clearStoredHealthForWorld(Player player, World world, Island island) { + InventoryStorage store = getInv(player); + String key = getStorageKeyForEvent(player, world, island); + String worldKey = getOverworldName(world); + Settings settings = addon.getSettings(); + if (settings.isHealth()) { + String k = settings.isIslandsHealth() ? key : worldKey; + store.getHealth().remove(k); + } + database.saveObjectAsync(store); + } + + /** + * Resets the stored food level to full (20) for a BentoBox world when the player is not + * currently in that world. Called when BentoBox fires a {@code PlayerResetHungerEvent} + * while the player is in a non-BentoBox world. + * @param player - online player + * @param world - the BentoBox world whose stored food level should be reset + * @param island - the island involved in the reset (may be null) + */ + public void clearStoredFoodForWorld(Player player, World world, Island island) { + InventoryStorage store = getInv(player); + String key = getStorageKeyForEvent(player, world, island); + String worldKey = getOverworldName(world); + Settings settings = addon.getSettings(); + if (settings.isFood()) { + String k = settings.isIslandsFood() ? key : worldKey; + store.setFood(k, 20); + } + database.saveObjectAsync(store); + } + } diff --git a/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java b/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java index 97110c5..b404d40 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java +++ b/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java @@ -17,6 +17,12 @@ import com.wasteofplastic.invswitcher.InvSwitcher; import world.bentobox.bentobox.api.events.island.IslandEnterEvent; +import world.bentobox.bentobox.api.events.player.PlayerBaseEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetEnderChestEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetExpEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetHealthEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetHungerEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetInventoryEvent; import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.util.Util; @@ -183,5 +189,100 @@ public void onPlayerQuit(final PlayerQuitEvent event) { addon.getStore().removeFromCache(event.getPlayer()); } + /** + * Intercepts BentoBox's inventory reset when the player is not in the BentoBox world. + * Cancels the direct clear and instead wipes the stored inventory data for that world. + * @param event - event + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onPlayerResetInventory(PlayerResetInventoryEvent event) { + if (!shouldInterceptPlayerReset(event)) return; + event.setCancelled(true); + Player player = Bukkit.getPlayer(event.getPlayerUUID()); + if (player != null) { + addon.getStore().clearStoredInventoryForWorld(player, event.getWorld(), event.getIsland()); + } + } + + /** + * Intercepts BentoBox's ender chest reset when the player is not in the BentoBox world. + * Cancels the direct clear and instead wipes the stored ender chest data for that world. + * @param event - event + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onPlayerResetEnderChest(PlayerResetEnderChestEvent event) { + if (!shouldInterceptPlayerReset(event)) return; + event.setCancelled(true); + Player player = Bukkit.getPlayer(event.getPlayerUUID()); + if (player != null) { + addon.getStore().clearStoredEnderChestForWorld(player, event.getWorld(), event.getIsland()); + } + } + + /** + * Intercepts BentoBox's experience reset when the player is not in the BentoBox world. + * Cancels the direct clear and instead zeroes the stored experience for that world. + * @param event - event + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onPlayerResetExp(PlayerResetExpEvent event) { + if (!shouldInterceptPlayerReset(event)) return; + event.setCancelled(true); + Player player = Bukkit.getPlayer(event.getPlayerUUID()); + if (player != null) { + addon.getStore().clearStoredExpForWorld(player, event.getWorld(), event.getIsland()); + } + } + + /** + * Intercepts BentoBox's health reset when the player is not in the BentoBox world. + * Cancels the direct reset and instead removes the stored health for that world + * (so the player receives max health when they next enter the world). + * @param event - event + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onPlayerResetHealth(PlayerResetHealthEvent event) { + if (!shouldInterceptPlayerReset(event)) return; + event.setCancelled(true); + Player player = Bukkit.getPlayer(event.getPlayerUUID()); + if (player != null) { + addon.getStore().clearStoredHealthForWorld(player, event.getWorld(), event.getIsland()); + } + } + + /** + * Intercepts BentoBox's hunger reset when the player is not in the BentoBox world. + * Cancels the direct reset and instead sets stored food to full (20) for that world. + * @param event - event + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onPlayerResetHunger(PlayerResetHungerEvent event) { + if (!shouldInterceptPlayerReset(event)) return; + event.setCancelled(true); + Player player = Bukkit.getPlayer(event.getPlayerUUID()); + if (player != null) { + addon.getStore().clearStoredFoodForWorld(player, event.getWorld(), event.getIsland()); + } + } + + /** + * Determines whether InvSwitcher should intercept a BentoBox player reset event. + * Returns true if the event's world is managed by InvSwitcher, the player is online, + * and the player is currently in a different world (not the event world). + * @param event - the reset event + * @return true if InvSwitcher should cancel the event and handle it itself + */ + private boolean shouldInterceptPlayerReset(PlayerBaseEvent event) { + World eventWorld = event.getWorld(); + if (!addon.getWorlds().contains(eventWorld)) { + return false; + } + Player player = Bukkit.getPlayer(event.getPlayerUUID()); + if (player == null) { + return false; + } + // Only intercept if the player is not currently in the event world + return !Util.sameWorld(player.getWorld(), eventWorld); + } } diff --git a/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java b/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java index 9cdb000..fbddcf0 100644 --- a/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java +++ b/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java @@ -645,4 +645,180 @@ public void testMigrationFromOldWorldKey() { verify(player.getInventory(), atLeastOnce()).setContents(any(ItemStack[].class)); } + // --- clearStoredXForWorld Tests --- + + /** + * When inventory is enabled and the event fires for a world the player is not in, + * the stored inventory for that world should be cleared. + * After clearing, loading inventory for that world should give empty contents. + */ + @Test + public void testClearStoredInventoryForWorld() { + sets.setStatistics(false); + sets.setAdvancements(false); + Island island = mock(Island.class); + // First save something in the store + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class, Mockito.RETURNS_MOCKS)) { + s.storeInventory(player, world); + assertTrue(s.isWorldStored(player, world), "Inventory should be stored before clearing"); + + // Clear stored inventory for the world + s.clearStoredInventoryForWorld(player, world, island); + + // isWorldStored returns true even after clearing (entry exists but is empty list) + assertTrue(s.isWorldStored(player, world)); + + // Load inventory back - should set empty contents to player + s.getInventory(player, world); + // setContents should have been called with empty array + verify(player.getInventory(), atLeastOnce()).setContents(any(ItemStack[].class)); + } + } + + /** + * When ender chest is enabled, clearStoredEnderChestForWorld should work without error. + */ + @Test + public void testClearStoredEnderChestForWorld() { + sets.setStatistics(false); + sets.setAdvancements(false); + Island island = mock(Island.class); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class, Mockito.RETURNS_MOCKS)) { + s.storeInventory(player, world); + // Should not throw + s.clearStoredEnderChestForWorld(player, world, island); + } + } + + /** + * When experience is enabled, clearStoredExpForWorld should zero out the stored exp. + */ + @Test + public void testClearStoredExpForWorld() { + sets.setStatistics(false); + sets.setAdvancements(false); + Island island = mock(Island.class); + when(player.getTotalExperience()).thenReturn(500); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class, Mockito.RETURNS_MOCKS)) { + s.storeInventory(player, world); + // Should not throw + s.clearStoredExpForWorld(player, world, island); + } + } + + /** + * When health is enabled, clearStoredHealthForWorld should work without error. + */ + @Test + public void testClearStoredHealthForWorld() { + sets.setStatistics(false); + sets.setAdvancements(false); + Island island = mock(Island.class); + when(player.getHealth()).thenReturn(10.0); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class, Mockito.RETURNS_MOCKS)) { + s.storeInventory(player, world); + // Should not throw + s.clearStoredHealthForWorld(player, world, island); + } + } + + /** + * When food is enabled, clearStoredFoodForWorld should work without error. + */ + @Test + public void testClearStoredFoodForWorld() { + sets.setStatistics(false); + sets.setAdvancements(false); + Island island = mock(Island.class); + when(player.getFoodLevel()).thenReturn(8); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class, Mockito.RETURNS_MOCKS)) { + s.storeInventory(player, world); + // Should not throw + s.clearStoredFoodForWorld(player, world, island); + } + } + + /** + * clearStoredInventoryForWorld should be a no-op when inventory is disabled in settings. + * No exceptions should be thrown. + */ + @Test + public void testClearStoredInventoryForWorldInventoryDisabled() { + sets.setInventory(false); + sets.setStatistics(false); + sets.setAdvancements(false); + Island island = mock(Island.class); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class, Mockito.RETURNS_MOCKS)) { + s.storeInventory(player, world); + // With inventory disabled, nothing is stored in the inventory map + assertFalse(s.isWorldStored(player, world)); + + // Calling clear should be a no-op (no exception, no effect) + s.clearStoredInventoryForWorld(player, world, island); + assertFalse(s.isWorldStored(player, world)); + } + } + + /** + * getStorageKeyForEvent should return the world name when islands mode is inactive. + */ + @Test + public void testGetStorageKeyForEventIslandsDisabled() { + sets.setIslandsActive(false); + Island island = mock(Island.class); + String key = s.getStorageKeyForEvent(player, world, island); + assertEquals("world", key); + } + + /** + * getStorageKeyForEvent should return world name when player has only 1 island. + */ + @Test + public void testGetStorageKeyForEventSingleIsland() { + sets.setIslandsActive(true); + Island island = mock(Island.class); + try (MockedStatic mockedUtil = mockStatic(Util.class)) { + mockedUtil.when(() -> Util.getWorld(world)).thenReturn(world); + when(islandsManager.getNumberOfConcurrentIslands(playerUUID, world)).thenReturn(1); + String key = s.getStorageKeyForEvent(player, world, island); + assertEquals("world", key); + } + } + + /** + * getStorageKeyForEvent should return island-specific key when player owns the island + * and has multiple islands. + */ + @Test + public void testGetStorageKeyForEventMultipleIslandsOwner() { + sets.setIslandsActive(true); + Island island = mock(Island.class); + when(island.getOwner()).thenReturn(playerUUID); + when(island.getUniqueId()).thenReturn("island-abc"); + try (MockedStatic mockedUtil = mockStatic(Util.class)) { + mockedUtil.when(() -> Util.getWorld(world)).thenReturn(world); + when(islandsManager.getNumberOfConcurrentIslands(playerUUID, world)).thenReturn(2); + String key = s.getStorageKeyForEvent(player, world, island); + assertEquals("world/island-abc", key); + } + } + + /** + * getStorageKeyForEvent should return just the world name when player does not own the island + * (e.g., kicked from a team). Uses the world-level key since the player is a member, not owner. + */ + @Test + public void testGetStorageKeyForEventMultipleIslandsNotOwner() { + sets.setIslandsActive(true); + Island island = mock(Island.class); + UUID otherOwner = UUID.randomUUID(); + when(island.getOwner()).thenReturn(otherOwner); // player is NOT the owner + try (MockedStatic mockedUtil = mockStatic(Util.class)) { + mockedUtil.when(() -> Util.getWorld(world)).thenReturn(world); + when(islandsManager.getNumberOfConcurrentIslands(playerUUID, world)).thenReturn(2); + String key = s.getStorageKeyForEvent(player, world, island); + assertEquals("world", key); // Falls back to world name + } + } + } diff --git a/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java b/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java index cd3f6b1..0c4c61f 100644 --- a/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java +++ b/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java @@ -1,6 +1,8 @@ package com.wasteofplastic.invswitcher.listeners; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +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.mockStatic; @@ -37,6 +39,11 @@ import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.events.island.IslandEnterEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetEnderChestEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetExpEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetHealthEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetHungerEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetInventoryEvent; import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.managers.IslandsManager; import world.bentobox.bentobox.util.Util; @@ -64,6 +71,8 @@ public class PlayerListenerTest { private Settings settings; @Mock private IslandsManager islandsManager; + @Mock + private Island island; private UUID playerUUID; private MockedStatic mockedBentoBox; @@ -353,4 +362,159 @@ public void testOnPlayerRespawnNoIsland() { verify(store, never()).storeAndSave(any(), any(), any(boolean.class)); } + // --- Player Reset Event Tests --- + + /** + * When the event world is not managed by InvSwitcher, the event should not be intercepted + * and the store clear methods should not be called. + */ + @Test + public void testOnPlayerResetInventoryWorldNotManaged() { + // notWorld is not in the addon's worlds set + when(addon.getWorlds()).thenReturn(Set.of(world)); // only 'world' is managed + PlayerResetInventoryEvent event = new PlayerResetInventoryEvent(notWorld, island, playerUUID); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) { + mockedBukkit.when(() -> Bukkit.getPlayer(playerUUID)).thenReturn(player); + pl.onPlayerResetInventory(event); + } + assertFalse(event.isCancelled(), "Event should not be cancelled when world is not managed"); + verify(store, never()).clearStoredInventoryForWorld(any(), any(), any()); + } + + /** + * When the player is offline, the event should not be intercepted. + */ + @Test + public void testOnPlayerResetInventoryPlayerOffline() { + PlayerResetInventoryEvent event = new PlayerResetInventoryEvent(world, island, playerUUID); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) { + mockedBukkit.when(() -> Bukkit.getPlayer(playerUUID)).thenReturn(null); // offline + pl.onPlayerResetInventory(event); + } + assertFalse(event.isCancelled(), "Event should not be cancelled when player is offline"); + verify(store, never()).clearStoredInventoryForWorld(any(), any(), any()); + } + + /** + * When the player is currently in the event world, BentoBox should handle the reset directly. + */ + @Test + public void testOnPlayerResetInventoryPlayerInEventWorld() { + // player.getWorld() returns 'world', event world is also 'world' + when(player.getWorld()).thenReturn(world); + PlayerResetInventoryEvent event = new PlayerResetInventoryEvent(world, island, playerUUID); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class); + MockedStatic mockedUtil = mockStatic(Util.class)) { + mockedBukkit.when(() -> Bukkit.getPlayer(playerUUID)).thenReturn(player); + mockedUtil.when(() -> Util.sameWorld(world, world)).thenReturn(true); + pl.onPlayerResetInventory(event); + } + assertFalse(event.isCancelled(), "Event should not be cancelled when player is in event world"); + verify(store, never()).clearStoredInventoryForWorld(any(), any(), any()); + } + + /** + * When the player is in a non-BentoBox world, the inventory reset event should be cancelled + * and the stored inventory for the BentoBox world should be cleared. + */ + @Test + public void testOnPlayerResetInventoryPlayerInDifferentWorld() { + // player is in notWorld, event fires for world + when(player.getWorld()).thenReturn(notWorld); + PlayerResetInventoryEvent event = new PlayerResetInventoryEvent(world, island, playerUUID); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class); + MockedStatic mockedUtil = mockStatic(Util.class)) { + mockedBukkit.when(() -> Bukkit.getPlayer(playerUUID)).thenReturn(player); + mockedUtil.when(() -> Util.sameWorld(notWorld, world)).thenReturn(false); + pl.onPlayerResetInventory(event); + } + assertTrue(event.isCancelled(), "Event should be cancelled when player is in a different world"); + verify(store).clearStoredInventoryForWorld(player, world, island); + } + + /** + * Ender chest reset should be intercepted when the player is in a different world. + */ + @Test + public void testOnPlayerResetEnderChestPlayerInDifferentWorld() { + when(player.getWorld()).thenReturn(notWorld); + PlayerResetEnderChestEvent event = new PlayerResetEnderChestEvent(world, island, playerUUID); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class); + MockedStatic mockedUtil = mockStatic(Util.class)) { + mockedBukkit.when(() -> Bukkit.getPlayer(playerUUID)).thenReturn(player); + mockedUtil.when(() -> Util.sameWorld(notWorld, world)).thenReturn(false); + pl.onPlayerResetEnderChest(event); + } + assertTrue(event.isCancelled(), "Event should be cancelled when player is in a different world"); + verify(store).clearStoredEnderChestForWorld(player, world, island); + } + + /** + * Ender chest reset should not be intercepted when the player is in the event world. + */ + @Test + public void testOnPlayerResetEnderChestPlayerInEventWorld() { + when(player.getWorld()).thenReturn(world); + PlayerResetEnderChestEvent event = new PlayerResetEnderChestEvent(world, island, playerUUID); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class); + MockedStatic mockedUtil = mockStatic(Util.class)) { + mockedBukkit.when(() -> Bukkit.getPlayer(playerUUID)).thenReturn(player); + mockedUtil.when(() -> Util.sameWorld(world, world)).thenReturn(true); + pl.onPlayerResetEnderChest(event); + } + assertFalse(event.isCancelled()); + verify(store, never()).clearStoredEnderChestForWorld(any(), any(), any()); + } + + /** + * Experience reset should be intercepted when the player is in a different world. + */ + @Test + public void testOnPlayerResetExpPlayerInDifferentWorld() { + when(player.getWorld()).thenReturn(notWorld); + PlayerResetExpEvent event = new PlayerResetExpEvent(world, island, playerUUID); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class); + MockedStatic mockedUtil = mockStatic(Util.class)) { + mockedBukkit.when(() -> Bukkit.getPlayer(playerUUID)).thenReturn(player); + mockedUtil.when(() -> Util.sameWorld(notWorld, world)).thenReturn(false); + pl.onPlayerResetExp(event); + } + assertTrue(event.isCancelled(), "Event should be cancelled when player is in a different world"); + verify(store).clearStoredExpForWorld(player, world, island); + } + + /** + * Health reset should be intercepted when the player is in a different world. + */ + @Test + public void testOnPlayerResetHealthPlayerInDifferentWorld() { + when(player.getWorld()).thenReturn(notWorld); + PlayerResetHealthEvent event = new PlayerResetHealthEvent(world, island, playerUUID); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class); + MockedStatic mockedUtil = mockStatic(Util.class)) { + mockedBukkit.when(() -> Bukkit.getPlayer(playerUUID)).thenReturn(player); + mockedUtil.when(() -> Util.sameWorld(notWorld, world)).thenReturn(false); + pl.onPlayerResetHealth(event); + } + assertTrue(event.isCancelled(), "Event should be cancelled when player is in a different world"); + verify(store).clearStoredHealthForWorld(player, world, island); + } + + /** + * Hunger reset should be intercepted when the player is in a different world. + */ + @Test + public void testOnPlayerResetHungerPlayerInDifferentWorld() { + when(player.getWorld()).thenReturn(notWorld); + PlayerResetHungerEvent event = new PlayerResetHungerEvent(world, island, playerUUID); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class); + MockedStatic mockedUtil = mockStatic(Util.class)) { + mockedBukkit.when(() -> Bukkit.getPlayer(playerUUID)).thenReturn(player); + mockedUtil.when(() -> Util.sameWorld(notWorld, world)).thenReturn(false); + pl.onPlayerResetHunger(event); + } + assertTrue(event.isCancelled(), "Event should be cancelled when player is in a different world"); + verify(store).clearStoredFoodForWorld(player, world, island); + } + } From 6bb28d34d4d223732d900ce83e9fe0503a3025b8 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 29 May 2026 21:14:11 -0700 Subject: [PATCH 07/15] feat: per-world economy via Vault provider InvSwitcher now registers itself as the Vault Economy provider at Highest priority and keeps a separate balance per switched world. Transactions route to the correct world's balance even when the player is offline or in another world, fixing cross-world shop sales. Worlds InvSwitcher does not manage are delegated to the previously-registered economy (e.g. EssentialsX/EssentialsC). - InventoryStorage: add per-world `money` map, persisted `lastKey` (offline routing) and `imported` flag. - economy/InvEconomy: full Economy implementation with world-aware and non-world-aware routing, delegation, one-time balance import, starting balance, and a setBalance helper. - Store: money key resolution (getMoneyKey/getCurrentMoneyKey), online/offline storage access, and clearStoredMoneyForWorld. - InvSwitcher: capture delegate + register provider (deferred a tick), run commands/placeholders, and refresh BentoBox's VaultHook so addons like Bank route through us instead of the stale early-captured economy. - PlayerListener: intercept PlayerResetMoneyEvent when the player is not in the event world, zeroing the correct world's stored balance. - Commands: / balance, / pay, admin / eco give|take|set|balance, plus balance placeholders and en-US locale. - Settings/config: options.money, options.islands.money and an economy block. - Tests: InvEconomyTest plus Store and PlayerListener coverage (118 total). Co-Authored-By: Claude Opus 4.8 --- pom.xml | 14 + .../invswitcher/InvSwitcher.java | 91 ++++ .../wasteofplastic/invswitcher/PhManager.java | 50 ++ .../wasteofplastic/invswitcher/Settings.java | 87 ++++ .../com/wasteofplastic/invswitcher/Store.java | 106 ++++- .../commands/AbstractMoneyCommand.java | 63 +++ .../admin/AbstractAdminMoneyCommand.java | 33 ++ .../commands/admin/AdminBalanceCommand.java | 48 ++ .../commands/admin/AdminGiveCommand.java | 53 +++ .../commands/admin/AdminMoneyCommand.java | 36 ++ .../commands/admin/AdminSetCommand.java | 53 +++ .../commands/admin/AdminTakeCommand.java | 58 +++ .../commands/user/BalanceCommand.java | 41 ++ .../invswitcher/commands/user/PayCommand.java | 76 ++++ .../dataobjects/InventoryStorage.java | 94 ++++ .../invswitcher/economy/InvEconomy.java | 428 ++++++++++++++++++ .../invswitcher/listeners/PlayerListener.java | 24 + src/main/resources/config.yml | 24 + src/main/resources/locales/en-US.yml | 38 ++ .../wasteofplastic/invswitcher/StoreTest.java | 37 ++ .../invswitcher/economy/InvEconomyTest.java | 266 +++++++++++ .../listeners/PlayerListenerTest.java | 51 +++ 22 files changed, 1770 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/wasteofplastic/invswitcher/PhManager.java create mode 100644 src/main/java/com/wasteofplastic/invswitcher/commands/AbstractMoneyCommand.java create mode 100644 src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminMoneyCommand.java create mode 100644 src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminBalanceCommand.java create mode 100644 src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminGiveCommand.java create mode 100644 src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminMoneyCommand.java create mode 100644 src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminSetCommand.java create mode 100644 src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminTakeCommand.java create mode 100644 src/main/java/com/wasteofplastic/invswitcher/commands/user/BalanceCommand.java create mode 100644 src/main/java/com/wasteofplastic/invswitcher/commands/user/PayCommand.java create mode 100644 src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java create mode 100644 src/main/resources/locales/en-US.yml create mode 100644 src/test/java/com/wasteofplastic/invswitcher/economy/InvEconomyTest.java diff --git a/pom.xml b/pom.xml index cf83be3..0c1bbd1 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,7 @@ 1.21.11-R0.1-SNAPSHOT 3.17.0-SNAPSHOT + 1.7 ${build.version}-SNAPSHOT @@ -126,6 +127,11 @@ papermc https://repo.papermc.io/repository/maven-public/ + + + jitpack.io + https://jitpack.io + @@ -188,6 +194,14 @@ ${bentobox.version} provided + + + + com.github.MilkBowl + VaultAPI + ${vault.version} + provided + diff --git a/src/main/java/com/wasteofplastic/invswitcher/InvSwitcher.java b/src/main/java/com/wasteofplastic/invswitcher/InvSwitcher.java index 960e846..64d9753 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/InvSwitcher.java +++ b/src/main/java/com/wasteofplastic/invswitcher/InvSwitcher.java @@ -8,12 +8,20 @@ import org.bukkit.Bukkit; import org.bukkit.World; +import org.bukkit.plugin.RegisteredServiceProvider; +import org.bukkit.plugin.ServicePriority; +import com.wasteofplastic.invswitcher.commands.admin.AdminMoneyCommand; +import com.wasteofplastic.invswitcher.commands.user.BalanceCommand; +import com.wasteofplastic.invswitcher.commands.user.PayCommand; +import com.wasteofplastic.invswitcher.economy.InvEconomy; import com.wasteofplastic.invswitcher.listeners.PlayerListener; +import net.milkbowl.vault.economy.Economy; import world.bentobox.bentobox.api.addons.Addon; import world.bentobox.bentobox.api.configuration.Config; import world.bentobox.bentobox.database.DatabaseSetup.DatabaseType; +import world.bentobox.bentobox.hooks.VaultHook; /** * Inventory switcher for worlds. Switches advancements too. @@ -31,6 +39,8 @@ public class InvSwitcher extends Addon { private Set worlds = new HashSet<>(); + private InvEconomy economy; + @Override public void onLoad() { // Save default config.yml @@ -74,6 +84,64 @@ public void allLoaded() { store = new Store(this); // Register the listeners registerListener(new PlayerListener(this)); + // Set up the per-world economy. Deferred by a tick so that the underlying economy + // plugin (e.g. EssentialsX) has finished registering its own provider with Vault, + // letting us capture it as the delegate for unmanaged worlds. + if (settings.isMoney()) { + if (Bukkit.getPluginManager().getPlugin("Vault") == null) { + logError("options.money is enabled but the Vault plugin is not installed - per-world money disabled."); + } else { + Bukkit.getScheduler().runTask(getPlugin(), this::setupEconomy); + } + } + } + + /** + * Captures the previously-registered Vault economy (to delegate unmanaged worlds to) and + * registers InvSwitcher's own per-world economy at the highest priority so it intercepts + * every economy call. Runs once. + */ + private void setupEconomy() { + if (economy != null) { + return; + } + // Capture the existing provider BEFORE we register ourselves, skipping our own type + // so a re-run can never capture itself into a delegation loop. + Economy delegate = null; + RegisteredServiceProvider rsp = Bukkit.getServicesManager().getRegistration(Economy.class); + if (rsp != null && !(rsp.getProvider() instanceof InvEconomy)) { + delegate = rsp.getProvider(); + } + if (delegate == null) { + logWarning("No previous economy was found - InvSwitcher will be the only economy. " + + "Worlds it does not manage will share a single balance."); + } else { + log("Per-world economy enabled - delegating unmanaged worlds to " + delegate.getName()); + } + economy = new InvEconomy(this, delegate); + Bukkit.getServicesManager().register(Economy.class, economy, getPlugin(), ServicePriority.Highest); + + // BentoBox captured its VaultHook during early hook registration, before we registered - + // so it (and addons that use it, e.g. Bank, Level, Upgrades) still points at the previous + // economy. Re-run the hook so it re-reads the now-highest provider (us). The VaultHook is a + // single shared instance held by those addons, so refreshing it updates them too. + refreshBentoBoxVaultHook(); + + // Register commands and placeholders with the game modes whose worlds we manage + PhManager phManager = new PhManager(this); + getPlugin().getAddonsManager().getGameModeAddons().stream() + .filter(gm -> worlds.contains(gm.getOverWorld())) + .forEach(gm -> { + gm.getPlayerCommand().ifPresent(pc -> { + new BalanceCommand(this, pc); + new PayCommand(this, pc); + }); + gm.getAdminCommand().ifPresent(ac -> new AdminMoneyCommand(this, ac)); + if (!phManager.registerPlaceholders(gm)) { + logWarning("Could not register economy placeholders - no PlaceholderManager available."); + } + log("Per-world economy hooking into " + gm.getDescription().getName()); + }); } @Override @@ -88,6 +156,13 @@ public void onEnable() { @Override public void onDisable() { + // Unregister our economy so a reload does not stack providers + if (economy != null) { + Bukkit.getServicesManager().unregister(Economy.class, economy); + economy = null; + // Re-point BentoBox's VaultHook at whatever economy remains (e.g. EssentialsC) + refreshBentoBoxVaultHook(); + } // save cache if (store != null) { getStore().saveOnShutdown(); @@ -95,6 +170,22 @@ public void onDisable() { } + /** + * Re-runs BentoBox's VaultHook so it re-reads the highest-priority economy currently + * registered with the services manager. BentoBox addons such as Bank hold this same hook + * instance, so they pick up the change without needing to re-hook themselves. + */ + private void refreshBentoBoxVaultHook() { + getPlugin().getVault().ifPresent(VaultHook::hook); + } + + /** + * @return the per-world economy, or null if money is disabled or Vault is absent + */ + public InvEconomy getEconomy() { + return economy; + } + /** * @return the store diff --git a/src/main/java/com/wasteofplastic/invswitcher/PhManager.java b/src/main/java/com/wasteofplastic/invswitcher/PhManager.java new file mode 100644 index 0000000..86d3e9b --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/PhManager.java @@ -0,0 +1,50 @@ +package com.wasteofplastic.invswitcher; + +import com.wasteofplastic.invswitcher.economy.InvEconomy; + +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.api.user.User; + +/** + * Registers PlaceholderAPI placeholders for a player's per-world balance. + * @author tastybento + */ +public class PhManager { + + private final BentoBox plugin; + private final InvSwitcher addon; + + public PhManager(InvSwitcher addon) { + this.addon = addon; + this.plugin = addon.getPlugin(); + } + + /** + * Register the balance placeholders for a game mode. + * @param gm - game mode addon + * @return true if registered, false if no placeholder manager is available + */ + public boolean registerPlaceholders(GameModeAddon gm) { + if (plugin.getPlaceholdersManager() == null) { + return false; + } + String prefix = gm.getDescription().getName().toLowerCase() + "_invswitcher_"; + // Raw balance number for the player's current world + plugin.getPlaceholdersManager().registerPlaceholder(addon, prefix + "balance", + user -> balance(user, false)); + // Formatted balance (currency name + decimals) for the player's current world + plugin.getPlaceholdersManager().registerPlaceholder(addon, prefix + "balance_formatted", + user -> balance(user, true)); + return true; + } + + private String balance(User user, boolean formatted) { + InvEconomy eco = addon.getEconomy(); + if (eco == null || user == null || !user.isPlayer()) { + return ""; + } + double bal = eco.getBalance(user.getPlayer()); + return formatted ? eco.format(bal) : String.valueOf(bal); + } +} diff --git a/src/main/java/com/wasteofplastic/invswitcher/Settings.java b/src/main/java/com/wasteofplastic/invswitcher/Settings.java index 6d57227..be2944d 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/Settings.java +++ b/src/main/java/com/wasteofplastic/invswitcher/Settings.java @@ -34,6 +34,13 @@ public class Settings implements ConfigObject { private boolean enderChest = true; @ConfigEntry(path = "options.statistics") private boolean statistics = true; + @ConfigComment("Per-world money. Requires the Vault plugin and an economy plugin (e.g. EssentialsX).") + @ConfigComment("When enabled, InvSwitcher registers itself as the Vault economy and keeps a separate") + @ConfigComment("balance for each switched world. Transactions route to the correct world even when the") + @ConfigComment("player is offline or in a different world. Worlds InvSwitcher does not manage are passed") + @ConfigComment("through to the previous economy plugin.") + @ConfigEntry(path = "options.money") + private boolean money = true; @ConfigComment("Switch inventories based on island. Only applies if players own more than one island.") @ConfigComment("Each sub-option controls whether that aspect is switched per-island.") @@ -56,6 +63,32 @@ public class Settings implements ConfigObject { private boolean islandsEnderChest = true; @ConfigEntry(path = "options.islands.statistics") private boolean islandsStatistics = false; + @ConfigComment("If true, each of a player's islands has its own wallet. If false (default) money is") + @ConfigComment("per-world only. Per-island money is best-effort for offline players.") + @ConfigEntry(path = "options.islands.money") + private boolean islandsMoney = false; + + @ConfigComment("") + @ConfigComment("Economy settings. Only used when options.money is true.") + @ConfigComment("Balance given to a player the first time they enter a managed world (unless imported).") + @ConfigEntry(path = "economy.starting-balance") + private double startingBalance = 0.0; + @ConfigComment("Currency names used when formatting amounts.") + @ConfigEntry(path = "economy.currency-name-singular") + private String currencyNameSingular = "Dollar"; + @ConfigEntry(path = "economy.currency-name-plural") + private String currencyNamePlural = "Dollars"; + @ConfigComment("Number of digits after the decimal point.") + @ConfigEntry(path = "economy.fractional-digits") + private int fractionalDigits = 2; + @ConfigComment("Import each player's existing balance from the previous economy plugin once, the first") + @ConfigComment("time they enter a managed world, so nobody loses money on first run.") + @ConfigEntry(path = "economy.import-existing-balances") + private boolean importExistingBalances = true; + @ConfigComment("Pass transactions for worlds InvSwitcher does not manage through to the previous economy") + @ConfigComment("plugin (e.g. EssentialsX). Disable to make InvSwitcher the sole economy for every world.") + @ConfigEntry(path = "economy.delegate-unmanaged-worlds") + private boolean delegateUnmanagedWorlds = true; /** * @return the worlds @@ -225,5 +258,59 @@ public boolean isIslandsStatistics() { public void setIslandsStatistics(boolean islandsStatistics) { this.islandsStatistics = islandsStatistics; } + /** + * @return whether per-world money is enabled + */ + public boolean isMoney() { + return money; + } + public void setMoney(boolean money) { + this.money = money; + } + /** + * @return whether money is switched per-island + */ + public boolean isIslandsMoney() { + return islandsMoney; + } + public void setIslandsMoney(boolean islandsMoney) { + this.islandsMoney = islandsMoney; + } + public double getStartingBalance() { + return startingBalance; + } + public void setStartingBalance(double startingBalance) { + this.startingBalance = startingBalance; + } + public String getCurrencyNameSingular() { + return currencyNameSingular; + } + public void setCurrencyNameSingular(String currencyNameSingular) { + this.currencyNameSingular = currencyNameSingular; + } + public String getCurrencyNamePlural() { + return currencyNamePlural; + } + public void setCurrencyNamePlural(String currencyNamePlural) { + this.currencyNamePlural = currencyNamePlural; + } + public int getFractionalDigits() { + return fractionalDigits; + } + public void setFractionalDigits(int fractionalDigits) { + this.fractionalDigits = fractionalDigits; + } + public boolean isImportExistingBalances() { + return importExistingBalances; + } + public void setImportExistingBalances(boolean importExistingBalances) { + this.importExistingBalances = importExistingBalances; + } + public boolean isDelegateUnmanagedWorlds() { + return delegateUnmanagedWorlds; + } + public void setDelegateUnmanagedWorlds(boolean delegateUnmanagedWorlds) { + this.delegateUnmanagedWorlds = delegateUnmanagedWorlds; + } } diff --git a/src/main/java/com/wasteofplastic/invswitcher/Store.java b/src/main/java/com/wasteofplastic/invswitcher/Store.java index 89c0c37..1e6e7c7 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/Store.java +++ b/src/main/java/com/wasteofplastic/invswitcher/Store.java @@ -39,6 +39,7 @@ import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.OfflinePlayer; import org.bukkit.Registry; import org.bukkit.Statistic; import org.bukkit.World; @@ -65,7 +66,7 @@ public class Store { private static final CharSequence THE_END = "_the_end"; private static final CharSequence NETHER = "_nether"; - static final String DEFAULT_WORLD_KEY = "default"; + public static final String DEFAULT_WORLD_KEY = "default"; private final Database database; private final Map cache; private final Map currentKey; @@ -410,6 +411,9 @@ public void storeAndSave(Player player, World world, boolean shutdown) { // otherwise compute from location String islandKey = currentKey.getOrDefault(player.getUniqueId(), getStorageKey(player, world)); String worldKey = getOverworldName(world); + // Persist the key so economy transactions for this player can be routed to the + // world they were last in, even after they log out. + store.setLastKey(islandKey); // Each option saves to the island key or the world key based on its island sub-setting Settings settings = addon.getSettings(); if (settings.isInventory()) { @@ -806,4 +810,104 @@ public void clearStoredFoodForWorld(Player player, World world, Island island) { database.saveObjectAsync(store); } + /** + * Zeroes the stored money balance for a BentoBox world when the player is not currently in + * that world. Called when BentoBox fires a {@code PlayerResetMoneyEvent} (e.g. on island + * reset) while the player is in a non-BentoBox world, so the correct world's balance is + * cleared rather than BentoBox withdrawing from the wrong world. + * @param player - online player + * @param world - the BentoBox world whose stored balance should be zeroed + * @param island - the island involved in the reset (may be null) + */ + public void clearStoredMoneyForWorld(Player player, World world, Island island) { + InventoryStorage store = getInv(player); + String key = getStorageKeyForEvent(player, world, island); + String worldKey = getOverworldName(world); + Settings settings = addon.getSettings(); + if (settings.isMoney()) { + String k = settings.isIslandsMoney() ? key : worldKey; + store.setMoney(k, 0D); + } + database.saveObjectAsync(store); + } + + // ------ ECONOMY SUPPORT ------ + + /** + * Get the {@link InventoryStorage} for any player UUID, online or offline. For online + * players this returns the cached session object so economy changes stay consistent + * with the rest of their data. For offline players a transient copy is loaded from the + * database and is deliberately not cached, so a later login reloads fresh data. + * @param uuid - player UUID + * @return the player's inventory storage (never null) + */ + public InventoryStorage getStorageObject(UUID uuid) { + if (cache.containsKey(uuid)) { + return cache.get(uuid); + } + if (database.objectExists(uuid.toString())) { + InventoryStorage store = database.loadObject(uuid.toString()); + if (store != null) { + return store; + } + } + InventoryStorage store = new InventoryStorage(); + store.setUniqueId(uuid.toString()); + return store; + } + + /** + * Persist a storage object asynchronously. + * @param store - storage to save + */ + public void saveStorage(InventoryStorage store) { + database.saveObjectAsync(store); + } + + /** + * Resolve the money storage key for a player in a specific world. + * @param player - player (online or offline) + * @param world - world to resolve the key for + * @return the money key, or {@code null} if the world is not managed by InvSwitcher + */ + public String getMoneyKey(OfflinePlayer player, World world) { + if (world == null || !addon.getWorlds().contains(world)) { + return null; + } + String overworldName = getOverworldName(world); + Settings settings = addon.getSettings(); + if (!settings.isIslandsActive() || !settings.isIslandsMoney()) { + return overworldName; + } + // Best-effort per-island routing + Player online = player.getPlayer(); + if (online != null && world.equals(online.getWorld())) { + return getStorageKey(online, world); + } + // Offline or in another world: fall back to the player's last island key for this overworld + String last = getStorageObject(player.getUniqueId()).getLastKey(); + if (last != null && last.startsWith(overworldName + "/")) { + return last; + } + return overworldName; + } + + /** + * Resolve the money storage key for a player's current (online) or last-known (offline) + * world, used when a caller does not specify a world. + * @param player - player (online or offline) + * @return the money key, or {@code null} if it cannot be resolved to a managed world + */ + public String getCurrentMoneyKey(OfflinePlayer player) { + Player online = player.getPlayer(); + if (online != null) { + return getMoneyKey(player, online.getWorld()); + } + String last = getStorageObject(player.getUniqueId()).getLastKey(); + if (last == null || DEFAULT_WORLD_KEY.equals(last)) { + return null; + } + return last; + } + } diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/AbstractMoneyCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/AbstractMoneyCommand.java new file mode 100644 index 0000000..b6ac292 --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/AbstractMoneyCommand.java @@ -0,0 +1,63 @@ +package com.wasteofplastic.invswitcher.commands; + +import com.wasteofplastic.invswitcher.InvSwitcher; +import com.wasteofplastic.invswitcher.economy.InvEconomy; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; + +/** + * Shared base for InvSwitcher's economy commands. + * @author tastybento + */ +public abstract class AbstractMoneyCommand extends CompositeCommand { + + protected final InvSwitcher addon; + + /** + * Constructor for a top-level command registered into a game mode's command tree. The + * InvSwitcher addon must be passed explicitly, otherwise the command would inherit the + * game mode addon from its parent. + */ + protected AbstractMoneyCommand(InvSwitcher addon, CompositeCommand parent, String label, String... aliases) { + super(addon, parent, label, aliases); + this.addon = getAddon(); + } + + /** + * Constructor for a nested sub-command; inherits the addon from its parent. + */ + protected AbstractMoneyCommand(CompositeCommand parent, String label, String... aliases) { + super(parent, label, aliases); + this.addon = getAddon(); + } + + /** + * @return the per-world economy, or null if money is disabled or Vault is absent + */ + protected InvEconomy economy() { + return addon.getEconomy(); + } + + /** + * Parse a strictly-positive money amount, sending an error message on failure. + * @param user - command sender + * @param arg - the argument to parse + * @return the amount, or null if it could not be parsed or was not positive + */ + protected Double parseAmount(User user, String arg) { + double amount; + try { + amount = Double.parseDouble(arg); + } catch (NumberFormatException e) { + user.sendMessage("invswitcher.errors.not-a-number", TextVariables.NUMBER, arg); + return null; + } + if (amount <= 0 || Double.isNaN(amount) || Double.isInfinite(amount)) { + user.sendMessage("invswitcher.errors.must-be-positive"); + return null; + } + return amount; + } +} diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminMoneyCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminMoneyCommand.java new file mode 100644 index 0000000..438e374 --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminMoneyCommand.java @@ -0,0 +1,33 @@ +package com.wasteofplastic.invswitcher.commands.admin; + +import com.wasteofplastic.invswitcher.commands.AbstractMoneyCommand; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; + +/** + * Shared base for admin economy sub-commands of the form {@code }. + * @author tastybento + */ +public abstract class AbstractAdminMoneyCommand extends AbstractMoneyCommand { + + protected AbstractAdminMoneyCommand(CompositeCommand parent, String label, String... aliases) { + super(parent, label, aliases); + } + + /** + * Resolve a target player by name, sending an error message if unknown. + * @param user - command sender + * @param name - player name + * @return the target user, or null if unknown + */ + protected User resolveTarget(User user, String name) { + User target = getPlayers().getUser(name); + if (target == null || target.getUniqueId() == null) { + user.sendMessage("general.errors.unknown-player", TextVariables.NAME, name); + return null; + } + return target; + } +} diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminBalanceCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminBalanceCommand.java new file mode 100644 index 0000000..8e7f89a --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminBalanceCommand.java @@ -0,0 +1,48 @@ +package com.wasteofplastic.invswitcher.commands.admin; + +import java.util.List; + +import com.wasteofplastic.invswitcher.economy.InvEconomy; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; + +/** + * Shows an admin another player's balance for the world that player is currently in (or was last in). + * @author tastybento + */ +public class AdminBalanceCommand extends AbstractAdminMoneyCommand { + + public AdminBalanceCommand(CompositeCommand parent) { + super(parent, "balance", "bal"); + } + + @Override + public void setup() { + this.setPermission("invswitcher.admin.eco.balance"); + this.setParametersHelp("invswitcher.commands.admin.balance.parameters"); + this.setDescription("invswitcher.commands.admin.balance.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + if (args.size() != 1) { + this.showHelp(this, user); + return false; + } + InvEconomy eco = economy(); + if (eco == null) { + user.sendMessage("invswitcher.errors.no-economy"); + return false; + } + User target = resolveTarget(user, args.get(0)); + if (target == null) { + return false; + } + user.sendMessage("invswitcher.commands.admin.balance.balance", + TextVariables.NAME, target.getName(), + TextVariables.NUMBER, eco.format(eco.getBalance(target.getOfflinePlayer()))); + return true; + } +} diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminGiveCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminGiveCommand.java new file mode 100644 index 0000000..e5253ef --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminGiveCommand.java @@ -0,0 +1,53 @@ +package com.wasteofplastic.invswitcher.commands.admin; + +import java.util.List; + +import com.wasteofplastic.invswitcher.economy.InvEconomy; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; + +/** + * Gives money to a player in the world they are currently in (or were last in). + * @author tastybento + */ +public class AdminGiveCommand extends AbstractAdminMoneyCommand { + + public AdminGiveCommand(CompositeCommand parent) { + super(parent, "give"); + } + + @Override + public void setup() { + this.setPermission("invswitcher.admin.eco.give"); + this.setParametersHelp("invswitcher.commands.admin.give.parameters"); + this.setDescription("invswitcher.commands.admin.give.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + if (args.size() != 2) { + this.showHelp(this, user); + return false; + } + InvEconomy eco = economy(); + if (eco == null) { + user.sendMessage("invswitcher.errors.no-economy"); + return false; + } + User target = resolveTarget(user, args.get(0)); + if (target == null) { + return false; + } + Double amount = parseAmount(user, args.get(1)); + if (amount == null) { + return false; + } + eco.depositPlayer(target.getOfflinePlayer(), amount); + user.sendMessage("invswitcher.commands.admin.give.success", + TextVariables.NAME, target.getName(), + TextVariables.NUMBER, eco.format(eco.getBalance(target.getOfflinePlayer()))); + return true; + } +} diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminMoneyCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminMoneyCommand.java new file mode 100644 index 0000000..7bf7fe4 --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminMoneyCommand.java @@ -0,0 +1,36 @@ +package com.wasteofplastic.invswitcher.commands.admin; + +import java.util.List; + +import com.wasteofplastic.invswitcher.InvSwitcher; +import com.wasteofplastic.invswitcher.commands.AbstractMoneyCommand; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; + +/** + * Admin economy command container: {@code give}, {@code take}, {@code set} and {@code balance}. + * @author tastybento + */ +public class AdminMoneyCommand extends AbstractMoneyCommand { + + public AdminMoneyCommand(InvSwitcher addon, CompositeCommand parent) { + super(addon, parent, "eco", "money"); + } + + @Override + public void setup() { + this.setPermission("invswitcher.admin.eco"); + this.setDescription("invswitcher.commands.admin.eco.description"); + new AdminBalanceCommand(this); + new AdminGiveCommand(this); + new AdminTakeCommand(this); + new AdminSetCommand(this); + } + + @Override + public boolean execute(User user, String label, List args) { + this.showHelp(this, user); + return true; + } +} diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminSetCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminSetCommand.java new file mode 100644 index 0000000..d2a9e4c --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminSetCommand.java @@ -0,0 +1,53 @@ +package com.wasteofplastic.invswitcher.commands.admin; + +import java.util.List; + +import com.wasteofplastic.invswitcher.economy.InvEconomy; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; + +/** + * Sets a player's balance in the world they are currently in (or were last in). + * @author tastybento + */ +public class AdminSetCommand extends AbstractAdminMoneyCommand { + + public AdminSetCommand(CompositeCommand parent) { + super(parent, "set"); + } + + @Override + public void setup() { + this.setPermission("invswitcher.admin.eco.set"); + this.setParametersHelp("invswitcher.commands.admin.set.parameters"); + this.setDescription("invswitcher.commands.admin.set.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + if (args.size() != 2) { + this.showHelp(this, user); + return false; + } + InvEconomy eco = economy(); + if (eco == null) { + user.sendMessage("invswitcher.errors.no-economy"); + return false; + } + User target = resolveTarget(user, args.get(0)); + if (target == null) { + return false; + } + Double amount = parseAmount(user, args.get(1)); + if (amount == null) { + return false; + } + eco.setBalance(target.getOfflinePlayer(), amount); + user.sendMessage("invswitcher.commands.admin.set.success", + TextVariables.NAME, target.getName(), + TextVariables.NUMBER, eco.format(eco.getBalance(target.getOfflinePlayer()))); + return true; + } +} diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminTakeCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminTakeCommand.java new file mode 100644 index 0000000..265ea80 --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminTakeCommand.java @@ -0,0 +1,58 @@ +package com.wasteofplastic.invswitcher.commands.admin; + +import java.util.List; + +import com.wasteofplastic.invswitcher.economy.InvEconomy; + +import net.milkbowl.vault.economy.EconomyResponse; +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; + +/** + * Takes money from a player in the world they are currently in (or were last in). + * @author tastybento + */ +public class AdminTakeCommand extends AbstractAdminMoneyCommand { + + public AdminTakeCommand(CompositeCommand parent) { + super(parent, "take"); + } + + @Override + public void setup() { + this.setPermission("invswitcher.admin.eco.take"); + this.setParametersHelp("invswitcher.commands.admin.take.parameters"); + this.setDescription("invswitcher.commands.admin.take.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + if (args.size() != 2) { + this.showHelp(this, user); + return false; + } + InvEconomy eco = economy(); + if (eco == null) { + user.sendMessage("invswitcher.errors.no-economy"); + return false; + } + User target = resolveTarget(user, args.get(0)); + if (target == null) { + return false; + } + Double amount = parseAmount(user, args.get(1)); + if (amount == null) { + return false; + } + EconomyResponse response = eco.withdrawPlayer(target.getOfflinePlayer(), amount); + if (!response.transactionSuccess()) { + user.sendMessage("invswitcher.errors.insufficient-funds"); + return false; + } + user.sendMessage("invswitcher.commands.admin.take.success", + TextVariables.NAME, target.getName(), + TextVariables.NUMBER, eco.format(eco.getBalance(target.getOfflinePlayer()))); + return true; + } +} diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/user/BalanceCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/user/BalanceCommand.java new file mode 100644 index 0000000..ee62996 --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/user/BalanceCommand.java @@ -0,0 +1,41 @@ +package com.wasteofplastic.invswitcher.commands.user; + +import java.util.List; + +import com.wasteofplastic.invswitcher.InvSwitcher; +import com.wasteofplastic.invswitcher.commands.AbstractMoneyCommand; +import com.wasteofplastic.invswitcher.economy.InvEconomy; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; + +/** + * Shows a player their balance for the world they are currently in. + * @author tastybento + */ +public class BalanceCommand extends AbstractMoneyCommand { + + public BalanceCommand(InvSwitcher addon, CompositeCommand parent) { + super(addon, parent, "balance", "bal", "money"); + } + + @Override + public void setup() { + this.setOnlyPlayer(true); + this.setPermission("invswitcher.balance"); + this.setDescription("invswitcher.commands.balance.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + InvEconomy eco = economy(); + if (eco == null) { + user.sendMessage("invswitcher.errors.no-economy"); + return false; + } + double balance = eco.getBalance(user.getPlayer()); + user.sendMessage("invswitcher.commands.balance.balance", TextVariables.NUMBER, eco.format(balance)); + return true; + } +} diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/user/PayCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/user/PayCommand.java new file mode 100644 index 0000000..9daf22c --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/user/PayCommand.java @@ -0,0 +1,76 @@ +package com.wasteofplastic.invswitcher.commands.user; + +import java.util.List; + +import com.wasteofplastic.invswitcher.InvSwitcher; +import com.wasteofplastic.invswitcher.commands.AbstractMoneyCommand; +import com.wasteofplastic.invswitcher.economy.InvEconomy; + +import net.milkbowl.vault.economy.EconomyResponse; +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; + +/** + * Pays another player from the sender's current-world balance. The recipient is credited in the + * world they are currently in (or were last in), so the payment is world-correct even if they are + * offline or elsewhere. + * @author tastybento + */ +public class PayCommand extends AbstractMoneyCommand { + + public PayCommand(InvSwitcher addon, CompositeCommand parent) { + super(addon, parent, "pay"); + } + + @Override + public void setup() { + this.setOnlyPlayer(true); + this.setPermission("invswitcher.pay"); + this.setParametersHelp("invswitcher.commands.pay.parameters"); + this.setDescription("invswitcher.commands.pay.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + if (args.size() != 2) { + this.showHelp(this, user); + return false; + } + InvEconomy eco = economy(); + if (eco == null) { + user.sendMessage("invswitcher.errors.no-economy"); + return false; + } + User target = getPlayers().getUser(args.get(0)); + if (target == null || target.getUniqueId() == null) { + user.sendMessage("general.errors.unknown-player", TextVariables.NAME, args.get(0)); + return false; + } + if (target.getUniqueId().equals(user.getUniqueId())) { + user.sendMessage("invswitcher.errors.cannot-pay-self"); + return false; + } + Double amount = parseAmount(user, args.get(1)); + if (amount == null) { + return false; + } + if (!eco.has(user.getPlayer(), amount)) { + user.sendMessage("invswitcher.errors.insufficient-funds"); + return false; + } + EconomyResponse withdrawal = eco.withdrawPlayer(user.getPlayer(), amount); + if (!withdrawal.transactionSuccess()) { + user.sendMessage("invswitcher.errors.insufficient-funds"); + return false; + } + eco.depositPlayer(target.getOfflinePlayer(), amount); + user.sendMessage("invswitcher.commands.pay.sent", + TextVariables.NUMBER, eco.format(amount), TextVariables.NAME, target.getName()); + if (target.isOnline()) { + target.sendMessage("invswitcher.commands.pay.received", + TextVariables.NUMBER, eco.format(amount), TextVariables.NAME, user.getName()); + } + return true; + } +} diff --git a/src/main/java/com/wasteofplastic/invswitcher/dataobjects/InventoryStorage.java b/src/main/java/com/wasteofplastic/invswitcher/dataobjects/InventoryStorage.java index dcf4cbf..75aa4ae 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/dataobjects/InventoryStorage.java +++ b/src/main/java/com/wasteofplastic/invswitcher/dataobjects/InventoryStorage.java @@ -52,6 +52,26 @@ public class InventoryStorage implements DataObject { @Expose private Map exp = new HashMap<>(); + /** + * Map of world/island key to the player's money balance for that world. + */ + @Expose + private Map money = new HashMap<>(); + + /** + * The last storage key the player was tracked under. Persisted so that economy + * transactions for an offline player can be routed to the world they were last in. + */ + @Expose + private String lastKey; + + /** + * Whether this player's pre-existing balance has already been imported from the + * previous economy provider. Prevents the one-time import from running twice. + */ + @Expose + private boolean imported; + /** * Map of world name to player location. */ @@ -234,6 +254,79 @@ public void setExp(String overworldName, int totalExperience) { this.exp.put(overworldName, totalExperience); } + /** + * Gets the money map. + * @return the money map keyed by world/island key + */ + public Map getMoney() { + return money; + } + + /** + * Sets the money map. + * @param money the money map to set + */ + public void setMoney(Map money) { + this.money = money; + } + + /** + * Gets the money balance for a specific key. + * @param key the world/island key + * @return the balance, or null if no balance has been stored for this key + */ + public Double getMoney(String key) { + return money == null ? null : money.get(key); + } + + /** + * Checks whether a balance has been stored for a specific key. + * @param key the world/island key + * @return true if a balance exists for this key + */ + public boolean hasMoney(String key) { + return money != null && money.containsKey(key); + } + + /** + * Sets the money balance for a specific key. + * @param key the world/island key + * @param balance the balance to set + */ + public void setMoney(String key, double balance) { + this.money.put(key, balance); + } + + /** + * Gets the last storage key the player was tracked under. + * @return the last key, or null if never set + */ + public String getLastKey() { + return lastKey; + } + + /** + * Sets the last storage key the player was tracked under. + * @param lastKey the last key to set + */ + public void setLastKey(String lastKey) { + this.lastKey = lastKey; + } + + /** + * @return whether this player's balance has already been imported + */ + public boolean isImported() { + return imported; + } + + /** + * @param imported whether this player's balance has been imported + */ + public void setImported(boolean imported) { + this.imported = imported; + } + /** * Sets the location for a specific world. * @param worldName the world name @@ -437,6 +530,7 @@ public void clearWorldData(String worldName) { this.gameMode.remove(worldName); this.advancements.remove(worldName); this.enderChest.remove(worldName); + this.money.remove(worldName); clearStats(worldName); } diff --git a/src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java b/src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java new file mode 100644 index 0000000..b19fecf --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java @@ -0,0 +1,428 @@ +package com.wasteofplastic.invswitcher.economy; + +import java.util.Collections; +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.World; + +import com.wasteofplastic.invswitcher.InvSwitcher; +import com.wasteofplastic.invswitcher.Settings; +import com.wasteofplastic.invswitcher.Store; +import com.wasteofplastic.invswitcher.dataobjects.InventoryStorage; + +import net.milkbowl.vault.economy.Economy; +import net.milkbowl.vault.economy.EconomyResponse; +import net.milkbowl.vault.economy.EconomyResponse.ResponseType; + +/** + * Vault {@link Economy} implementation that gives every InvSwitcher-managed world its own + * balance. InvSwitcher registers this provider at the highest priority so it intercepts every + * economy call server-wide and routes it to the correct world's balance — even when the + * target player is offline or in a different world. + *

+ * Worlds that InvSwitcher does not manage are passed through to the economy provider that was + * registered before InvSwitcher (the {@code delegate}, e.g. EssentialsX), so the rest of the + * server's economy is unaffected. + * + * @author tastybento + */ +public class InvEconomy implements Economy { + + private static final String NEGATIVE_DEPOSIT = "Cannot deposit a negative amount"; + private static final String NEGATIVE_WITHDRAW = "Cannot withdraw a negative amount"; + private static final String INSUFFICIENT_FUNDS = "Insufficient funds"; + + private final InvSwitcher addon; + private final Store store; + /** The economy provider that was registered before us. May be null if none existed. */ + private final Economy delegate; + + /** + * @param addon - the InvSwitcher addon + * @param delegate - the previously-registered economy, or null if none + */ + public InvEconomy(InvSwitcher addon, Economy delegate) { + this.addon = addon; + this.store = addon.getStore(); + this.delegate = delegate; + } + + private Settings settings() { + return addon.getSettings(); + } + + /** + * @return true if unmanaged worlds should be delegated to the previous provider + */ + private boolean delegating() { + return settings().isDelegateUnmanagedWorlds() && delegate != null; + } + + // ------ STATUS / FORMATTING ------ + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public String getName() { + return "InvSwitcher"; + } + + @Override + public boolean hasBankSupport() { + return delegate != null && delegate.hasBankSupport(); + } + + @Override + public int fractionalDigits() { + return settings().getFractionalDigits(); + } + + @Override + public String format(double amount) { + String name = Math.abs(amount - 1.0D) < 1.0E-9D ? currencyNameSingular() : currencyNamePlural(); + return String.format("%,." + Math.max(0, fractionalDigits()) + "f %s", amount, name); + } + + @Override + public String currencyNamePlural() { + return settings().getCurrencyNamePlural(); + } + + @Override + public String currencyNameSingular() { + return settings().getCurrencyNameSingular(); + } + + // ------ CORE BALANCE LOGIC (operates on a single storage object per call) ------ + + /** + * Ensure the key has a balance, seeding it (via import or starting balance) if absent. + * Mutates {@code s} in memory only; the caller is responsible for persisting. + */ + private double ensureBalance(OfflinePlayer player, InventoryStorage s, String key) { + if (!s.hasMoney(key)) { + s.setMoney(key, seedBalance(player, s)); + } + return s.getMoney(key); + } + + /** + * Determine the initial balance for a player's first-ever managed-world key: either their + * existing balance imported once from the previous economy, or the configured starting + * balance. + */ + private double seedBalance(OfflinePlayer player, InventoryStorage s) { + Settings settings = settings(); + if (settings.isImportExistingBalances() && delegate != null && !s.isImported()) { + s.setImported(true); + return delegate.getBalance(player); + } + return settings.getStartingBalance(); + } + + private double readSelf(OfflinePlayer player, String key) { + InventoryStorage s = store.getStorageObject(player.getUniqueId()); + boolean existed = s.hasMoney(key); + double balance = ensureBalance(player, s, key); + if (!existed) { + store.saveStorage(s); + } + return balance; + } + + private EconomyResponse depositSelf(OfflinePlayer player, String key, double amount) { + if (amount < 0) { + return new EconomyResponse(0, readSelf(player, key), ResponseType.FAILURE, NEGATIVE_DEPOSIT); + } + InventoryStorage s = store.getStorageObject(player.getUniqueId()); + double newBalance = ensureBalance(player, s, key) + amount; + s.setMoney(key, newBalance); + store.saveStorage(s); + return new EconomyResponse(amount, newBalance, ResponseType.SUCCESS, null); + } + + private EconomyResponse setSelf(OfflinePlayer player, String key, double amount) { + if (amount < 0) { + return new EconomyResponse(0, readSelf(player, key), ResponseType.FAILURE, NEGATIVE_DEPOSIT); + } + InventoryStorage s = store.getStorageObject(player.getUniqueId()); + // Mark imported so a later seed does not re-import on top of an explicitly set balance + s.setImported(true); + s.setMoney(key, amount); + store.saveStorage(s); + return new EconomyResponse(amount, amount, ResponseType.SUCCESS, null); + } + + private EconomyResponse withdrawSelf(OfflinePlayer player, String key, double amount) { + if (amount < 0) { + return new EconomyResponse(0, readSelf(player, key), ResponseType.FAILURE, NEGATIVE_WITHDRAW); + } + InventoryStorage s = store.getStorageObject(player.getUniqueId()); + double balance = ensureBalance(player, s, key); + if (balance < amount) { + return new EconomyResponse(0, balance, ResponseType.FAILURE, INSUFFICIENT_FUNDS); + } + double newBalance = balance - amount; + s.setMoney(key, newBalance); + store.saveStorage(s); + return new EconomyResponse(amount, newBalance, ResponseType.SUCCESS, null); + } + + // ------ ACCOUNT METHODS (OfflinePlayer) ------ + + @Override + public boolean hasAccount(OfflinePlayer player) { + return true; + } + + @Override + public boolean hasAccount(OfflinePlayer player, String worldName) { + return true; + } + + @Override + public double getBalance(OfflinePlayer player) { + String key = store.getCurrentMoneyKey(player); + if (key == null) { + return delegating() ? delegate.getBalance(player) : readSelf(player, Store.DEFAULT_WORLD_KEY); + } + return readSelf(player, key); + } + + @Override + public double getBalance(OfflinePlayer player, String worldName) { + String key = store.getMoneyKey(player, Bukkit.getWorld(worldName)); + if (key == null) { + return delegating() ? delegate.getBalance(player, worldName) : readSelf(player, Store.DEFAULT_WORLD_KEY); + } + return readSelf(player, key); + } + + @Override + public boolean has(OfflinePlayer player, double amount) { + return getBalance(player) >= amount; + } + + @Override + public boolean has(OfflinePlayer player, String worldName, double amount) { + return getBalance(player, worldName) >= amount; + } + + @Override + public EconomyResponse withdrawPlayer(OfflinePlayer player, double amount) { + String key = store.getCurrentMoneyKey(player); + if (key == null) { + return delegating() ? delegate.withdrawPlayer(player, amount) + : withdrawSelf(player, Store.DEFAULT_WORLD_KEY, amount); + } + return withdrawSelf(player, key, amount); + } + + @Override + public EconomyResponse withdrawPlayer(OfflinePlayer player, String worldName, double amount) { + String key = store.getMoneyKey(player, Bukkit.getWorld(worldName)); + if (key == null) { + return delegating() ? delegate.withdrawPlayer(player, worldName, amount) + : withdrawSelf(player, Store.DEFAULT_WORLD_KEY, amount); + } + return withdrawSelf(player, key, amount); + } + + @Override + public EconomyResponse depositPlayer(OfflinePlayer player, double amount) { + String key = store.getCurrentMoneyKey(player); + if (key == null) { + return delegating() ? delegate.depositPlayer(player, amount) + : depositSelf(player, Store.DEFAULT_WORLD_KEY, amount); + } + return depositSelf(player, key, amount); + } + + @Override + public EconomyResponse depositPlayer(OfflinePlayer player, String worldName, double amount) { + String key = store.getMoneyKey(player, Bukkit.getWorld(worldName)); + if (key == null) { + return delegating() ? delegate.depositPlayer(player, worldName, amount) + : depositSelf(player, Store.DEFAULT_WORLD_KEY, amount); + } + return depositSelf(player, key, amount); + } + + /** + * Set a player's balance for their current (online) or last-known (offline) world. Not part + * of the Vault interface; used by InvSwitcher's own admin commands. + * @param player - player + * @param amount - new balance + * @return the economy response + */ + public EconomyResponse setBalance(OfflinePlayer player, double amount) { + String key = store.getCurrentMoneyKey(player); + if (key == null) { + if (delegating()) { + // Vault has no set operation; emulate it against the delegate's balance + double delta = amount - delegate.getBalance(player); + return delta >= 0 ? delegate.depositPlayer(player, delta) : delegate.withdrawPlayer(player, -delta); + } + key = Store.DEFAULT_WORLD_KEY; + } + return setSelf(player, key, amount); + } + + @Override + public boolean createPlayerAccount(OfflinePlayer player) { + return true; + } + + @Override + public boolean createPlayerAccount(OfflinePlayer player, String worldName) { + return true; + } + + // ------ DEPRECATED NAME-BASED METHODS (delegate up to OfflinePlayer variants) ------ + + @Override + @Deprecated + public boolean hasAccount(String playerName) { + return hasAccount(Bukkit.getOfflinePlayer(playerName)); + } + + @Override + @Deprecated + public boolean hasAccount(String playerName, String worldName) { + return hasAccount(Bukkit.getOfflinePlayer(playerName), worldName); + } + + @Override + @Deprecated + public double getBalance(String playerName) { + return getBalance(Bukkit.getOfflinePlayer(playerName)); + } + + @Override + @Deprecated + public double getBalance(String playerName, String world) { + return getBalance(Bukkit.getOfflinePlayer(playerName), world); + } + + @Override + @Deprecated + public boolean has(String playerName, double amount) { + return has(Bukkit.getOfflinePlayer(playerName), amount); + } + + @Override + @Deprecated + public boolean has(String playerName, String worldName, double amount) { + return has(Bukkit.getOfflinePlayer(playerName), worldName, amount); + } + + @Override + @Deprecated + public EconomyResponse withdrawPlayer(String playerName, double amount) { + return withdrawPlayer(Bukkit.getOfflinePlayer(playerName), amount); + } + + @Override + @Deprecated + public EconomyResponse withdrawPlayer(String playerName, String worldName, double amount) { + return withdrawPlayer(Bukkit.getOfflinePlayer(playerName), worldName, amount); + } + + @Override + @Deprecated + public EconomyResponse depositPlayer(String playerName, double amount) { + return depositPlayer(Bukkit.getOfflinePlayer(playerName), amount); + } + + @Override + @Deprecated + public EconomyResponse depositPlayer(String playerName, String worldName, double amount) { + return depositPlayer(Bukkit.getOfflinePlayer(playerName), worldName, amount); + } + + @Override + @Deprecated + public boolean createPlayerAccount(String playerName) { + return createPlayerAccount(Bukkit.getOfflinePlayer(playerName)); + } + + @Override + @Deprecated + public boolean createPlayerAccount(String playerName, String worldName) { + return createPlayerAccount(Bukkit.getOfflinePlayer(playerName), worldName); + } + + // ------ BANK METHODS (passed through to the delegate; not partitioned per world) ------ + + private EconomyResponse bankUnsupported() { + return new EconomyResponse(0, 0, ResponseType.NOT_IMPLEMENTED, "InvSwitcher does not support banks"); + } + + @Override + public EconomyResponse createBank(String name, OfflinePlayer player) { + return delegate != null ? delegate.createBank(name, player) : bankUnsupported(); + } + + @Override + @Deprecated + public EconomyResponse createBank(String name, String player) { + return delegate != null ? delegate.createBank(name, player) : bankUnsupported(); + } + + @Override + public EconomyResponse deleteBank(String name) { + return delegate != null ? delegate.deleteBank(name) : bankUnsupported(); + } + + @Override + public EconomyResponse bankBalance(String name) { + return delegate != null ? delegate.bankBalance(name) : bankUnsupported(); + } + + @Override + public EconomyResponse bankHas(String name, double amount) { + return delegate != null ? delegate.bankHas(name, amount) : bankUnsupported(); + } + + @Override + public EconomyResponse bankWithdraw(String name, double amount) { + return delegate != null ? delegate.bankWithdraw(name, amount) : bankUnsupported(); + } + + @Override + public EconomyResponse bankDeposit(String name, double amount) { + return delegate != null ? delegate.bankDeposit(name, amount) : bankUnsupported(); + } + + @Override + public EconomyResponse isBankOwner(String name, OfflinePlayer player) { + return delegate != null ? delegate.isBankOwner(name, player) : bankUnsupported(); + } + + @Override + @Deprecated + public EconomyResponse isBankOwner(String name, String playerName) { + return delegate != null ? delegate.isBankOwner(name, playerName) : bankUnsupported(); + } + + @Override + public EconomyResponse isBankMember(String name, OfflinePlayer player) { + return delegate != null ? delegate.isBankMember(name, player) : bankUnsupported(); + } + + @Override + @Deprecated + public EconomyResponse isBankMember(String name, String playerName) { + return delegate != null ? delegate.isBankMember(name, playerName) : bankUnsupported(); + } + + @Override + public List getBanks() { + return delegate != null ? delegate.getBanks() : Collections.emptyList(); + } +} diff --git a/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java b/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java index b404d40..c156f38 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java +++ b/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java @@ -23,6 +23,7 @@ import world.bentobox.bentobox.api.events.player.PlayerResetHealthEvent; import world.bentobox.bentobox.api.events.player.PlayerResetHungerEvent; import world.bentobox.bentobox.api.events.player.PlayerResetInventoryEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetMoneyEvent; import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.util.Util; @@ -265,6 +266,29 @@ public void onPlayerResetHunger(PlayerResetHungerEvent event) { } } + /** + * Intercepts BentoBox's money reset when the player is not in the BentoBox world. BentoBox's + * default reset reads the player's current world balance and withdraws it from the + * event world, which is wrong when the player is elsewhere. Instead, cancel it and zero the + * stored balance for the event world directly. When the player is in the event world the + * reset is left to BentoBox, which routes correctly through InvSwitcher's economy. + * @param event - event + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onPlayerResetMoney(PlayerResetMoneyEvent event) { + if (!addon.getSettings().isMoney()) { + return; + } + if (!shouldInterceptPlayerReset(event)) { + return; + } + event.setCancelled(true); + Player player = Bukkit.getPlayer(event.getPlayerUUID()); + if (player != null) { + addon.getStore().clearStoredMoneyForWorld(player, event.getWorld(), event.getIsland()); + } + } + /** * Determines whether InvSwitcher should intercept a BentoBox player reset event. * Returns true if the event's world is managed by InvSwitcher, the player is online, diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 34a19b8..069f8fa 100755 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -20,6 +20,12 @@ options: experience: true ender-chest: true statistics: true + # Per-world money. Requires the Vault plugin and an economy plugin (e.g. EssentialsX). + # When enabled, InvSwitcher registers itself as the Vault economy and keeps a separate + # balance for each switched world. Transactions route to the correct world even when the + # player is offline or in a different world. Worlds InvSwitcher does not manage are passed + # through to the previous economy plugin. + money: true # Switch inventories based on island. Only applies if players own more than one island. # Each sub-option controls whether that aspect is switched per-island. # The world-level option must also be true for the island option to have any effect. @@ -33,3 +39,21 @@ options: experience: false ender-chest: true statistics: false + # If true, each of a player's islands has its own wallet. If false (default) money is + # per-world only. Per-island money is best-effort for offline players. + money: false +# Economy settings. Only used when options.money is true. +economy: + # Balance given to a player the first time they enter a managed world (unless imported). + starting-balance: 0.0 + # Currency names used when formatting amounts. + currency-name-singular: Dollar + currency-name-plural: Dollars + # Number of digits after the decimal point. + fractional-digits: 2 + # Import each player's existing balance from the previous economy plugin once, the first + # time they enter a managed world, so nobody loses money on first run. + import-existing-balances: true + # Pass transactions for worlds InvSwitcher does not manage through to the previous economy + # plugin (e.g. EssentialsX). Disable to make InvSwitcher the sole economy for every world. + delegate-unmanaged-worlds: true diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml new file mode 100644 index 0000000..c9ebe87 --- /dev/null +++ b/src/main/resources/locales/en-US.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - English (US) +########################################################################################### +invswitcher: + commands: + balance: + description: "show your money balance for this world" + balance: "&a Balance: &e[number]" + pay: + description: "pay another player" + parameters: " " + sent: "&a You paid [number] to [name]" + received: "&a You received [number] from [name]" + admin: + eco: + description: "manage player money for this world" + give: + description: "give money to a player" + parameters: " " + success: "&a Gave money to [name]. New balance: [number]" + take: + description: "take money from a player" + parameters: " " + success: "&a Took money from [name]. New balance: [number]" + set: + description: "set a player's balance" + parameters: " " + success: "&a Set [name]'s balance to [number]" + balance: + description: "show a player's balance" + parameters: "" + balance: "&a [name]'s balance: &e[number]" + errors: + no-economy: "&c The economy is not available." + insufficient-funds: "&c Insufficient funds." + not-a-number: "&c '[number]' is not a valid number." + must-be-positive: "&c The amount must be positive." + cannot-pay-self: "&c You cannot pay yourself." diff --git a/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java b/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java index fbddcf0..03c61b2 100644 --- a/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java +++ b/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java @@ -706,6 +706,43 @@ public void testClearStoredExpForWorld() { } } + /** + * When money is enabled, clearStoredMoneyForWorld should zero the stored balance for the world. + */ + @Test + public void testClearStoredMoneyForWorld() { + sets.setStatistics(false); + sets.setAdvancements(false); + sets.setMoney(true); + Island island = mock(Island.class); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class, Mockito.RETURNS_MOCKS)) { + s.storeInventory(player, world); + // Seed a balance for the world + s.getStorageObject(playerUUID).setMoney("world", 500D); + // Clear it + s.clearStoredMoneyForWorld(player, world, island); + } + assertEquals(0D, s.getStorageObject(playerUUID).getMoney("world"), 0.0001); + } + + /** + * clearStoredMoneyForWorld should be a no-op when money is disabled in settings. + */ + @Test + public void testClearStoredMoneyForWorldMoneyDisabled() { + sets.setStatistics(false); + sets.setAdvancements(false); + sets.setMoney(false); + Island island = mock(Island.class); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class, Mockito.RETURNS_MOCKS)) { + s.storeInventory(player, world); + s.getStorageObject(playerUUID).setMoney("world", 500D); + s.clearStoredMoneyForWorld(player, world, island); + } + // Balance untouched + assertEquals(500D, s.getStorageObject(playerUUID).getMoney("world"), 0.0001); + } + /** * When health is enabled, clearStoredHealthForWorld should work without error. */ diff --git a/src/test/java/com/wasteofplastic/invswitcher/economy/InvEconomyTest.java b/src/test/java/com/wasteofplastic/invswitcher/economy/InvEconomyTest.java new file mode 100644 index 0000000..6f14c76 --- /dev/null +++ b/src/test/java/com/wasteofplastic/invswitcher/economy/InvEconomyTest.java @@ -0,0 +1,266 @@ +package com.wasteofplastic.invswitcher.economy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.RETURNS_MOCKS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.mockbukkit.mockbukkit.MockBukkit; + +import com.wasteofplastic.invswitcher.InvSwitcher; +import com.wasteofplastic.invswitcher.Settings; +import com.wasteofplastic.invswitcher.Store; +import com.wasteofplastic.invswitcher.dataobjects.InventoryStorage; + +import net.milkbowl.vault.economy.Economy; +import net.milkbowl.vault.economy.EconomyResponse; +import net.milkbowl.vault.economy.EconomyResponse.ResponseType; +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.database.DatabaseSetup.DatabaseType; +import world.bentobox.bentobox.managers.IslandsManager; + +/** + * Tests for {@link InvEconomy} per-world routing, delegation, starting balance and import. + * @author tastybento + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class InvEconomyTest { + + @Mock + private InvSwitcher addon; + @Mock + private Player player; + @Mock + private World world; + @Mock + private world.bentobox.bentobox.Settings bbSettings; + @Mock + private IslandsManager islandsManager; + @Mock + private Logger logger; + @Mock + private Economy delegate; + + private Store store; + private Settings sets; + private Set bentoboxWorlds; + private UUID playerUUID; + private InvEconomy economy; + private MockedStatic mockedBentoBox; + + @BeforeEach + public void setUp() { + MockBukkit.mock(); + + BentoBox plugin = mock(BentoBox.class); + mockedBentoBox = Mockito.mockStatic(BentoBox.class); + mockedBentoBox.when(BentoBox::getInstance).thenReturn(plugin); + when(plugin.getSettings()).thenReturn(bbSettings); + DatabaseType mockDbt = mock(DatabaseType.class); + when(bbSettings.getDatabaseType()).thenReturn(mockDbt); + + playerUUID = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(playerUUID); + // Treat the player as online and in the managed world by default + when(player.getPlayer()).thenReturn(player); + when(player.getWorld()).thenReturn(world); + + when(world.getName()).thenReturn("world"); + + sets = new Settings(); + sets.setIslandsActive(false); + sets.setImportExistingBalances(false); + when(addon.getSettings()).thenReturn(sets); + when(addon.getLogger()).thenReturn(logger); + when(addon.getIslands()).thenReturn(islandsManager); + + bentoboxWorlds = new HashSet<>(); + bentoboxWorlds.add(world); + when(addon.getWorlds()).thenReturn(bentoboxWorlds); + + store = new Store(addon); + when(addon.getStore()).thenReturn(store); + + economy = new InvEconomy(addon, delegate); + } + + @AfterEach + public void tearDown() throws IOException { + if (mockedBentoBox != null) { + mockedBentoBox.close(); + } + MockBukkit.unmock(); + File file = new File("database"); + if (file.exists()) { + Files.walk(file.toPath()).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete); + } + } + + @Test + public void testEnabledAndName() { + assertTrue(economy.isEnabled()); + assertEquals("InvSwitcher", economy.getName()); + } + + @Test + public void testFormatSingularAndPlural() { + assertEquals("1.00 Dollar", economy.format(1.0)); + assertEquals("2.50 Dollars", economy.format(2.5)); + } + + @Test + public void testDepositManagedWorld() { + EconomyResponse r = economy.depositPlayer(player, 100.0); + assertTrue(r.transactionSuccess()); + assertEquals(100.0, economy.getBalance(player), 0.0001); + // Stored under the overworld key + assertEquals(100.0, store.getStorageObject(playerUUID).getMoney("world"), 0.0001); + // The delegate was never touched for a managed world + verify(delegate, never()).depositPlayer(any(Player.class), anyDouble()); + } + + @Test + public void testWithdrawSuccessAndInsufficient() { + economy.depositPlayer(player, 100.0); + EconomyResponse ok = economy.withdrawPlayer(player, 30.0); + assertTrue(ok.transactionSuccess()); + assertEquals(70.0, economy.getBalance(player), 0.0001); + + EconomyResponse fail = economy.withdrawPlayer(player, 1000.0); + assertFalse(fail.transactionSuccess()); + assertEquals(70.0, economy.getBalance(player), 0.0001); + } + + @Test + public void testStartingBalance() { + sets.setStartingBalance(50.0); + assertEquals(50.0, economy.getBalance(player), 0.0001); + } + + @Test + public void testImportOnce() { + sets.setImportExistingBalances(true); + when(delegate.getBalance(player)).thenReturn(500.0); + + // First touch imports the delegate balance + assertEquals(500.0, economy.getBalance(player), 0.0001); + assertTrue(store.getStorageObject(playerUUID).isImported()); + + // Even if the delegate balance changes, our stored balance is now authoritative + when(delegate.getBalance(player)).thenReturn(999.0); + assertEquals(500.0, economy.getBalance(player), 0.0001); + } + + @Test + public void testDelegateUnmanagedWorld() { + World lobby = mock(World.class); + when(lobby.getName()).thenReturn("lobby"); + when(player.getWorld()).thenReturn(lobby); // not a managed world + when(delegate.depositPlayer(player, 100.0)) + .thenReturn(new EconomyResponse(100, 100, ResponseType.SUCCESS, null)); + + EconomyResponse r = economy.depositPlayer(player, 100.0); + assertTrue(r.transactionSuccess()); + verify(delegate).depositPlayer(player, 100.0); + // Nothing stored in our own data + assertFalse(store.getStorageObject(playerUUID).hasMoney(Store.DEFAULT_WORLD_KEY)); + } + + @Test + public void testSetBalance() { + economy.depositPlayer(player, 100.0); + EconomyResponse r = economy.setBalance(player, 42.0); + assertTrue(r.transactionSuccess()); + assertEquals(42.0, economy.getBalance(player), 0.0001); + } + + @Test + public void testWorldAwareManagedRouting() { + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class, RETURNS_MOCKS)) { + mockedBukkit.when(() -> Bukkit.getWorld("world")).thenReturn(world); + EconomyResponse r = economy.depositPlayer(player, "world", 250.0); + assertTrue(r.transactionSuccess()); + } + assertEquals(250.0, store.getStorageObject(playerUUID).getMoney("world"), 0.0001); + verify(delegate, never()).depositPlayer(any(Player.class), eq("world"), anyDouble()); + } + + @Test + public void testWorldAwareUnmanagedDelegates() { + when(delegate.depositPlayer(player, "lobby", 10.0)) + .thenReturn(new EconomyResponse(10, 10, ResponseType.SUCCESS, null)); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class, RETURNS_MOCKS)) { + // Bukkit.getWorld for an unknown world returns null -> unmanaged -> delegate + mockedBukkit.when(() -> Bukkit.getWorld("lobby")).thenReturn(null); + economy.depositPlayer(player, "lobby", 10.0); + } + verify(delegate).depositPlayer(player, "lobby", 10.0); + } + + @Test + public void testOfflineRoutingUsesLastKey() { + // Simulate the player having been online: this caches the storage object and persists lastKey + sets.setStatistics(false); + sets.setAdvancements(false); + sets.setInventory(false); + sets.setEnderChest(false); + sets.setExperience(false); + sets.setFood(false); + sets.setHealth(false); + sets.setGamemode(false); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class, RETURNS_MOCKS)) { + store.getInventory(player, world); + store.storeInventory(player, world); // sets lastKey = "world" + } + // Now the player goes offline + when(player.getPlayer()).thenReturn(null); + + // A deposit while offline must route to their last world ("world") + EconomyResponse r = economy.depositPlayer(player, 75.0); + assertTrue(r.transactionSuccess()); + assertEquals(75.0, store.getStorageObject(playerUUID).getMoney("world"), 0.0001); + verify(delegate, never()).depositPlayer(any(Player.class), anyDouble()); + } + + @Test + public void testOfflineUnknownWorldDelegates() { + // Brand new player, offline, no lastKey -> cannot resolve -> delegate + when(player.getPlayer()).thenReturn(null); + when(delegate.depositPlayer(player, 5.0)) + .thenReturn(new EconomyResponse(5, 5, ResponseType.SUCCESS, null)); + economy.depositPlayer(player, 5.0); + verify(delegate).depositPlayer(player, 5.0); + } +} diff --git a/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java b/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java index 0c4c61f..00d4c5f 100644 --- a/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java +++ b/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java @@ -44,6 +44,7 @@ import world.bentobox.bentobox.api.events.player.PlayerResetHealthEvent; import world.bentobox.bentobox.api.events.player.PlayerResetHungerEvent; import world.bentobox.bentobox.api.events.player.PlayerResetInventoryEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetMoneyEvent; import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.managers.IslandsManager; import world.bentobox.bentobox.util.Util; @@ -517,4 +518,54 @@ public void testOnPlayerResetHungerPlayerInDifferentWorld() { verify(store).clearStoredFoodForWorld(player, world, island); } + /** + * Money reset should be intercepted when the player is in a different world. + */ + @Test + public void testOnPlayerResetMoneyPlayerInDifferentWorld() { + when(settings.isMoney()).thenReturn(true); + when(player.getWorld()).thenReturn(notWorld); + PlayerResetMoneyEvent event = new PlayerResetMoneyEvent(world, island, playerUUID); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class); + MockedStatic mockedUtil = mockStatic(Util.class)) { + mockedBukkit.when(() -> Bukkit.getPlayer(playerUUID)).thenReturn(player); + mockedUtil.when(() -> Util.sameWorld(notWorld, world)).thenReturn(false); + pl.onPlayerResetMoney(event); + } + assertTrue(event.isCancelled(), "Event should be cancelled when player is in a different world"); + verify(store).clearStoredMoneyForWorld(player, world, island); + } + + /** + * Money reset should be left to BentoBox (not intercepted) when the player is in the event world, + * because BentoBox's reset routes correctly through InvSwitcher's economy. + */ + @Test + public void testOnPlayerResetMoneyPlayerInEventWorld() { + when(settings.isMoney()).thenReturn(true); + when(player.getWorld()).thenReturn(world); + PlayerResetMoneyEvent event = new PlayerResetMoneyEvent(world, island, playerUUID); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class); + MockedStatic mockedUtil = mockStatic(Util.class)) { + mockedBukkit.when(() -> Bukkit.getPlayer(playerUUID)).thenReturn(player); + mockedUtil.when(() -> Util.sameWorld(world, world)).thenReturn(true); + pl.onPlayerResetMoney(event); + } + assertFalse(event.isCancelled()); + verify(store, never()).clearStoredMoneyForWorld(any(), any(), any()); + } + + /** + * Money reset should be ignored entirely when InvSwitcher money is disabled. + */ + @Test + public void testOnPlayerResetMoneyMoneyDisabled() { + when(settings.isMoney()).thenReturn(false); + when(player.getWorld()).thenReturn(notWorld); + PlayerResetMoneyEvent event = new PlayerResetMoneyEvent(world, island, playerUUID); + pl.onPlayerResetMoney(event); + assertFalse(event.isCancelled()); + verify(store, never()).clearStoredMoneyForWorld(any(), any(), any()); + } + } From fa236ea34bc8e0e178134e68f5462ebcf2c62030 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 30 May 2026 10:55:15 -0700 Subject: [PATCH 08/15] fix: register economy early and scope balance commands to the game mode Shop plugins (e.g. QuickShop-Hikari) resolve and cache their Vault economy provider during their own startup. InvSwitcher previously registered in allLoaded (after blueprints load), too late - so consumers cached the underlying economy and never called InvSwitcher. - Register the Vault provider in the addon's onEnable (before shop plugins bind) instead of allLoaded. InvEconomy is now lazy: it resolves the store and the delegate economy on first use, and treats a not-yet-ready store as unmanaged (routes to the delegate) so there is no startup-window NPE. - Resolve the delegate lazily as the highest-priority non-InvSwitcher provider, so it picks up economy plugins (e.g. EssentialsX) that register after us. - Scope economy commands and placeholders to the command's game mode world via CompositeCommand.getWorld(): /bsb balance shows the BSkyBlock balance, /ai balance the AcidIsland balance, regardless of where the player stands. Added a world-aware setBalance overload for admin set. - Add an economy.debug setting and a startup dump of registered Vault economy providers for troubleshooting. - Clarify config/comment: Vault is required, a separate economy plugin is optional; InvSwitcher can be the only economy. - Tests: store-not-ready delegation and world-aware setBalance (120 total). Co-Authored-By: Claude Opus 4.8 --- .../invswitcher/InvSwitcher.java | 97 +++++---- .../wasteofplastic/invswitcher/PhManager.java | 15 +- .../wasteofplastic/invswitcher/Settings.java | 21 +- .../commands/admin/AdminBalanceCommand.java | 2 +- .../commands/admin/AdminGiveCommand.java | 5 +- .../commands/admin/AdminSetCommand.java | 5 +- .../commands/admin/AdminTakeCommand.java | 5 +- .../commands/user/BalanceCommand.java | 4 +- .../invswitcher/commands/user/PayCommand.java | 9 +- .../invswitcher/economy/InvEconomy.java | 194 +++++++++++++----- src/main/resources/config.yml | 14 +- .../invswitcher/economy/InvEconomyTest.java | 24 +++ 12 files changed, 285 insertions(+), 110 deletions(-) diff --git a/src/main/java/com/wasteofplastic/invswitcher/InvSwitcher.java b/src/main/java/com/wasteofplastic/invswitcher/InvSwitcher.java index 64d9753..9e39e6f 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/InvSwitcher.java +++ b/src/main/java/com/wasteofplastic/invswitcher/InvSwitcher.java @@ -84,50 +84,62 @@ public void allLoaded() { store = new Store(this); // Register the listeners registerListener(new PlayerListener(this)); - // Set up the per-world economy. Deferred by a tick so that the underlying economy - // plugin (e.g. EssentialsX) has finished registering its own provider with Vault, - // letting us capture it as the delegate for unmanaged worlds. - if (settings.isMoney()) { + // Now that worlds are known, register the economy commands and placeholders. The economy + // provider itself was registered earlier, in onEnable, so it beats shop plugins that cache + // their Vault provider during their own startup. + if (economy != null) { + registerEconomyCommands(); + } + } + + @Override + public void onEnable() { + // Verify that we're not running on a YAML database + if (this.getPlugin().getSettings().getDatabaseType().equals(DatabaseType.YAML)) { + this.setState(State.DISABLED); + this.logError("This addon is incompatible with YAML database. Please use another type, like JSON."); + return; + } + // Register the Vault economy provider as early as possible (here in onEnable, not in + // allLoaded) so it is in place before economy-consuming plugins (e.g. QuickShop) resolve + // and cache their provider. The store, worlds and delegate are resolved lazily by + // InvEconomy, so they do not need to exist yet. + if (getSettings() != null && getSettings().isMoney()) { if (Bukkit.getPluginManager().getPlugin("Vault") == null) { logError("options.money is enabled but the Vault plugin is not installed - per-world money disabled."); } else { - Bukkit.getScheduler().runTask(getPlugin(), this::setupEconomy); + registerEconomyProvider(); } } } /** - * Captures the previously-registered Vault economy (to delegate unmanaged worlds to) and - * registers InvSwitcher's own per-world economy at the highest priority so it intercepts - * every economy call. Runs once. + * Creates and registers InvSwitcher's per-world economy at the highest Vault priority so it + * intercepts every economy call. Runs once. The provider is lazy - it resolves the store and + * the delegate economy on first use - so this can run before those are ready. */ - private void setupEconomy() { + private void registerEconomyProvider() { if (economy != null) { return; } - // Capture the existing provider BEFORE we register ourselves, skipping our own type - // so a re-run can never capture itself into a delegation loop. - Economy delegate = null; - RegisteredServiceProvider rsp = Bukkit.getServicesManager().getRegistration(Economy.class); - if (rsp != null && !(rsp.getProvider() instanceof InvEconomy)) { - delegate = rsp.getProvider(); - } - if (delegate == null) { - logWarning("No previous economy was found - InvSwitcher will be the only economy. " - + "Worlds it does not manage will share a single balance."); - } else { - log("Per-world economy enabled - delegating unmanaged worlds to " + delegate.getName()); - } - economy = new InvEconomy(this, delegate); + economy = new InvEconomy(this); Bukkit.getServicesManager().register(Economy.class, economy, getPlugin(), ServicePriority.Highest); - // BentoBox captured its VaultHook during early hook registration, before we registered - - // so it (and addons that use it, e.g. Bank, Level, Upgrades) still points at the previous - // economy. Re-run the hook so it re-reads the now-highest provider (us). The VaultHook is a - // single shared instance held by those addons, so refreshing it updates them too. + // BentoBox captured its VaultHook during early hook registration, before us, so it (and + // addons that use it, e.g. Bank) still points at the previous economy. Re-run the hook so + // it re-reads the now-highest provider (us). The VaultHook is a single shared instance held + // by those addons, so refreshing it updates them too. refreshBentoBoxVaultHook(); - // Register commands and placeholders with the game modes whose worlds we manage + // Dump the current economy provider chain so it is clear that we win the registration. + logEconomyRegistrations(); + } + + /** + * Registers the economy commands and placeholders against the game modes whose worlds + * InvSwitcher manages. Called from allLoaded, once worlds are known. + */ + private void registerEconomyCommands() { PhManager phManager = new PhManager(this); getPlugin().getAddonsManager().getGameModeAddons().stream() .filter(gm -> worlds.contains(gm.getOverWorld())) @@ -144,15 +156,6 @@ private void setupEconomy() { }); } - @Override - public void onEnable() { - // Verify that we're not running on a YAML database - if (this.getPlugin().getSettings().getDatabaseType().equals(DatabaseType.YAML)) { - this.setState(State.DISABLED); - this.logError("This addon is incompatible with YAML database. Please use another type, like JSON."); - } - } - @Override public void onDisable() { @@ -179,6 +182,26 @@ private void refreshBentoBoxVaultHook() { getPlugin().getVault().ifPresent(VaultHook::hook); } + /** + * Logs all registered Vault economy providers (highest priority first) and which one Vault + * will hand out. Useful for confirming InvSwitcher won the registration and for spotting + * consumers that cached a different provider before we registered. + */ + private void logEconomyRegistrations() { + log("Vault economy providers now registered (used by new lookups):"); + for (RegisteredServiceProvider r : Bukkit.getServicesManager().getRegistrations(Economy.class)) { + log(" - " + r.getProvider().getName() + " (" + r.getProvider().getClass().getName() + + ") priority=" + r.getPriority() + " registeredBy=" + r.getPlugin().getName()); + } + RegisteredServiceProvider top = Bukkit.getServicesManager().getRegistration(Economy.class); + if (top != null) { + log("Vault.getRegistration(Economy) returns: " + top.getProvider().getName() + " (" + + top.getProvider().getClass().getName() + ")"); + } + log("If a shop/economy plugin still uses the old balance, it cached its provider before now " + + "and must be loaded after BentoBox (or re-resolve on ServiceRegisterEvent)."); + } + /** * @return the per-world economy, or null if money is disabled or Vault is absent */ diff --git a/src/main/java/com/wasteofplastic/invswitcher/PhManager.java b/src/main/java/com/wasteofplastic/invswitcher/PhManager.java index 86d3e9b..6242aa5 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/PhManager.java +++ b/src/main/java/com/wasteofplastic/invswitcher/PhManager.java @@ -30,21 +30,24 @@ public boolean registerPlaceholders(GameModeAddon gm) { return false; } String prefix = gm.getDescription().getName().toLowerCase() + "_invswitcher_"; - // Raw balance number for the player's current world + // Balance is scoped to this game mode's world, so the placeholder is stable regardless of + // where the player currently is (e.g. bskyblock_invswitcher_balance is the BSkyBlock balance). + String worldName = gm.getOverWorld().getName(); + // Raw balance number plugin.getPlaceholdersManager().registerPlaceholder(addon, prefix + "balance", - user -> balance(user, false)); - // Formatted balance (currency name + decimals) for the player's current world + user -> balance(user, worldName, false)); + // Formatted balance (currency name + decimals) plugin.getPlaceholdersManager().registerPlaceholder(addon, prefix + "balance_formatted", - user -> balance(user, true)); + user -> balance(user, worldName, true)); return true; } - private String balance(User user, boolean formatted) { + private String balance(User user, String worldName, boolean formatted) { InvEconomy eco = addon.getEconomy(); if (eco == null || user == null || !user.isPlayer()) { return ""; } - double bal = eco.getBalance(user.getPlayer()); + double bal = eco.getBalance(user.getPlayer(), worldName); return formatted ? eco.format(bal) : String.valueOf(bal); } } diff --git a/src/main/java/com/wasteofplastic/invswitcher/Settings.java b/src/main/java/com/wasteofplastic/invswitcher/Settings.java index be2944d..2d4cd77 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/Settings.java +++ b/src/main/java/com/wasteofplastic/invswitcher/Settings.java @@ -34,11 +34,12 @@ public class Settings implements ConfigObject { private boolean enderChest = true; @ConfigEntry(path = "options.statistics") private boolean statistics = true; - @ConfigComment("Per-world money. Requires the Vault plugin and an economy plugin (e.g. EssentialsX).") - @ConfigComment("When enabled, InvSwitcher registers itself as the Vault economy and keeps a separate") - @ConfigComment("balance for each switched world. Transactions route to the correct world even when the") - @ConfigComment("player is offline or in a different world. Worlds InvSwitcher does not manage are passed") - @ConfigComment("through to the previous economy plugin.") + @ConfigComment("Per-world money. Requires the Vault plugin. A separate economy plugin (e.g. EssentialsX)") + @ConfigComment("is optional: InvSwitcher can be the only economy. When enabled, InvSwitcher registers") + @ConfigComment("itself as the Vault economy and keeps a separate balance for each switched world.") + @ConfigComment("Transactions route to the correct world even when the player is offline or in a different") + @ConfigComment("world. If another economy plugin is present, worlds InvSwitcher does not manage are passed") + @ConfigComment("through to it; if not, InvSwitcher handles every world itself.") @ConfigEntry(path = "options.money") private boolean money = true; @@ -89,6 +90,10 @@ public class Settings implements ConfigObject { @ConfigComment("plugin (e.g. EssentialsX). Disable to make InvSwitcher the sole economy for every world.") @ConfigEntry(path = "economy.delegate-unmanaged-worlds") private boolean delegateUnmanagedWorlds = true; + @ConfigComment("Log every economy transaction (deposit/withdraw/balance) to the console for") + @ConfigComment("troubleshooting. Verbose - only enable while diagnosing a problem.") + @ConfigEntry(path = "economy.debug") + private boolean economyDebug = false; /** * @return the worlds @@ -312,5 +317,11 @@ public boolean isDelegateUnmanagedWorlds() { public void setDelegateUnmanagedWorlds(boolean delegateUnmanagedWorlds) { this.delegateUnmanagedWorlds = delegateUnmanagedWorlds; } + public boolean isEconomyDebug() { + return economyDebug; + } + public void setEconomyDebug(boolean economyDebug) { + this.economyDebug = economyDebug; + } } diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminBalanceCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminBalanceCommand.java index 8e7f89a..65682b8 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminBalanceCommand.java +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminBalanceCommand.java @@ -42,7 +42,7 @@ public boolean execute(User user, String label, List args) { } user.sendMessage("invswitcher.commands.admin.balance.balance", TextVariables.NAME, target.getName(), - TextVariables.NUMBER, eco.format(eco.getBalance(target.getOfflinePlayer()))); + TextVariables.NUMBER, eco.format(eco.getBalance(target.getOfflinePlayer(), getWorld().getName()))); return true; } } diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminGiveCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminGiveCommand.java index e5253ef..b7febae 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminGiveCommand.java +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminGiveCommand.java @@ -44,10 +44,11 @@ public boolean execute(User user, String label, List args) { if (amount == null) { return false; } - eco.depositPlayer(target.getOfflinePlayer(), amount); + String world = getWorld().getName(); + eco.depositPlayer(target.getOfflinePlayer(), world, amount); user.sendMessage("invswitcher.commands.admin.give.success", TextVariables.NAME, target.getName(), - TextVariables.NUMBER, eco.format(eco.getBalance(target.getOfflinePlayer()))); + TextVariables.NUMBER, eco.format(eco.getBalance(target.getOfflinePlayer(), world))); return true; } } diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminSetCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminSetCommand.java index d2a9e4c..063ef42 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminSetCommand.java +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminSetCommand.java @@ -44,10 +44,11 @@ public boolean execute(User user, String label, List args) { if (amount == null) { return false; } - eco.setBalance(target.getOfflinePlayer(), amount); + String world = getWorld().getName(); + eco.setBalance(target.getOfflinePlayer(), world, amount); user.sendMessage("invswitcher.commands.admin.set.success", TextVariables.NAME, target.getName(), - TextVariables.NUMBER, eco.format(eco.getBalance(target.getOfflinePlayer()))); + TextVariables.NUMBER, eco.format(eco.getBalance(target.getOfflinePlayer(), world))); return true; } } diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminTakeCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminTakeCommand.java index 265ea80..28c4b33 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminTakeCommand.java +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminTakeCommand.java @@ -45,14 +45,15 @@ public boolean execute(User user, String label, List args) { if (amount == null) { return false; } - EconomyResponse response = eco.withdrawPlayer(target.getOfflinePlayer(), amount); + String world = getWorld().getName(); + EconomyResponse response = eco.withdrawPlayer(target.getOfflinePlayer(), world, amount); if (!response.transactionSuccess()) { user.sendMessage("invswitcher.errors.insufficient-funds"); return false; } user.sendMessage("invswitcher.commands.admin.take.success", TextVariables.NAME, target.getName(), - TextVariables.NUMBER, eco.format(eco.getBalance(target.getOfflinePlayer()))); + TextVariables.NUMBER, eco.format(eco.getBalance(target.getOfflinePlayer(), world))); return true; } } diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/user/BalanceCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/user/BalanceCommand.java index ee62996..88e4d97 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/commands/user/BalanceCommand.java +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/user/BalanceCommand.java @@ -34,7 +34,9 @@ public boolean execute(User user, String label, List args) { user.sendMessage("invswitcher.errors.no-economy"); return false; } - double balance = eco.getBalance(user.getPlayer()); + // Scope to this command's game mode world, not the player's current location, so + // /bsb balance always shows the BSkyBlock balance, /ai balance the AcidIsland balance, etc. + double balance = eco.getBalance(user.getPlayer(), getWorld().getName()); user.sendMessage("invswitcher.commands.balance.balance", TextVariables.NUMBER, eco.format(balance)); return true; } diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/user/PayCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/user/PayCommand.java index 9daf22c..aebe7d4 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/commands/user/PayCommand.java +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/user/PayCommand.java @@ -55,16 +55,19 @@ public boolean execute(User user, String label, List args) { if (amount == null) { return false; } - if (!eco.has(user.getPlayer(), amount)) { + // Pay within this command's game mode economy (its world), regardless of where either + // player is currently standing. + String world = getWorld().getName(); + if (!eco.has(user.getPlayer(), world, amount)) { user.sendMessage("invswitcher.errors.insufficient-funds"); return false; } - EconomyResponse withdrawal = eco.withdrawPlayer(user.getPlayer(), amount); + EconomyResponse withdrawal = eco.withdrawPlayer(user.getPlayer(), world, amount); if (!withdrawal.transactionSuccess()) { user.sendMessage("invswitcher.errors.insufficient-funds"); return false; } - eco.depositPlayer(target.getOfflinePlayer(), amount); + eco.depositPlayer(target.getOfflinePlayer(), world, amount); user.sendMessage("invswitcher.commands.pay.sent", TextVariables.NUMBER, eco.format(amount), TextVariables.NAME, target.getName()); if (target.isOnline()) { diff --git a/src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java b/src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java index b19fecf..59c2e1a 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java +++ b/src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java @@ -5,7 +5,7 @@ import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; -import org.bukkit.World; +import org.bukkit.plugin.RegisteredServiceProvider; import com.wasteofplastic.invswitcher.InvSwitcher; import com.wasteofplastic.invswitcher.Settings; @@ -35,29 +35,100 @@ public class InvEconomy implements Economy { private static final String INSUFFICIENT_FUNDS = "Insufficient funds"; private final InvSwitcher addon; - private final Store store; - /** The economy provider that was registered before us. May be null if none existed. */ - private final Economy delegate; + /** The economy to fall back to for unmanaged worlds (e.g. EssentialsX). Resolved lazily on + * first use, because we register before other economy plugins finish registering. */ + private Economy delegate; + private boolean delegateResolved; /** * @param addon - the InvSwitcher addon - * @param delegate - the previously-registered economy, or null if none */ - public InvEconomy(InvSwitcher addon, Economy delegate) { + public InvEconomy(InvSwitcher addon) { + this.addon = addon; + } + + /** + * Package-private constructor that sets the delegate explicitly instead of resolving it + * lazily from the services manager. Used by tests. + * @param addon - the InvSwitcher addon + * @param delegate - the delegate economy (may be null) + */ + InvEconomy(InvSwitcher addon, Economy delegate) { this.addon = addon; - this.store = addon.getStore(); this.delegate = delegate; + this.delegateResolved = true; } private Settings settings() { return addon.getSettings(); } + /** + * The store, fetched lazily. May be null very early in startup (before allLoaded), but is + * always set long before any player can trade. + */ + private Store store() { + return addon.getStore(); + } + + /** + * The player's current money key, or null if the world is unmanaged or the store is not yet + * ready (very early in startup). A null result routes the call to the delegate. + */ + private String currentKey(OfflinePlayer player) { + Store s = store(); + return s == null ? null : s.getCurrentMoneyKey(player); + } + + /** + * The player's money key for a named world, or null if unmanaged / store not ready. + */ + private String worldKey(OfflinePlayer player, String worldName) { + Store s = store(); + return s == null ? null : s.getMoneyKey(player, Bukkit.getWorld(worldName)); + } + + /** + * Resolve - once, lazily - the highest-priority economy that is not us, to delegate + * unmanaged-world transactions to. Done lazily because when we register (during onEnable) + * other economy plugins such as EssentialsX may not have registered their provider yet. + * @return the delegate economy, or null if none exists + */ + private Economy delegate() { + if (!delegateResolved) { + Economy found = null; + for (RegisteredServiceProvider r : Bukkit.getServicesManager() + .getRegistrations(Economy.class)) { + if (!(r.getProvider() instanceof InvEconomy)) { + found = r.getProvider(); + break; + } + } + delegate = found; + delegateResolved = true; + addon.log(found != null + ? "Per-world economy: delegating unmanaged-world transactions to " + found.getName() + : "Per-world economy: no other economy found; InvSwitcher is the only economy."); + } + return delegate; + } + + private void debug(String msg) { + if (settings().isEconomyDebug()) { + addon.log("[economy] " + msg); + } + } + + private static String who(OfflinePlayer player) { + String name = player.getName() != null ? player.getName() : player.getUniqueId().toString(); + return name + (player.getPlayer() != null ? " (online)" : " (offline)"); + } + /** * @return true if unmanaged worlds should be delegated to the previous provider */ private boolean delegating() { - return settings().isDelegateUnmanagedWorlds() && delegate != null; + return settings().isDelegateUnmanagedWorlds() && delegate() != null; } // ------ STATUS / FORMATTING ------ @@ -74,7 +145,7 @@ public String getName() { @Override public boolean hasBankSupport() { - return delegate != null && delegate.hasBankSupport(); + return delegate() != null && delegate().hasBankSupport(); } @Override @@ -118,19 +189,19 @@ private double ensureBalance(OfflinePlayer player, InventoryStorage s, String ke */ private double seedBalance(OfflinePlayer player, InventoryStorage s) { Settings settings = settings(); - if (settings.isImportExistingBalances() && delegate != null && !s.isImported()) { + if (settings.isImportExistingBalances() && delegate() != null && !s.isImported()) { s.setImported(true); - return delegate.getBalance(player); + return delegate().getBalance(player); } return settings.getStartingBalance(); } private double readSelf(OfflinePlayer player, String key) { - InventoryStorage s = store.getStorageObject(player.getUniqueId()); + InventoryStorage s = store().getStorageObject(player.getUniqueId()); boolean existed = s.hasMoney(key); double balance = ensureBalance(player, s, key); if (!existed) { - store.saveStorage(s); + store().saveStorage(s); } return balance; } @@ -139,10 +210,10 @@ private EconomyResponse depositSelf(OfflinePlayer player, String key, double amo if (amount < 0) { return new EconomyResponse(0, readSelf(player, key), ResponseType.FAILURE, NEGATIVE_DEPOSIT); } - InventoryStorage s = store.getStorageObject(player.getUniqueId()); + InventoryStorage s = store().getStorageObject(player.getUniqueId()); double newBalance = ensureBalance(player, s, key) + amount; s.setMoney(key, newBalance); - store.saveStorage(s); + store().saveStorage(s); return new EconomyResponse(amount, newBalance, ResponseType.SUCCESS, null); } @@ -150,11 +221,11 @@ private EconomyResponse setSelf(OfflinePlayer player, String key, double amount) if (amount < 0) { return new EconomyResponse(0, readSelf(player, key), ResponseType.FAILURE, NEGATIVE_DEPOSIT); } - InventoryStorage s = store.getStorageObject(player.getUniqueId()); + InventoryStorage s = store().getStorageObject(player.getUniqueId()); // Mark imported so a later seed does not re-import on top of an explicitly set balance s.setImported(true); s.setMoney(key, amount); - store.saveStorage(s); + store().saveStorage(s); return new EconomyResponse(amount, amount, ResponseType.SUCCESS, null); } @@ -162,14 +233,14 @@ private EconomyResponse withdrawSelf(OfflinePlayer player, String key, double am if (amount < 0) { return new EconomyResponse(0, readSelf(player, key), ResponseType.FAILURE, NEGATIVE_WITHDRAW); } - InventoryStorage s = store.getStorageObject(player.getUniqueId()); + InventoryStorage s = store().getStorageObject(player.getUniqueId()); double balance = ensureBalance(player, s, key); if (balance < amount) { return new EconomyResponse(0, balance, ResponseType.FAILURE, INSUFFICIENT_FUNDS); } double newBalance = balance - amount; s.setMoney(key, newBalance); - store.saveStorage(s); + store().saveStorage(s); return new EconomyResponse(amount, newBalance, ResponseType.SUCCESS, null); } @@ -187,18 +258,19 @@ public boolean hasAccount(OfflinePlayer player, String worldName) { @Override public double getBalance(OfflinePlayer player) { - String key = store.getCurrentMoneyKey(player); + String key = currentKey(player); + debug("getBalance " + who(player) + " key=" + key + (key == null ? (delegating() ? " -> DELEGATE" : " -> default") : "")); if (key == null) { - return delegating() ? delegate.getBalance(player) : readSelf(player, Store.DEFAULT_WORLD_KEY); + return delegating() ? delegate().getBalance(player) : readSelf(player, Store.DEFAULT_WORLD_KEY); } return readSelf(player, key); } @Override public double getBalance(OfflinePlayer player, String worldName) { - String key = store.getMoneyKey(player, Bukkit.getWorld(worldName)); + String key = worldKey(player, worldName); if (key == null) { - return delegating() ? delegate.getBalance(player, worldName) : readSelf(player, Store.DEFAULT_WORLD_KEY); + return delegating() ? delegate().getBalance(player, worldName) : readSelf(player, Store.DEFAULT_WORLD_KEY); } return readSelf(player, key); } @@ -215,9 +287,11 @@ public boolean has(OfflinePlayer player, String worldName, double amount) { @Override public EconomyResponse withdrawPlayer(OfflinePlayer player, double amount) { - String key = store.getCurrentMoneyKey(player); + String key = currentKey(player); + debug("withdrawPlayer " + who(player) + " amount=" + amount + " key=" + key + + (key == null ? (delegating() ? " -> DELEGATE" : " -> default") : " -> self")); if (key == null) { - return delegating() ? delegate.withdrawPlayer(player, amount) + return delegating() ? delegate().withdrawPlayer(player, amount) : withdrawSelf(player, Store.DEFAULT_WORLD_KEY, amount); } return withdrawSelf(player, key, amount); @@ -225,9 +299,11 @@ public EconomyResponse withdrawPlayer(OfflinePlayer player, double amount) { @Override public EconomyResponse withdrawPlayer(OfflinePlayer player, String worldName, double amount) { - String key = store.getMoneyKey(player, Bukkit.getWorld(worldName)); + String key = worldKey(player, worldName); + debug("withdrawPlayer(world) " + who(player) + " world=" + worldName + " amount=" + amount + " key=" + key + + (key == null ? (delegating() ? " -> DELEGATE" : " -> default") : " -> self")); if (key == null) { - return delegating() ? delegate.withdrawPlayer(player, worldName, amount) + return delegating() ? delegate().withdrawPlayer(player, worldName, amount) : withdrawSelf(player, Store.DEFAULT_WORLD_KEY, amount); } return withdrawSelf(player, key, amount); @@ -235,9 +311,11 @@ public EconomyResponse withdrawPlayer(OfflinePlayer player, String worldName, do @Override public EconomyResponse depositPlayer(OfflinePlayer player, double amount) { - String key = store.getCurrentMoneyKey(player); + String key = currentKey(player); + debug("depositPlayer " + who(player) + " amount=" + amount + " key=" + key + + (key == null ? (delegating() ? " -> DELEGATE" : " -> default") : " -> self")); if (key == null) { - return delegating() ? delegate.depositPlayer(player, amount) + return delegating() ? delegate().depositPlayer(player, amount) : depositSelf(player, Store.DEFAULT_WORLD_KEY, amount); } return depositSelf(player, key, amount); @@ -245,9 +323,11 @@ public EconomyResponse depositPlayer(OfflinePlayer player, double amount) { @Override public EconomyResponse depositPlayer(OfflinePlayer player, String worldName, double amount) { - String key = store.getMoneyKey(player, Bukkit.getWorld(worldName)); + String key = worldKey(player, worldName); + debug("depositPlayer(world) " + who(player) + " world=" + worldName + " amount=" + amount + " key=" + key + + (key == null ? (delegating() ? " -> DELEGATE" : " -> default") : " -> self")); if (key == null) { - return delegating() ? delegate.depositPlayer(player, worldName, amount) + return delegating() ? delegate().depositPlayer(player, worldName, amount) : depositSelf(player, Store.DEFAULT_WORLD_KEY, amount); } return depositSelf(player, key, amount); @@ -261,12 +341,34 @@ public EconomyResponse depositPlayer(OfflinePlayer player, String worldName, dou * @return the economy response */ public EconomyResponse setBalance(OfflinePlayer player, double amount) { - String key = store.getCurrentMoneyKey(player); + String key = currentKey(player); if (key == null) { if (delegating()) { // Vault has no set operation; emulate it against the delegate's balance - double delta = amount - delegate.getBalance(player); - return delta >= 0 ? delegate.depositPlayer(player, delta) : delegate.withdrawPlayer(player, -delta); + double delta = amount - delegate().getBalance(player); + return delta >= 0 ? delegate().depositPlayer(player, delta) : delegate().withdrawPlayer(player, -delta); + } + key = Store.DEFAULT_WORLD_KEY; + } + return setSelf(player, key, amount); + } + + /** + * Set a player's balance for a specific world. Not part of the Vault interface; used by + * InvSwitcher's own admin commands so that, e.g., {@code /bsb eco set} targets the BSkyBlock + * balance regardless of where the player or admin is standing. + * @param player - player + * @param worldName - world to set the balance in + * @param amount - new balance + * @return the economy response + */ + public EconomyResponse setBalance(OfflinePlayer player, String worldName, double amount) { + String key = worldKey(player, worldName); + if (key == null) { + if (delegating()) { + double delta = amount - delegate().getBalance(player, worldName); + return delta >= 0 ? delegate().depositPlayer(player, worldName, delta) + : delegate().withdrawPlayer(player, worldName, -delta); } key = Store.DEFAULT_WORLD_KEY; } @@ -365,64 +467,64 @@ private EconomyResponse bankUnsupported() { @Override public EconomyResponse createBank(String name, OfflinePlayer player) { - return delegate != null ? delegate.createBank(name, player) : bankUnsupported(); + return delegate() != null ? delegate().createBank(name, player) : bankUnsupported(); } @Override @Deprecated public EconomyResponse createBank(String name, String player) { - return delegate != null ? delegate.createBank(name, player) : bankUnsupported(); + return delegate() != null ? delegate().createBank(name, player) : bankUnsupported(); } @Override public EconomyResponse deleteBank(String name) { - return delegate != null ? delegate.deleteBank(name) : bankUnsupported(); + return delegate() != null ? delegate().deleteBank(name) : bankUnsupported(); } @Override public EconomyResponse bankBalance(String name) { - return delegate != null ? delegate.bankBalance(name) : bankUnsupported(); + return delegate() != null ? delegate().bankBalance(name) : bankUnsupported(); } @Override public EconomyResponse bankHas(String name, double amount) { - return delegate != null ? delegate.bankHas(name, amount) : bankUnsupported(); + return delegate() != null ? delegate().bankHas(name, amount) : bankUnsupported(); } @Override public EconomyResponse bankWithdraw(String name, double amount) { - return delegate != null ? delegate.bankWithdraw(name, amount) : bankUnsupported(); + return delegate() != null ? delegate().bankWithdraw(name, amount) : bankUnsupported(); } @Override public EconomyResponse bankDeposit(String name, double amount) { - return delegate != null ? delegate.bankDeposit(name, amount) : bankUnsupported(); + return delegate() != null ? delegate().bankDeposit(name, amount) : bankUnsupported(); } @Override public EconomyResponse isBankOwner(String name, OfflinePlayer player) { - return delegate != null ? delegate.isBankOwner(name, player) : bankUnsupported(); + return delegate() != null ? delegate().isBankOwner(name, player) : bankUnsupported(); } @Override @Deprecated public EconomyResponse isBankOwner(String name, String playerName) { - return delegate != null ? delegate.isBankOwner(name, playerName) : bankUnsupported(); + return delegate() != null ? delegate().isBankOwner(name, playerName) : bankUnsupported(); } @Override public EconomyResponse isBankMember(String name, OfflinePlayer player) { - return delegate != null ? delegate.isBankMember(name, player) : bankUnsupported(); + return delegate() != null ? delegate().isBankMember(name, player) : bankUnsupported(); } @Override @Deprecated public EconomyResponse isBankMember(String name, String playerName) { - return delegate != null ? delegate.isBankMember(name, playerName) : bankUnsupported(); + return delegate() != null ? delegate().isBankMember(name, playerName) : bankUnsupported(); } @Override public List getBanks() { - return delegate != null ? delegate.getBanks() : Collections.emptyList(); + return delegate() != null ? delegate().getBanks() : Collections.emptyList(); } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 069f8fa..e04efdd 100755 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -20,11 +20,12 @@ options: experience: true ender-chest: true statistics: true - # Per-world money. Requires the Vault plugin and an economy plugin (e.g. EssentialsX). - # When enabled, InvSwitcher registers itself as the Vault economy and keeps a separate - # balance for each switched world. Transactions route to the correct world even when the - # player is offline or in a different world. Worlds InvSwitcher does not manage are passed - # through to the previous economy plugin. + # Per-world money. Requires the Vault plugin. A separate economy plugin (e.g. EssentialsX) is + # optional: InvSwitcher can be the only economy. When enabled, InvSwitcher registers itself as + # the Vault economy and keeps a separate balance for each switched world. Transactions route to + # the correct world even when the player is offline or in a different world. If another economy + # plugin is present, worlds InvSwitcher does not manage are passed through to it; if not, + # InvSwitcher handles every world itself. money: true # Switch inventories based on island. Only applies if players own more than one island. # Each sub-option controls whether that aspect is switched per-island. @@ -57,3 +58,6 @@ economy: # Pass transactions for worlds InvSwitcher does not manage through to the previous economy # plugin (e.g. EssentialsX). Disable to make InvSwitcher the sole economy for every world. delegate-unmanaged-worlds: true + # Log every economy transaction (deposit/withdraw/balance) to the console for troubleshooting. + # Verbose - only enable while diagnosing a problem. + debug: false diff --git a/src/test/java/com/wasteofplastic/invswitcher/economy/InvEconomyTest.java b/src/test/java/com/wasteofplastic/invswitcher/economy/InvEconomyTest.java index 6f14c76..9b42e2c 100644 --- a/src/test/java/com/wasteofplastic/invswitcher/economy/InvEconomyTest.java +++ b/src/test/java/com/wasteofplastic/invswitcher/economy/InvEconomyTest.java @@ -206,6 +206,16 @@ public void testSetBalance() { assertEquals(42.0, economy.getBalance(player), 0.0001); } + @Test + public void testSetBalanceWorld() { + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class, RETURNS_MOCKS)) { + mockedBukkit.when(() -> Bukkit.getWorld("world")).thenReturn(world); + EconomyResponse r = economy.setBalance(player, "world", 42.0); + assertTrue(r.transactionSuccess()); + } + assertEquals(42.0, store.getStorageObject(playerUUID).getMoney("world"), 0.0001); + } + @Test public void testWorldAwareManagedRouting() { try (MockedStatic mockedBukkit = mockStatic(Bukkit.class, RETURNS_MOCKS)) { @@ -254,6 +264,20 @@ public void testOfflineRoutingUsesLastKey() { verify(delegate, never()).depositPlayer(any(Player.class), anyDouble()); } + @Test + public void testStoreNotReadyDelegates() { + // Provider registered before the store exists (the onEnable window): calls must route to + // the delegate rather than NPE. + when(addon.getStore()).thenReturn(null); + when(delegate.depositPlayer(player, 20.0)) + .thenReturn(new EconomyResponse(20, 20, ResponseType.SUCCESS, null)); + when(delegate.getBalance(player)).thenReturn(123.0); + + assertEquals(123.0, economy.getBalance(player), 0.0001); + economy.depositPlayer(player, 20.0); + verify(delegate).depositPlayer(player, 20.0); + } + @Test public void testOfflineUnknownWorldDelegates() { // Brand new player, offline, no lastKey -> cannot resolve -> delegate From 1eba9731b841d8ddd0160ea885fb0ba0e7429b1c Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 30 May 2026 10:55:49 -0700 Subject: [PATCH 09/15] chore: update api-version to 3.17.0 in addon.yml --- src/main/resources/addon.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index 7eba1d8..5cfe563 100755 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -1,7 +1,7 @@ name: InvSwitcher main: com.wasteofplastic.invswitcher.InvSwitcher version: ${version}${build.number} -api-version: 3.0.0 +api-version: 3.17.0 authors: tastybento From 75619298e0c6c2b54a2d062ad49081a4f20f33c1 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 30 May 2026 10:59:59 -0700 Subject: [PATCH 10/15] chore: gate economy console output behind economy.debug The verbose startup dump of registered Vault economy providers and the lazy delegate-resolution line now only print when economy.debug is true, keeping the console clean by default. Per-transaction logging was already gated. Co-Authored-By: Claude Opus 4.8 --- .../java/com/wasteofplastic/invswitcher/InvSwitcher.java | 6 ++++-- .../com/wasteofplastic/invswitcher/economy/InvEconomy.java | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/wasteofplastic/invswitcher/InvSwitcher.java b/src/main/java/com/wasteofplastic/invswitcher/InvSwitcher.java index 9e39e6f..4179d36 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/InvSwitcher.java +++ b/src/main/java/com/wasteofplastic/invswitcher/InvSwitcher.java @@ -131,8 +131,10 @@ private void registerEconomyProvider() { // by those addons, so refreshing it updates them too. refreshBentoBoxVaultHook(); - // Dump the current economy provider chain so it is clear that we win the registration. - logEconomyRegistrations(); + // Dump the current economy provider chain (debug only) so it is clear we win the registration. + if (getSettings().isEconomyDebug()) { + logEconomyRegistrations(); + } } /** diff --git a/src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java b/src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java index 59c2e1a..68ac154 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java +++ b/src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java @@ -106,9 +106,9 @@ private Economy delegate() { } delegate = found; delegateResolved = true; - addon.log(found != null - ? "Per-world economy: delegating unmanaged-world transactions to " + found.getName() - : "Per-world economy: no other economy found; InvSwitcher is the only economy."); + debug(found != null + ? "delegating unmanaged-world transactions to " + found.getName() + : "no other economy found; InvSwitcher is the only economy."); } return delegate; } From 5d7d3061ee62928430b7eee0e4e446e963bf0537 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 30 May 2026 12:06:00 -0700 Subject: [PATCH 11/15] feat: tab-complete online player names for admin eco commands The give/take/set/balance sub-commands of / eco take a player as their first argument; offer online player names (vanish-aware) for it via a shared tabComplete in AbstractAdminMoneyCommand. Co-Authored-By: Claude Opus 4.8 --- .../admin/AbstractAdminMoneyCommand.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminMoneyCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminMoneyCommand.java index 438e374..cfc94b8 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminMoneyCommand.java +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminMoneyCommand.java @@ -1,10 +1,15 @@ package com.wasteofplastic.invswitcher.commands.admin; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + import com.wasteofplastic.invswitcher.commands.AbstractMoneyCommand; import world.bentobox.bentobox.api.commands.CompositeCommand; import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.util.Util; /** * Shared base for admin economy sub-commands of the form {@code }. @@ -30,4 +35,17 @@ protected User resolveTarget(User user, String name) { } return target; } + + /** + * Tab-complete the player parameter (the first argument of these commands) with the names of + * online players. The command tree is {@code eco [amount]}, so the + * player slot is the third token in the dispatched args. + */ + @Override + public Optional> tabComplete(User user, String alias, List args) { + if (args.size() == 3) { + return Optional.of(Util.tabLimit(new ArrayList<>(Util.getOnlinePlayerList(user)), args.get(2))); + } + return Optional.empty(); + } } From d29d10582ed7a1bdb82736150efa6f1087c61ff3 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 30 May 2026 12:06:00 -0700 Subject: [PATCH 12/15] i18n: add locale files for all BentoBox-supported languages Add translated locale files (cs, de, es, fr, hr, hu, id, it, ja, ko, lv, nl, pl, pt-BR, pt, ro, ru, tr, uk, vi, zh-CN, zh-HK) mirroring the languages shipped by BentoBox. Each matches en-US's keys, preserving colour codes, [number]/[name] placeholders and command syntax. Co-Authored-By: Claude Opus 4.8 --- src/main/resources/locales/cs.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/de.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/es.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/fr.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/hr.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/hu.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/id.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/it.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/ja.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/ko.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/lv.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/nl.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/pl.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/pt-BR.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/pt.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/ro.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/ru.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/tr.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/uk.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/vi.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/zh-CN.yml | 38 ++++++++++++++++++++++++++++ src/main/resources/locales/zh-HK.yml | 38 ++++++++++++++++++++++++++++ 22 files changed, 836 insertions(+) create mode 100644 src/main/resources/locales/cs.yml create mode 100644 src/main/resources/locales/de.yml create mode 100644 src/main/resources/locales/es.yml create mode 100644 src/main/resources/locales/fr.yml create mode 100644 src/main/resources/locales/hr.yml create mode 100644 src/main/resources/locales/hu.yml create mode 100644 src/main/resources/locales/id.yml create mode 100644 src/main/resources/locales/it.yml create mode 100644 src/main/resources/locales/ja.yml create mode 100644 src/main/resources/locales/ko.yml create mode 100644 src/main/resources/locales/lv.yml create mode 100644 src/main/resources/locales/nl.yml create mode 100644 src/main/resources/locales/pl.yml create mode 100644 src/main/resources/locales/pt-BR.yml create mode 100644 src/main/resources/locales/pt.yml create mode 100644 src/main/resources/locales/ro.yml create mode 100644 src/main/resources/locales/ru.yml create mode 100644 src/main/resources/locales/tr.yml create mode 100644 src/main/resources/locales/uk.yml create mode 100644 src/main/resources/locales/vi.yml create mode 100644 src/main/resources/locales/zh-CN.yml create mode 100644 src/main/resources/locales/zh-HK.yml diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml new file mode 100644 index 0000000..565a8fb --- /dev/null +++ b/src/main/resources/locales/cs.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Czech +########################################################################################### +invswitcher: + commands: + balance: + description: "zobrazí tvůj zůstatek pro tento svět" + balance: "&a Zůstatek: &e[number]" + pay: + description: "zaplatit jinému hráči" + parameters: " " + sent: "&a Zaplatil jsi [number] hráči [name]" + received: "&a Obdržel jsi [number] od hráče [name]" + admin: + eco: + description: "spravovat peníze hráčů v tomto světě" + give: + description: "dát hráči peníze" + parameters: " " + success: "&a Peníze předány hráči [name]. Nový zůstatek: [number]" + take: + description: "vzít hráči peníze" + parameters: " " + success: "&a Peníze odebrány hráči [name]. Nový zůstatek: [number]" + set: + description: "nastavit zůstatek hráče" + parameters: " " + success: "&a Zůstatek hráče [name] nastaven na [number]" + balance: + description: "zobrazit zůstatek hráče" + parameters: "" + balance: "&a Zůstatek hráče [name]: &e[number]" + errors: + no-economy: "&c Ekonomika není dostupná." + insufficient-funds: "&c Nedostatek peněz." + not-a-number: "&c '[number]' není platné číslo." + must-be-positive: "&c Částka musí být kladná." + cannot-pay-self: "&c Nemůžeš zaplatit sám sobě." diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml new file mode 100644 index 0000000..ad4d8fc --- /dev/null +++ b/src/main/resources/locales/de.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - German +########################################################################################### +invswitcher: + commands: + balance: + description: "zeige deinen Kontostand für diese Welt" + balance: "&a Kontostand: &e[number]" + pay: + description: "zahle einem anderen Spieler Geld" + parameters: " " + sent: "&a Du hast [number] an [name] gezahlt" + received: "&a Du hast [number] von [name] erhalten" + admin: + eco: + description: "verwalte das Geld von Spielern in dieser Welt" + give: + description: "gib einem Spieler Geld" + parameters: " " + success: "&a [name] Geld gegeben. Neuer Kontostand: [number]" + take: + description: "nimm einem Spieler Geld weg" + parameters: " " + success: "&a [name] Geld weggenommen. Neuer Kontostand: [number]" + set: + description: "setze den Kontostand eines Spielers" + parameters: " " + success: "&a Kontostand von [name] auf [number] gesetzt" + balance: + description: "zeige den Kontostand eines Spielers" + parameters: "" + balance: "&a Kontostand von [name]: &e[number]" + errors: + no-economy: "&c Die Wirtschaft ist nicht verfügbar." + insufficient-funds: "&c Nicht genügend Geld." + not-a-number: "&c '[number]' ist keine gültige Zahl." + must-be-positive: "&c Der Betrag muss positiv sein." + cannot-pay-self: "&c Du kannst dir nicht selbst Geld zahlen." diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml new file mode 100644 index 0000000..76164d5 --- /dev/null +++ b/src/main/resources/locales/es.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Spanish +########################################################################################### +invswitcher: + commands: + balance: + description: "muestra tu saldo en este mundo" + balance: "&a Saldo: &e[number]" + pay: + description: "paga a otro jugador" + parameters: " " + sent: "&a Has pagado [number] a [name]" + received: "&a Has recibido [number] de [name]" + admin: + eco: + description: "gestiona el dinero de los jugadores en este mundo" + give: + description: "da dinero a un jugador" + parameters: " " + success: "&a Dinero dado a [name]. Nuevo saldo: [number]" + take: + description: "quita dinero a un jugador" + parameters: " " + success: "&a Dinero quitado a [name]. Nuevo saldo: [number]" + set: + description: "establece el saldo de un jugador" + parameters: " " + success: "&a Saldo de [name] establecido en [number]" + balance: + description: "muestra el saldo de un jugador" + parameters: "" + balance: "&a Saldo de [name]: &e[number]" + errors: + no-economy: "&c La economía no está disponible." + insufficient-funds: "&c Fondos insuficientes." + not-a-number: "&c '[number]' no es un número válido." + must-be-positive: "&c La cantidad debe ser positiva." + cannot-pay-self: "&c No puedes pagarte a ti mismo." diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml new file mode 100644 index 0000000..fc14fc7 --- /dev/null +++ b/src/main/resources/locales/fr.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - French +########################################################################################### +invswitcher: + commands: + balance: + description: "affiche votre solde pour ce monde" + balance: "&a Solde : &e[number]" + pay: + description: "payer un autre joueur" + parameters: " " + sent: "&a Vous avez payé [number] à [name]" + received: "&a Vous avez reçu [number] de [name]" + admin: + eco: + description: "gérer l'argent des joueurs dans ce monde" + give: + description: "donner de l'argent à un joueur" + parameters: " " + success: "&a Argent donné à [name]. Nouveau solde : [number]" + take: + description: "retirer de l'argent à un joueur" + parameters: " " + success: "&a Argent retiré à [name]. Nouveau solde : [number]" + set: + description: "définir le solde d'un joueur" + parameters: " " + success: "&a Solde de [name] défini à [number]" + balance: + description: "afficher le solde d'un joueur" + parameters: "" + balance: "&a Solde de [name] : &e[number]" + errors: + no-economy: "&c L'économie n'est pas disponible." + insufficient-funds: "&c Fonds insuffisants." + not-a-number: "&c '[number]' n'est pas un nombre valide." + must-be-positive: "&c Le montant doit être positif." + cannot-pay-self: "&c Vous ne pouvez pas vous payer vous-même." diff --git a/src/main/resources/locales/hr.yml b/src/main/resources/locales/hr.yml new file mode 100644 index 0000000..8629299 --- /dev/null +++ b/src/main/resources/locales/hr.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Croatian +########################################################################################### +invswitcher: + commands: + balance: + description: "prikaži svoj saldo za ovaj svijet" + balance: "&a Saldo: &e[number]" + pay: + description: "plati drugom igraču" + parameters: " " + sent: "&a Platio si [number] igraču [name]" + received: "&a Primio si [number] od igrača [name]" + admin: + eco: + description: "upravljaj novcem igrača u ovom svijetu" + give: + description: "daj novac igraču" + parameters: " " + success: "&a Novac dan igraču [name]. Novi saldo: [number]" + take: + description: "oduzmi novac igraču" + parameters: " " + success: "&a Novac oduzet igraču [name]. Novi saldo: [number]" + set: + description: "postavi saldo igrača" + parameters: " " + success: "&a Saldo igrača [name] postavljen na [number]" + balance: + description: "prikaži saldo igrača" + parameters: "" + balance: "&a Saldo igrača [name]: &e[number]" + errors: + no-economy: "&c Ekonomija nije dostupna." + insufficient-funds: "&c Nedovoljno sredstava." + not-a-number: "&c '[number]' nije valjani broj." + must-be-positive: "&c Iznos mora biti pozitivan." + cannot-pay-self: "&c Ne možeš platiti samom sebi." diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml new file mode 100644 index 0000000..743a000 --- /dev/null +++ b/src/main/resources/locales/hu.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Hungarian +########################################################################################### +invswitcher: + commands: + balance: + description: "megmutatja az egyenlegedet ezen a világon" + balance: "&a Egyenleg: &e[number]" + pay: + description: "fizetés egy másik játékosnak" + parameters: " " + sent: "&a Fizettél [number] összeget neki: [name]" + received: "&a Kaptál [number] összeget tőle: [name]" + admin: + eco: + description: "a játékosok pénzének kezelése ezen a világon" + give: + description: "pénz adása egy játékosnak" + parameters: " " + success: "&a Pénz adva neki: [name]. Új egyenleg: [number]" + take: + description: "pénz elvétele egy játékostól" + parameters: " " + success: "&a Pénz elvéve tőle: [name]. Új egyenleg: [number]" + set: + description: "egy játékos egyenlegének beállítása" + parameters: " " + success: "&a [name] egyenlege beállítva: [number]" + balance: + description: "egy játékos egyenlegének megjelenítése" + parameters: "" + balance: "&a [name] egyenlege: &e[number]" + errors: + no-economy: "&c A gazdaság nem érhető el." + insufficient-funds: "&c Nincs elég pénzed." + not-a-number: "&c '[number]' nem érvényes szám." + must-be-positive: "&c Az összegnek pozitívnak kell lennie." + cannot-pay-self: "&c Nem fizethetsz saját magadnak." diff --git a/src/main/resources/locales/id.yml b/src/main/resources/locales/id.yml new file mode 100644 index 0000000..cebeb93 --- /dev/null +++ b/src/main/resources/locales/id.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Indonesian +########################################################################################### +invswitcher: + commands: + balance: + description: "tampilkan saldo uangmu untuk dunia ini" + balance: "&a Saldo: &e[number]" + pay: + description: "bayar pemain lain" + parameters: " " + sent: "&a Kamu membayar [number] kepada [name]" + received: "&a Kamu menerima [number] dari [name]" + admin: + eco: + description: "kelola uang pemain untuk dunia ini" + give: + description: "beri uang kepada pemain" + parameters: " " + success: "&a Memberi uang kepada [name]. Saldo baru: [number]" + take: + description: "ambil uang dari pemain" + parameters: " " + success: "&a Mengambil uang dari [name]. Saldo baru: [number]" + set: + description: "atur saldo pemain" + parameters: " " + success: "&a Saldo [name] diatur menjadi [number]" + balance: + description: "tampilkan saldo pemain" + parameters: "" + balance: "&a Saldo [name]: &e[number]" + errors: + no-economy: "&c Ekonomi tidak tersedia." + insufficient-funds: "&c Dana tidak cukup." + not-a-number: "&c '[number]' bukan angka yang valid." + must-be-positive: "&c Jumlah harus positif." + cannot-pay-self: "&c Kamu tidak bisa membayar dirimu sendiri." diff --git a/src/main/resources/locales/it.yml b/src/main/resources/locales/it.yml new file mode 100644 index 0000000..a2bd4d4 --- /dev/null +++ b/src/main/resources/locales/it.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Italian +########################################################################################### +invswitcher: + commands: + balance: + description: "mostra il tuo saldo per questo mondo" + balance: "&a Saldo: &e[number]" + pay: + description: "paga un altro giocatore" + parameters: " " + sent: "&a Hai pagato [number] a [name]" + received: "&a Hai ricevuto [number] da [name]" + admin: + eco: + description: "gestisci il denaro dei giocatori in questo mondo" + give: + description: "dai denaro a un giocatore" + parameters: " " + success: "&a Denaro dato a [name]. Nuovo saldo: [number]" + take: + description: "togli denaro a un giocatore" + parameters: " " + success: "&a Denaro tolto a [name]. Nuovo saldo: [number]" + set: + description: "imposta il saldo di un giocatore" + parameters: " " + success: "&a Saldo di [name] impostato a [number]" + balance: + description: "mostra il saldo di un giocatore" + parameters: "" + balance: "&a Saldo di [name]: &e[number]" + errors: + no-economy: "&c L'economia non è disponibile." + insufficient-funds: "&c Fondi insufficienti." + not-a-number: "&c '[number]' non è un numero valido." + must-be-positive: "&c L'importo deve essere positivo." + cannot-pay-self: "&c Non puoi pagare te stesso." diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml new file mode 100644 index 0000000..0aec612 --- /dev/null +++ b/src/main/resources/locales/ja.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Japanese +########################################################################################### +invswitcher: + commands: + balance: + description: "このワールドでの所持金を表示します" + balance: "&a 残高: &e[number]" + pay: + description: "他のプレイヤーに支払います" + parameters: " " + sent: "&a [name] に [number] を支払いました" + received: "&a [name] から [number] を受け取りました" + admin: + eco: + description: "このワールドのプレイヤーの所持金を管理します" + give: + description: "プレイヤーにお金を与えます" + parameters: " " + success: "&a [name] にお金を与えました。新しい残高: [number]" + take: + description: "プレイヤーからお金を取り上げます" + parameters: " " + success: "&a [name] からお金を取り上げました。新しい残高: [number]" + set: + description: "プレイヤーの残高を設定します" + parameters: " " + success: "&a [name] の残高を [number] に設定しました" + balance: + description: "プレイヤーの残高を表示します" + parameters: "" + balance: "&a [name] の残高: &e[number]" + errors: + no-economy: "&c 経済機能が利用できません。" + insufficient-funds: "&c 残高が不足しています。" + not-a-number: "&c '[number]' は有効な数値ではありません。" + must-be-positive: "&c 金額は正の値である必要があります。" + cannot-pay-self: "&c 自分自身に支払うことはできません。" diff --git a/src/main/resources/locales/ko.yml b/src/main/resources/locales/ko.yml new file mode 100644 index 0000000..51ed7a9 --- /dev/null +++ b/src/main/resources/locales/ko.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Korean +########################################################################################### +invswitcher: + commands: + balance: + description: "이 월드에서의 잔액을 표시합니다" + balance: "&a 잔액: &e[number]" + pay: + description: "다른 플레이어에게 지불합니다" + parameters: " " + sent: "&a [name] 님에게 [number] 을(를) 지불했습니다" + received: "&a [name] 님으로부터 [number] 을(를) 받았습니다" + admin: + eco: + description: "이 월드의 플레이어 돈을 관리합니다" + give: + description: "플레이어에게 돈을 지급합니다" + parameters: " " + success: "&a [name] 님에게 돈을 지급했습니다. 새 잔액: [number]" + take: + description: "플레이어에게서 돈을 회수합니다" + parameters: " " + success: "&a [name] 님에게서 돈을 회수했습니다. 새 잔액: [number]" + set: + description: "플레이어의 잔액을 설정합니다" + parameters: " " + success: "&a [name] 님의 잔액을 [number] (으)로 설정했습니다" + balance: + description: "플레이어의 잔액을 표시합니다" + parameters: "" + balance: "&a [name] 님의 잔액: &e[number]" + errors: + no-economy: "&c 경제 시스템을 사용할 수 없습니다." + insufficient-funds: "&c 잔액이 부족합니다." + not-a-number: "&c '[number]' 은(는) 올바른 숫자가 아닙니다." + must-be-positive: "&c 금액은 0보다 커야 합니다." + cannot-pay-self: "&c 자기 자신에게 지불할 수 없습니다." diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml new file mode 100644 index 0000000..d79b1f1 --- /dev/null +++ b/src/main/resources/locales/lv.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Latvian +########################################################################################### +invswitcher: + commands: + balance: + description: "parāda tavu naudas atlikumu šajā pasaulē" + balance: "&a Atlikums: &e[number]" + pay: + description: "samaksāt citam spēlētājam" + parameters: " " + sent: "&a Tu samaksāji [number] spēlētājam [name]" + received: "&a Tu saņēmi [number] no spēlētāja [name]" + admin: + eco: + description: "pārvaldīt spēlētāju naudu šajā pasaulē" + give: + description: "iedot spēlētājam naudu" + parameters: " " + success: "&a Nauda iedota spēlētājam [name]. Jaunais atlikums: [number]" + take: + description: "atņemt spēlētājam naudu" + parameters: " " + success: "&a Nauda atņemta spēlētājam [name]. Jaunais atlikums: [number]" + set: + description: "iestatīt spēlētāja atlikumu" + parameters: " " + success: "&a Spēlētāja [name] atlikums iestatīts uz [number]" + balance: + description: "parādīt spēlētāja atlikumu" + parameters: "" + balance: "&a Spēlētāja [name] atlikums: &e[number]" + errors: + no-economy: "&c Ekonomika nav pieejama." + insufficient-funds: "&c Nepietiek naudas." + not-a-number: "&c '[number]' nav derīgs skaitlis." + must-be-positive: "&c Summai jābūt pozitīvai." + cannot-pay-self: "&c Tu nevari samaksāt pats sev." diff --git a/src/main/resources/locales/nl.yml b/src/main/resources/locales/nl.yml new file mode 100644 index 0000000..c8446cd --- /dev/null +++ b/src/main/resources/locales/nl.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Dutch +########################################################################################### +invswitcher: + commands: + balance: + description: "toon je saldo voor deze wereld" + balance: "&a Saldo: &e[number]" + pay: + description: "betaal een andere speler" + parameters: " " + sent: "&a Je hebt [number] aan [name] betaald" + received: "&a Je hebt [number] van [name] ontvangen" + admin: + eco: + description: "beheer het geld van spelers in deze wereld" + give: + description: "geef geld aan een speler" + parameters: " " + success: "&a Geld gegeven aan [name]. Nieuw saldo: [number]" + take: + description: "neem geld af van een speler" + parameters: " " + success: "&a Geld afgenomen van [name]. Nieuw saldo: [number]" + set: + description: "stel het saldo van een speler in" + parameters: " " + success: "&a Saldo van [name] ingesteld op [number]" + balance: + description: "toon het saldo van een speler" + parameters: "" + balance: "&a Saldo van [name]: &e[number]" + errors: + no-economy: "&c De economie is niet beschikbaar." + insufficient-funds: "&c Onvoldoende geld." + not-a-number: "&c '[number]' is geen geldig getal." + must-be-positive: "&c Het bedrag moet positief zijn." + cannot-pay-self: "&c Je kunt niet aan jezelf betalen." diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml new file mode 100644 index 0000000..2402a07 --- /dev/null +++ b/src/main/resources/locales/pl.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Polish +########################################################################################### +invswitcher: + commands: + balance: + description: "pokaż swój stan konta dla tego świata" + balance: "&a Stan konta: &e[number]" + pay: + description: "zapłać innemu graczowi" + parameters: " " + sent: "&a Zapłaciłeś [number] graczowi [name]" + received: "&a Otrzymałeś [number] od gracza [name]" + admin: + eco: + description: "zarządzaj pieniędzmi graczy w tym świecie" + give: + description: "daj graczowi pieniądze" + parameters: " " + success: "&a Przekazano pieniądze graczowi [name]. Nowy stan konta: [number]" + take: + description: "zabierz graczowi pieniądze" + parameters: " " + success: "&a Zabrano pieniądze graczowi [name]. Nowy stan konta: [number]" + set: + description: "ustaw stan konta gracza" + parameters: " " + success: "&a Stan konta gracza [name] ustawiono na [number]" + balance: + description: "pokaż stan konta gracza" + parameters: "" + balance: "&a Stan konta gracza [name]: &e[number]" + errors: + no-economy: "&c Ekonomia jest niedostępna." + insufficient-funds: "&c Niewystarczające środki." + not-a-number: "&c '[number]' nie jest prawidłową liczbą." + must-be-positive: "&c Kwota musi być dodatnia." + cannot-pay-self: "&c Nie możesz zapłacić samemu sobie." diff --git a/src/main/resources/locales/pt-BR.yml b/src/main/resources/locales/pt-BR.yml new file mode 100644 index 0000000..3514967 --- /dev/null +++ b/src/main/resources/locales/pt-BR.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Brazilian Portuguese +########################################################################################### +invswitcher: + commands: + balance: + description: "mostra seu saldo neste mundo" + balance: "&a Saldo: &e[number]" + pay: + description: "pague a outro jogador" + parameters: " " + sent: "&a Você pagou [number] para [name]" + received: "&a Você recebeu [number] de [name]" + admin: + eco: + description: "gerencie o dinheiro dos jogadores neste mundo" + give: + description: "dê dinheiro a um jogador" + parameters: " " + success: "&a Dinheiro dado a [name]. Novo saldo: [number]" + take: + description: "retire dinheiro de um jogador" + parameters: " " + success: "&a Dinheiro retirado de [name]. Novo saldo: [number]" + set: + description: "defina o saldo de um jogador" + parameters: " " + success: "&a Saldo de [name] definido para [number]" + balance: + description: "mostre o saldo de um jogador" + parameters: "" + balance: "&a Saldo de [name]: &e[number]" + errors: + no-economy: "&c A economia não está disponível." + insufficient-funds: "&c Fundos insuficientes." + not-a-number: "&c '[number]' não é um número válido." + must-be-positive: "&c A quantia deve ser positiva." + cannot-pay-self: "&c Você não pode pagar a si mesmo." diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml new file mode 100644 index 0000000..74dbe5c --- /dev/null +++ b/src/main/resources/locales/pt.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Portuguese +########################################################################################### +invswitcher: + commands: + balance: + description: "mostra o teu saldo neste mundo" + balance: "&a Saldo: &e[number]" + pay: + description: "paga a outro jogador" + parameters: " " + sent: "&a Pagaste [number] a [name]" + received: "&a Recebeste [number] de [name]" + admin: + eco: + description: "gere o dinheiro dos jogadores neste mundo" + give: + description: "dá dinheiro a um jogador" + parameters: " " + success: "&a Dinheiro dado a [name]. Novo saldo: [number]" + take: + description: "retira dinheiro a um jogador" + parameters: " " + success: "&a Dinheiro retirado a [name]. Novo saldo: [number]" + set: + description: "define o saldo de um jogador" + parameters: " " + success: "&a Saldo de [name] definido para [number]" + balance: + description: "mostra o saldo de um jogador" + parameters: "" + balance: "&a Saldo de [name]: &e[number]" + errors: + no-economy: "&c A economia não está disponível." + insufficient-funds: "&c Fundos insuficientes." + not-a-number: "&c '[number]' não é um número válido." + must-be-positive: "&c O valor deve ser positivo." + cannot-pay-self: "&c Não podes pagar a ti próprio." diff --git a/src/main/resources/locales/ro.yml b/src/main/resources/locales/ro.yml new file mode 100644 index 0000000..1614ec7 --- /dev/null +++ b/src/main/resources/locales/ro.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Romanian +########################################################################################### +invswitcher: + commands: + balance: + description: "afișează soldul tău pentru această lume" + balance: "&a Sold: &e[number]" + pay: + description: "plătește alt jucător" + parameters: " " + sent: "&a Ai plătit [number] lui [name]" + received: "&a Ai primit [number] de la [name]" + admin: + eco: + description: "gestionează banii jucătorilor în această lume" + give: + description: "dă bani unui jucător" + parameters: " " + success: "&a Bani dați lui [name]. Sold nou: [number]" + take: + description: "ia bani de la un jucător" + parameters: " " + success: "&a Bani luați de la [name]. Sold nou: [number]" + set: + description: "setează soldul unui jucător" + parameters: " " + success: "&a Soldul lui [name] setat la [number]" + balance: + description: "afișează soldul unui jucător" + parameters: "" + balance: "&a Soldul lui [name]: &e[number]" + errors: + no-economy: "&c Economia nu este disponibilă." + insufficient-funds: "&c Fonduri insuficiente." + not-a-number: "&c '[number]' nu este un număr valid." + must-be-positive: "&c Suma trebuie să fie pozitivă." + cannot-pay-self: "&c Nu te poți plăti pe tine însuți." diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml new file mode 100644 index 0000000..77ccade --- /dev/null +++ b/src/main/resources/locales/ru.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Russian +########################################################################################### +invswitcher: + commands: + balance: + description: "показать ваш баланс для этого мира" + balance: "&a Баланс: &e[number]" + pay: + description: "заплатить другому игроку" + parameters: " " + sent: "&a Вы заплатили [number] игроку [name]" + received: "&a Вы получили [number] от игрока [name]" + admin: + eco: + description: "управлять деньгами игроков в этом мире" + give: + description: "выдать деньги игроку" + parameters: " " + success: "&a Деньги выданы игроку [name]. Новый баланс: [number]" + take: + description: "забрать деньги у игрока" + parameters: " " + success: "&a Деньги забраны у игрока [name]. Новый баланс: [number]" + set: + description: "установить баланс игрока" + parameters: " " + success: "&a Баланс игрока [name] установлен на [number]" + balance: + description: "показать баланс игрока" + parameters: "" + balance: "&a Баланс игрока [name]: &e[number]" + errors: + no-economy: "&c Экономика недоступна." + insufficient-funds: "&c Недостаточно средств." + not-a-number: "&c '[number]' не является допустимым числом." + must-be-positive: "&c Сумма должна быть положительной." + cannot-pay-self: "&c Вы не можете заплатить самому себе." diff --git a/src/main/resources/locales/tr.yml b/src/main/resources/locales/tr.yml new file mode 100644 index 0000000..28c3acb --- /dev/null +++ b/src/main/resources/locales/tr.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Turkish +########################################################################################### +invswitcher: + commands: + balance: + description: "bu dünyadaki bakiyeni gösterir" + balance: "&a Bakiye: &e[number]" + pay: + description: "başka bir oyuncuya öde" + parameters: " " + sent: "&a [name] oyuncusuna [number] ödedin" + received: "&a [name] oyuncusundan [number] aldın" + admin: + eco: + description: "bu dünyadaki oyuncuların parasını yönet" + give: + description: "bir oyuncuya para ver" + parameters: " " + success: "&a [name] oyuncusuna para verildi. Yeni bakiye: [number]" + take: + description: "bir oyuncudan para al" + parameters: " " + success: "&a [name] oyuncusundan para alındı. Yeni bakiye: [number]" + set: + description: "bir oyuncunun bakiyesini ayarla" + parameters: " " + success: "&a [name] oyuncusunun bakiyesi [number] olarak ayarlandı" + balance: + description: "bir oyuncunun bakiyesini göster" + parameters: "" + balance: "&a [name] oyuncusunun bakiyesi: &e[number]" + errors: + no-economy: "&c Ekonomi kullanılamıyor." + insufficient-funds: "&c Yetersiz bakiye." + not-a-number: "&c '[number]' geçerli bir sayı değil." + must-be-positive: "&c Miktar pozitif olmalıdır." + cannot-pay-self: "&c Kendine ödeme yapamazsın." diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml new file mode 100644 index 0000000..0e8026f --- /dev/null +++ b/src/main/resources/locales/uk.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Ukrainian +########################################################################################### +invswitcher: + commands: + balance: + description: "показати ваш баланс для цього світу" + balance: "&a Баланс: &e[number]" + pay: + description: "заплатити іншому гравцеві" + parameters: " " + sent: "&a Ви заплатили [number] гравцеві [name]" + received: "&a Ви отримали [number] від гравця [name]" + admin: + eco: + description: "керувати грошима гравців у цьому світі" + give: + description: "видати гроші гравцеві" + parameters: " " + success: "&a Гроші видано гравцеві [name]. Новий баланс: [number]" + take: + description: "забрати гроші у гравця" + parameters: " " + success: "&a Гроші забрано у гравця [name]. Новий баланс: [number]" + set: + description: "встановити баланс гравця" + parameters: " " + success: "&a Баланс гравця [name] встановлено на [number]" + balance: + description: "показати баланс гравця" + parameters: "" + balance: "&a Баланс гравця [name]: &e[number]" + errors: + no-economy: "&c Економіка недоступна." + insufficient-funds: "&c Недостатньо коштів." + not-a-number: "&c '[number]' не є дійсним числом." + must-be-positive: "&c Сума повинна бути додатною." + cannot-pay-self: "&c Ви не можете заплатити самому собі." diff --git a/src/main/resources/locales/vi.yml b/src/main/resources/locales/vi.yml new file mode 100644 index 0000000..f13020e --- /dev/null +++ b/src/main/resources/locales/vi.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Vietnamese +########################################################################################### +invswitcher: + commands: + balance: + description: "hiển thị số dư của bạn cho thế giới này" + balance: "&a Số dư: &e[number]" + pay: + description: "trả tiền cho người chơi khác" + parameters: " " + sent: "&a Bạn đã trả [number] cho [name]" + received: "&a Bạn đã nhận [number] từ [name]" + admin: + eco: + description: "quản lý tiền của người chơi trong thế giới này" + give: + description: "đưa tiền cho người chơi" + parameters: " " + success: "&a Đã đưa tiền cho [name]. Số dư mới: [number]" + take: + description: "lấy tiền từ người chơi" + parameters: " " + success: "&a Đã lấy tiền từ [name]. Số dư mới: [number]" + set: + description: "đặt số dư của người chơi" + parameters: " " + success: "&a Đã đặt số dư của [name] thành [number]" + balance: + description: "hiển thị số dư của người chơi" + parameters: "" + balance: "&a Số dư của [name]: &e[number]" + errors: + no-economy: "&c Nền kinh tế không khả dụng." + insufficient-funds: "&c Không đủ tiền." + not-a-number: "&c '[number]' không phải là số hợp lệ." + must-be-positive: "&c Số tiền phải là số dương." + cannot-pay-self: "&c Bạn không thể trả tiền cho chính mình." diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml new file mode 100644 index 0000000..620f6f4 --- /dev/null +++ b/src/main/resources/locales/zh-CN.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Simplified Chinese +########################################################################################### +invswitcher: + commands: + balance: + description: "显示你在此世界的余额" + balance: "&a 余额:&e[number]" + pay: + description: "向其他玩家付款" + parameters: " " + sent: "&a 你已向 [name] 支付 [number]" + received: "&a 你已从 [name] 收到 [number]" + admin: + eco: + description: "管理此世界中玩家的金钱" + give: + description: "给予玩家金钱" + parameters: " " + success: "&a 已给予 [name] 金钱。新余额:[number]" + take: + description: "扣除玩家金钱" + parameters: " " + success: "&a 已扣除 [name] 金钱。新余额:[number]" + set: + description: "设置玩家的余额" + parameters: " " + success: "&a 已将 [name] 的余额设置为 [number]" + balance: + description: "显示玩家的余额" + parameters: "" + balance: "&a [name] 的余额:&e[number]" + errors: + no-economy: "&c 经济功能不可用。" + insufficient-funds: "&c 余额不足。" + not-a-number: "&c '[number]' 不是有效的数字。" + must-be-positive: "&c 金额必须为正数。" + cannot-pay-self: "&c 你不能向自己付款。" diff --git a/src/main/resources/locales/zh-HK.yml b/src/main/resources/locales/zh-HK.yml new file mode 100644 index 0000000..c74cc74 --- /dev/null +++ b/src/main/resources/locales/zh-HK.yml @@ -0,0 +1,38 @@ +########################################################################################### +# InvSwitcher locale file - Traditional Chinese +########################################################################################### +invswitcher: + commands: + balance: + description: "顯示你在此世界的餘額" + balance: "&a 餘額:&e[number]" + pay: + description: "向其他玩家付款" + parameters: " " + sent: "&a 你已向 [name] 支付 [number]" + received: "&a 你已從 [name] 收到 [number]" + admin: + eco: + description: "管理此世界中玩家的金錢" + give: + description: "給予玩家金錢" + parameters: " " + success: "&a 已給予 [name] 金錢。新餘額:[number]" + take: + description: "扣除玩家金錢" + parameters: " " + success: "&a 已扣除 [name] 金錢。新餘額:[number]" + set: + description: "設定玩家的餘額" + parameters: " " + success: "&a 已將 [name] 的餘額設定為 [number]" + balance: + description: "顯示玩家的餘額" + parameters: "" + balance: "&a [name] 的餘額:&e[number]" + errors: + no-economy: "&c 經濟功能無法使用。" + insufficient-funds: "&c 餘額不足。" + not-a-number: "&c '[number]' 不是有效的數字。" + must-be-positive: "&c 金額必須為正數。" + cannot-pay-self: "&c 你不能向自己付款。" From 020aab861ece30fd4b04457201420a2771c4f685 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 30 May 2026 12:08:18 -0700 Subject: [PATCH 13/15] chore: bump version to 1.18.0 Co-Authored-By: Claude Opus 4.8 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 0c1bbd1..84c291a 100644 --- a/pom.xml +++ b/pom.xml @@ -65,7 +65,7 @@ -LOCAL - 1.17.2 + 1.18.0 BentoBoxWorld_addon-invSwitcher bentobox-world From b73cd7d6d45fb775dc0434215d511b2d492fe9d6 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 30 May 2026 17:52:35 -0700 Subject: [PATCH 14/15] refactor: remove duplicated code in admin economy commands SonarCloud flagged 40-55% duplication across the admin eco command classes. Extract the shared scaffolding into AbstractAdminAmountCommand (template method: subclasses supply the money op and success key), and add requireEconomy() and sendBalanceMessage() helpers to the command bases. Give/ take/set/balance and pay now delegate to these instead of repeating the validation and balance-reporting blocks. No behaviour change; 120 tests pass. Co-Authored-By: Claude Opus 4.8 --- .../commands/AbstractMoneyCommand.java | 13 ++++ .../admin/AbstractAdminAmountCommand.java | 68 +++++++++++++++++++ .../admin/AbstractAdminMoneyCommand.java | 12 ++++ .../commands/admin/AdminBalanceCommand.java | 13 +--- .../commands/admin/AdminGiveCommand.java | 40 +++-------- .../commands/admin/AdminSetCommand.java | 40 +++-------- .../commands/admin/AdminTakeCommand.java | 43 +++--------- .../invswitcher/commands/user/PayCommand.java | 3 +- 8 files changed, 129 insertions(+), 103 deletions(-) create mode 100644 src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminAmountCommand.java diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/AbstractMoneyCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/AbstractMoneyCommand.java index b6ac292..057f4c1 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/commands/AbstractMoneyCommand.java +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/AbstractMoneyCommand.java @@ -40,6 +40,19 @@ protected InvEconomy economy() { return addon.getEconomy(); } + /** + * Get the economy or send the "no economy" error to the user. + * @param user - command sender + * @return the economy, or null if unavailable (an error has been sent) + */ + protected InvEconomy requireEconomy(User user) { + InvEconomy eco = economy(); + if (eco == null) { + user.sendMessage("invswitcher.errors.no-economy"); + } + return eco; + } + /** * Parse a strictly-positive money amount, sending an error message on failure. * @param user - command sender diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminAmountCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminAmountCommand.java new file mode 100644 index 0000000..690a5c3 --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminAmountCommand.java @@ -0,0 +1,68 @@ +package com.wasteofplastic.invswitcher.commands.admin; + +import java.util.List; + +import org.bukkit.OfflinePlayer; + +import com.wasteofplastic.invswitcher.economy.InvEconomy; + +import net.milkbowl.vault.economy.EconomyResponse; +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; + +/** + * Base for admin economy commands of the form {@code } (give, take, + * set). Handles the shared validation, applies the subclass's money operation against the + * command's game mode world, and reports the resulting balance. Subclasses only define the + * operation and the success message. + * @author tastybento + */ +public abstract class AbstractAdminAmountCommand extends AbstractAdminMoneyCommand { + + protected AbstractAdminAmountCommand(CompositeCommand parent, String label, String... aliases) { + super(parent, label, aliases); + } + + /** + * Perform the money operation on the target for the given world. + * @param eco - the economy + * @param target - the target player + * @param world - the command's game mode world name + * @param amount - the parsed, positive amount + * @return the economy response (a non-success response reports insufficient funds) + */ + protected abstract EconomyResponse apply(InvEconomy eco, OfflinePlayer target, String world, double amount); + + /** + * @return the locale key of the success message, which receives [name] and [number] + */ + protected abstract String successKey(); + + @Override + public boolean execute(User user, String label, List args) { + if (args.size() != 2) { + this.showHelp(this, user); + return false; + } + InvEconomy eco = requireEconomy(user); + if (eco == null) { + return false; + } + User target = resolveTarget(user, args.get(0)); + if (target == null) { + return false; + } + Double amount = parseAmount(user, args.get(1)); + if (amount == null) { + return false; + } + String world = getWorld().getName(); + EconomyResponse response = apply(eco, target.getOfflinePlayer(), world, amount); + if (!response.transactionSuccess()) { + user.sendMessage("invswitcher.errors.insufficient-funds"); + return false; + } + sendBalanceMessage(user, successKey(), target, world); + return true; + } +} diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminMoneyCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminMoneyCommand.java index cfc94b8..c15271c 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminMoneyCommand.java +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminMoneyCommand.java @@ -36,6 +36,18 @@ protected User resolveTarget(User user, String name) { return target; } + /** + * Send a message reporting the target's balance for the given world, with [name] and [number]. + * @param user - command sender + * @param messageKey - locale key of the message + * @param target - the target player + * @param world - the world to report the balance for + */ + protected void sendBalanceMessage(User user, String messageKey, User target, String world) { + user.sendMessage(messageKey, TextVariables.NAME, target.getName(), + TextVariables.NUMBER, economy().format(economy().getBalance(target.getOfflinePlayer(), world))); + } + /** * Tab-complete the player parameter (the first argument of these commands) with the names of * online players. The command tree is {@code eco [amount]}, so the diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminBalanceCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminBalanceCommand.java index 65682b8..cd49157 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminBalanceCommand.java +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminBalanceCommand.java @@ -2,14 +2,11 @@ import java.util.List; -import com.wasteofplastic.invswitcher.economy.InvEconomy; - import world.bentobox.bentobox.api.commands.CompositeCommand; -import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; /** - * Shows an admin another player's balance for the world that player is currently in (or was last in). + * Shows an admin another player's balance for this command's game mode world. * @author tastybento */ public class AdminBalanceCommand extends AbstractAdminMoneyCommand { @@ -31,18 +28,14 @@ public boolean execute(User user, String label, List args) { this.showHelp(this, user); return false; } - InvEconomy eco = economy(); - if (eco == null) { - user.sendMessage("invswitcher.errors.no-economy"); + if (requireEconomy(user) == null) { return false; } User target = resolveTarget(user, args.get(0)); if (target == null) { return false; } - user.sendMessage("invswitcher.commands.admin.balance.balance", - TextVariables.NAME, target.getName(), - TextVariables.NUMBER, eco.format(eco.getBalance(target.getOfflinePlayer(), getWorld().getName()))); + sendBalanceMessage(user, "invswitcher.commands.admin.balance.balance", target, getWorld().getName()); return true; } } diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminGiveCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminGiveCommand.java index b7febae..363a8d0 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminGiveCommand.java +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminGiveCommand.java @@ -1,18 +1,17 @@ package com.wasteofplastic.invswitcher.commands.admin; -import java.util.List; +import org.bukkit.OfflinePlayer; import com.wasteofplastic.invswitcher.economy.InvEconomy; +import net.milkbowl.vault.economy.EconomyResponse; import world.bentobox.bentobox.api.commands.CompositeCommand; -import world.bentobox.bentobox.api.localization.TextVariables; -import world.bentobox.bentobox.api.user.User; /** - * Gives money to a player in the world they are currently in (or were last in). + * Gives money to a player in this command's game mode world. * @author tastybento */ -public class AdminGiveCommand extends AbstractAdminMoneyCommand { +public class AdminGiveCommand extends AbstractAdminAmountCommand { public AdminGiveCommand(CompositeCommand parent) { super(parent, "give"); @@ -26,29 +25,12 @@ public void setup() { } @Override - public boolean execute(User user, String label, List args) { - if (args.size() != 2) { - this.showHelp(this, user); - return false; - } - InvEconomy eco = economy(); - if (eco == null) { - user.sendMessage("invswitcher.errors.no-economy"); - return false; - } - User target = resolveTarget(user, args.get(0)); - if (target == null) { - return false; - } - Double amount = parseAmount(user, args.get(1)); - if (amount == null) { - return false; - } - String world = getWorld().getName(); - eco.depositPlayer(target.getOfflinePlayer(), world, amount); - user.sendMessage("invswitcher.commands.admin.give.success", - TextVariables.NAME, target.getName(), - TextVariables.NUMBER, eco.format(eco.getBalance(target.getOfflinePlayer(), world))); - return true; + protected EconomyResponse apply(InvEconomy eco, OfflinePlayer target, String world, double amount) { + return eco.depositPlayer(target, world, amount); + } + + @Override + protected String successKey() { + return "invswitcher.commands.admin.give.success"; } } diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminSetCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminSetCommand.java index 063ef42..f421f9e 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminSetCommand.java +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminSetCommand.java @@ -1,18 +1,17 @@ package com.wasteofplastic.invswitcher.commands.admin; -import java.util.List; +import org.bukkit.OfflinePlayer; import com.wasteofplastic.invswitcher.economy.InvEconomy; +import net.milkbowl.vault.economy.EconomyResponse; import world.bentobox.bentobox.api.commands.CompositeCommand; -import world.bentobox.bentobox.api.localization.TextVariables; -import world.bentobox.bentobox.api.user.User; /** - * Sets a player's balance in the world they are currently in (or were last in). + * Sets a player's balance in this command's game mode world. * @author tastybento */ -public class AdminSetCommand extends AbstractAdminMoneyCommand { +public class AdminSetCommand extends AbstractAdminAmountCommand { public AdminSetCommand(CompositeCommand parent) { super(parent, "set"); @@ -26,29 +25,12 @@ public void setup() { } @Override - public boolean execute(User user, String label, List args) { - if (args.size() != 2) { - this.showHelp(this, user); - return false; - } - InvEconomy eco = economy(); - if (eco == null) { - user.sendMessage("invswitcher.errors.no-economy"); - return false; - } - User target = resolveTarget(user, args.get(0)); - if (target == null) { - return false; - } - Double amount = parseAmount(user, args.get(1)); - if (amount == null) { - return false; - } - String world = getWorld().getName(); - eco.setBalance(target.getOfflinePlayer(), world, amount); - user.sendMessage("invswitcher.commands.admin.set.success", - TextVariables.NAME, target.getName(), - TextVariables.NUMBER, eco.format(eco.getBalance(target.getOfflinePlayer(), world))); - return true; + protected EconomyResponse apply(InvEconomy eco, OfflinePlayer target, String world, double amount) { + return eco.setBalance(target, world, amount); + } + + @Override + protected String successKey() { + return "invswitcher.commands.admin.set.success"; } } diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminTakeCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminTakeCommand.java index 28c4b33..2a9e679 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminTakeCommand.java +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminTakeCommand.java @@ -1,19 +1,17 @@ package com.wasteofplastic.invswitcher.commands.admin; -import java.util.List; +import org.bukkit.OfflinePlayer; import com.wasteofplastic.invswitcher.economy.InvEconomy; import net.milkbowl.vault.economy.EconomyResponse; import world.bentobox.bentobox.api.commands.CompositeCommand; -import world.bentobox.bentobox.api.localization.TextVariables; -import world.bentobox.bentobox.api.user.User; /** - * Takes money from a player in the world they are currently in (or were last in). + * Takes money from a player in this command's game mode world. * @author tastybento */ -public class AdminTakeCommand extends AbstractAdminMoneyCommand { +public class AdminTakeCommand extends AbstractAdminAmountCommand { public AdminTakeCommand(CompositeCommand parent) { super(parent, "take"); @@ -27,33 +25,12 @@ public void setup() { } @Override - public boolean execute(User user, String label, List args) { - if (args.size() != 2) { - this.showHelp(this, user); - return false; - } - InvEconomy eco = economy(); - if (eco == null) { - user.sendMessage("invswitcher.errors.no-economy"); - return false; - } - User target = resolveTarget(user, args.get(0)); - if (target == null) { - return false; - } - Double amount = parseAmount(user, args.get(1)); - if (amount == null) { - return false; - } - String world = getWorld().getName(); - EconomyResponse response = eco.withdrawPlayer(target.getOfflinePlayer(), world, amount); - if (!response.transactionSuccess()) { - user.sendMessage("invswitcher.errors.insufficient-funds"); - return false; - } - user.sendMessage("invswitcher.commands.admin.take.success", - TextVariables.NAME, target.getName(), - TextVariables.NUMBER, eco.format(eco.getBalance(target.getOfflinePlayer(), world))); - return true; + protected EconomyResponse apply(InvEconomy eco, OfflinePlayer target, String world, double amount) { + return eco.withdrawPlayer(target, world, amount); + } + + @Override + protected String successKey() { + return "invswitcher.commands.admin.take.success"; } } diff --git a/src/main/java/com/wasteofplastic/invswitcher/commands/user/PayCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/user/PayCommand.java index aebe7d4..bb48646 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/commands/user/PayCommand.java +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/user/PayCommand.java @@ -37,9 +37,8 @@ public boolean execute(User user, String label, List args) { this.showHelp(this, user); return false; } - InvEconomy eco = economy(); + InvEconomy eco = requireEconomy(user); if (eco == null) { - user.sendMessage("invswitcher.errors.no-economy"); return false; } User target = getPlayers().getUser(args.get(0)); From 6044c41121d74eb7b3e82779cca9f3e5746195a6 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 30 May 2026 18:17:46 -0700 Subject: [PATCH 15/15] fix: address Copilot review on PR #51 - InventoryStorage.setMoney/clearWorldData: guard against a null money map, which can occur after Gson deserialization of pre-1.18.0 records (field initializers are bypassed). Prevents NPEs in economy operations. - InvEconomy.setSelf: use a 'Cannot set a negative balance' message instead of the misleading 'Cannot deposit a negative amount'. - PlayerListener reset handlers (inventory/ender-chest/exp/health/hunger): only intercept (and cancel) BentoBox's reset when the corresponding aspect is being switched. Previously a disabled aspect cancelled the event then no-op'd, silently dropping the reset. Matches the money handler. - Tests: null-money handling, the disabled-switching no-intercept guard, and updated existing reset tests to enable the relevant aspect (123 total). Co-Authored-By: Claude Opus 4.8 --- .../dataobjects/InventoryStorage.java | 7 ++++++- .../invswitcher/economy/InvEconomy.java | 3 ++- .../invswitcher/listeners/PlayerListener.java | 10 +++++++++ .../dataobjects/InventoryStorageTest.java | 21 +++++++++++++++++++ .../listeners/PlayerListenerTest.java | 19 +++++++++++++++++ 5 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/wasteofplastic/invswitcher/dataobjects/InventoryStorage.java b/src/main/java/com/wasteofplastic/invswitcher/dataobjects/InventoryStorage.java index 75aa4ae..25b8caf 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/dataobjects/InventoryStorage.java +++ b/src/main/java/com/wasteofplastic/invswitcher/dataobjects/InventoryStorage.java @@ -294,6 +294,9 @@ public boolean hasMoney(String key) { * @param balance the balance to set */ public void setMoney(String key, double balance) { + if (this.money == null) { + this.money = new HashMap<>(); + } this.money.put(key, balance); } @@ -530,7 +533,9 @@ public void clearWorldData(String worldName) { this.gameMode.remove(worldName); this.advancements.remove(worldName); this.enderChest.remove(worldName); - this.money.remove(worldName); + if (this.money != null) { + this.money.remove(worldName); + } clearStats(worldName); } diff --git a/src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java b/src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java index 68ac154..b786ee4 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java +++ b/src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java @@ -32,6 +32,7 @@ public class InvEconomy implements Economy { private static final String NEGATIVE_DEPOSIT = "Cannot deposit a negative amount"; private static final String NEGATIVE_WITHDRAW = "Cannot withdraw a negative amount"; + private static final String NEGATIVE_SET = "Cannot set a negative balance"; private static final String INSUFFICIENT_FUNDS = "Insufficient funds"; private final InvSwitcher addon; @@ -219,7 +220,7 @@ private EconomyResponse depositSelf(OfflinePlayer player, String key, double amo private EconomyResponse setSelf(OfflinePlayer player, String key, double amount) { if (amount < 0) { - return new EconomyResponse(0, readSelf(player, key), ResponseType.FAILURE, NEGATIVE_DEPOSIT); + return new EconomyResponse(0, readSelf(player, key), ResponseType.FAILURE, NEGATIVE_SET); } InventoryStorage s = store().getStorageObject(player.getUniqueId()); // Mark imported so a later seed does not re-import on top of an explicitly set balance diff --git a/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java b/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java index c156f38..d81e119 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java +++ b/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java @@ -197,6 +197,8 @@ public void onPlayerQuit(final PlayerQuitEvent event) { */ @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onPlayerResetInventory(PlayerResetInventoryEvent event) { + // If we are not switching inventories, let BentoBox perform its own reset. + if (!addon.getSettings().isInventory()) return; if (!shouldInterceptPlayerReset(event)) return; event.setCancelled(true); Player player = Bukkit.getPlayer(event.getPlayerUUID()); @@ -212,6 +214,8 @@ public void onPlayerResetInventory(PlayerResetInventoryEvent event) { */ @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onPlayerResetEnderChest(PlayerResetEnderChestEvent event) { + // If we are not switching ender chests, let BentoBox perform its own reset. + if (!addon.getSettings().isEnderChest()) return; if (!shouldInterceptPlayerReset(event)) return; event.setCancelled(true); Player player = Bukkit.getPlayer(event.getPlayerUUID()); @@ -227,6 +231,8 @@ public void onPlayerResetEnderChest(PlayerResetEnderChestEvent event) { */ @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onPlayerResetExp(PlayerResetExpEvent event) { + // If we are not switching experience, let BentoBox perform its own reset. + if (!addon.getSettings().isExperience()) return; if (!shouldInterceptPlayerReset(event)) return; event.setCancelled(true); Player player = Bukkit.getPlayer(event.getPlayerUUID()); @@ -243,6 +249,8 @@ public void onPlayerResetExp(PlayerResetExpEvent event) { */ @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onPlayerResetHealth(PlayerResetHealthEvent event) { + // If we are not switching health, let BentoBox perform its own reset. + if (!addon.getSettings().isHealth()) return; if (!shouldInterceptPlayerReset(event)) return; event.setCancelled(true); Player player = Bukkit.getPlayer(event.getPlayerUUID()); @@ -258,6 +266,8 @@ public void onPlayerResetHealth(PlayerResetHealthEvent event) { */ @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onPlayerResetHunger(PlayerResetHungerEvent event) { + // If we are not switching food, let BentoBox perform its own reset. + if (!addon.getSettings().isFood()) return; if (!shouldInterceptPlayerReset(event)) return; event.setCancelled(true); Player player = Bukkit.getPlayer(event.getPlayerUUID()); diff --git a/src/test/java/com/wasteofplastic/invswitcher/dataobjects/InventoryStorageTest.java b/src/test/java/com/wasteofplastic/invswitcher/dataobjects/InventoryStorageTest.java index a450181..439ef82 100644 --- a/src/test/java/com/wasteofplastic/invswitcher/dataobjects/InventoryStorageTest.java +++ b/src/test/java/com/wasteofplastic/invswitcher/dataobjects/InventoryStorageTest.java @@ -381,4 +381,25 @@ public void testGetEntityStats() { } + /** + * The money map can be null after Gson deserialization of pre-1.18.0 records (field + * initializers are bypassed). setMoney must lazily create the map rather than throw. + */ + @Test + public void testSetMoneyWhenMapNull() { + is.setMoney((Map) null); + is.setMoney("world", 100.0); + assertEquals(100.0, is.getMoney("world"), 0.0001); + } + + /** + * clearWorldData must not throw when the money map is null. + */ + @Test + public void testClearWorldDataWhenMoneyNull() { + is.setMoney((Map) null); + is.clearWorldData("world"); + assertNull(is.getMoney("world")); + } + } diff --git a/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java b/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java index 00d4c5f..7580208 100644 --- a/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java +++ b/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java @@ -420,6 +420,7 @@ public void testOnPlayerResetInventoryPlayerInEventWorld() { */ @Test public void testOnPlayerResetInventoryPlayerInDifferentWorld() { + when(settings.isInventory()).thenReturn(true); // player is in notWorld, event fires for world when(player.getWorld()).thenReturn(notWorld); PlayerResetInventoryEvent event = new PlayerResetInventoryEvent(world, island, playerUUID); @@ -433,11 +434,26 @@ public void testOnPlayerResetInventoryPlayerInDifferentWorld() { verify(store).clearStoredInventoryForWorld(player, world, island); } + /** + * When inventory switching is disabled, the reset must NOT be intercepted - it is left to + * BentoBox - otherwise the reset would be cancelled and never performed. + */ + @Test + public void testOnPlayerResetInventoryNotInterceptedWhenSwitchingDisabled() { + when(settings.isInventory()).thenReturn(false); + when(player.getWorld()).thenReturn(notWorld); + PlayerResetInventoryEvent event = new PlayerResetInventoryEvent(world, island, playerUUID); + pl.onPlayerResetInventory(event); + assertFalse(event.isCancelled(), "Event should not be cancelled when inventory switching is disabled"); + verify(store, never()).clearStoredInventoryForWorld(any(), any(), any()); + } + /** * Ender chest reset should be intercepted when the player is in a different world. */ @Test public void testOnPlayerResetEnderChestPlayerInDifferentWorld() { + when(settings.isEnderChest()).thenReturn(true); when(player.getWorld()).thenReturn(notWorld); PlayerResetEnderChestEvent event = new PlayerResetEnderChestEvent(world, island, playerUUID); try (MockedStatic mockedBukkit = mockStatic(Bukkit.class); @@ -472,6 +488,7 @@ public void testOnPlayerResetEnderChestPlayerInEventWorld() { */ @Test public void testOnPlayerResetExpPlayerInDifferentWorld() { + when(settings.isExperience()).thenReturn(true); when(player.getWorld()).thenReturn(notWorld); PlayerResetExpEvent event = new PlayerResetExpEvent(world, island, playerUUID); try (MockedStatic mockedBukkit = mockStatic(Bukkit.class); @@ -489,6 +506,7 @@ public void testOnPlayerResetExpPlayerInDifferentWorld() { */ @Test public void testOnPlayerResetHealthPlayerInDifferentWorld() { + when(settings.isHealth()).thenReturn(true); when(player.getWorld()).thenReturn(notWorld); PlayerResetHealthEvent event = new PlayerResetHealthEvent(world, island, playerUUID); try (MockedStatic mockedBukkit = mockStatic(Bukkit.class); @@ -506,6 +524,7 @@ public void testOnPlayerResetHealthPlayerInDifferentWorld() { */ @Test public void testOnPlayerResetHungerPlayerInDifferentWorld() { + when(settings.isFood()).thenReturn(true); when(player.getWorld()).thenReturn(notWorld); PlayerResetHungerEvent event = new PlayerResetHungerEvent(world, island, playerUUID); try (MockedStatic mockedBukkit = mockStatic(Bukkit.class);