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/pom.xml b/pom.xml index c6baa7e..84c291a 100644 --- a/pom.xml +++ b/pom.xml @@ -58,13 +58,14 @@ 1.17.5 1.21.11-R0.1-SNAPSHOT - 2.7.1-SNAPSHOT + 3.17.0-SNAPSHOT + 1.7 ${build.version}-SNAPSHOT -LOCAL - 1.17.1 + 1.18.0 BentoBoxWorld_addon-invSwitcher bentobox-world @@ -114,14 +115,6 @@ - - - jitpack.io - https://jitpack.io - - true - - spigot-repo https://hub.spigotmc.org/nexus/content/repositories/snapshots @@ -134,6 +127,11 @@ papermc https://repo.papermc.io/repository/maven-public/ + + + jitpack.io + https://jitpack.io + @@ -146,9 +144,9 @@ - com.github.MockBukkit - MockBukkit - v1.21-SNAPSHOT + org.mockbukkit.mockbukkit + mockbukkit-v1.21 + 4.110.0 test @@ -196,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..4179d36 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,12 @@ public void allLoaded() { store = new Store(this); // Register the listeners registerListener(new PlayerListener(this)); + // 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 @@ -82,12 +98,76 @@ public void onEnable() { 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 { + registerEconomyProvider(); + } + } + } + + /** + * 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 registerEconomyProvider() { + if (economy != null) { + return; + } + economy = new InvEconomy(this); + Bukkit.getServicesManager().register(Economy.class, economy, getPlugin(), ServicePriority.Highest); + + // 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(); + + // Dump the current economy provider chain (debug only) so it is clear we win the registration. + if (getSettings().isEconomyDebug()) { + 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())) + .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 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 +175,42 @@ 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); + } + + /** + * 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 + */ + 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..6242aa5 --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/PhManager.java @@ -0,0 +1,53 @@ +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_"; + // 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, worldName, false)); + // Formatted balance (currency name + decimals) + plugin.getPlaceholdersManager().registerPlaceholder(addon, prefix + "balance_formatted", + user -> balance(user, worldName, true)); + return true; + } + + 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(), 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 6d57227..2d4cd77 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/Settings.java +++ b/src/main/java/com/wasteofplastic/invswitcher/Settings.java @@ -34,6 +34,14 @@ 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. 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; @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 +64,36 @@ 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; + @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 @@ -225,5 +263,65 @@ 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; + } + public boolean isEconomyDebug() { + return economyDebug; + } + public void setEconomyDebug(boolean economyDebug) { + this.economyDebug = economyDebug; + } } diff --git a/src/main/java/com/wasteofplastic/invswitcher/Store.java b/src/main/java/com/wasteofplastic/invswitcher/Store.java index 5ef1022..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; @@ -320,7 +321,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 +339,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) { @@ -401,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()) { @@ -667,4 +680,234 @@ 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); + } + + /** + * 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..057f4c1 --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/AbstractMoneyCommand.java @@ -0,0 +1,76 @@ +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(); + } + + /** + * 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 + * @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/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 new file mode 100644 index 0000000..c15271c --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AbstractAdminMoneyCommand.java @@ -0,0 +1,63 @@ +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 }. + * @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; + } + + /** + * 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 + * 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(); + } +} 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..cd49157 --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminBalanceCommand.java @@ -0,0 +1,41 @@ +package com.wasteofplastic.invswitcher.commands.admin; + +import java.util.List; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; + +/** + * Shows an admin another player's balance for this command's game mode world. + * @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; + } + if (requireEconomy(user) == null) { + return false; + } + User target = resolveTarget(user, args.get(0)); + if (target == null) { + return false; + } + 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 new file mode 100644 index 0000000..363a8d0 --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminGiveCommand.java @@ -0,0 +1,36 @@ +package com.wasteofplastic.invswitcher.commands.admin; + +import org.bukkit.OfflinePlayer; + +import com.wasteofplastic.invswitcher.economy.InvEconomy; + +import net.milkbowl.vault.economy.EconomyResponse; +import world.bentobox.bentobox.api.commands.CompositeCommand; + +/** + * Gives money to a player in this command's game mode world. + * @author tastybento + */ +public class AdminGiveCommand extends AbstractAdminAmountCommand { + + 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 + 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/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..f421f9e --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminSetCommand.java @@ -0,0 +1,36 @@ +package com.wasteofplastic.invswitcher.commands.admin; + +import org.bukkit.OfflinePlayer; + +import com.wasteofplastic.invswitcher.economy.InvEconomy; + +import net.milkbowl.vault.economy.EconomyResponse; +import world.bentobox.bentobox.api.commands.CompositeCommand; + +/** + * Sets a player's balance in this command's game mode world. + * @author tastybento + */ +public class AdminSetCommand extends AbstractAdminAmountCommand { + + 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 + 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 new file mode 100644 index 0000000..2a9e679 --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/admin/AdminTakeCommand.java @@ -0,0 +1,36 @@ +package com.wasteofplastic.invswitcher.commands.admin; + +import org.bukkit.OfflinePlayer; + +import com.wasteofplastic.invswitcher.economy.InvEconomy; + +import net.milkbowl.vault.economy.EconomyResponse; +import world.bentobox.bentobox.api.commands.CompositeCommand; + +/** + * Takes money from a player in this command's game mode world. + * @author tastybento + */ +public class AdminTakeCommand extends AbstractAdminAmountCommand { + + 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 + 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/BalanceCommand.java b/src/main/java/com/wasteofplastic/invswitcher/commands/user/BalanceCommand.java new file mode 100644 index 0000000..88e4d97 --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/user/BalanceCommand.java @@ -0,0 +1,43 @@ +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; + } + // 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 new file mode 100644 index 0000000..bb48646 --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/commands/user/PayCommand.java @@ -0,0 +1,78 @@ +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 = requireEconomy(user); + if (eco == null) { + 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; + } + // 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(), world, amount); + if (!withdrawal.transactionSuccess()) { + user.sendMessage("invswitcher.errors.insufficient-funds"); + return false; + } + eco.depositPlayer(target.getOfflinePlayer(), world, 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..25b8caf 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,82 @@ 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) { + if (this.money == null) { + this.money = new HashMap<>(); + } + 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 +533,9 @@ public void clearWorldData(String worldName) { this.gameMode.remove(worldName); this.advancements.remove(worldName); this.enderChest.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 new file mode 100644 index 0000000..b786ee4 --- /dev/null +++ b/src/main/java/com/wasteofplastic/invswitcher/economy/InvEconomy.java @@ -0,0 +1,531 @@ +package com.wasteofplastic.invswitcher.economy; + +import java.util.Collections; +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.plugin.RegisteredServiceProvider; + +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 NEGATIVE_SET = "Cannot set a negative balance"; + private static final String INSUFFICIENT_FUNDS = "Insufficient funds"; + + private final InvSwitcher addon; + /** 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 + */ + 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.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; + debug(found != null + ? "delegating unmanaged-world transactions to " + found.getName() + : "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; + } + + // ------ 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_SET); + } + 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 = 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 readSelf(player, key); + } + + @Override + public double getBalance(OfflinePlayer player, String worldName) { + String key = worldKey(player, 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 = 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) + : withdrawSelf(player, Store.DEFAULT_WORLD_KEY, amount); + } + return withdrawSelf(player, key, amount); + } + + @Override + public EconomyResponse withdrawPlayer(OfflinePlayer player, String worldName, double amount) { + 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) + : withdrawSelf(player, Store.DEFAULT_WORLD_KEY, amount); + } + return withdrawSelf(player, key, amount); + } + + @Override + public EconomyResponse depositPlayer(OfflinePlayer player, double amount) { + 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) + : depositSelf(player, Store.DEFAULT_WORLD_KEY, amount); + } + return depositSelf(player, key, amount); + } + + @Override + public EconomyResponse depositPlayer(OfflinePlayer player, String worldName, double amount) { + 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) + : 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 = 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); + } + 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; + } + 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 97110c5..d81e119 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java +++ b/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java @@ -17,6 +17,13 @@ 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.api.events.player.PlayerResetMoneyEvent; import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.util.Util; @@ -183,5 +190,133 @@ 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 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()); + 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 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()); + 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 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()); + 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 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()); + 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 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()); + if (player != null) { + addon.getStore().clearStoredFoodForWorld(player, event.getWorld(), event.getIsland()); + } + } + + /** + * 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, + * 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/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 diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 34a19b8..e04efdd 100755 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -20,6 +20,13 @@ options: experience: true ender-chest: true statistics: true + # 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. # The world-level option must also be true for the island option to have any effect. @@ -33,3 +40,24 @@ 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 + # 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/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/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/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 你不能向自己付款。" diff --git a/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java b/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java index dfe5419..03c61b2 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)}. */ @@ -601,4 +645,217 @@ 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 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. + */ + @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/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/economy/InvEconomyTest.java b/src/test/java/com/wasteofplastic/invswitcher/economy/InvEconomyTest.java new file mode 100644 index 0000000..9b42e2c --- /dev/null +++ b/src/test/java/com/wasteofplastic/invswitcher/economy/InvEconomyTest.java @@ -0,0 +1,290 @@ +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 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)) { + 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 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 + 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 cd3f6b1..7580208 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,12 @@ 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.api.events.player.PlayerResetMoneyEvent; import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.managers.IslandsManager; import world.bentobox.bentobox.util.Util; @@ -64,6 +72,8 @@ public class PlayerListenerTest { private Settings settings; @Mock private IslandsManager islandsManager; + @Mock + private Island island; private UUID playerUUID; private MockedStatic mockedBentoBox; @@ -353,4 +363,228 @@ 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() { + 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); + 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); + } + + /** + * 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); + 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(settings.isExperience()).thenReturn(true); + 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(settings.isHealth()).thenReturn(true); + 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(settings.isFood()).thenReturn(true); + 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); + } + + /** + * 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()); + } + }