commit 586306c9faffc40cfb2ae25c1ab9c77adf82e9f6 Author: pavel444-byte Date: Tue Jul 28 15:01:23 2026 +0500 Intial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c36566d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +target/ +.idea/ +*.iml +.classpath +.project +.settings/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4229402 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# AntiInventories + +AntiInventories is a small Paper 1.21.11 add-on for Multiverse-Inventories 5. +It lets selected worlds keep the inventory a player already has instead of +loading a different Multiverse-Inventories profile. + +For example, with `AutoMine` configured: + +```text +Wild_Forest1 -> AutoMine +``` + +The player arrives in `AutoMine` with the exact inventory they had in +`Wild_Forest1`. Login, logout, and optional game-mode profile handling are also +bypassed while the player is in a configured world, so reconnecting does not +silently replace that inventory. + +## Requirements + +- Paper 1.21.11 +- Java 21 or newer +- Multiverse-Core 5 +- Multiverse-Inventories 5.3.1 or newer + +## Build + +```bash +mvn clean package +``` + +The finished plugin is `target/AntiInventories-1.0.0.jar`. + +## Install + +1. Back up the Multiverse-Inventories data directory. +2. Put `AntiInventories-1.0.0.jar` in the server's `plugins` directory. +3. Make sure Multiverse-Core and Multiverse-Inventories are installed. +4. Start the server. +5. Edit `plugins/AntiInventories/config.yml`, or use the commands below. + +## Commands + +| Command | Purpose | +| --- | --- | +| `/antiinv add ` | Add a bypass world | +| `/antiinv remove ` | Remove a bypass world | +| `/antiinv list` | Show bypass worlds and direction | +| `/antiinv reload` | Reload `config.yml` | + +The aliases `/antiinventories`, `/antiinv`, and `/ai` are available. Commands +require `antiinventories.admin`, which defaults to server operators. + +## Direction setting + +`bypass-direction` controls which crossings are ignored: + +- `ENTERING`: keep the current inventory only when entering a bypass world. +- `LEAVING`: keep it only when leaving a bypass world. +- `EITHER`: keep it when either side is a bypass world. + +`EITHER` is the default. Be aware that it intentionally lets a bypass world act +as a bridge between otherwise separated inventory groups. Use `ENTERING` if +players should carry items into `AutoMine` but should load the destination +profile when they leave it. + +The plugin cancels Multiverse-Inventories' official share-handling event. It +does not duplicate, serialize, clear, or restore item stacks itself. Because +the whole Multiverse-Inventories transaction is skipped, other profile values +such as experience, health, and hunger also remain unchanged on a bypassed +crossing. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..013ab86 --- /dev/null +++ b/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + dev.antiinventories + anti-inventories + 1.0.0 + AntiInventories + Bypass Multiverse-Inventories for selected worlds. + + + 21 + UTF-8 + 1.21.11-R0.1-SNAPSHOT + 5.14.1 + + + + + papermc + https://repo.papermc.io/repository/maven-public/ + + + + + + io.papermc.paper + paper-api + ${paper.version} + provided + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + AntiInventories-${project.version} + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + false + + + + + diff --git a/src/main/java/dev/antiinventories/AntiInventoriesCommand.java b/src/main/java/dev/antiinventories/AntiInventoriesCommand.java new file mode 100644 index 0000000..c23233e --- /dev/null +++ b/src/main/java/dev/antiinventories/AntiInventoriesCommand.java @@ -0,0 +1,146 @@ +package dev.antiinventories; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class AntiInventoriesCommand implements CommandExecutor, TabCompleter { + private static final List SUBCOMMANDS = List.of("add", "remove", "list", "reload"); + + private final AntiInventoriesPlugin plugin; + private final BypassSettings settings; + + AntiInventoriesCommand(AntiInventoriesPlugin plugin, BypassSettings settings) { + this.plugin = plugin; + this.settings = settings; + } + + @Override + public boolean onCommand( + @NotNull CommandSender sender, + @NotNull Command command, + @NotNull String label, + @NotNull String[] args) { + if (args.length == 0) { + sendUsage(sender, label); + return true; + } + + return switch (args[0].toLowerCase(Locale.ROOT)) { + case "add" -> addWorld(sender, label, args); + case "remove" -> removeWorld(sender, label, args); + case "list" -> listWorlds(sender); + case "reload" -> reload(sender); + default -> { + sendUsage(sender, label); + yield true; + } + }; + } + + private boolean addWorld(CommandSender sender, String label, String[] args) { + if (args.length != 2) { + sender.sendMessage(Component.text("Usage: /" + label + " add ", NamedTextColor.RED)); + return true; + } + + String worldName = canonicalWorldName(args[1]); + if (!settings.add(worldName)) { + sender.sendMessage(Component.text(worldName + " is already a bypass world.", NamedTextColor.YELLOW)); + return true; + } + + plugin.saveBypassWorlds(); + sender.sendMessage(Component.text("Added " + worldName + " to the bypass worlds.", NamedTextColor.GREEN)); + if (Bukkit.getWorld(worldName) == null) { + sender.sendMessage(Component.text("Warning: that world is not currently loaded.", NamedTextColor.YELLOW)); + } + return true; + } + + private boolean removeWorld(CommandSender sender, String label, String[] args) { + if (args.length != 2) { + sender.sendMessage(Component.text("Usage: /" + label + " remove ", NamedTextColor.RED)); + return true; + } + + if (!settings.remove(args[1])) { + sender.sendMessage(Component.text(args[1] + " is not a bypass world.", NamedTextColor.YELLOW)); + return true; + } + + plugin.saveBypassWorlds(); + sender.sendMessage(Component.text("Removed " + args[1] + " from the bypass worlds.", NamedTextColor.GREEN)); + return true; + } + + private boolean listWorlds(CommandSender sender) { + List worlds = settings.worlds(); + String formattedWorlds = worlds.isEmpty() ? "(none)" : String.join(", ", worlds); + sender.sendMessage( + Component.text("Bypass worlds: ", NamedTextColor.GOLD) + .append(Component.text(formattedWorlds, NamedTextColor.WHITE))); + sender.sendMessage( + Component.text("Direction: ", NamedTextColor.GOLD) + .append(Component.text(settings.direction().name(), NamedTextColor.WHITE))); + return true; + } + + private boolean reload(CommandSender sender) { + plugin.reloadPluginConfig(); + sender.sendMessage(Component.text("AntiInventories configuration reloaded.", NamedTextColor.GREEN)); + return true; + } + + private void sendUsage(CommandSender sender, String label) { + sender.sendMessage(Component.text("AntiInventories commands:", NamedTextColor.GOLD)); + sender.sendMessage(Component.text("/" + label + " add ", NamedTextColor.YELLOW)); + sender.sendMessage(Component.text("/" + label + " remove ", NamedTextColor.YELLOW)); + sender.sendMessage(Component.text("/" + label + " list", NamedTextColor.YELLOW)); + sender.sendMessage(Component.text("/" + label + " reload", NamedTextColor.YELLOW)); + } + + private static String canonicalWorldName(String input) { + World world = Bukkit.getWorld(input); + return world == null ? input : world.getName(); + } + + @Override + public @Nullable List onTabComplete( + @NotNull CommandSender sender, + @NotNull Command command, + @NotNull String alias, + @NotNull String[] args) { + if (args.length == 1) { + return filter(SUBCOMMANDS, args[0]); + } + if (args.length == 2 && args[0].equalsIgnoreCase("add")) { + return filter(Bukkit.getWorlds().stream().map(World::getName).toList(), args[1]); + } + if (args.length == 2 && args[0].equalsIgnoreCase("remove")) { + return filter(settings.worlds(), args[1]); + } + return List.of(); + } + + private static List filter(List candidates, String prefix) { + String normalizedPrefix = prefix.toLowerCase(Locale.ROOT); + List matches = new ArrayList<>(); + for (String candidate : candidates) { + if (candidate.toLowerCase(Locale.ROOT).startsWith(normalizedPrefix)) { + matches.add(candidate); + } + } + return matches; + } +} diff --git a/src/main/java/dev/antiinventories/AntiInventoriesPlugin.java b/src/main/java/dev/antiinventories/AntiInventoriesPlugin.java new file mode 100644 index 0000000..f3b4f0e --- /dev/null +++ b/src/main/java/dev/antiinventories/AntiInventoriesPlugin.java @@ -0,0 +1,53 @@ +package dev.antiinventories; + +import java.util.Objects; +import org.bukkit.command.PluginCommand; +import org.bukkit.plugin.java.JavaPlugin; + +public final class AntiInventoriesPlugin extends JavaPlugin { + private final BypassSettings settings = new BypassSettings(); + + @Override + public void onEnable() { + saveDefaultConfig(); + reloadPluginConfig(); + + MultiverseInventoriesHook hook = new MultiverseInventoriesHook(this, settings); + if (!hook.register()) { + getServer().getPluginManager().disablePlugin(this); + return; + } + + AntiInventoriesCommand commandHandler = new AntiInventoriesCommand(this, settings); + PluginCommand command = Objects.requireNonNull( + getCommand("antiinventories"), + "antiinventories command is missing from plugin.yml"); + command.setExecutor(commandHandler); + command.setTabCompleter(commandHandler); + + getLogger().info( + "Enabled with " + settings.worlds().size() + + " bypass world(s), direction " + settings.direction() + "."); + } + + void reloadPluginConfig() { + reloadConfig(); + String configuredDirection = getConfig().getString("bypass-direction", "EITHER"); + BypassDirection parsedDirection = BypassDirection.parse(configuredDirection); + if (!parsedDirection.name().equalsIgnoreCase(configuredDirection.trim())) { + getLogger().warning( + "Unknown bypass-direction '" + configuredDirection + "'; using EITHER."); + } + + settings.load( + getConfig().getStringList("bypass-worlds"), + configuredDirection, + getConfig().getBoolean("bypass-gamemode-handling", true), + getConfig().getBoolean("bypass-session-handling", true)); + } + + void saveBypassWorlds() { + getConfig().set("bypass-worlds", settings.worlds()); + saveConfig(); + } +} diff --git a/src/main/java/dev/antiinventories/BypassDirection.java b/src/main/java/dev/antiinventories/BypassDirection.java new file mode 100644 index 0000000..0e4b7ea --- /dev/null +++ b/src/main/java/dev/antiinventories/BypassDirection.java @@ -0,0 +1,30 @@ +package dev.antiinventories; + +import java.util.Locale; + +enum BypassDirection { + ENTERING, + LEAVING, + EITHER; + + static BypassDirection parse(String value) { + if (value == null) { + return EITHER; + } + + try { + return valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + return EITHER; + } + } + + boolean matches(boolean sourceIsBypassed, boolean destinationIsBypassed) { + return switch (this) { + case ENTERING -> destinationIsBypassed; + case LEAVING -> sourceIsBypassed; + case EITHER -> sourceIsBypassed || destinationIsBypassed; + }; + } +} + diff --git a/src/main/java/dev/antiinventories/BypassSettings.java b/src/main/java/dev/antiinventories/BypassSettings.java new file mode 100644 index 0000000..c80c28f --- /dev/null +++ b/src/main/java/dev/antiinventories/BypassSettings.java @@ -0,0 +1,70 @@ +package dev.antiinventories; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +final class BypassSettings { + private final Map worlds = new LinkedHashMap<>(); + private BypassDirection direction = BypassDirection.EITHER; + private boolean bypassGameModeHandling = true; + private boolean bypassSessionHandling = true; + + void load( + Collection configuredWorlds, + String configuredDirection, + boolean bypassGameMode, + boolean bypassSession) { + worlds.clear(); + configuredWorlds.forEach(this::add); + direction = BypassDirection.parse(configuredDirection); + bypassGameModeHandling = bypassGameMode; + bypassSessionHandling = bypassSession; + } + + boolean add(String worldName) { + String cleanName = clean(worldName); + if (cleanName.isEmpty()) { + return false; + } + return worlds.putIfAbsent(normalize(cleanName), cleanName) == null; + } + + boolean remove(String worldName) { + return worlds.remove(normalize(clean(worldName))) != null; + } + + boolean contains(String worldName) { + return worlds.containsKey(normalize(clean(worldName))); + } + + boolean shouldBypass(String fromWorld, String toWorld) { + return direction.matches(contains(fromWorld), contains(toWorld)); + } + + boolean shouldBypassGameMode(String currentWorld) { + return bypassGameModeHandling && contains(currentWorld); + } + + boolean shouldBypassSession(String currentWorld) { + return bypassSessionHandling && contains(currentWorld); + } + + List worlds() { + return List.copyOf(worlds.values()); + } + + BypassDirection direction() { + return direction; + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } + + private static String normalize(String value) { + return value.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/dev/antiinventories/MultiverseInventoriesHook.java b/src/main/java/dev/antiinventories/MultiverseInventoriesHook.java new file mode 100644 index 0000000..53a5eeb --- /dev/null +++ b/src/main/java/dev/antiinventories/MultiverseInventoriesHook.java @@ -0,0 +1,139 @@ +package dev.antiinventories; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.logging.Level; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; + +final class MultiverseInventoriesHook { + private static final String WORLD_CHANGE_EVENT = + "org.mvplugins.multiverse.inventories.event.WorldChangeShareHandlingEvent"; + private static final String GAME_MODE_CHANGE_EVENT = + "org.mvplugins.multiverse.inventories.event.GameModeChangeShareHandlingEvent"; + private static final String READ_ONLY_EVENT = + "org.mvplugins.multiverse.inventories.event.ReadOnlyShareHandlingEvent"; + private static final String WRITE_ONLY_EVENT = + "org.mvplugins.multiverse.inventories.event.WriteOnlyShareHandlingEvent"; + + private final AntiInventoriesPlugin plugin; + private final BypassSettings settings; + private final Listener listener = new Listener() { + }; + + MultiverseInventoriesHook(AntiInventoriesPlugin plugin, BypassSettings settings) { + this.plugin = plugin; + this.settings = settings; + } + + boolean register() { + PluginManager pluginManager = plugin.getServer().getPluginManager(); + Plugin inventories = pluginManager.getPlugin("Multiverse-Inventories"); + if (inventories == null || !inventories.isEnabled()) { + plugin.getLogger().severe("Multiverse-Inventories is not installed or enabled."); + return false; + } + + try { + registerWorldChangeHook(pluginManager, inventories.getClass().getClassLoader()); + registerGameModeChangeHook(pluginManager, inventories.getClass().getClassLoader()); + registerSessionHook(pluginManager, inventories.getClass().getClassLoader(), READ_ONLY_EVENT); + registerSessionHook(pluginManager, inventories.getClass().getClassLoader(), WRITE_ONLY_EVENT); + return true; + } catch (ReflectiveOperationException | LinkageError exception) { + plugin.getLogger().log( + Level.SEVERE, + "This Multiverse-Inventories version does not expose the required share-handling events.", + exception); + return false; + } + } + + private void registerWorldChangeHook(PluginManager manager, ClassLoader classLoader) + throws ReflectiveOperationException { + Class eventClass = loadEventClass(WORLD_CHANGE_EVENT, classLoader); + Method getFromWorld = eventClass.getMethod("getFromWorld"); + Method getToWorld = eventClass.getMethod("getToWorld"); + + manager.registerEvent( + eventClass, + listener, + EventPriority.HIGHEST, + (ignored, event) -> { + try { + String fromWorld = (String) getFromWorld.invoke(event); + String toWorld = (String) getToWorld.invoke(event); + if (settings.shouldBypass(fromWorld, toWorld)) { + ((Cancellable) event).setCancelled(true); + } + } catch (IllegalAccessException | InvocationTargetException exception) { + plugin.getLogger().log(Level.SEVERE, "Could not process a Multiverse world change.", exception); + } + }, + plugin, + false); + } + + private void registerGameModeChangeHook(PluginManager manager, ClassLoader classLoader) + throws ReflectiveOperationException { + Class eventClass = loadEventClass(GAME_MODE_CHANGE_EVENT, classLoader); + registerCurrentWorldHook( + manager, + eventClass, + event -> settings.shouldBypassGameMode(event.getWorld().getName()), + "game-mode"); + } + + private void registerSessionHook(PluginManager manager, ClassLoader classLoader, String eventClassName) + throws ReflectiveOperationException { + Class eventClass = loadEventClass(eventClassName, classLoader); + registerCurrentWorldHook( + manager, + eventClass, + player -> settings.shouldBypassSession(player.getWorld().getName()), + "session"); + } + + private void registerCurrentWorldHook( + PluginManager manager, + Class eventClass, + PlayerRule rule, + String handlingName) + throws NoSuchMethodException { + Method getPlayer = eventClass.getMethod("getPlayer"); + manager.registerEvent( + eventClass, + listener, + EventPriority.HIGHEST, + (ignored, event) -> { + try { + Player player = (Player) getPlayer.invoke(event); + if (rule.shouldBypass(player)) { + ((Cancellable) event).setCancelled(true); + } + } catch (IllegalAccessException | InvocationTargetException exception) { + plugin.getLogger().log( + Level.SEVERE, + "Could not process Multiverse " + handlingName + " handling.", + exception); + } + }, + plugin, + false); + } + + private static Class loadEventClass(String className, ClassLoader classLoader) + throws ClassNotFoundException { + return Class.forName(className, true, classLoader).asSubclass(Event.class); + } + + @FunctionalInterface + private interface PlayerRule { + boolean shouldBypass(Player player); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..eb14bde --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,18 @@ +# Worlds listed here bypass Multiverse-Inventories share handling. +# World names are matched case-insensitively. +bypass-worlds: + - AutoMine + +# When a bypass world should make a world change keep the current inventory: +# ENTERING - only when the destination is listed +# LEAVING - only when the source is listed +# EITHER - when the source or destination is listed +bypass-direction: EITHER + +# If Multiverse-Inventories separates data by game mode, also prevent a +# game-mode inventory swap while a player is inside a bypass world. +bypass-gamemode-handling: true + +# Prevent Multiverse-Inventories from loading or saving one of its profiles +# when a player joins or leaves the server inside a bypass world. +bypass-session-handling: true diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..51ab37b --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,23 @@ +name: AntiInventories +version: '1.0.0' +main: dev.antiinventories.AntiInventoriesPlugin +description: Keeps the current inventory when players cross selected world borders. +api-version: '1.21.11' +author: AntiInventories +depend: + - Multiverse-Inventories + +commands: + antiinventories: + description: Manage worlds that bypass Multiverse-Inventories. + usage: /antiinventories [world] + aliases: + - antiinv + - ai + permission: antiinventories.admin + +permissions: + antiinventories.admin: + description: Allows management of AntiInventories. + default: op + diff --git a/src/test/java/dev/antiinventories/BypassSettingsTest.java b/src/test/java/dev/antiinventories/BypassSettingsTest.java new file mode 100644 index 0000000..54e88bb --- /dev/null +++ b/src/test/java/dev/antiinventories/BypassSettingsTest.java @@ -0,0 +1,58 @@ +package dev.antiinventories; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class BypassSettingsTest { + @Test + void matchesWorldNamesCaseInsensitively() { + BypassSettings settings = settings(BypassDirection.EITHER, "AutoMine"); + + assertTrue(settings.contains("automine")); + assertTrue(settings.contains("AUTOMINE")); + assertFalse(settings.contains("Wild_Forest1")); + } + + @Test + void eitherDirectionBypassesEnteringAndLeaving() { + BypassSettings settings = settings(BypassDirection.EITHER, "AutoMine"); + + assertTrue(settings.shouldBypass("Wild_Forest1", "AutoMine")); + assertTrue(settings.shouldBypass("AutoMine", "Wild_Forest1")); + assertFalse(settings.shouldBypass("Wild_Forest1", "Lobby")); + } + + @Test + void enteringDirectionOnlyChecksDestination() { + BypassSettings settings = settings(BypassDirection.ENTERING, "AutoMine"); + + assertTrue(settings.shouldBypass("Wild_Forest1", "AutoMine")); + assertFalse(settings.shouldBypass("AutoMine", "Wild_Forest1")); + } + + @Test + void leavingDirectionOnlyChecksSource() { + BypassSettings settings = settings(BypassDirection.LEAVING, "AutoMine"); + + assertFalse(settings.shouldBypass("Wild_Forest1", "AutoMine")); + assertTrue(settings.shouldBypass("AutoMine", "Wild_Forest1")); + } + + @Test + void invalidDirectionFallsBackToEither() { + BypassSettings settings = new BypassSettings(); + settings.load(List.of("AutoMine"), "invalid", true, true); + + assertTrue(settings.shouldBypass("Wild_Forest1", "AutoMine")); + assertTrue(settings.shouldBypass("AutoMine", "Wild_Forest1")); + } + + private static BypassSettings settings(BypassDirection direction, String... worlds) { + BypassSettings settings = new BypassSettings(); + settings.load(List.of(worlds), direction.name(), true, true); + return settings; + } +}