From 735175b61886e148e43e218b5118dbddb207843b Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 30 Mar 2026 13:43:43 -0700 Subject: [PATCH 1/2] Add per-island inventory switching for concurrent island ownership (#40) Players who own multiple islands now get separate inventories per island, preventing cross-island resource cheating. Inventories switch when entering an owned island's protection zone, teleporting between owned islands, or respawning on a different owned island after death. Island inventories stay consistent across Overworld/Nether/End dimensions. Controlled by the new options.islands config toggle (default: true). No behavior change for games without concurrent island ownership. Also fixes pre-existing @AfterEach (JUnit 5) annotations in tests that should have been @After (JUnit 4), and fixes advancements being stored under raw world name instead of the normalized storage key. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../wasteofplastic/invswitcher/Settings.java | 16 +- .../com/wasteofplastic/invswitcher/Store.java | 175 +++++++++++++-- .../dataobjects/InventoryStorage.java | 209 ++++++++++++++---- .../invswitcher/listeners/PlayerListener.java | 98 ++++++++ src/main/resources/config.yml | 2 + .../wasteofplastic/invswitcher/StoreTest.java | 146 +++++++++++- .../listeners/PlayerListenerTest.java | 173 ++++++++++++++- 7 files changed, 751 insertions(+), 68 deletions(-) diff --git a/src/main/java/com/wasteofplastic/invswitcher/Settings.java b/src/main/java/com/wasteofplastic/invswitcher/Settings.java index 247e802..cb8bccb 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/Settings.java +++ b/src/main/java/com/wasteofplastic/invswitcher/Settings.java @@ -34,6 +34,9 @@ public class Settings implements ConfigObject { private boolean enderChest = true; @ConfigEntry(path = "options.statistics") private boolean statistics = true; + @ConfigComment("Switch inventories based on island. Only applies if players own more than one island.") + @ConfigEntry(path = "options.islands") + private boolean islands = true; /** * @return the worlds @@ -143,6 +146,17 @@ public boolean isStatistics() { public void setStatistics(boolean statistics) { this.statistics = statistics; } - + /** + * @return whether per-island inventory switching is enabled + */ + public boolean isIslands() { + return islands; + } + /** + * @param islands whether to enable per-island inventory switching + */ + public void setIslands(boolean islands) { + this.islands = islands; + } } diff --git a/src/main/java/com/wasteofplastic/invswitcher/Store.java b/src/main/java/com/wasteofplastic/invswitcher/Store.java index cb392db..187b0ef 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/Store.java +++ b/src/main/java/com/wasteofplastic/invswitcher/Store.java @@ -30,11 +30,13 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import org.bukkit.Bukkit; +import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Registry; import org.bukkit.Statistic; @@ -49,7 +51,9 @@ import com.wasteofplastic.invswitcher.dataobjects.InventoryStorage; +import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.database.Database; +import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.util.Util; /** @@ -62,12 +66,109 @@ public class Store { private static final CharSequence NETHER = "_nether"; private final Database database; private final Map cache; + private final Map currentKey; private final InvSwitcher addon; public Store(InvSwitcher addon) { this.addon = addon; database = new Database<>(addon, InventoryStorage.class); cache = new HashMap<>(); + currentKey = new HashMap<>(); + } + + /** + * Compute the storage key for a player based on their current location. + * Returns "worldName/islandId" if per-island mode is active and the player + * owns multiple concurrent islands, otherwise returns just "worldName". + * @param player - player + * @param world - world + * @return storage key + */ + public String getStorageKey(Player player, World world) { + return getStorageKey(player, world, player.getLocation(), null); + } + + /** + * Compute the storage key for a player targeting a specific island. + * @param player - player + * @param world - world + * @param island - target island (may be null) + * @return storage key + */ + public String getStorageKey(Player player, World world, Island island) { + return getStorageKey(player, world, player.getLocation(), island); + } + + /** + * Compute the storage key for a player at a specific location, optionally targeting a known island. + * @param player - player + * @param world - world + * @param location - location to check + * @param island - target island, or null to detect from location + * @return storage key + */ + String getStorageKey(Player player, World world, Location location, Island island) { + String overworldName = getOverworldName(world); + + if (!addon.getSettings().isIslands()) { + return overworldName; + } + + // Check if player owns multiple concurrent islands in this world + World overworld = Util.getWorld(world); + int count = addon.getIslands().getNumberOfConcurrentIslands(player.getUniqueId(), + Objects.requireNonNull(overworld)); + if (count <= 1) { + return overworldName; + } + + // If a specific island was provided, use it + if (island != null && island.getOwner() != null + && island.getOwner().equals(player.getUniqueId())) { + return overworldName + "/" + island.getUniqueId(); + } + + // If in generic nether/end (not island nether/end), preserve current key + if (world.getEnvironment() != World.Environment.NORMAL) { + boolean isIslandDimension = (world.getEnvironment() == World.Environment.NETHER) + ? BentoBox.getInstance().getIWM().isIslandNether(world) + : BentoBox.getInstance().getIWM().isIslandEnd(world); + if (!isIslandDimension) { + String current = currentKey.get(player.getUniqueId()); + return (current != null) ? current : overworldName; + } + } + + // Detect island from location + Optional islandOpt = addon.getIslands().getIslandAt(location); + if (islandOpt.isPresent()) { + Island loc = islandOpt.get(); + if (loc.getOwner() != null && loc.getOwner().equals(player.getUniqueId())) { + return overworldName + "/" + loc.getUniqueId(); + } + } + + // Fallback: use current key if available, else world name + String current = currentKey.get(player.getUniqueId()); + return (current != null) ? current : overworldName; + } + + /** + * Get the overworld name from any world by stripping nether/end suffixes. + * @param world - world + * @return overworld name + */ + private String getOverworldName(World world) { + return (world.getName().replace(THE_END, "")).replace(NETHER, ""); + } + + /** + * Get the current storage key for a player. + * @param player - player + * @return the current storage key, or null if not set + */ + public String getCurrentKey(Player player) { + return currentKey.get(player.getUniqueId()); } /** @@ -79,8 +180,8 @@ public Store(InvSwitcher addon) { public boolean isWorldStored(Player player, World world) { // Get the store InventoryStorage store = getInv(player); - String overworldName = (world.getName().replace(THE_END, "")).replace(NETHER, ""); - return store.isInventory(overworldName); + String key = getStorageKey(player, world); + return store.isInventory(key); } /** @@ -89,38 +190,63 @@ public boolean isWorldStored(Player player, World world) { * @param world - world */ public void getInventory(Player player, World world) { + getInventory(player, world, null); + } + + /** + * Gets items for world and island. Changes the inventory of player immediately. + * @param player - player + * @param world - world + * @param island - target island, or null to detect from location + */ + public void getInventory(Player player, World world, Island island) { // Get the store InventoryStorage store = getInv(player); - // Do not differentiate between world environments. - String overworldName = Objects.requireNonNull(Util.getWorld(world)).getName(); + String key = (island != null) ? getStorageKey(player, world, island) : getStorageKey(player, world); + + // Always track the resolved key (including island suffix) so future saves go to the right slot + currentKey.put(player.getUniqueId(), key); + + // Backward compat: if island-specific key has no data, migrate from world-only key. + // This only happens once — the world-only data is cleared after migration so that + // other islands don't also inherit a duplicate copy. + String loadKey = key; + if (key.contains("/") && !store.isInventory(key)) { + String overworldName = getOverworldName(world); + if (store.isInventory(overworldName)) { + loadKey = overworldName; + // Clear the world-only data so it can't be claimed by another island + store.clearWorldData(overworldName); + } + } // Inventory if (addon.getSettings().isInventory()) { - player.getInventory().setContents(store.getInventory(overworldName).toArray(new ItemStack[0])); + player.getInventory().setContents(store.getInventory(loadKey).toArray(new ItemStack[0])); } if (addon.getSettings().isHealth()) { - setHeath(store, player, overworldName); + setHeath(store, player, loadKey); } if (addon.getSettings().isFood()) { - setFood(store, player, overworldName); + setFood(store, player, loadKey); } if (addon.getSettings().isExperience()) { // Experience - setTotalExperience(player, store.getExp().getOrDefault(overworldName, 0)); + setTotalExperience(player, store.getExp().getOrDefault(loadKey, 0)); } if (addon.getSettings().isGamemode()) { // Game modes - player.setGameMode(store.getGameMode(overworldName)); + player.setGameMode(store.getGameMode(loadKey)); } if (addon.getSettings().isAdvancements()) { - setAdvancements(store, player, overworldName); + setAdvancements(store, player, loadKey); } if (addon.getSettings().isEnderChest()) { - player.getEnderChest().setContents(store.getEnderChest(overworldName).toArray(new ItemStack[0])); + player.getEnderChest().setContents(store.getEnderChest(loadKey).toArray(new ItemStack[0])); } if (addon.getSettings().isStatistics()) { - getStats(store, player, overworldName); + getStats(store, player, loadKey); } } @@ -169,6 +295,7 @@ private void setAdvancements(InventoryStorage store, Player player, String overw public void removeFromCache(Player player) { cache.remove(player.getUniqueId()); + currentKey.remove(player.getUniqueId()); } /** @@ -213,45 +340,45 @@ public void storeInventory(Player player, World world) { public void storeAndSave(Player player, World world, boolean shutdown) { // Get the player's store InventoryStorage store = getInv(player); - // Do not differentiate between world environments - String worldName = world.getName(); - String overworldName = (world.getName().replace(THE_END, "")).replace(NETHER, ""); + // Use the current tracked key if available (ensures we save to the correct island slot), + // otherwise compute from location + String key = currentKey.getOrDefault(player.getUniqueId(), getStorageKey(player, world)); if (addon.getSettings().isInventory()) { // Copy the player's items to the store List contents = Arrays.asList(player.getInventory().getContents()); - store.setInventory(overworldName, contents); + store.setInventory(key, contents); } if (addon.getSettings().isHealth()) { - store.setHealth(overworldName, player.getHealth()); + store.setHealth(key, player.getHealth()); } if (addon.getSettings().isFood()) { - store.setFood(overworldName, player.getFoodLevel()); + store.setFood(key, player.getFoodLevel()); } if (addon.getSettings().isExperience()) { - store.setExp(overworldName, getTotalExperience(player)); + store.setExp(key, getTotalExperience(player)); } if (addon.getSettings().isGamemode()) { - store.setGameMode(overworldName, player.getGameMode()); + store.setGameMode(key, player.getGameMode()); } if (addon.getSettings().isAdvancements()) { // Advancements - store.clearAdvancement(worldName); + store.clearAdvancement(key); Iterator it = Bukkit.advancementIterator(); while (it.hasNext()) { Advancement a = it.next(); AdvancementProgress p = player.getAdvancementProgress(a); if (!p.getAwardedCriteria().isEmpty()) { - store.setAdvancement(worldName, a.getKey().toString(), new ArrayList<>(p.getAwardedCriteria())); + store.setAdvancement(key, a.getKey().toString(), new ArrayList<>(p.getAwardedCriteria())); } } } if (addon.getSettings().isEnderChest()) { // Copy the player's ender chest items to the store List contents = Arrays.asList(player.getEnderChest().getContents()); - store.setEnderChest(overworldName, contents); + store.setEnderChest(key, contents); } if (addon.getSettings().isStatistics()) { - saveStats(store, player, overworldName, shutdown).thenAccept(database::saveObjectAsync); + saveStats(store, player, key, shutdown).thenAccept(database::saveObjectAsync); return; } database.saveObjectAsync(store); diff --git a/src/main/java/com/wasteofplastic/invswitcher/dataobjects/InventoryStorage.java b/src/main/java/com/wasteofplastic/invswitcher/dataobjects/InventoryStorage.java index 48c2b8e..dcf4cbf 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/dataobjects/InventoryStorage.java +++ b/src/main/java/com/wasteofplastic/invswitcher/dataobjects/InventoryStorage.java @@ -22,45 +22,104 @@ @Table(name = "InventoryStorage") public class InventoryStorage implements DataObject { + /** + * The unique identifier for this inventory storage. + */ @Expose private String uniqueId; + + /** + * Map of world name to inventory contents. + */ @Expose private Map> inventory = new HashMap<>(); + + /** + * Map of world name to player health. + */ @Expose private Map health = new HashMap<>(); + + /** + * Map of world name to player food level. + */ @Expose private Map food = new HashMap<>(); + + /** + * Map of world name to player experience. + */ @Expose private Map exp = new HashMap<>(); + + /** + * Map of world name to player location. + */ @Expose private Map location = new HashMap<>(); + + /** + * Map of world name to player game mode. + */ @Expose private Map gameMode = new HashMap<>(); + + /** + * Map of world name to advancements (keyed by advancement key and criteria). + */ @Expose private Map>> advancements = new HashMap<>(); + + /** + * Map of world name to Ender Chest inventory contents. + */ @Expose private Map> enderChest = new HashMap<>(); + + /** + * Map of world name to untyped statistics. + */ @Expose private Map> untypedStats = new HashMap<>(); + + /** + * Map of world name to block statistics. + */ @Expose private Map>> blockStats = new HashMap<>(); + + /** + * Map of world name to item statistics. + */ @Expose private Map>> itemStats = new HashMap<>(); + + /** + * Map of world name to entity statistics. + */ @Expose private Map>> entityStats = new HashMap<>(); + /** + * Gets the unique identifier for this inventory storage. + * @return the uniqueId + */ @Override public String getUniqueId() { return uniqueId; } + /** + * Sets the unique identifier for this inventory storage. + * @param uniqueId the uniqueId to set + */ @Override public void setUniqueId(String uniqueId) { this.uniqueId = uniqueId; - } /** + * Gets the inventory map. * @return the inventory */ public Map> getInventory() { @@ -68,6 +127,7 @@ public Map> getInventory() { } /** + * Gets the health map. * @return the health */ public Map getHealth() { @@ -75,6 +135,7 @@ public Map getHealth() { } /** + * Gets the food map. * @return the food */ public Map getFood() { @@ -82,6 +143,7 @@ public Map getFood() { } /** + * Gets the experience map. * @return the exp */ public Map getExp() { @@ -89,14 +151,15 @@ public Map getExp() { } /** + * Gets the location map. * @return the location */ public Map getLocation() { return location; } - /** + * Sets the inventory map. * @param inventory the inventory to set */ public void setInventory(Map> inventory) { @@ -104,16 +167,16 @@ public void setInventory(Map> inventory) { } /** - * + * Sets the inventory for a specific world. * @param worldname the world name * @param inventory the inventory to set */ public void setInventory(String worldname, List inventory) { this.inventory.put(worldname, inventory); - } /** + * Sets the health map. * @param health the health to set */ public void setHealth(Map health) { @@ -121,6 +184,7 @@ public void setHealth(Map health) { } /** + * Sets the food map. * @param food the food to set */ public void setFood(Map food) { @@ -128,6 +192,7 @@ public void setFood(Map food) { } /** + * Sets the experience map. * @param exp the exp to set */ public void setExp(Map exp) { @@ -135,108 +200,149 @@ public void setExp(Map exp) { } /** + * Sets the location map. * @param location the location to set */ public void setLocation(Map location) { this.location = location; } + /** + * Sets the health for a specific world. + * @param overworldName the world name + * @param health2 the health value to set + */ public void setHealth(String overworldName, double health2) { this.health.put(overworldName, health2); - } + /** + * Sets the food level for a specific world. + * @param overworldName the world name + * @param foodLevel the food level to set + */ public void setFood(String overworldName, int foodLevel) { this.food.put(overworldName, foodLevel); - } + /** + * Sets the experience for a specific world. + * @param overworldName the world name + * @param totalExperience the experience value to set + */ public void setExp(String overworldName, int totalExperience) { this.exp.put(overworldName, totalExperience); - } + /** + * Sets the location for a specific world. + * @param worldName the world name + * @param location2 the location to set + */ public void setLocation(String worldName, Location location2) { this.location.put(worldName, location2); - } + /** + * Gets the inventory for a specific world. + * @param overworldName the world name + * @return the inventory list + */ public List getInventory(String overworldName) { return inventory == null ? new ArrayList<>() : inventory.getOrDefault(overworldName, new ArrayList<>()); } /** - * Check if an inventory for this world exists or not - * @param overworldName - over world name - * @return true if there is an inventory for this world, false if not. + * Checks if an inventory exists for a specific world. + * @param overworldName the world name + * @return true if inventory exists, false otherwise */ public boolean isInventory(String overworldName) { return inventory != null && inventory.containsKey(overworldName); } + /** + * Sets the game mode for a specific world. + * @param worldName the world name + * @param gameMode the game mode to set + */ public void setGameMode(String worldName, GameMode gameMode) { this.gameMode.put(worldName, gameMode); } + /** + * Gets the game mode for a specific world. + * @param worldName the world name + * @return the game mode, or SURVIVAL if not set + */ public GameMode getGameMode(String worldName) { return this.gameMode.getOrDefault(worldName, GameMode.SURVIVAL); } + /** + * Sets an advancement for a specific world. + * @param worldName the world name + * @param key the advancement key + * @param criteria the advancement criteria + */ public void setAdvancement(String worldName, String key, List criteria) { this.advancements.computeIfAbsent(worldName, k -> new HashMap<>()).put(key, criteria); } /** - * Clears advancements for world - * @param worldName - world name + * Clears advancements for a specific world. + * @param worldName the world name */ public void clearAdvancement(String worldName) { this.advancements.remove(worldName); } /** - * @return the advancements + * Gets the advancements for a specific world. + * @param worldName the world name + * @return the advancements map */ public Map> getAdvancements(String worldName) { return advancements.getOrDefault(worldName, Collections.emptyMap()); } /** - * Get the EnderChest inventory - * @param overworldName - world name - * @return inventory + * Gets the Ender Chest inventory for a specific world. + * @param overworldName the world name + * @return the Ender Chest inventory list */ public List getEnderChest(String overworldName) { return enderChest == null ? new ArrayList<>() : enderChest.getOrDefault(overworldName, new ArrayList<>()); } /** - * + * Sets the Ender Chest inventory for a specific world. * @param worldname the world name * @param inventory the inventory to set */ public void setEnderChest(String worldname, List inventory) { this.enderChest.put(worldname, inventory); - } /** - * @return the enderChest + * Gets the Ender Chest inventory map. + * @return the enderChest map */ public Map> getEnderChest() { return enderChest; } /** - * @param enderChest the enderChest to set + * Sets the Ender Chest inventory map. + * @param enderChest the enderChest map to set */ public void setEnderChest(Map> enderChest) { this.enderChest = enderChest; } /** - * Clear the stats for player for world name - * @param worldName World name + * Clears all statistics for a player for a specific world. + * @param worldName the world name */ public void clearStats(String worldName) { this.blockStats.remove(worldName); @@ -246,69 +352,92 @@ public void clearStats(String worldName) { } /** - * Get Untyped stats - * @param worldName World name - * @return the untypedStats + * Gets the untyped statistics for a specific world. + * @param worldName the world name + * @return the untypedStats map */ public Map getUntypedStats(String worldName) { return untypedStats.computeIfAbsent(worldName, k -> new EnumMap<>(Statistic.class)); } /** - * @param worldName World name - * @param untypedStats the untypedStats to set + * Sets the untyped statistics for a specific world. + * @param worldName the world name + * @param untypedStats the untypedStats map to set */ public void setUntypedStats(String worldName, Map untypedStats) { this.untypedStats.put(worldName, untypedStats); } /** - * @param worldName World name - * @return the blockStats + * Gets the block statistics for a specific world. + * @param worldName the world name + * @return the blockStats map */ public Map> getBlockStats(String worldName) { return blockStats.computeIfAbsent(worldName, k -> new EnumMap<>(Statistic.class)); } /** - * @param worldName World name - * @param blockStats the blockStats to set + * Sets the block statistics for a specific world. + * @param worldName the world name + * @param blockStats the blockStats map to set */ public void setBlockStats(String worldName, Map> blockStats) { this.blockStats.put(worldName, blockStats); } /** - * @param worldName World name - * @return the itemStats + * Gets the item statistics for a specific world. + * @param worldName the world name + * @return the itemStats map */ public Map> getItemStats(String worldName) { return itemStats.computeIfAbsent(worldName, k -> new EnumMap<>(Statistic.class)); } /** - * @param worldName World name - * @param itemStats the itemStats to set + * Sets the item statistics for a specific world. + * @param worldName the world name + * @param itemStats the itemStats map to set */ public void setItemStats(String worldName, Map> itemStats) { this.itemStats.put(worldName, itemStats); } /** - * @param worldName World name - * @return the entityStats + * Gets the entity statistics for a specific world. + * @param worldName the world name + * @return the entityStats map */ public Map> getEntityStats(String worldName) { return entityStats.computeIfAbsent(worldName, k -> new EnumMap<>(Statistic.class)); } /** - * @param worldName World name - * @param entityStats the entityStats to set + * Sets the entity statistics for a specific world. + * @param worldName the world name + * @param entityStats the entityStats map to set */ public void setEntityStats(String worldName, Map> entityStats) { this.entityStats.put(worldName, entityStats); } + /** + * Clears all data for a specific world key. Used during migration from world-only + * keys to island-specific keys to prevent data duplication. + * @param worldName the world name key to clear + */ + public void clearWorldData(String worldName) { + this.inventory.remove(worldName); + this.health.remove(worldName); + this.food.remove(worldName); + this.exp.remove(worldName); + this.location.remove(worldName); + this.gameMode.remove(worldName); + this.advancements.remove(worldName); + this.enderChest.remove(worldName); + clearStats(worldName); + } } \ No newline at end of file diff --git a/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java b/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java index 2574209..bfa6d4e 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java +++ b/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java @@ -1,5 +1,9 @@ package com.wasteofplastic.invswitcher.listeners; +import java.util.Objects; +import java.util.Optional; + +import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -7,9 +11,14 @@ import org.bukkit.event.player.PlayerChangedWorldEvent; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.entity.Player; import com.wasteofplastic.invswitcher.InvSwitcher; +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.events.island.IslandEnterEvent; +import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.util.Util; /** @@ -52,6 +61,60 @@ public void onWorldEnter(final PlayerChangedWorldEvent event) { addon.getStore().getInventory(event.getPlayer(), to); } + /** + * Handles inventory switching when a player enters an island they own. + * Only triggers when per-island switching is enabled and the player owns multiple islands. + * @param event - event + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onIslandEnter(IslandEnterEvent event) { + BentoBox.getInstance().logDebug("IslandEnterEvent triggered for player " + event.getPlayerUUID() + " on island " + event.getIsland().getUniqueId()); + if (!addon.getSettings().isIslands()) { + return; + } + + Player player = Bukkit.getPlayer(event.getPlayerUUID()); + if (player == null) { + return; + } + + World world = player.getWorld(); + if (!addon.getWorlds().contains(world)) { + BentoBox.getInstance().logDebug("World " + world.getName() + " is not in the list of worlds to manage. Ignoring island enter event."); + return; + } + + Island island = event.getIsland(); + + // Only switch if the player owns the island they're entering + if (island.getOwner() == null || !island.getOwner().equals(player.getUniqueId())) { + BentoBox.getInstance().logDebug("Player " + player.getName() + " does not own island " + island.getUniqueId() + ". No inventory switch."); + return; + } + + // Only switch if player owns multiple islands (otherwise key is the same) + World overworld = Util.getWorld(world); + int count = addon.getIslands().getNumberOfConcurrentIslands( + player.getUniqueId(), Objects.requireNonNull(overworld)); + if (count <= 1) { + BentoBox.getInstance().logDebug("Player " + player.getName() + " owns only one island in world " + overworld.getName() + ". No inventory switch."); + return; + } + + // Compute new key and compare to current key + String newKey = addon.getStore().getStorageKey(player, world, island); + String currentKeyValue = addon.getStore().getCurrentKey(player); + if (newKey.equals(currentKeyValue)) { + BentoBox.getInstance().logDebug("Player " + player.getName() + " is entering island " + island.getUniqueId() + " which has the same inventory key as their current location. No inventory switch."); + return; // same island, no switch + } + + // Switch: store old, load new + BentoBox.getInstance().logDebug("Switching inventory for player " + player.getName() + " from key " + currentKeyValue + " to new key " + newKey); + addon.getStore().storeInventory(player, world); + addon.getStore().getInventory(player, world, island); + } + /** * Loads inventory @@ -64,6 +127,41 @@ public void onPlayerJoin(final PlayerJoinEvent event) { } } + /** + * Handles inventory switching when a player respawns on a different island they own. + * @param event - event + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onPlayerRespawn(PlayerRespawnEvent event) { + if (!addon.getSettings().isIslands()) { + return; + } + + Player player = event.getPlayer(); + World world = event.getRespawnLocation().getWorld(); + if (world == null || !addon.getWorlds().contains(world)) { + return; + } + + // Determine which island the respawn location is on + Optional islandOpt = addon.getIslands().getIslandAt(event.getRespawnLocation()); + if (islandOpt.isEmpty()) { + return; + } + + Island island = islandOpt.get(); + String newKey = addon.getStore().getStorageKey(player, world, island); + String currentKeyValue = addon.getStore().getCurrentKey(player); + + if (currentKeyValue != null && !newKey.equals(currentKeyValue)) { + // Player died on one island, respawning on another they own. + // Save the post-death state (empty inventory, etc.) to the old island key. + addon.getStore().storeAndSave(player, world, false); + // Load the respawn island's inventory + addon.getStore().getInventory(player, world, island); + } + } + /** * Saves inventory * @param event - event diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 31e0b37..27537c5 100755 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -20,3 +20,5 @@ options: experience: true ender-chest: true statistics: true + # Switch inventories based on island. Only applies if players own more than one island. + islands: true diff --git a/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java b/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java index b5bc708..4692935 100644 --- a/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java +++ b/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java @@ -1,7 +1,9 @@ package com.wasteofplastic.invswitcher; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyFloat; @@ -20,10 +22,13 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; +import java.util.HashSet; +import java.util.Optional; import java.util.UUID; import java.util.logging.Logger; import org.bukkit.Bukkit; +import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.World; import org.bukkit.World.Environment; @@ -46,6 +51,9 @@ import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.Settings; import world.bentobox.bentobox.database.DatabaseSetup.DatabaseType; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.IslandWorldManager; +import world.bentobox.bentobox.managers.IslandsManager; import world.bentobox.bentobox.util.Util; /** @@ -63,11 +71,15 @@ public class StoreTest { private World world; @Mock private Settings settings; + @Mock + private IslandsManager islandsManager; private Store s; private com.wasteofplastic.invswitcher.Settings sets; + private UUID playerUUID; + @Mock private Logger logger; @@ -88,8 +100,8 @@ public void setUp() when(plugin.getSettings()).thenReturn(settings); // Player mock - UUID uuid = UUID.randomUUID(); - when(player.getUniqueId()).thenReturn(uuid); + playerUUID = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(playerUUID); AttributeInstance attribute = mock(AttributeInstance.class); // Health when(attribute.getValue()).thenReturn(18D); @@ -114,6 +126,7 @@ public void setUp() // Addon when(addon.getLogger()).thenReturn(logger); + when(addon.getIslands()).thenReturn(islandsManager); //PowerMockito.mockStatic(Util.class); try (MockedStatic utilities = Mockito.mockStatic(Util.class)) { @@ -123,6 +136,9 @@ public void setUp() DatabaseType mockDbt = mock(DatabaseType.class); when(settings.getDatabaseType()).thenReturn(mockDbt); + // Disable island switching by default for existing tests + sets.setIslands(false); + // Class under test s = new Store(addon); } @@ -281,4 +297,130 @@ public void testSaveOnlinePlayers() { } } + // --- Per-island storage key tests --- + + @Test + public void testGetStorageKeyIslandsDisabled() { + sets.setIslands(false); + String key = s.getStorageKey(player, world); + assertEquals("world", key); // nether suffix stripped + } + + @Test + public void testGetStorageKeySingleIsland() { + sets.setIslands(true); + try (MockedStatic utilities = Mockito.mockStatic(Util.class)) { + utilities.when(() -> Util.getWorld(world)).thenReturn(world); + when(islandsManager.getNumberOfConcurrentIslands(playerUUID, world)).thenReturn(1); + String key = s.getStorageKey(player, world); + assertEquals("world", key); // just overworld name, no island suffix + } + } + + @Test + public void testGetStorageKeyMultipleIslandsOnOwnIsland() { + sets.setIslands(true); + Island island = mock(Island.class); + when(island.getOwner()).thenReturn(playerUUID); + when(island.getUniqueId()).thenReturn("island-123"); + Location loc = mock(Location.class); + when(player.getLocation()).thenReturn(loc); + + try (MockedStatic utilities = Mockito.mockStatic(Util.class)) { + utilities.when(() -> Util.getWorld(world)).thenReturn(world); + when(islandsManager.getNumberOfConcurrentIslands(playerUUID, world)).thenReturn(2); + when(islandsManager.getIslandAt(loc)).thenReturn(Optional.of(island)); + + String key = s.getStorageKey(player, world); + assertEquals("world/island-123", key); + } + } + + @Test + public void testGetStorageKeyMultipleIslandsOnOtherPlayerIsland() { + sets.setIslands(true); + Island island = mock(Island.class); + UUID otherPlayer = UUID.randomUUID(); + when(island.getOwner()).thenReturn(otherPlayer); + Location loc = mock(Location.class); + when(player.getLocation()).thenReturn(loc); + + try (MockedStatic utilities = Mockito.mockStatic(Util.class)) { + utilities.when(() -> Util.getWorld(world)).thenReturn(world); + when(islandsManager.getNumberOfConcurrentIslands(playerUUID, world)).thenReturn(2); + when(islandsManager.getIslandAt(loc)).thenReturn(Optional.of(island)); + + String key = s.getStorageKey(player, world); + // Not on own island, falls back to overworld name + assertEquals("world", key); + } + } + + @Test + public void testGetStorageKeyWithSpecificIsland() { + sets.setIslands(true); + Island island = mock(Island.class); + when(island.getOwner()).thenReturn(playerUUID); + when(island.getUniqueId()).thenReturn("island-456"); + + try (MockedStatic utilities = Mockito.mockStatic(Util.class)) { + utilities.when(() -> Util.getWorld(world)).thenReturn(world); + when(islandsManager.getNumberOfConcurrentIslands(playerUUID, world)).thenReturn(2); + + String key = s.getStorageKey(player, world, island); + assertEquals("world/island-456", key); + } + } + + @Test + public void testGetStorageKeyGenericNether() { + sets.setIslands(true); + // Set up a nether world + World netherWorld = mock(World.class); + when(netherWorld.getName()).thenReturn("world_nether"); + when(netherWorld.getEnvironment()).thenReturn(Environment.NETHER); + + // Set up overworld for Util.getWorld + World overworld = mock(World.class); + when(overworld.getName()).thenReturn("world"); + + // Set up BentoBox IWM + BentoBox plugin = mock(BentoBox.class); + IslandWorldManager iwm = mock(IslandWorldManager.class); + when(plugin.getIWM()).thenReturn(iwm); + when(iwm.isIslandNether(netherWorld)).thenReturn(false); // generic nether + + try (MockedStatic utilities = Mockito.mockStatic(Util.class); + MockedStatic bentoBoxStatic = mockStatic(BentoBox.class)) { + utilities.when(() -> Util.getWorld(netherWorld)).thenReturn(overworld); + bentoBoxStatic.when(BentoBox::getInstance).thenReturn(plugin); + when(islandsManager.getNumberOfConcurrentIslands(playerUUID, overworld)).thenReturn(2); + + // No current key set yet, should fall back to overworld name + String key = s.getStorageKey(player, netherWorld); + assertEquals("world", key); + } + } + + @Test + public void testGetCurrentKeyNullByDefault() { + assertNull(s.getCurrentKey(player)); + } + + @Test + public void testGetCurrentKeySetAfterGetInventory() { + sets.setIslands(false); + s.getInventory(player, world); + assertEquals("world", s.getCurrentKey(player)); + } + + @Test + public void testRemoveFromCacheClearsCurrentKey() { + sets.setIslands(false); + s.getInventory(player, world); + assertNotNull(s.getCurrentKey(player)); + s.removeFromCache(player); + assertNull(s.getCurrentKey(player)); + } + } diff --git a/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java b/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java index 36c67ea..5ab9129 100644 --- a/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java +++ b/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java @@ -2,18 +2,24 @@ import static org.junit.Assert.assertNotNull; import static org.mockito.ArgumentMatchers.any; +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.util.Optional; import java.util.Set; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.Location; import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.player.PlayerChangedWorldEvent; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -23,15 +29,19 @@ import org.mockito.junit.MockitoJUnitRunner; import com.wasteofplastic.invswitcher.InvSwitcher; +import com.wasteofplastic.invswitcher.Settings; import com.wasteofplastic.invswitcher.Store; +import world.bentobox.bentobox.api.events.island.IslandEnterEvent; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.IslandsManager; import world.bentobox.bentobox.util.Util; /** * @author tastybento * */ -@RunWith(MockitoJUnitRunner.class) +@RunWith(MockitoJUnitRunner.Silent.class) public class PlayerListenerTest { @Mock @@ -45,11 +55,19 @@ public class PlayerListenerTest { private World world; @Mock private World notWorld; + @Mock + private Settings settings; + @Mock + private IslandsManager islandsManager; + + private UUID playerUUID; /** */ @Before public void setUp() { + playerUUID = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(playerUUID); // Util // Mock the static method try (MockedStatic mockedBukkit = mockStatic(Util.class, Mockito.RETURNS_MOCKS)) { @@ -61,6 +79,8 @@ public void setUp() { // Addon when(addon.getStore()).thenReturn(store); when(addon.getWorlds()).thenReturn(Set.of(world)); + when(addon.getSettings()).thenReturn(settings); + when(addon.getIslands()).thenReturn(islandsManager); pl = new PlayerListener(addon); } @@ -157,4 +177,155 @@ public void testOnPlayerQuitNotCoveredWorld() { verify(store).removeFromCache(player); } + // --- Island Enter Event Tests --- + + @Test + public void testOnIslandEnterDisabled() { + when(settings.isIslands()).thenReturn(false); + Island island = mock(Island.class); + IslandEnterEvent event = new IslandEnterEvent(island, playerUUID, false, null, island, null); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) { + mockedBukkit.when(() -> Bukkit.getPlayer(playerUUID)).thenReturn(player); + pl.onIslandEnter(event); + } + verify(store, never()).storeInventory(any(), any()); + } + + @Test + public void testOnIslandEnterNotOwner() { + when(settings.isIslands()).thenReturn(true); + Island island = mock(Island.class); + UUID otherOwner = UUID.randomUUID(); + when(island.getOwner()).thenReturn(otherOwner); + IslandEnterEvent event = new IslandEnterEvent(island, playerUUID, false, null, island, null); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) { + mockedBukkit.when(() -> Bukkit.getPlayer(playerUUID)).thenReturn(player); + pl.onIslandEnter(event); + } + verify(store, never()).storeInventory(any(), any()); + } + + @Test + public void testOnIslandEnterSingleIsland() { + when(settings.isIslands()).thenReturn(true); + Island island = mock(Island.class); + when(island.getOwner()).thenReturn(playerUUID); + + IslandEnterEvent event = new IslandEnterEvent(island, playerUUID, false, null, island, null); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class); + MockedStatic utilities = mockStatic(Util.class)) { + mockedBukkit.when(() -> Bukkit.getPlayer(playerUUID)).thenReturn(player); + utilities.when(() -> Util.getWorld(world)).thenReturn(world); + when(islandsManager.getNumberOfConcurrentIslands(playerUUID, world)).thenReturn(1); + pl.onIslandEnter(event); + } + verify(store, never()).storeInventory(any(), any()); + } + + @Test + public void testOnIslandEnterMultipleIslandsSameKey() { + when(settings.isIslands()).thenReturn(true); + Island island = mock(Island.class); + when(island.getOwner()).thenReturn(playerUUID); + when(island.getUniqueId()).thenReturn("island-1"); + + // Current key already matches + when(store.getStorageKey(player, world, island)).thenReturn("world/island-1"); + when(store.getCurrentKey(player)).thenReturn("world/island-1"); + + IslandEnterEvent event = new IslandEnterEvent(island, playerUUID, false, null, island, null); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class); + MockedStatic utilities = mockStatic(Util.class)) { + mockedBukkit.when(() -> Bukkit.getPlayer(playerUUID)).thenReturn(player); + utilities.when(() -> Util.getWorld(world)).thenReturn(world); + when(islandsManager.getNumberOfConcurrentIslands(playerUUID, world)).thenReturn(2); + pl.onIslandEnter(event); + } + // Same key, no switch + verify(store, never()).storeInventory(any(), any()); + } + + @Test + public void testOnIslandEnterMultipleIslandsDifferentKey() { + when(settings.isIslands()).thenReturn(true); + Island island = mock(Island.class); + when(island.getOwner()).thenReturn(playerUUID); + when(island.getUniqueId()).thenReturn("island-2"); + + when(store.getStorageKey(player, world, island)).thenReturn("world/island-2"); + when(store.getCurrentKey(player)).thenReturn("world/island-1"); + + IslandEnterEvent event = new IslandEnterEvent(island, playerUUID, false, null, island, null); + try (MockedStatic mockedBukkit = mockStatic(Bukkit.class); + MockedStatic utilities = mockStatic(Util.class)) { + mockedBukkit.when(() -> Bukkit.getPlayer(playerUUID)).thenReturn(player); + utilities.when(() -> Util.getWorld(world)).thenReturn(world); + when(islandsManager.getNumberOfConcurrentIslands(playerUUID, world)).thenReturn(2); + pl.onIslandEnter(event); + } + // Different key, switch should happen + verify(store).storeInventory(player, world); + verify(store).getInventory(player, world, island); + } + + // --- Respawn Event Tests --- + + @Test + public void testOnPlayerRespawnDisabled() { + when(settings.isIslands()).thenReturn(false); + Location respawnLoc = mock(Location.class); + when(respawnLoc.getWorld()).thenReturn(world); + PlayerRespawnEvent event = new PlayerRespawnEvent(player, respawnLoc, false); + pl.onPlayerRespawn(event); + verify(store, never()).storeAndSave(any(), any(), any(boolean.class)); + } + + @Test + public void testOnPlayerRespawnSameIsland() { + when(settings.isIslands()).thenReturn(true); + Location respawnLoc = mock(Location.class); + when(respawnLoc.getWorld()).thenReturn(world); + + Island island = mock(Island.class); + when(islandsManager.getIslandAt(respawnLoc)).thenReturn(Optional.of(island)); + when(store.getStorageKey(player, world, island)).thenReturn("world/island-1"); + when(store.getCurrentKey(player)).thenReturn("world/island-1"); + + PlayerRespawnEvent event = new PlayerRespawnEvent(player, respawnLoc, false); + pl.onPlayerRespawn(event); + // Same island, no switch + verify(store, never()).storeAndSave(any(), any(), any(boolean.class)); + } + + @Test + public void testOnPlayerRespawnDifferentIsland() { + when(settings.isIslands()).thenReturn(true); + Location respawnLoc = mock(Location.class); + when(respawnLoc.getWorld()).thenReturn(world); + + Island island = mock(Island.class); + when(islandsManager.getIslandAt(respawnLoc)).thenReturn(Optional.of(island)); + when(store.getStorageKey(player, world, island)).thenReturn("world/island-2"); + when(store.getCurrentKey(player)).thenReturn("world/island-1"); + + PlayerRespawnEvent event = new PlayerRespawnEvent(player, respawnLoc, false); + pl.onPlayerRespawn(event); + // Different island, switch should happen + verify(store).storeAndSave(player, world, false); + verify(store).getInventory(player, world, island); + } + + @Test + public void testOnPlayerRespawnNoIsland() { + when(settings.isIslands()).thenReturn(true); + Location respawnLoc = mock(Location.class); + when(respawnLoc.getWorld()).thenReturn(world); + when(islandsManager.getIslandAt(respawnLoc)).thenReturn(Optional.empty()); + + PlayerRespawnEvent event = new PlayerRespawnEvent(player, respawnLoc, false); + pl.onPlayerRespawn(event); + // No island at respawn, no switch + verify(store, never()).storeAndSave(any(), any(), any(boolean.class)); + } + } From 0bbb6bfa95eda633180f3f1e5fc4f962194a2146 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 30 Mar 2026 13:58:38 -0700 Subject: [PATCH 2/2] Add granular per-island switching options Replace the single islands boolean with a nested config that lets admins control which aspects are switched per-island independently. Each option (inventory, health, food, advancements, gamemode, experience, ender-chest, statistics) can be toggled separately under options.islands. Options default to matching the original behavior (inventory and ender-chest on, rest off). The world-level option must also be enabled for the island sub-option to take effect. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../wasteofplastic/invswitcher/Settings.java | 83 +++++++++++-- .../com/wasteofplastic/invswitcher/Store.java | 115 ++++++++++-------- .../invswitcher/listeners/PlayerListener.java | 4 +- src/main/resources/config.yml | 13 +- .../wasteofplastic/invswitcher/StoreTest.java | 18 +-- .../listeners/PlayerListenerTest.java | 18 +-- 6 files changed, 172 insertions(+), 79 deletions(-) diff --git a/src/main/java/com/wasteofplastic/invswitcher/Settings.java b/src/main/java/com/wasteofplastic/invswitcher/Settings.java index cb8bccb..6d57227 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/Settings.java +++ b/src/main/java/com/wasteofplastic/invswitcher/Settings.java @@ -34,9 +34,28 @@ public class Settings implements ConfigObject { private boolean enderChest = true; @ConfigEntry(path = "options.statistics") private boolean statistics = true; + @ConfigComment("Switch inventories based on island. Only applies if players own more than one island.") - @ConfigEntry(path = "options.islands") - private boolean islands = true; + @ConfigComment("Each sub-option controls whether that aspect is switched per-island.") + @ConfigComment("The world-level option must also be true for the island option to have any effect.") + @ConfigEntry(path = "options.islands.active") + private boolean islandsActive = true; + @ConfigEntry(path = "options.islands.inventory") + private boolean islandsInventory = true; + @ConfigEntry(path = "options.islands.health") + private boolean islandsHealth = false; + @ConfigEntry(path = "options.islands.food") + private boolean islandsFood = false; + @ConfigEntry(path = "options.islands.advancements") + private boolean islandsAdvancements = false; + @ConfigEntry(path = "options.islands.gamemode") + private boolean islandsGamemode = false; + @ConfigEntry(path = "options.islands.experience") + private boolean islandsExperience = false; + @ConfigEntry(path = "options.islands.ender-chest") + private boolean islandsEnderChest = true; + @ConfigEntry(path = "options.islands.statistics") + private boolean islandsStatistics = false; /** * @return the worlds @@ -147,16 +166,64 @@ public void setStatistics(boolean statistics) { this.statistics = statistics; } /** - * @return whether per-island inventory switching is enabled + * @return whether per-island switching is active */ - public boolean isIslands() { - return islands; + public boolean isIslandsActive() { + return islandsActive; } /** - * @param islands whether to enable per-island inventory switching + * @param islandsActive whether to enable per-island switching */ - public void setIslands(boolean islands) { - this.islands = islands; + public void setIslandsActive(boolean islandsActive) { + this.islandsActive = islandsActive; + } + public boolean isIslandsInventory() { + return islandsInventory; + } + public void setIslandsInventory(boolean islandsInventory) { + this.islandsInventory = islandsInventory; + } + public boolean isIslandsHealth() { + return islandsHealth; + } + public void setIslandsHealth(boolean islandsHealth) { + this.islandsHealth = islandsHealth; + } + public boolean isIslandsFood() { + return islandsFood; + } + public void setIslandsFood(boolean islandsFood) { + this.islandsFood = islandsFood; + } + public boolean isIslandsAdvancements() { + return islandsAdvancements; + } + public void setIslandsAdvancements(boolean islandsAdvancements) { + this.islandsAdvancements = islandsAdvancements; + } + public boolean isIslandsGamemode() { + return islandsGamemode; + } + public void setIslandsGamemode(boolean islandsGamemode) { + this.islandsGamemode = islandsGamemode; + } + public boolean isIslandsExperience() { + return islandsExperience; + } + public void setIslandsExperience(boolean islandsExperience) { + this.islandsExperience = islandsExperience; + } + public boolean isIslandsEnderChest() { + return islandsEnderChest; + } + public void setIslandsEnderChest(boolean islandsEnderChest) { + this.islandsEnderChest = islandsEnderChest; + } + public boolean isIslandsStatistics() { + return islandsStatistics; + } + public void setIslandsStatistics(boolean islandsStatistics) { + this.islandsStatistics = islandsStatistics; } } diff --git a/src/main/java/com/wasteofplastic/invswitcher/Store.java b/src/main/java/com/wasteofplastic/invswitcher/Store.java index 187b0ef..41e1362 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/Store.java +++ b/src/main/java/com/wasteofplastic/invswitcher/Store.java @@ -110,7 +110,7 @@ public String getStorageKey(Player player, World world, Island island) { String getStorageKey(Player player, World world, Location location, Island island) { String overworldName = getOverworldName(world); - if (!addon.getSettings().isIslands()) { + if (!addon.getSettings().isIslandsActive()) { return overworldName; } @@ -203,50 +203,57 @@ public void getInventory(Player player, World world, Island island) { // Get the store InventoryStorage store = getInv(player); - String key = (island != null) ? getStorageKey(player, world, island) : getStorageKey(player, world); + String islandKey = (island != null) ? getStorageKey(player, world, island) : getStorageKey(player, world); + String worldKey = getOverworldName(world); - // Always track the resolved key (including island suffix) so future saves go to the right slot - currentKey.put(player.getUniqueId(), key); + // Always track the island-level key so future saves and island detection work correctly + currentKey.put(player.getUniqueId(), islandKey); // Backward compat: if island-specific key has no data, migrate from world-only key. // This only happens once — the world-only data is cleared after migration so that // other islands don't also inherit a duplicate copy. - String loadKey = key; - if (key.contains("/") && !store.isInventory(key)) { - String overworldName = getOverworldName(world); - if (store.isInventory(overworldName)) { - loadKey = overworldName; + String islandLoadKey = islandKey; + if (islandKey.contains("/") && !store.isInventory(islandKey)) { + if (store.isInventory(worldKey)) { + islandLoadKey = worldKey; // Clear the world-only data so it can't be claimed by another island - store.clearWorldData(overworldName); + store.clearWorldData(worldKey); } } - // Inventory - if (addon.getSettings().isInventory()) { - player.getInventory().setContents(store.getInventory(loadKey).toArray(new ItemStack[0])); + // Each option uses the island key or the world key based on its island sub-setting + Settings settings = addon.getSettings(); + if (settings.isInventory()) { + String k = settings.isIslandsInventory() ? islandLoadKey : worldKey; + player.getInventory().setContents(store.getInventory(k).toArray(new ItemStack[0])); } - if (addon.getSettings().isHealth()) { - setHeath(store, player, loadKey); + if (settings.isHealth()) { + String k = settings.isIslandsHealth() ? islandLoadKey : worldKey; + setHeath(store, player, k); } - if (addon.getSettings().isFood()) { - setFood(store, player, loadKey); + if (settings.isFood()) { + String k = settings.isIslandsFood() ? islandLoadKey : worldKey; + setFood(store, player, k); } - if (addon.getSettings().isExperience()) { - // Experience - setTotalExperience(player, store.getExp().getOrDefault(loadKey, 0)); + if (settings.isExperience()) { + String k = settings.isIslandsExperience() ? islandLoadKey : worldKey; + setTotalExperience(player, store.getExp().getOrDefault(k, 0)); } - if (addon.getSettings().isGamemode()) { - // Game modes - player.setGameMode(store.getGameMode(loadKey)); + if (settings.isGamemode()) { + String k = settings.isIslandsGamemode() ? islandLoadKey : worldKey; + player.setGameMode(store.getGameMode(k)); } - if (addon.getSettings().isAdvancements()) { - setAdvancements(store, player, loadKey); + if (settings.isAdvancements()) { + String k = settings.isIslandsAdvancements() ? islandLoadKey : worldKey; + setAdvancements(store, player, k); } - if (addon.getSettings().isEnderChest()) { - player.getEnderChest().setContents(store.getEnderChest(loadKey).toArray(new ItemStack[0])); + if (settings.isEnderChest()) { + String k = settings.isIslandsEnderChest() ? islandLoadKey : worldKey; + player.getEnderChest().setContents(store.getEnderChest(k).toArray(new ItemStack[0])); } - if (addon.getSettings().isStatistics()) { - getStats(store, player, loadKey); + if (settings.isStatistics()) { + String k = settings.isIslandsStatistics() ? islandLoadKey : worldKey; + getStats(store, player, k); } } @@ -342,43 +349,51 @@ public void storeAndSave(Player player, World world, boolean shutdown) { InventoryStorage store = getInv(player); // Use the current tracked key if available (ensures we save to the correct island slot), // otherwise compute from location - String key = currentKey.getOrDefault(player.getUniqueId(), getStorageKey(player, world)); - if (addon.getSettings().isInventory()) { - // Copy the player's items to the store + String islandKey = currentKey.getOrDefault(player.getUniqueId(), getStorageKey(player, world)); + String worldKey = getOverworldName(world); + // Each option saves to the island key or the world key based on its island sub-setting + Settings settings = addon.getSettings(); + if (settings.isInventory()) { + String k = settings.isIslandsInventory() ? islandKey : worldKey; List contents = Arrays.asList(player.getInventory().getContents()); - store.setInventory(key, contents); + store.setInventory(k, contents); } - if (addon.getSettings().isHealth()) { - store.setHealth(key, player.getHealth()); + if (settings.isHealth()) { + String k = settings.isIslandsHealth() ? islandKey : worldKey; + store.setHealth(k, player.getHealth()); } - if (addon.getSettings().isFood()) { - store.setFood(key, player.getFoodLevel()); + if (settings.isFood()) { + String k = settings.isIslandsFood() ? islandKey : worldKey; + store.setFood(k, player.getFoodLevel()); } - if (addon.getSettings().isExperience()) { - store.setExp(key, getTotalExperience(player)); + if (settings.isExperience()) { + String k = settings.isIslandsExperience() ? islandKey : worldKey; + store.setExp(k, getTotalExperience(player)); } - if (addon.getSettings().isGamemode()) { - store.setGameMode(key, player.getGameMode()); + if (settings.isGamemode()) { + String k = settings.isIslandsGamemode() ? islandKey : worldKey; + store.setGameMode(k, player.getGameMode()); } - if (addon.getSettings().isAdvancements()) { - // Advancements - store.clearAdvancement(key); + if (settings.isAdvancements()) { + String k = settings.isIslandsAdvancements() ? islandKey : worldKey; + store.clearAdvancement(k); Iterator it = Bukkit.advancementIterator(); while (it.hasNext()) { Advancement a = it.next(); AdvancementProgress p = player.getAdvancementProgress(a); if (!p.getAwardedCriteria().isEmpty()) { - store.setAdvancement(key, a.getKey().toString(), new ArrayList<>(p.getAwardedCriteria())); + store.setAdvancement(k, a.getKey().toString(), new ArrayList<>(p.getAwardedCriteria())); } } } - if (addon.getSettings().isEnderChest()) { - // Copy the player's ender chest items to the store + if (settings.isEnderChest()) { + String k = settings.isIslandsEnderChest() ? islandKey : worldKey; List contents = Arrays.asList(player.getEnderChest().getContents()); - store.setEnderChest(key, contents); + store.setEnderChest(k, contents); } - if (addon.getSettings().isStatistics()) { - saveStats(store, player, key, shutdown).thenAccept(database::saveObjectAsync); + if (settings.isStatistics()) { + String k = settings.isIslandsStatistics() ? islandKey : worldKey; + saveStats(store, player, k, shutdown).thenAccept(database::saveObjectAsync); return; } database.saveObjectAsync(store); diff --git a/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java b/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java index bfa6d4e..d23ee7c 100644 --- a/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java +++ b/src/main/java/com/wasteofplastic/invswitcher/listeners/PlayerListener.java @@ -69,7 +69,7 @@ public void onWorldEnter(final PlayerChangedWorldEvent event) { @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onIslandEnter(IslandEnterEvent event) { BentoBox.getInstance().logDebug("IslandEnterEvent triggered for player " + event.getPlayerUUID() + " on island " + event.getIsland().getUniqueId()); - if (!addon.getSettings().isIslands()) { + if (!addon.getSettings().isIslandsActive()) { return; } @@ -133,7 +133,7 @@ public void onPlayerJoin(final PlayerJoinEvent event) { */ @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onPlayerRespawn(PlayerRespawnEvent event) { - if (!addon.getSettings().isIslands()) { + if (!addon.getSettings().isIslandsActive()) { return; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 27537c5..34a19b8 100755 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -21,4 +21,15 @@ options: ender-chest: true statistics: true # Switch inventories based on island. Only applies if players own more than one island. - islands: true + # 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. + islands: + active: true + inventory: true + health: false + food: false + advancements: false + gamemode: false + experience: false + ender-chest: true + statistics: false diff --git a/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java b/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java index 4692935..97f2e69 100644 --- a/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java +++ b/src/test/java/com/wasteofplastic/invswitcher/StoreTest.java @@ -137,7 +137,7 @@ public void setUp() when(settings.getDatabaseType()).thenReturn(mockDbt); // Disable island switching by default for existing tests - sets.setIslands(false); + sets.setIslandsActive(false); // Class under test s = new Store(addon); @@ -301,14 +301,14 @@ public void testSaveOnlinePlayers() { @Test public void testGetStorageKeyIslandsDisabled() { - sets.setIslands(false); + sets.setIslandsActive(false); String key = s.getStorageKey(player, world); assertEquals("world", key); // nether suffix stripped } @Test public void testGetStorageKeySingleIsland() { - sets.setIslands(true); + sets.setIslandsActive(true); try (MockedStatic utilities = Mockito.mockStatic(Util.class)) { utilities.when(() -> Util.getWorld(world)).thenReturn(world); when(islandsManager.getNumberOfConcurrentIslands(playerUUID, world)).thenReturn(1); @@ -319,7 +319,7 @@ public void testGetStorageKeySingleIsland() { @Test public void testGetStorageKeyMultipleIslandsOnOwnIsland() { - sets.setIslands(true); + sets.setIslandsActive(true); Island island = mock(Island.class); when(island.getOwner()).thenReturn(playerUUID); when(island.getUniqueId()).thenReturn("island-123"); @@ -338,7 +338,7 @@ public void testGetStorageKeyMultipleIslandsOnOwnIsland() { @Test public void testGetStorageKeyMultipleIslandsOnOtherPlayerIsland() { - sets.setIslands(true); + sets.setIslandsActive(true); Island island = mock(Island.class); UUID otherPlayer = UUID.randomUUID(); when(island.getOwner()).thenReturn(otherPlayer); @@ -358,7 +358,7 @@ public void testGetStorageKeyMultipleIslandsOnOtherPlayerIsland() { @Test public void testGetStorageKeyWithSpecificIsland() { - sets.setIslands(true); + sets.setIslandsActive(true); Island island = mock(Island.class); when(island.getOwner()).thenReturn(playerUUID); when(island.getUniqueId()).thenReturn("island-456"); @@ -374,7 +374,7 @@ public void testGetStorageKeyWithSpecificIsland() { @Test public void testGetStorageKeyGenericNether() { - sets.setIslands(true); + sets.setIslandsActive(true); // Set up a nether world World netherWorld = mock(World.class); when(netherWorld.getName()).thenReturn("world_nether"); @@ -409,14 +409,14 @@ public void testGetCurrentKeyNullByDefault() { @Test public void testGetCurrentKeySetAfterGetInventory() { - sets.setIslands(false); + sets.setIslandsActive(false); s.getInventory(player, world); assertEquals("world", s.getCurrentKey(player)); } @Test public void testRemoveFromCacheClearsCurrentKey() { - sets.setIslands(false); + sets.setIslandsActive(false); s.getInventory(player, world); assertNotNull(s.getCurrentKey(player)); s.removeFromCache(player); diff --git a/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java b/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java index 5ab9129..fd4121a 100644 --- a/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java +++ b/src/test/java/com/wasteofplastic/invswitcher/listeners/PlayerListenerTest.java @@ -181,7 +181,7 @@ public void testOnPlayerQuitNotCoveredWorld() { @Test public void testOnIslandEnterDisabled() { - when(settings.isIslands()).thenReturn(false); + when(settings.isIslandsActive()).thenReturn(false); Island island = mock(Island.class); IslandEnterEvent event = new IslandEnterEvent(island, playerUUID, false, null, island, null); try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) { @@ -193,7 +193,7 @@ public void testOnIslandEnterDisabled() { @Test public void testOnIslandEnterNotOwner() { - when(settings.isIslands()).thenReturn(true); + when(settings.isIslandsActive()).thenReturn(true); Island island = mock(Island.class); UUID otherOwner = UUID.randomUUID(); when(island.getOwner()).thenReturn(otherOwner); @@ -207,7 +207,7 @@ public void testOnIslandEnterNotOwner() { @Test public void testOnIslandEnterSingleIsland() { - when(settings.isIslands()).thenReturn(true); + when(settings.isIslandsActive()).thenReturn(true); Island island = mock(Island.class); when(island.getOwner()).thenReturn(playerUUID); @@ -224,7 +224,7 @@ public void testOnIslandEnterSingleIsland() { @Test public void testOnIslandEnterMultipleIslandsSameKey() { - when(settings.isIslands()).thenReturn(true); + when(settings.isIslandsActive()).thenReturn(true); Island island = mock(Island.class); when(island.getOwner()).thenReturn(playerUUID); when(island.getUniqueId()).thenReturn("island-1"); @@ -247,7 +247,7 @@ public void testOnIslandEnterMultipleIslandsSameKey() { @Test public void testOnIslandEnterMultipleIslandsDifferentKey() { - when(settings.isIslands()).thenReturn(true); + when(settings.isIslandsActive()).thenReturn(true); Island island = mock(Island.class); when(island.getOwner()).thenReturn(playerUUID); when(island.getUniqueId()).thenReturn("island-2"); @@ -272,7 +272,7 @@ public void testOnIslandEnterMultipleIslandsDifferentKey() { @Test public void testOnPlayerRespawnDisabled() { - when(settings.isIslands()).thenReturn(false); + when(settings.isIslandsActive()).thenReturn(false); Location respawnLoc = mock(Location.class); when(respawnLoc.getWorld()).thenReturn(world); PlayerRespawnEvent event = new PlayerRespawnEvent(player, respawnLoc, false); @@ -282,7 +282,7 @@ public void testOnPlayerRespawnDisabled() { @Test public void testOnPlayerRespawnSameIsland() { - when(settings.isIslands()).thenReturn(true); + when(settings.isIslandsActive()).thenReturn(true); Location respawnLoc = mock(Location.class); when(respawnLoc.getWorld()).thenReturn(world); @@ -299,7 +299,7 @@ public void testOnPlayerRespawnSameIsland() { @Test public void testOnPlayerRespawnDifferentIsland() { - when(settings.isIslands()).thenReturn(true); + when(settings.isIslandsActive()).thenReturn(true); Location respawnLoc = mock(Location.class); when(respawnLoc.getWorld()).thenReturn(world); @@ -317,7 +317,7 @@ public void testOnPlayerRespawnDifferentIsland() { @Test public void testOnPlayerRespawnNoIsland() { - when(settings.isIslands()).thenReturn(true); + when(settings.isIslandsActive()).thenReturn(true); Location respawnLoc = mock(Location.class); when(respawnLoc.getWorld()).thenReturn(world); when(islandsManager.getIslandAt(respawnLoc)).thenReturn(Optional.empty());