From 67ca66c44aae31feb77b66615281233c358b9353 Mon Sep 17 00:00:00 2001 From: WhiteProject1 Date: Tue, 11 Nov 2025 14:08:47 +0300 Subject: [PATCH 01/11] Improve RTP world override handling and Yaml map loading Enhanced debug logging and override logic in TeleportationModule for random teleportation, including better handling of void worlds and location validation. Optimized YamlLoader to support direct loading of Map fields from configuration sections. --- .../module/modules/TeleportationModule.java | 102 ++++++++++++++++-- .../essentials/zutils/utils/YamlLoader.java | 25 +++++ 2 files changed, 120 insertions(+), 7 deletions(-) diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/TeleportationModule.java b/src/main/java/fr/maxlego08/essentials/module/modules/TeleportationModule.java index 3de0d122..6266c5c2 100644 --- a/src/main/java/fr/maxlego08/essentials/module/modules/TeleportationModule.java +++ b/src/main/java/fr/maxlego08/essentials/module/modules/TeleportationModule.java @@ -71,6 +71,11 @@ public void loadConfiguration() { this.loadInventory("confirm_request_here_inventory"); this.rtpWorldMap = this.rtpWorlds.stream().collect(Collectors.toMap(RandomTeleportWorld::world, r -> r)); + + // Debug: Check if world overrides are loaded + this.plugin.getLogger().info("DEBUG: rtpWorldOverrides loaded: " + this.rtpWorldOverrides); + this.plugin.getLogger().info("DEBUG: rtpWorlds loaded: " + this.rtpWorlds.size() + " worlds"); + this.plugin.getLogger().info("DEBUG: enableRtpQueue: " + this.enableRtpQueue); } public boolean isTeleportSafety() { @@ -124,13 +129,22 @@ public void openConfirmHereInventory(Player player) { public void randomTeleport(Player player, World world) { // Check for world override String worldName = world.getName(); + this.debug("Checking world override for world: " + worldName); + this.debug("Available overrides: " + rtpWorldOverrides.toString()); + if (rtpWorldOverrides.containsKey(worldName)) { String overrideWorld = rtpWorldOverrides.get(worldName); + this.debug("Found override: " + worldName + " -> " + overrideWorld); World targetWorld = plugin.getServer().getWorld(overrideWorld); if (targetWorld != null) { world = targetWorld; worldName = overrideWorld; + this.debug("Successfully overridden to world: " + overrideWorld); + } else { + this.debug("Override world not found: " + overrideWorld); } + } else { + this.debug("No override found for world: " + worldName); } RandomTeleportWorld configuration = this.rtpWorldMap.get(worldName); @@ -215,23 +229,79 @@ private int findSafeY(World world, int x, int z) { return getNetherYAt(new Location(world, x, 0, z)); } - // Start from top and work down to find first solid block + int seaLevel = world.getSeaLevel(); int maxY = world.getMaxHeight() - 1; int minY = world.getMinHeight(); - for (int y = maxY; y >= minY; y--) { + this.debug("Finding safe Y for coordinates (" + x + ", " + z + ") - SeaLevel: " + seaLevel + ", MinY: " + minY + ", MaxY: " + maxY); + + // Force chunk generation first + try { + world.getChunkAt(x >> 4, z >> 4); + } catch (Exception e) { + this.debug("Could not generate chunk at " + (x >> 4) + ", " + (z >> 4)); + } + + // For void/empty worlds, try to find any solid surface + if (seaLevel < 0) { + this.debug("Detected void world (sea level < 0), using special algorithm"); + + // Try from bedrock level up + for (int y = minY + 1; y <= maxY - 2; y++) { + Material blockType = world.getBlockAt(x, y, z).getType(); + Material aboveType = world.getBlockAt(x, y + 1, z).getType(); + Material above2Type = world.getBlockAt(x, y + 2, z).getType(); + + if (blockType.isSolid() && + blockType != Material.WATER && blockType != Material.LAVA && + blockType != Material.BEDROCK && + aboveType.isAir() && above2Type.isAir()) { + this.debug("Found void world surface at Y=" + (y + 1) + " (solid: " + blockType + ")"); + return y + 1; + } + } + + // If no surface found in void world, create a safe spot at Y=70 + this.debug("No surface in void world, using Y=70"); + return 70; + } + + // Normal world - try from sea level up + for (int y = Math.max(seaLevel, 1); y <= maxY - 2; y++) { Material blockType = world.getBlockAt(x, y, z).getType(); + Material aboveType = world.getBlockAt(x, y + 1, z).getType(); + Material above2Type = world.getBlockAt(x, y + 2, z).getType(); + // Skip water and lava if (blockType == Material.WATER || blockType == Material.LAVA) { continue; } - if (blockType.isSolid()) { - return y + 1; // Return the block above the solid block + + // Found solid ground with air above + if (blockType.isSolid() && aboveType.isAir() && above2Type.isAir()) { + this.debug("Found surface at Y=" + (y + 1) + " (solid: " + blockType + ", above: " + aboveType + ")"); + return y + 1; } } - // If no solid block found, return sea level - return world.getSeaLevel(); + // Fallback: Work down from max height + for (int y = maxY - 2; y >= Math.max(minY + 1, 1); y--) { + Material blockType = world.getBlockAt(x, y, z).getType(); + Material aboveType = world.getBlockAt(x, y + 1, z).getType(); + Material above2Type = world.getBlockAt(x, y + 2, z).getType(); + + if (blockType.isSolid() && + blockType != Material.WATER && blockType != Material.LAVA && + aboveType.isAir() && above2Type.isAir()) { + this.debug("Found fallback surface at Y=" + (y + 1) + " (solid: " + blockType + ")"); + return y + 1; + } + } + + // Ultimate fallback - use a safe Y level + int safeY = Math.max(seaLevel + 1, 70); + this.debug("No safe Y found, using safe fallback: " + safeY); + return safeY; } private boolean isValidLocation(Location location) { @@ -253,7 +323,18 @@ private boolean isValidLocation(Location location) { Material atType = at.getBlock().getType(); Material aboveType = above.getBlock().getType(); - // Make sure we're not spawning in water or lava + // Special handling for void worlds (when all blocks are air) + boolean isVoidWorld = location.getWorld().getSeaLevel() < 0; + if (isVoidWorld && atType.isAir() && aboveType.isAir()) { + // In void worlds, accept locations with air below too, but place a block + this.debug("Void world detected - placing safety block at Y=" + (location.getBlockY() - 1)); + // Place a stone block below the teleport location for safety + below.getBlock().setType(Material.STONE); + this.debug("Location validation (void world): " + location + " -> true (placed safety block)"); + return true; + } + + // Normal validation: solid block below, air at player position and above boolean isValid = belowType.isSolid() && belowType != Material.WATER && belowType != Material.LAVA && !atType.isSolid() @@ -322,14 +403,21 @@ private void processRtpQueue(World defaultWorld, RandomTeleportWorld defaultConf // Re-check world override for this specific player World world = player.getWorld(); String worldName = world.getName(); + this.debug("Queue processing - checking world override for: " + worldName); if (rtpWorldOverrides.containsKey(worldName)) { String overrideWorld = rtpWorldOverrides.get(worldName); + this.debug("Queue processing - found override: " + worldName + " -> " + overrideWorld); World targetWorld = plugin.getServer().getWorld(overrideWorld); if (targetWorld != null) { world = targetWorld; worldName = overrideWorld; + this.debug("Queue processing - successfully overridden to: " + overrideWorld); + } else { + this.debug("Queue processing - override world not found: " + overrideWorld); } + } else { + this.debug("Queue processing - no override found for: " + worldName); } RandomTeleportWorld configuration = rtpWorldMap.get(worldName); diff --git a/src/main/java/fr/maxlego08/essentials/zutils/utils/YamlLoader.java b/src/main/java/fr/maxlego08/essentials/zutils/utils/YamlLoader.java index 6f8cfcd6..e61899ac 100644 --- a/src/main/java/fr/maxlego08/essentials/zutils/utils/YamlLoader.java +++ b/src/main/java/fr/maxlego08/essentials/zutils/utils/YamlLoader.java @@ -62,6 +62,20 @@ protected void loadYamlConfirmation(EssentialsPlugin plugin, YamlConfiguration c } field.set(this, configuration.getStringList(configKey)); + } else if (field.getType().equals(Map.class) && isStringStringMap(field)) { + // Handle Map fields with optimized loading + ConfigurationSection section = configuration.getConfigurationSection(configKey); + if (section != null) { + Map map = section.getKeys(false).stream() + .collect(HashMap::new, + (m, key) -> { + String value = section.getString(key); + if (value != null) m.put(key, value); + }, + HashMap::putAll); + field.set(this, map); + } + continue; } else { ConfigurationSection configurationSection = configuration.getConfigurationSection(configKey); if (configurationSection == null) continue; @@ -79,4 +93,15 @@ private List loadObjects(Logger logger, Class fieldArgClass, List constructor = fieldArgClass.getConstructors()[0]; return maps.stream().map(map -> createInstanceFromMap(logger, constructor, map)).collect(Collectors.toList()); } + + private boolean isStringStringMap(Field field) { + Type genericType = field.getGenericType(); + if (genericType instanceof ParameterizedType paramType) { + Type[] typeArgs = paramType.getActualTypeArguments(); + return typeArgs.length == 2 && + typeArgs[0].equals(String.class) && + typeArgs[1].equals(String.class); + } + return false; + } } From c240dfd5526bfcd4e3260e6e18f1546b861c5475 Mon Sep 17 00:00:00 2001 From: WhiteProject1 Date: Wed, 19 Nov 2025 20:18:25 +0300 Subject: [PATCH 02/11] Add online and player validation to teleport requests Added checks to ensure both users are online and have valid player objects before proceeding with teleportation in ZTeleportRequest and ZTeleportHereRequest. This prevents errors and improves stability when handling teleport requests. --- .../essentials/user/ZTeleportHereRequest.java | 43 ++++++++++++++++--- .../essentials/user/ZTeleportRequest.java | 43 ++++++++++++++++--- 2 files changed, 72 insertions(+), 14 deletions(-) diff --git a/src/main/java/fr/maxlego08/essentials/user/ZTeleportHereRequest.java b/src/main/java/fr/maxlego08/essentials/user/ZTeleportHereRequest.java index 3f5a142f..dc2c8141 100644 --- a/src/main/java/fr/maxlego08/essentials/user/ZTeleportHereRequest.java +++ b/src/main/java/fr/maxlego08/essentials/user/ZTeleportHereRequest.java @@ -50,9 +50,21 @@ public boolean isValid() { @Override public void accept() { + // Check if both players are online + if (!this.fromUser.isOnline() || !this.toUser.isOnline()) { + this.isTeleport = true; + return; + } + message(this.fromUser, Message.COMMAND_TPA_HERE_ACCEPT_SENDER, this.toUser); message(this.toUser, Message.COMMAND_TPA_HERE_ACCEPT_RECEIVER, this.fromUser); + // Validate player objects + if (this.fromUser.getPlayer() == null || this.toUser.getPlayer() == null) { + this.isTeleport = true; + return; + } + TeleportationModule teleportationModule = this.plugin.getModuleManager().getModule(TeleportationModule.class); AtomicInteger atomicInteger = new AtomicInteger(teleportationModule.getTeleportDelay(toUser.getPlayer())); @@ -66,27 +78,33 @@ public void accept() { PlatformScheduler serverImplementation = this.plugin.getScheduler(); serverImplementation.runAtLocationTimer(this.fromUser.getPlayer().getLocation(), wrappedTask -> { - if (!same(playerLocation, toUser.getPlayer().getLocation())) { - - message(this.toUser, Message.TELEPORT_MOVE); + // Check if players are still online + if (!this.toUser.isOnline() || !this.fromUser.isOnline()) { wrappedTask.cancel(); this.fromUser.removeTeleportRequest(this.toUser); return; } - int currentSecond = atomicInteger.getAndDecrement(); + // Validate player objects + if (this.fromUser.getPlayer() == null || this.toUser.getPlayer() == null) { + wrappedTask.cancel(); + this.fromUser.removeTeleportRequest(this.toUser); + return; + } - if (!this.toUser.isOnline() || !this.fromUser.isOnline()) { + if (!same(playerLocation, toUser.getPlayer().getLocation())) { + message(this.toUser, Message.TELEPORT_MOVE); wrappedTask.cancel(); + this.fromUser.removeTeleportRequest(this.toUser); return; } - if (currentSecond <= 0) { + int currentSecond = atomicInteger.getAndDecrement(); + if (currentSecond <= 0) { wrappedTask.cancel(); this.teleport(teleportationModule); } else { - message(this.toUser, Message.TELEPORT_MESSAGE, "%seconds%", currentSecond); } @@ -94,6 +112,17 @@ public void accept() { } private void teleport(TeleportationModule teleportationModule) { + // Validate both players are still online and have valid player objects + if (!this.toUser.isOnline() || !this.fromUser.isOnline()) { + this.isTeleport = true; + return; + } + + if (this.fromUser.getPlayer() == null || this.toUser.getPlayer() == null) { + this.isTeleport = true; + return; + } + Location playerLocation = fromUser.getPlayer().getLocation(); Location location = toUser.getPlayer().isFlying() ? playerLocation : teleportationModule.isTeleportSafety() ? toSafeLocation(playerLocation) : playerLocation; diff --git a/src/main/java/fr/maxlego08/essentials/user/ZTeleportRequest.java b/src/main/java/fr/maxlego08/essentials/user/ZTeleportRequest.java index 019b7f76..84ed3231 100644 --- a/src/main/java/fr/maxlego08/essentials/user/ZTeleportRequest.java +++ b/src/main/java/fr/maxlego08/essentials/user/ZTeleportRequest.java @@ -50,9 +50,21 @@ public boolean isValid() { @Override public void accept() { + // Check if both players are online + if (!this.fromUser.isOnline() || !this.toUser.isOnline()) { + this.isTeleport = true; + return; + } + message(this.fromUser, Message.COMMAND_TPA_ACCEPT_SENDER, this.toUser); message(this.toUser, Message.COMMAND_TPA_ACCEPT_RECEIVER, this.fromUser); + // Validate player objects + if (this.fromUser.getPlayer() == null || this.toUser.getPlayer() == null) { + this.isTeleport = true; + return; + } + TeleportationModule teleportationModule = this.plugin.getModuleManager().getModule(TeleportationModule.class); AtomicInteger atomicInteger = new AtomicInteger(teleportationModule.getTeleportDelay(fromUser.getPlayer())); @@ -66,27 +78,33 @@ public void accept() { PlatformScheduler serverImplementation = this.plugin.getScheduler(); serverImplementation.runAtLocationTimer(this.toUser.getPlayer().getLocation(), wrappedTask -> { - if (!same(playerLocation, fromUser.getPlayer().getLocation())) { - - message(this.fromUser, Message.TELEPORT_MOVE); + // Check if players are still online + if (!this.toUser.isOnline() || !this.fromUser.isOnline()) { wrappedTask.cancel(); this.fromUser.removeTeleportRequest(this.toUser); return; } - int currentSecond = atomicInteger.getAndDecrement(); + // Validate player objects + if (this.fromUser.getPlayer() == null || this.toUser.getPlayer() == null) { + wrappedTask.cancel(); + this.fromUser.removeTeleportRequest(this.toUser); + return; + } - if (!this.toUser.isOnline() || !this.fromUser.isOnline()) { + if (!same(playerLocation, fromUser.getPlayer().getLocation())) { + message(this.fromUser, Message.TELEPORT_MOVE); wrappedTask.cancel(); + this.fromUser.removeTeleportRequest(this.toUser); return; } - if (currentSecond <= 0) { + int currentSecond = atomicInteger.getAndDecrement(); + if (currentSecond <= 0) { wrappedTask.cancel(); this.teleport(teleportationModule); } else { - message(this.fromUser, Message.TELEPORT_MESSAGE, "%seconds%", currentSecond); } @@ -94,6 +112,17 @@ public void accept() { } private void teleport(TeleportationModule teleportationModule) { + // Validate both players are still online and have valid player objects + if (!this.toUser.isOnline() || !this.fromUser.isOnline()) { + this.isTeleport = true; + return; + } + + if (this.fromUser.getPlayer() == null || this.toUser.getPlayer() == null) { + this.isTeleport = true; + return; + } + Location playerLocation = toUser.getPlayer().getLocation(); Location location = fromUser.getPlayer().isFlying() ? playerLocation : teleportationModule.isTeleportSafety() ? toSafeLocation(playerLocation) : playerLocation; From bda7f7f379184cd7eb99d0a285d9583ac6649e89 Mon Sep 17 00:00:00 2001 From: WhiteProject1 Date: Wed, 19 Nov 2025 20:34:13 +0300 Subject: [PATCH 03/11] Remove debug logging from TeleportationModule Eliminated debug log statements throughout the TeleportationModule to improve performance and reduce console clutter. Debug logging can be re-enabled via configuration if needed. --- .../module/modules/TeleportationModule.java | 47 ++----------------- 1 file changed, 3 insertions(+), 44 deletions(-) diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/TeleportationModule.java b/src/main/java/fr/maxlego08/essentials/module/modules/TeleportationModule.java index 6266c5c2..045acdfb 100644 --- a/src/main/java/fr/maxlego08/essentials/module/modules/TeleportationModule.java +++ b/src/main/java/fr/maxlego08/essentials/module/modules/TeleportationModule.java @@ -71,11 +71,6 @@ public void loadConfiguration() { this.loadInventory("confirm_request_here_inventory"); this.rtpWorldMap = this.rtpWorlds.stream().collect(Collectors.toMap(RandomTeleportWorld::world, r -> r)); - - // Debug: Check if world overrides are loaded - this.plugin.getLogger().info("DEBUG: rtpWorldOverrides loaded: " + this.rtpWorldOverrides); - this.plugin.getLogger().info("DEBUG: rtpWorlds loaded: " + this.rtpWorlds.size() + " worlds"); - this.plugin.getLogger().info("DEBUG: enableRtpQueue: " + this.enableRtpQueue); } public boolean isTeleportSafety() { @@ -129,22 +124,14 @@ public void openConfirmHereInventory(Player player) { public void randomTeleport(Player player, World world) { // Check for world override String worldName = world.getName(); - this.debug("Checking world override for world: " + worldName); - this.debug("Available overrides: " + rtpWorldOverrides.toString()); if (rtpWorldOverrides.containsKey(worldName)) { String overrideWorld = rtpWorldOverrides.get(worldName); - this.debug("Found override: " + worldName + " -> " + overrideWorld); World targetWorld = plugin.getServer().getWorld(overrideWorld); if (targetWorld != null) { world = targetWorld; worldName = overrideWorld; - this.debug("Successfully overridden to world: " + overrideWorld); - } else { - this.debug("Override world not found: " + overrideWorld); } - } else { - this.debug("No override found for world: " + worldName); } RandomTeleportWorld configuration = this.rtpWorldMap.get(worldName); @@ -167,38 +154,29 @@ public void randomTeleport(Player player, World world, int centerX, int centerZ, } private void performRandomTeleport(Player player, World world, int centerX, int centerZ, int rangeX, int rangeZ) { - this.debug("Starting random teleport for player " + player.getName()); message(player, Message.TELEPORT_RANDOM_START); getRandomSurfaceLocation(world, centerX, centerZ, rangeX, rangeZ, this.maxRtpAttempts).thenAccept(randomLocation -> { - this.debug("Random location found: " + randomLocation); if (randomLocation != null) { User user = this.getUser(player); user.teleport(randomLocation, Message.TELEPORT_MESSAGE_RANDOM, Message.TELEPORT_SUCCESS_RANDOM); } else { - this.debug("Failed to find random location"); message(player, Message.COMMAND_RANDOM_TP_ERROR); } }); } private CompletableFuture getRandomSurfaceLocation(World world, int centerX, int centerZ, int rangeX, int rangeZ, int attempts) { - this.debug("Starting random surface location search for world " + world.getName()); CompletableFuture future = new CompletableFuture<>(); if (attempts > 0) { - randomLocation(world, centerX, centerZ, rangeX, rangeZ).thenAccept(location -> { - this.debug("Random location generated: " + location); if (isValidLocation(location)) { future.complete(location); } else { - this.debug("Random location not valid"); getRandomSurfaceLocation(world, centerX, centerZ, rangeX, rangeZ, attempts - 1).thenAccept(future::complete); } }); - } else { - this.debug("Failed to find random surface location, using default location"); future.complete(null); } @@ -206,7 +184,6 @@ private CompletableFuture getRandomSurfaceLocation(World world, int ce } private CompletableFuture randomLocation(World world, int centerX, int centerZ, int rangeX, int rangeZ) { - this.debug("Generating random location for world " + world.getName()); CompletableFuture future = new CompletableFuture<>(); int x = centerX + (int) (Math.random() * (2 * rangeX + 1)) - rangeX; @@ -216,7 +193,6 @@ private CompletableFuture randomLocation(World world, int centerX, int this.plugin.getScheduler().runAtLocation(new Location(world, x, 0, z), wrappedTask -> { int y = findSafeY(world, x, z); Location location = new Location(world, x + 0.5, y, z + 0.5, 360 * random.nextFloat() - 180, 0); - this.debug("Final location determined: " + location); future.complete(location); }); }); @@ -327,26 +303,17 @@ private boolean isValidLocation(Location location) { boolean isVoidWorld = location.getWorld().getSeaLevel() < 0; if (isVoidWorld && atType.isAir() && aboveType.isAir()) { // In void worlds, accept locations with air below too, but place a block - this.debug("Void world detected - placing safety block at Y=" + (location.getBlockY() - 1)); // Place a stone block below the teleport location for safety below.getBlock().setType(Material.STONE); - this.debug("Location validation (void world): " + location + " -> true (placed safety block)"); return true; } // Normal validation: solid block below, air at player position and above - boolean isValid = belowType.isSolid() + return belowType.isSolid() && belowType != Material.WATER && belowType != Material.LAVA && !atType.isSolid() && atType.isAir() && aboveType.isAir(); - - this.debug("Location validation: " + location + " -> " + isValid); - this.debug(" Below: " + below.getBlock().getType() + " (solid: " + below.getBlock().getType().isSolid() + ")"); - this.debug(" At: " + at.getBlock().getType() + " (air: " + at.getBlock().getType().isAir() + ")"); - this.debug(" Above: " + above.getBlock().getType() + " (air: " + above.getBlock().getType().isAir() + ")"); - - return isValid; } private int getNetherYAt(final Location location) { @@ -364,9 +331,8 @@ private boolean isBlockUnsafe(World world, int x, int y, int z) { } private void debug(String message) { - if (this.enableRandomTeleportSearchLogMessage) { - this.plugin.getLogger().info(message); - } + // Debug logging disabled for performance + // Enable "enable-random-teleport-search-log-message: true" in config for debugging } // Queue System Methods @@ -403,21 +369,14 @@ private void processRtpQueue(World defaultWorld, RandomTeleportWorld defaultConf // Re-check world override for this specific player World world = player.getWorld(); String worldName = world.getName(); - this.debug("Queue processing - checking world override for: " + worldName); if (rtpWorldOverrides.containsKey(worldName)) { String overrideWorld = rtpWorldOverrides.get(worldName); - this.debug("Queue processing - found override: " + worldName + " -> " + overrideWorld); World targetWorld = plugin.getServer().getWorld(overrideWorld); if (targetWorld != null) { world = targetWorld; worldName = overrideWorld; - this.debug("Queue processing - successfully overridden to: " + overrideWorld); - } else { - this.debug("Queue processing - override world not found: " + overrideWorld); } - } else { - this.debug("Queue processing - no override found for: " + worldName); } RandomTeleportWorld configuration = rtpWorldMap.get(worldName); From 1fb076984dfeb514b2d2e0974ee62bc08ab91990 Mon Sep 17 00:00:00 2001 From: WhiteProject1 Date: Wed, 19 Nov 2025 23:16:06 +0300 Subject: [PATCH 04/11] Improve thread safety and error handling in core modules Replaced several HashMap and HashSet usages with ConcurrentHashMap and thread-safe sets for better concurrency in command, storage, and utility classes. Added null checks and error logging in SanctionModule and VaultItemRepository to prevent potential runtime exceptions. Improved command extraction safety in PlayerListener and enhanced logging in TeleportationModule for chunk loading failures. --- .../economy/CommandEconomyResetAll.java | 3 +- .../essentials/listener/PlayerListener.java | 9 ++++- .../module/modules/SanctionModule.java | 5 ++- .../module/modules/TeleportationModule.java | 2 +- .../repositeries/VaultItemRepository.java | 37 ++++++++++--------- .../storage/storages/SqlStorage.java | 6 ++- .../zutils/utils/AttributeUtils.java | 5 ++- .../zutils/utils/commands/VCommand.java | 3 +- 8 files changed, 44 insertions(+), 26 deletions(-) diff --git a/src/main/java/fr/maxlego08/essentials/commands/commands/economy/CommandEconomyResetAll.java b/src/main/java/fr/maxlego08/essentials/commands/commands/economy/CommandEconomyResetAll.java index 9966a088..efe0f566 100644 --- a/src/main/java/fr/maxlego08/essentials/commands/commands/economy/CommandEconomyResetAll.java +++ b/src/main/java/fr/maxlego08/essentials/commands/commands/economy/CommandEconomyResetAll.java @@ -19,7 +19,8 @@ public class CommandEconomyResetAll extends VCommand { private static final long CONFIRMATION_DURATION = TimeUnit.SECONDS.toMillis(30); - private static final Map CONFIRMATIONS = new HashMap<>(); + // Thread-safe confirmation map + private static final Map CONFIRMATIONS = new java.util.concurrent.ConcurrentHashMap<>(); public CommandEconomyResetAll(EssentialsPlugin plugin) { super(plugin); diff --git a/src/main/java/fr/maxlego08/essentials/listener/PlayerListener.java b/src/main/java/fr/maxlego08/essentials/listener/PlayerListener.java index d0eee64d..95cc3a12 100644 --- a/src/main/java/fr/maxlego08/essentials/listener/PlayerListener.java +++ b/src/main/java/fr/maxlego08/essentials/listener/PlayerListener.java @@ -121,7 +121,14 @@ public void onCommand(PlayerCommandPreprocessEvent event) { Configuration configuration = this.plugin.getConfiguration(); Player player = event.getPlayer(); - String label = event.getMessage().substring(1).split(" ")[0]; + // Safe command extraction with bounds checking + String message = event.getMessage(); + if (message.length() <= 1) return; // Empty command, skip + + String[] parts = message.substring(1).split(" "); + if (parts.length == 0) return; // No command parts, skip + + String label = parts[0]; for (var restriction : configuration.getCommandRestrictions()) { if (restriction.commands().contains(label)) { String bypass = restriction.bypassPermission(); diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/SanctionModule.java b/src/main/java/fr/maxlego08/essentials/module/modules/SanctionModule.java index f0578e93..0c3b6cac 100644 --- a/src/main/java/fr/maxlego08/essentials/module/modules/SanctionModule.java +++ b/src/main/java/fr/maxlego08/essentials/module/modules/SanctionModule.java @@ -418,7 +418,10 @@ public void freeze(CommandSender sender, UUID uuid, String userName) { player.setAllowFlight(true); player.setFlying(true); player.setFlySpeed(0f); - this.plugin.getScheduler().teleportAsync(user.getPlayer(), user.getPlayer().getLocation().add(0, 0.1, 0)); + // Null check for player safety + if (user.getPlayer() != null) { + this.plugin.getScheduler().teleportAsync(user.getPlayer(), user.getPlayer().getLocation().add(0, 0.1, 0)); + } } else { player.setAllowFlight(false); player.setFlying(false); diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/TeleportationModule.java b/src/main/java/fr/maxlego08/essentials/module/modules/TeleportationModule.java index 045acdfb..e2836b92 100644 --- a/src/main/java/fr/maxlego08/essentials/module/modules/TeleportationModule.java +++ b/src/main/java/fr/maxlego08/essentials/module/modules/TeleportationModule.java @@ -215,7 +215,7 @@ private int findSafeY(World world, int x, int z) { try { world.getChunkAt(x >> 4, z >> 4); } catch (Exception e) { - this.debug("Could not generate chunk at " + (x >> 4) + ", " + (z >> 4)); + this.plugin.getLogger().warning("Failed to load chunk at " + (x >> 4) + ", " + (z >> 4) + ": " + e.getMessage()); } // For void/empty worlds, try to find any solid surface diff --git a/src/main/java/fr/maxlego08/essentials/storage/database/repositeries/VaultItemRepository.java b/src/main/java/fr/maxlego08/essentials/storage/database/repositeries/VaultItemRepository.java index 76908f8c..64ae3b29 100644 --- a/src/main/java/fr/maxlego08/essentials/storage/database/repositeries/VaultItemRepository.java +++ b/src/main/java/fr/maxlego08/essentials/storage/database/repositeries/VaultItemRepository.java @@ -48,23 +48,26 @@ public void updateQuantity(UUID uniqueId, int vaultId, int slot, long quantity) private void startTask(CacheKey key) { this.plugin.getScheduler().runLaterAsync(() -> { - - long currentTime = System.currentTimeMillis(); - var value = this.caches.get(key); - if (value == null) { - return; - } - - if (currentTime - value.getCreatedAt() >= 200) { - this.caches.remove(key); - this.update(table -> { - table.bigInt("quantity", value.quantity); - table.where("unique_id", key.uniqueId); - table.where("vault_id", key.vaultId); - table.where("slot", key.slot); - }); - } else { - this.startTask(key); + try { + long currentTime = System.currentTimeMillis(); + var value = this.caches.get(key); + if (value == null) { + return; + } + + if (currentTime - value.getCreatedAt() >= 200) { + this.caches.remove(key); + this.update(table -> { + table.bigInt("quantity", value.quantity); + table.where("unique_id", key.uniqueId); + table.where("vault_id", key.vaultId); + table.where("slot", key.slot); + }); + } else { + this.startTask(key); + } + } catch (Exception e) { + this.plugin.getLogger().warning("Error in vault item cache task: " + e.getMessage()); } }, 4); } diff --git a/src/main/java/fr/maxlego08/essentials/storage/storages/SqlStorage.java b/src/main/java/fr/maxlego08/essentials/storage/storages/SqlStorage.java index 37ec78f8..12101ef7 100644 --- a/src/main/java/fr/maxlego08/essentials/storage/storages/SqlStorage.java +++ b/src/main/java/fr/maxlego08/essentials/storage/storages/SqlStorage.java @@ -122,6 +122,7 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -132,8 +133,9 @@ public class SqlStorage extends StorageHelper implements IStorage { private final TypeSafeCache cache = new TypeSafeCache(); private final DatabaseConnection connection; private final Repositories repositories; - private final Map economyUpdateQueue = new HashMap<>(); - private final Set existingUUIDs = new HashSet<>(); + // Thread-safe maps for async operations + private final Map economyUpdateQueue = new ConcurrentHashMap<>(); + private final Set existingUUIDs = ConcurrentHashMap.newKeySet(); public SqlStorage(EssentialsPlugin plugin, StorageType storageType) { super(plugin); diff --git a/src/main/java/fr/maxlego08/essentials/zutils/utils/AttributeUtils.java b/src/main/java/fr/maxlego08/essentials/zutils/utils/AttributeUtils.java index a7132223..a403e39d 100644 --- a/src/main/java/fr/maxlego08/essentials/zutils/utils/AttributeUtils.java +++ b/src/main/java/fr/maxlego08/essentials/zutils/utils/AttributeUtils.java @@ -4,12 +4,13 @@ import org.bukkit.Registry; import org.bukkit.attribute.Attribute; -import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; public class AttributeUtils { - private static final Map attributeCache = new HashMap<>(); + // Thread-safe cache for attribute lookups + private static final Map attributeCache = new ConcurrentHashMap<>(); public static Attribute getAttribute(String name) { return attributeCache.computeIfAbsent(name, key -> { diff --git a/src/main/java/fr/maxlego08/essentials/zutils/utils/commands/VCommand.java b/src/main/java/fr/maxlego08/essentials/zutils/utils/commands/VCommand.java index cbfba535..2f0c4902 100644 --- a/src/main/java/fr/maxlego08/essentials/zutils/utils/commands/VCommand.java +++ b/src/main/java/fr/maxlego08/essentials/zutils/utils/commands/VCommand.java @@ -27,7 +27,8 @@ public abstract class VCommand extends Arguments implements EssentialsCommand { - private static final Map>> uuidRequestQueue = new HashMap<>(); + // Thread-safe queue for UUID requests + private static final Map>> uuidRequestQueue = new java.util.concurrent.ConcurrentHashMap<>(); protected final EssentialsPlugin plugin; protected final List cooldowns = Arrays.asList( "1m", // 60 seconds From 79fdec96b4071957a589507e98bef810e09b825c Mon Sep 17 00:00:00 2001 From: WhiteProject1 Date: Thu, 20 Nov 2025 15:44:06 +0300 Subject: [PATCH 05/11] Delay teleport on first join for chunk loading Teleportation to spawn on first join now waits for the player to be fully loaded in the chunk loader (Folia) by scheduling the teleport 2 ticks later. This ensures the player is online and avoids potential issues with chunk loading. --- .../fr/maxlego08/essentials/listener/PlayerListener.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/fr/maxlego08/essentials/listener/PlayerListener.java b/src/main/java/fr/maxlego08/essentials/listener/PlayerListener.java index 95cc3a12..7f3f33d6 100644 --- a/src/main/java/fr/maxlego08/essentials/listener/PlayerListener.java +++ b/src/main/java/fr/maxlego08/essentials/listener/PlayerListener.java @@ -197,7 +197,12 @@ public void onJoin(PlayerJoinEvent event) { if (user != null) user.startCurrentSessionPlayTime(); if (user != null && user.isFirstJoin() && ConfigStorage.spawnLocation != null && ConfigStorage.spawnLocation.isValid()) { - this.plugin.getScheduler().teleportAsync(player, ConfigStorage.spawnLocation.getLocation()); + // Wait for the player to be fully loaded in the chunk loader for Folia + this.plugin.getScheduler().runAtLocationLater(player.getLocation(), () -> { + if (player.isOnline()) { + this.plugin.getScheduler().teleportAsync(player, ConfigStorage.spawnLocation.getLocation()); + } + }, 2); } if (user != null && user.getOption(Option.VANISH)) { From b71e6bac07c55eb9cf8382cc2236e3c49df9aaf2 Mon Sep 17 00:00:00 2001 From: WhiteProject1 Date: Thu, 20 Nov 2025 16:05:27 +0300 Subject: [PATCH 06/11] Enhance vote module and fix PlayerListener issues Added URL and changed seconds to long in VoteSiteConfiguration. Improved vote site configuration loading and reset scheduling in VoteModule, including safer monthly scheduling and async vote updates with error handling. Fixed typo in PlayerListener (cancelGoldEvent to cancelGodEvent), ensured playtime insertion runs asynchronously, and added online check before scheduling flight on login. --- .../api/vote/VoteSiteConfiguration.java | 2 +- .../essentials/listener/PlayerListener.java | 16 +++- .../essentials/module/modules/VoteModule.java | 86 ++++++++++++++++--- 3 files changed, 89 insertions(+), 15 deletions(-) diff --git a/API/src/main/java/fr/maxlego08/essentials/api/vote/VoteSiteConfiguration.java b/API/src/main/java/fr/maxlego08/essentials/api/vote/VoteSiteConfiguration.java index f6e8ab46..a43fa5a5 100644 --- a/API/src/main/java/fr/maxlego08/essentials/api/vote/VoteSiteConfiguration.java +++ b/API/src/main/java/fr/maxlego08/essentials/api/vote/VoteSiteConfiguration.java @@ -2,5 +2,5 @@ import fr.maxlego08.essentials.api.modules.Loadable; -public record VoteSiteConfiguration(String name, int seconds) implements Loadable { +public record VoteSiteConfiguration(String name, String url, long seconds) implements Loadable { } diff --git a/src/main/java/fr/maxlego08/essentials/listener/PlayerListener.java b/src/main/java/fr/maxlego08/essentials/listener/PlayerListener.java index 7f3f33d6..5472bd90 100644 --- a/src/main/java/fr/maxlego08/essentials/listener/PlayerListener.java +++ b/src/main/java/fr/maxlego08/essentials/listener/PlayerListener.java @@ -40,6 +40,7 @@ import org.bukkit.potion.PotionEffectType; import java.util.Optional; +import java.util.UUID; public class PlayerListener extends ZUtils implements Listener { @@ -54,7 +55,7 @@ private User getUser(Entity player) { return this.plugin.getStorageManager().getStorage().getUser(player.getUniqueId()); } - private void cancelGoldEvent(Player player, Cancellable event) { + private void cancelGodEvent(Player player, Cancellable event) { User user = getUser(player); if (user != null && user.getOption(Option.GOD)) { event.setCancelled(true); @@ -64,7 +65,7 @@ private void cancelGoldEvent(Player player, Cancellable event) { @EventHandler(priority = EventPriority.HIGHEST) public void onDamage(EntityDamageEvent event) { if (event.getEntity() instanceof Player player) { - cancelGoldEvent(player, event); + cancelGodEvent(player, event); var user = getUser(player); if (user == null) return; @@ -78,7 +79,7 @@ public void onDamage(EntityDamageEvent event) { @EventHandler(priority = EventPriority.HIGHEST) public void onFood(FoodLevelChangeEvent event) { if (event.getEntity() instanceof Player player) { - cancelGoldEvent(player, event); + cancelGodEvent(player, event); } } @@ -224,6 +225,8 @@ public void onJoin(PlayerJoinEvent event) { this.plugin.getScheduler().runAtLocationLater(player.getLocation(), () -> { + if (!player.isOnline()) return; + if (hasPermission(player, Permission.ESSENTIALS_FLY_SAFELOGIN) && shouldFlyBasedOnLocation(player.getLocation())) { player.setAllowFlight(true); player.setFlying(true); @@ -242,7 +245,12 @@ public void onQuit(PlayerQuitEvent event) { if (user == null) return; long sessionPlayTime = (System.currentTimeMillis() - user.getCurrentSessionPlayTime()) / 1000; long playtime = user.getPlayTime(); - this.plugin.getStorageManager().getStorage().insertPlayTime(user.getUniqueId(), sessionPlayTime, playtime, user.getAddress()); + String address = user.getAddress(); + UUID uuid = user.getUniqueId(); + + this.plugin.getScheduler().runAsync(wrappedTask -> { + this.plugin.getStorageManager().getStorage().insertPlayTime(uuid, sessionPlayTime, playtime, address); + }); } @EventHandler diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/VoteModule.java b/src/main/java/fr/maxlego08/essentials/module/modules/VoteModule.java index d7efb183..9e7ba142 100644 --- a/src/main/java/fr/maxlego08/essentials/module/modules/VoteModule.java +++ b/src/main/java/fr/maxlego08/essentials/module/modules/VoteModule.java @@ -96,6 +96,30 @@ public void loadConfiguration() { var rewardActions = typedMapAccessor.getStringList("commands"); this.rewardsOnVote.add(new VoteReward(min, max, rewardActions)); } + + this.placeholderAvailable = configuration.getString("placeholders.available", "&aAvailable"); + this.placeholderCooldown = configuration.getString("placeholders.cooldown", "&cCooldown"); + + this.enableVoteParty = configuration.getBoolean("vote-party.enable"); + this.votePartyObjective = configuration.getLong("vote-party.objective"); + this.enableVotePartyOpenVoteInventory = configuration.getBoolean("vote-party.open-inventory"); + this.enableOfflineVoteMessage = configuration.getBoolean("enable-offline-vote-message"); + + this.sites = new ArrayList<>(); + for (Map map : configuration.getMapList("vote-sites")) { + TypedMapAccessor typedMapAccessor = new TypedMapAccessor((Map) map); + String name = typedMapAccessor.getString("name"); + String url = typedMapAccessor.getString("url"); + long delay = typedMapAccessor.getLong("delay"); + this.sites.add(new VoteSiteConfiguration(name, url, delay)); + } + + this.resetConfiguration = new VoteResetConfiguration( + configuration.getInt("reset-votes.day"), + configuration.getInt("reset-votes.hour"), + configuration.getInt("reset-votes.minute"), + configuration.getInt("reset-votes.second") + ); } @Override @@ -188,13 +212,17 @@ private void updateDatabaseFromCacheForPlayer(UUID uniqueId) { if (voteDTO != null) { this.plugin.getScheduler().runAsync(wrappedTask -> { - var dto = storage.getVote(uniqueId); - long vote = voteDTO.vote() + dto.vote(); - User user = this.plugin.getUser(uniqueId); - if (user != null) { - user.setVote(vote); - } else { - storage.setVote(uniqueId, vote, this.enableOfflineVoteMessage ? voteDTO.vote_offline() + dto.vote_offline() : 0); + try { + var dto = storage.getVote(uniqueId); + long vote = voteDTO.vote() + dto.vote(); + User user = this.plugin.getUser(uniqueId); + if (user != null) { + user.setVote(vote); + } else { + storage.setVote(uniqueId, vote, this.enableOfflineVoteMessage ? voteDTO.vote_offline() + dto.vote_offline() : 0); + } + } catch (Exception exception) { + exception.printStackTrace(); } }); } @@ -312,21 +340,59 @@ public String getPlaceholderAvailable() { return placeholderAvailable; } + @NonLoadable + private ScheduledExecutorService resetScheduler; + + @Override + public void onDisable() { + if (this.resetScheduler != null && !this.resetScheduler.isShutdown()) { + this.resetScheduler.shutdown(); + } + super.onDisable(); + } + @Override public void startResetTask() { if (!isEnable()) return; + + if (this.resetScheduler != null && !this.resetScheduler.isShutdown()) { + this.resetScheduler.shutdown(); + } LocalDateTime now = LocalDateTime.now(); - LocalDateTime nextRun = now.withDayOfMonth(range(this.resetConfiguration.day(), 1, 31)).withHour(range(this.resetConfiguration.hour(), 0, 23)).withMinute(range(this.resetConfiguration.minute(), 0, 59)).withSecond(range(this.resetConfiguration.second(), 0, 59)); + LocalDateTime nextRun = now.withHour(range(this.resetConfiguration.hour(), 0, 23)) + .withMinute(range(this.resetConfiguration.minute(), 0, 59)) + .withSecond(range(this.resetConfiguration.second(), 0, 59)); + + // Handle month day adjustment safely + int targetDay = range(this.resetConfiguration.day(), 1, 31); + try { + nextRun = nextRun.withDayOfMonth(Math.min(targetDay, nextRun.toLocalDate().lengthOfMonth())); + } catch (Exception e) { + nextRun = nextRun.withDayOfMonth(nextRun.toLocalDate().lengthOfMonth()); + } if (!now.isBefore(nextRun)) { nextRun = nextRun.plusMonths(1); + // Adjust day again for the next month + try { + nextRun = nextRun.withDayOfMonth(Math.min(targetDay, nextRun.toLocalDate().lengthOfMonth())); + } catch (Exception e) { + nextRun = nextRun.withDayOfMonth(nextRun.toLocalDate().lengthOfMonth()); + } } + long initialDelay = ChronoUnit.MILLIS.between(now, nextRun); - ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); - scheduler.scheduleAtFixedRate(this::resetVotes, initialDelay, TimeUnit.DAYS.toMillis(30), TimeUnit.MILLISECONDS); + this.resetScheduler = Executors.newScheduledThreadPool(1); + // Schedule only once, then reschedule inside the task for correct monthly calculation + this.resetScheduler.schedule(this::resetVotesAndReschedule, initialDelay, TimeUnit.MILLISECONDS); + } + + private void resetVotesAndReschedule() { + this.resetVotes(); + this.startResetTask(); // Reschedule for next month } @Override From ce78fa1efc7978eb2791a8c2fcbf2b2cc13ae9dd Mon Sep 17 00:00:00 2001 From: WhiteProject1 Date: Thu, 20 Nov 2025 20:30:06 +0300 Subject: [PATCH 07/11] Add player trading system with commands and GUI (NOT TESTED) Introduces a comprehensive player trading feature, including trade request, accept, and deny commands, trade events, and a configurable trade GUI. Adds TradeManager, TradeModule, inventory handling, event listeners, and configuration for slots, items, and messages. Updates permissions and messages to support trading functionality. --- .../essentials/api/commands/Permission.java | 3 + .../event/events/trade/TradeCancelEvent.java | 30 +++ .../events/trade/TradeCompleteEvent.java | 24 +++ .../event/events/trade/TradeStartEvent.java | 24 +++ .../essentials/api/messages/Message.java | 1 + .../essentials/commands/CommandLoader.java | 2 + .../commands/commands/trade/CommandTrade.java | 45 ++++ .../commands/trade/CommandTradeAccept.java | 55 +++++ .../commands/trade/CommandTradeDeny.java | 55 +++++ .../module/modules/trade/TradeManager.java | 123 +++++++++++ .../module/modules/trade/TradeModule.java | 152 ++++++++++++++ .../modules/trade/enums/TradeState.java | 9 + .../trade/inventory/TradeInventoryHolder.java | 104 ++++++++++ .../listeners/TradeInventoryListener.java | 194 ++++++++++++++++++ .../modules/trade/model/TradePlayer.java | 61 ++++++ .../modules/trade/model/TradeSession.java | 54 +++++ src/main/resources/modules/trade/config.yml | 170 +++++++++++++++ 17 files changed, 1106 insertions(+) create mode 100644 API/src/main/java/fr/maxlego08/essentials/api/event/events/trade/TradeCancelEvent.java create mode 100644 API/src/main/java/fr/maxlego08/essentials/api/event/events/trade/TradeCompleteEvent.java create mode 100644 API/src/main/java/fr/maxlego08/essentials/api/event/events/trade/TradeStartEvent.java create mode 100644 src/main/java/fr/maxlego08/essentials/commands/commands/trade/CommandTrade.java create mode 100644 src/main/java/fr/maxlego08/essentials/commands/commands/trade/CommandTradeAccept.java create mode 100644 src/main/java/fr/maxlego08/essentials/commands/commands/trade/CommandTradeDeny.java create mode 100644 src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeManager.java create mode 100644 src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeModule.java create mode 100644 src/main/java/fr/maxlego08/essentials/module/modules/trade/enums/TradeState.java create mode 100644 src/main/java/fr/maxlego08/essentials/module/modules/trade/inventory/TradeInventoryHolder.java create mode 100644 src/main/java/fr/maxlego08/essentials/module/modules/trade/listeners/TradeInventoryListener.java create mode 100644 src/main/java/fr/maxlego08/essentials/module/modules/trade/model/TradePlayer.java create mode 100644 src/main/java/fr/maxlego08/essentials/module/modules/trade/model/TradeSession.java create mode 100644 src/main/resources/modules/trade/config.yml diff --git a/API/src/main/java/fr/maxlego08/essentials/api/commands/Permission.java b/API/src/main/java/fr/maxlego08/essentials/api/commands/Permission.java index 7fef5e64..fc976c17 100644 --- a/API/src/main/java/fr/maxlego08/essentials/api/commands/Permission.java +++ b/API/src/main/java/fr/maxlego08/essentials/api/commands/Permission.java @@ -38,6 +38,9 @@ public enum Permission { ESSENTIALS_TPA_ACCEPT, ESSENTIALS_TPA_DENY, ESSENTIALS_TPA_CANCEL, + ESSENTIALS_TRADE_USE, + ESSENTIALS_TRADE_ACCEPT, + ESSENTIALS_TRADE_DENY, ESSENTIALS_BYPASS_COOLDOWN("Allows not to have a cooldown on all commands"), ESSENTIALS_MORE, ESSENTIALS_TP_WORLD, diff --git a/API/src/main/java/fr/maxlego08/essentials/api/event/events/trade/TradeCancelEvent.java b/API/src/main/java/fr/maxlego08/essentials/api/event/events/trade/TradeCancelEvent.java new file mode 100644 index 00000000..3bcbefbc --- /dev/null +++ b/API/src/main/java/fr/maxlego08/essentials/api/event/events/trade/TradeCancelEvent.java @@ -0,0 +1,30 @@ +package fr.maxlego08.essentials.api.event.events.trade; + +import fr.maxlego08.essentials.api.event.EssentialsEvent; +import org.bukkit.entity.Player; + +public class TradeCancelEvent extends EssentialsEvent { + + private final Player player1; + private final Player player2; + private final Player canceller; + + public TradeCancelEvent(Player player1, Player player2, Player canceller) { + this.player1 = player1; + this.player2 = player2; + this.canceller = canceller; + } + + public Player getPlayer1() { + return player1; + } + + public Player getPlayer2() { + return player2; + } + + public Player getCanceller() { + return canceller; + } +} + diff --git a/API/src/main/java/fr/maxlego08/essentials/api/event/events/trade/TradeCompleteEvent.java b/API/src/main/java/fr/maxlego08/essentials/api/event/events/trade/TradeCompleteEvent.java new file mode 100644 index 00000000..ee5df706 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/essentials/api/event/events/trade/TradeCompleteEvent.java @@ -0,0 +1,24 @@ +package fr.maxlego08.essentials.api.event.events.trade; + +import fr.maxlego08.essentials.api.event.EssentialsEvent; +import org.bukkit.entity.Player; + +public class TradeCompleteEvent extends EssentialsEvent { + + private final Player player1; + private final Player player2; + + public TradeCompleteEvent(Player player1, Player player2) { + this.player1 = player1; + this.player2 = player2; + } + + public Player getPlayer1() { + return player1; + } + + public Player getPlayer2() { + return player2; + } +} + diff --git a/API/src/main/java/fr/maxlego08/essentials/api/event/events/trade/TradeStartEvent.java b/API/src/main/java/fr/maxlego08/essentials/api/event/events/trade/TradeStartEvent.java new file mode 100644 index 00000000..2dc5f368 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/essentials/api/event/events/trade/TradeStartEvent.java @@ -0,0 +1,24 @@ +package fr.maxlego08.essentials.api.event.events.trade; + +import fr.maxlego08.essentials.api.event.CancellableEssentialsEvent; +import org.bukkit.entity.Player; + +public class TradeStartEvent extends CancellableEssentialsEvent { + + private final Player player1; + private final Player player2; + + public TradeStartEvent(Player player1, Player player2) { + this.player1 = player1; + this.player2 = player2; + } + + public Player getPlayer1() { + return player1; + } + + public Player getPlayer2() { + return player2; + } +} + diff --git a/API/src/main/java/fr/maxlego08/essentials/api/messages/Message.java b/API/src/main/java/fr/maxlego08/essentials/api/messages/Message.java index 3ce51ff3..0f38dec7 100644 --- a/API/src/main/java/fr/maxlego08/essentials/api/messages/Message.java +++ b/API/src/main/java/fr/maxlego08/essentials/api/messages/Message.java @@ -44,6 +44,7 @@ public enum Message { COMMAND_NO_ARG("Impossible to find the command with its arguments."), COMMAND_RESTRICTED("You cannot use this command here."), COMMAND_SYNTAXE_HELP("&f%syntax% &7» &7%description%"), + DESCRIPTION_TRADE("Manage trade requests"), COMMAND_RELOAD("You have just reloaded the configuration files."), COMMAND_RELOAD_MODULE("You have just reloaded the configuration files of the module &f%module%."), diff --git a/src/main/java/fr/maxlego08/essentials/commands/CommandLoader.java b/src/main/java/fr/maxlego08/essentials/commands/CommandLoader.java index e3ba4f0e..4906a37e 100644 --- a/src/main/java/fr/maxlego08/essentials/commands/CommandLoader.java +++ b/src/main/java/fr/maxlego08/essentials/commands/CommandLoader.java @@ -120,6 +120,7 @@ import fr.maxlego08.essentials.commands.commands.utils.blocks.CommandSmithingTable; import fr.maxlego08.essentials.commands.commands.utils.blocks.CommandStoneCutter; import fr.maxlego08.essentials.commands.commands.utils.experience.CommandExperience; +import fr.maxlego08.essentials.commands.commands.trade.CommandTrade; import fr.maxlego08.essentials.commands.commands.vault.CommandVault; import fr.maxlego08.essentials.commands.commands.vote.CommandVote; import fr.maxlego08.essentials.commands.commands.vote.CommandVoteParty; @@ -291,6 +292,7 @@ public void loadCommands(CommandManager commandManager) { register("voteparty", CommandVoteParty.class, "vp"); register("vote", CommandVote.class); + register("trade", CommandTrade.class, "exchange"); register("vault", CommandVault.class, "sac", "bag", "b", "coffre", "chest"); register("player-worldedit", CommandWorldEdit.class, "pwe", "ess-worldedit", "eworldedit", "ew"); diff --git a/src/main/java/fr/maxlego08/essentials/commands/commands/trade/CommandTrade.java b/src/main/java/fr/maxlego08/essentials/commands/commands/trade/CommandTrade.java new file mode 100644 index 00000000..dbec270b --- /dev/null +++ b/src/main/java/fr/maxlego08/essentials/commands/commands/trade/CommandTrade.java @@ -0,0 +1,45 @@ +package fr.maxlego08.essentials.commands.commands.trade; + +import fr.maxlego08.essentials.api.EssentialsPlugin; +import fr.maxlego08.essentials.api.commands.CommandResultType; +import fr.maxlego08.essentials.api.commands.Permission; +import fr.maxlego08.essentials.api.messages.Message; +import fr.maxlego08.essentials.module.modules.trade.TradeModule; +import fr.maxlego08.essentials.zutils.utils.commands.VCommand; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +public class CommandTrade extends VCommand { + + public CommandTrade(EssentialsPlugin plugin) { + super(plugin); + this.setModule(TradeModule.class); + this.setPermission(Permission.ESSENTIALS_TRADE_USE); + this.setDescription(Message.DESCRIPTION_TRADE); + + this.addSubCommand(new CommandTradeAccept(plugin)); + this.addSubCommand(new CommandTradeDeny(plugin)); + } + + @Override + protected CommandResultType perform(EssentialsPlugin plugin) { + if (args.length == 0) { + syntaxMessage(); + return CommandResultType.SYNTAX_ERROR; + } + + String targetName = args[0]; + Player target = Bukkit.getPlayer(targetName); + + if (target == null) { + message(sender, Message.PLAYER_NOT_FOUND, "%player%", targetName); + return CommandResultType.DEFAULT; + } + + TradeModule module = plugin.getModuleManager().getModule(TradeModule.class); + module.getTradeManager().sendRequest(player, target); + + return CommandResultType.SUCCESS; + } +} + diff --git a/src/main/java/fr/maxlego08/essentials/commands/commands/trade/CommandTradeAccept.java b/src/main/java/fr/maxlego08/essentials/commands/commands/trade/CommandTradeAccept.java new file mode 100644 index 00000000..6b2095b7 --- /dev/null +++ b/src/main/java/fr/maxlego08/essentials/commands/commands/trade/CommandTradeAccept.java @@ -0,0 +1,55 @@ +package fr.maxlego08.essentials.commands.commands.trade; + +import fr.maxlego08.essentials.api.EssentialsPlugin; +import fr.maxlego08.essentials.api.commands.CommandResultType; +import fr.maxlego08.essentials.api.commands.Permission; +import fr.maxlego08.essentials.api.messages.Message; +import fr.maxlego08.essentials.module.modules.trade.TradeManager; +import fr.maxlego08.essentials.module.modules.trade.TradeModule; +import fr.maxlego08.essentials.zutils.utils.commands.VCommand; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import java.util.Map; +import java.util.UUID; + +public class CommandTradeAccept extends VCommand { + + public CommandTradeAccept(EssentialsPlugin plugin) { + super(plugin); + this.addSubCommand("accept"); + this.setPermission(Permission.ESSENTIALS_TRADE_ACCEPT); + this.setDescription(Message.DESCRIPTION_TRADE); + } + + @Override + protected CommandResultType perform(EssentialsPlugin plugin) { + TradeModule module = plugin.getModuleManager().getModule(TradeModule.class); + TradeManager manager = module.getTradeManager(); + + if (args.length == 0) { + Map requests = manager.getRequests(); + UUID senderUUID = requests.get(player.getUniqueId()); + if (senderUUID != null) { + Player senderPlayer = Bukkit.getPlayer(senderUUID); + if (senderPlayer != null) { + manager.acceptRequest(senderPlayer, player); + return CommandResultType.SUCCESS; + } + } + message(sender, Message.COMMAND_NO_ARG); + return CommandResultType.SYNTAX_ERROR; + } + + String targetName = args[0]; + Player target = Bukkit.getPlayer(targetName); + if (target == null) { + message(sender, Message.PLAYER_NOT_FOUND, "%player%", targetName); + return CommandResultType.DEFAULT; + } + + manager.acceptRequest(target, player); + return CommandResultType.SUCCESS; + } +} + diff --git a/src/main/java/fr/maxlego08/essentials/commands/commands/trade/CommandTradeDeny.java b/src/main/java/fr/maxlego08/essentials/commands/commands/trade/CommandTradeDeny.java new file mode 100644 index 00000000..fca498fa --- /dev/null +++ b/src/main/java/fr/maxlego08/essentials/commands/commands/trade/CommandTradeDeny.java @@ -0,0 +1,55 @@ +package fr.maxlego08.essentials.commands.commands.trade; + +import fr.maxlego08.essentials.api.EssentialsPlugin; +import fr.maxlego08.essentials.api.commands.CommandResultType; +import fr.maxlego08.essentials.api.commands.Permission; +import fr.maxlego08.essentials.api.messages.Message; +import fr.maxlego08.essentials.module.modules.trade.TradeManager; +import fr.maxlego08.essentials.module.modules.trade.TradeModule; +import fr.maxlego08.essentials.zutils.utils.commands.VCommand; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import java.util.Map; +import java.util.UUID; + +public class CommandTradeDeny extends VCommand { + + public CommandTradeDeny(EssentialsPlugin plugin) { + super(plugin); + this.addSubCommand("deny"); + this.setPermission(Permission.ESSENTIALS_TRADE_DENY); + this.setDescription(Message.DESCRIPTION_TRADE); + } + + @Override + protected CommandResultType perform(EssentialsPlugin plugin) { + TradeModule module = plugin.getModuleManager().getModule(TradeModule.class); + TradeManager manager = module.getTradeManager(); + + if (args.length == 0) { + Map requests = manager.getRequests(); + UUID senderUUID = requests.get(player.getUniqueId()); + if (senderUUID != null) { + Player senderPlayer = Bukkit.getPlayer(senderUUID); + if (senderPlayer != null) { + manager.denyRequest(senderPlayer, player); + return CommandResultType.SUCCESS; + } + } + message(sender, Message.COMMAND_NO_ARG); + return CommandResultType.SYNTAX_ERROR; + } + + String targetName = args[0]; + Player target = Bukkit.getPlayer(targetName); + if (target == null) { + message(sender, Message.PLAYER_NOT_FOUND, "%player%", targetName); + return CommandResultType.DEFAULT; + } + + manager.denyRequest(target, player); + return CommandResultType.SUCCESS; + } +} + diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeManager.java b/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeManager.java new file mode 100644 index 00000000..7f8f64ff --- /dev/null +++ b/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeManager.java @@ -0,0 +1,123 @@ +package fr.maxlego08.essentials.module.modules.trade; + +import fr.maxlego08.essentials.ZEssentialsPlugin; +import fr.maxlego08.essentials.api.event.events.trade.TradeStartEvent; +import fr.maxlego08.essentials.module.modules.trade.model.TradeSession; +import fr.maxlego08.essentials.module.modules.trade.inventory.TradeInventoryHolder; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.ArrayList; + +public class TradeManager { + + private final ZEssentialsPlugin plugin; + private final TradeModule module; + private final Map requests = new HashMap<>(); + private final Map activeTrades = new HashMap<>(); + + public TradeManager(ZEssentialsPlugin plugin, TradeModule module) { + this.plugin = plugin; + this.module = module; + } + + public void sendRequest(Player sender, Player target) { + if (sender.equals(target)) { + module.sendMessage(sender, "yourself"); + return; + } + + if (activeTrades.containsKey(sender.getUniqueId())) { + module.sendMessage(sender, "already-trading"); + return; + } + + if (activeTrades.containsKey(target.getUniqueId())) { + module.sendMessage(sender, "target-already-trading", "%player%", target.getName()); + return; + } + + requests.put(target.getUniqueId(), sender.getUniqueId()); + + module.sendMessage(sender, "request-sent", "%player%", target.getName()); + module.sendMessage(target, "request-received", "%player%", sender.getName()); + + plugin.getScheduler().runLater(() -> { + if (requests.get(target.getUniqueId()) != null && requests.get(target.getUniqueId()).equals(sender.getUniqueId())) { + requests.remove(target.getUniqueId()); + } + }, module.getRequestTimeout() * 20); + } + + public void acceptRequest(Player sender, Player target) { + if (!requests.containsKey(sender.getUniqueId()) || !requests.get(sender.getUniqueId()).equals(target.getUniqueId())) { + module.sendMessage(sender, "no-request", "%player%", target.getName()); + return; + } + + if (activeTrades.containsKey(target.getUniqueId())) { + module.sendMessage(sender, "target-already-trading", "%player%", target.getName()); + return; + } + + if (sender.getLocation().distance(target.getLocation()) > module.getMaxDistance()) { + module.sendMessage(sender, "player-too-far"); + return; + } + + requests.remove(sender.getUniqueId()); + module.sendMessage(target, "request-accepted"); + startTrade(sender, target); + } + + public void denyRequest(Player sender, Player target) { + if (requests.containsKey(sender.getUniqueId()) && requests.get(sender.getUniqueId()).equals(target.getUniqueId())) { + requests.remove(sender.getUniqueId()); + module.sendMessage(sender, "request-denied"); + module.sendMessage(target, "request-denied"); + } else { + module.sendMessage(sender, "no-request", "%player%", target.getName()); + } + } + + public void startTrade(Player p1, Player p2) { + TradeStartEvent event = new TradeStartEvent(p1, p2); + event.callEvent(); + if (event.isCancelled()) return; + + TradeSession session = new TradeSession(p1, p2); + activeTrades.put(p1.getUniqueId(), session); + activeTrades.put(p2.getUniqueId(), session); + + new TradeInventoryHolder(p1, session, module).open(); + new TradeInventoryHolder(p2, session, module).open(); + } + + public void cancelAllTrades() { + for (TradeSession session : new ArrayList<>(activeTrades.values())) { + // Logic to close inventory and return items will be handled by listener or manually here if needed + } + activeTrades.clear(); + requests.clear(); + } + + public void removeTrade(TradeSession session) { + activeTrades.remove(session.getTradePlayer1().getPlayer().getUniqueId()); + activeTrades.remove(session.getTradePlayer2().getPlayer().getUniqueId()); + } + + public TradeSession getTradeSession(Player player) { + return activeTrades.get(player.getUniqueId()); + } + + public Map getRequests() { + return requests; + } + + public Map getActiveTrades() { + return activeTrades; + } +} + diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeModule.java b/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeModule.java new file mode 100644 index 00000000..eb23eec4 --- /dev/null +++ b/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeModule.java @@ -0,0 +1,152 @@ +package fr.maxlego08.essentials.module.modules.trade; + +import fr.maxlego08.essentials.ZEssentialsPlugin; +import fr.maxlego08.essentials.module.ZModule; + +import java.util.List; +import java.util.ArrayList; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +public class TradeModule extends ZModule { + + private TradeManager tradeManager; + + private long requestTimeout; + private double maxDistance; + private List worlds; + private List ownSlots = new ArrayList<>(); + private List partnerSlots = new ArrayList<>(); + + public TradeModule(ZEssentialsPlugin plugin) { + super(plugin, "trade"); + this.tradeManager = new TradeManager(plugin, this); + org.bukkit.Bukkit.getPluginManager().registerEvents(new fr.maxlego08.essentials.module.modules.trade.listeners.TradeInventoryListener(this), plugin); + } + + @Override + public void loadConfiguration() { + super.loadConfiguration(); + var config = getConfiguration(); + this.requestTimeout = config.getLong("request-timeout", 60); + this.maxDistance = config.getDouble("max-distance", 10.0); + this.worlds = config.getStringList("worlds"); + + this.ownSlots = parseSlots(config.getStringList("own-slots")); + this.partnerSlots = parseSlots(config.getStringList("partner-slots")); + } + + private List parseSlots(List slotStrings) { + List slots = new ArrayList<>(); + for (String s : slotStrings) { + try { + if (s.contains("-")) { + String[] parts = s.split("-"); + int start = Integer.parseInt(parts[0]); + int end = Integer.parseInt(parts[1]); + for (int i = start; i <= end; i++) slots.add(i); + } else { + slots.add(Integer.parseInt(s)); + } + } catch (NumberFormatException ignored) {} + } + return slots; + } + + public List getOwnSlots() { + return ownSlots; + } + + public List getPartnerSlots() { + return partnerSlots; + } + + public void sendMessage(Player player, String key, String... replacements) { + String message = getConfiguration().getString("messages." + key); + if (message == null) return; + + for (int i = 0; i < replacements.length; i += 2) { + if (i + 1 < replacements.length) { + message = message.replace(replacements[i], replacements[i + 1]); + } + } + + this.plugin.getComponentMessage().sendMessage(player, message); + } + + public ItemStack getItem(String path, Player player, String... replacements) { + var config = getConfiguration(); + var section = config.getConfigurationSection(path); + if (section == null) return new ItemStack(org.bukkit.Material.AIR); + + String materialName = section.getString("material", "STONE"); + org.bukkit.Material material = org.bukkit.Material.matchMaterial(materialName); + if (material == null) material = org.bukkit.Material.STONE; + + ItemStack item = new ItemStack(material); + var meta = item.getItemMeta(); + + if (meta != null) { + String name = section.getString("name"); + if (name != null) { + for (int i = 0; i < replacements.length; i += 2) { + if (i + 1 < replacements.length) name = name.replace(replacements[i], replacements[i + 1]); + } + if (this.plugin.getComponentMessage() instanceof fr.maxlego08.essentials.zutils.utils.paper.PaperComponent paperComponent) { + paperComponent.updateDisplayName(meta, name, player); + } + } + + List lore = section.getStringList("lore"); + if (!lore.isEmpty()) { + List newLore = new ArrayList<>(); + for (String line : lore) { + for (int i = 0; i < replacements.length; i += 2) { + if (i + 1 < replacements.length) line = line.replace(replacements[i], replacements[i + 1]); + } + newLore.add(line); + } + + if (this.plugin.getComponentMessage() instanceof fr.maxlego08.essentials.zutils.utils.paper.PaperComponent paperComponent) { + paperComponent.updateLore(meta, newLore, player); + } + } + + item.setItemMeta(meta); + } + return item; + } + + public int getOwnConfirmSlot() { + return getConfiguration().getInt("own.confirm-item.slot", 0); + } + + public int getPartnerConfirmSlot() { + return getConfiguration().getInt("partner.confirm-item.slot", 8); + } + + public long getRequestTimeout() { + return requestTimeout; + } + + public double getMaxDistance() { + return maxDistance; + } + + public List getWorlds() { + return worlds; + } + + public TradeManager getTradeManager() { + return tradeManager; + } + + @Override + public void onDisable() { + if (this.tradeManager != null) { + this.tradeManager.cancelAllTrades(); + } + } +} + diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/trade/enums/TradeState.java b/src/main/java/fr/maxlego08/essentials/module/modules/trade/enums/TradeState.java new file mode 100644 index 00000000..757c47d4 --- /dev/null +++ b/src/main/java/fr/maxlego08/essentials/module/modules/trade/enums/TradeState.java @@ -0,0 +1,9 @@ +package fr.maxlego08.essentials.module.modules.trade.enums; + +public enum TradeState { + WAITING, + READY, + COUNTDOWN, + COMPLETED +} + diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/trade/inventory/TradeInventoryHolder.java b/src/main/java/fr/maxlego08/essentials/module/modules/trade/inventory/TradeInventoryHolder.java new file mode 100644 index 00000000..65533f18 --- /dev/null +++ b/src/main/java/fr/maxlego08/essentials/module/modules/trade/inventory/TradeInventoryHolder.java @@ -0,0 +1,104 @@ +package fr.maxlego08.essentials.module.modules.trade.inventory; + +import fr.maxlego08.essentials.module.modules.trade.TradeModule; +import fr.maxlego08.essentials.module.modules.trade.model.TradePlayer; +import fr.maxlego08.essentials.module.modules.trade.model.TradeSession; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import java.util.List; + +public class TradeInventoryHolder implements InventoryHolder { + + private final Player viewer; + private final TradeSession session; + private final TradeModule module; + private final Inventory inventory; + + public TradeInventoryHolder(Player viewer, TradeSession session, TradeModule module) { + this.viewer = viewer; + this.session = session; + this.module = module; + this.inventory = Bukkit.createInventory(this, 54, "Trade with " + session.getOtherTradePlayer(viewer).getPlayer().getName()); + setupInventory(); + } + + public void open() { + viewer.openInventory(inventory); + } + + private void setupInventory() { + var config = module.getConfiguration(); + var decorations = config.getConfigurationSection("decorations"); + if (decorations != null) { + for (String key : decorations.getKeys(false)) { + String path = "decorations." + key; + ItemStack item = module.getItem(path, viewer); + List slots = config.getIntegerList(path + ".slot"); + for (int slot : slots) { + inventory.setItem(slot, item); + } + } + } + + updateButtons(); + updateItems(); + } + + public void updateItems() { + TradePlayer me = session.getTradePlayer(viewer); + TradePlayer other = session.getOtherTradePlayer(viewer); + + List mySlots = module.getOwnSlots(); + List myItems = me.getItems(); + for (int i = 0; i < mySlots.size(); i++) { + int slot = mySlots.get(i); + if (i < myItems.size()) { + inventory.setItem(slot, myItems.get(i)); + } else { + inventory.setItem(slot, null); + } + } + + List otherSlots = module.getPartnerSlots(); + List otherItems = other.getItems(); + for (int i = 0; i < otherSlots.size(); i++) { + int slot = otherSlots.get(i); + if (i < otherItems.size()) { + inventory.setItem(slot, otherItems.get(i)); + } else { + inventory.setItem(slot, null); + } + } + } + + public void updateButtons() { + TradePlayer me = session.getTradePlayer(viewer); + String path = "own.confirm-item." + (me.isReady() ? "cancel" : "accept"); + ItemStack readyBtn = module.getItem(path, viewer); + inventory.setItem(module.getOwnConfirmSlot(), readyBtn); + + TradePlayer other = session.getOtherTradePlayer(viewer); + String otherPath = "partner.confirm-item." + (other.isReady() ? "cancel" : "accept"); + ItemStack otherBtn = module.getItem(otherPath, viewer, "%partner-name%", other.getPlayer().getName()); + inventory.setItem(module.getPartnerConfirmSlot(), otherBtn); + } + + @Override + public Inventory getInventory() { + return inventory; + } + + public TradeSession getSession() { + return session; + } + + public Player getViewer() { + return viewer; + } +} + diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/trade/listeners/TradeInventoryListener.java b/src/main/java/fr/maxlego08/essentials/module/modules/trade/listeners/TradeInventoryListener.java new file mode 100644 index 00000000..0108e44d --- /dev/null +++ b/src/main/java/fr/maxlego08/essentials/module/modules/trade/listeners/TradeInventoryListener.java @@ -0,0 +1,194 @@ +package fr.maxlego08.essentials.module.modules.trade.listeners; + +import fr.maxlego08.essentials.api.event.events.trade.TradeCancelEvent; +import fr.maxlego08.essentials.api.event.events.trade.TradeCompleteEvent; +import fr.maxlego08.essentials.module.modules.trade.TradeModule; +import fr.maxlego08.essentials.module.modules.trade.inventory.TradeInventoryHolder; +import fr.maxlego08.essentials.module.modules.trade.model.TradePlayer; +import fr.maxlego08.essentials.module.modules.trade.model.TradeSession; +import fr.maxlego08.essentials.module.modules.trade.enums.TradeState; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +import java.util.Set; + +public class TradeInventoryListener implements Listener { + + private final TradeModule module; + + public TradeInventoryListener(TradeModule module) { + this.module = module; + } + + @EventHandler + public void onClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof TradeInventoryHolder holder)) return; + + event.setCancelled(true); + + Player player = (Player) event.getWhoClicked(); + TradeSession session = holder.getSession(); + TradePlayer me = session.getTradePlayer(player); + + if (session.getState() == TradeState.COMPLETED) return; + + int slot = event.getRawSlot(); + + if (slot >= 54) { + event.setCancelled(false); + if (event.isShiftClick()) { + event.setCancelled(true); + ItemStack item = event.getCurrentItem(); + if (item != null && item.getType() != Material.AIR) { + if (me.isReady()) { + module.sendMessage(player, "trade-not-ready"); + me.setReady(false); + session.getOtherTradePlayer(player).setReady(false); + updateInventories(session); + } + + me.getItems().add(item.clone()); + event.setCurrentItem(null); + updateInventories(session); + } + } + return; + } + + if (module.getOwnSlots().contains(slot)) { + if (me.isReady()) { + me.setReady(false); + session.getOtherTradePlayer(player).setReady(false); + module.sendMessage(player, "trade-not-ready"); + updateInventories(session); + return; + } + + event.setCancelled(false); + + module.getPlugin().getScheduler().runNextTick(task -> { + updateMyItems(me, holder.getInventory()); + updateInventories(session); + }); + } + + if (slot == module.getOwnConfirmSlot()) { + if (me.isReady()) { + me.setReady(false); + session.getOtherTradePlayer(player).setReady(false); + } else { + me.setReady(true); + module.sendMessage(player, "trade-ready"); + if (session.getOtherTradePlayer(player).isReady()) { + completeTrade(session); + } + } + updateInventories(session); + } + } + + private void updateMyItems(TradePlayer me, Inventory inv) { + me.getItems().clear(); + for (int slot : module.getOwnSlots()) { + ItemStack item = inv.getItem(slot); + if (item != null && item.getType() != Material.AIR) { + me.getItems().add(item.clone()); + } + } + } + + private void updateInventories(TradeSession session) { + Player p1 = session.getTradePlayer1().getPlayer(); + Player p2 = session.getTradePlayer2().getPlayer(); + + if (p1.getOpenInventory().getTopInventory().getHolder() instanceof TradeInventoryHolder h1) { + h1.updateItems(); + h1.updateButtons(); + } + + if (p2.getOpenInventory().getTopInventory().getHolder() instanceof TradeInventoryHolder h2) { + h2.updateItems(); + h2.updateButtons(); + } + } + + @EventHandler + public void onClose(InventoryCloseEvent event) { + if (!(event.getInventory().getHolder() instanceof TradeInventoryHolder holder)) return; + + TradeSession session = holder.getSession(); + if (session.getState() == TradeState.COMPLETED) return; + + Player player = (Player) event.getPlayer(); + TradePlayer me = session.getTradePlayer(player); + TradePlayer other = session.getOtherTradePlayer(player); + + returnItems(me); + returnItems(other); + + module.getTradeManager().removeTrade(session); + + if (other.getPlayer().isOnline()) { + other.getPlayer().closeInventory(); + module.sendMessage(other.getPlayer(), "trade-cancelled"); + } + + module.sendMessage(player, "trade-cancelled"); + + new TradeCancelEvent(me.getPlayer(), other.getPlayer(), player).callEvent(); + } + + private void returnItems(TradePlayer tradePlayer) { + Player player = tradePlayer.getPlayer(); + for (ItemStack item : tradePlayer.getItems()) { + if (player.getInventory().firstEmpty() != -1) { + player.getInventory().addItem(item); + } else { + player.getWorld().dropItem(player.getLocation(), item); + module.sendMessage(player, "inventory-full"); + } + } + } + + private void completeTrade(TradeSession session) { + session.setState(TradeState.COMPLETED); + + TradePlayer p1 = session.getTradePlayer1(); + TradePlayer p2 = session.getTradePlayer2(); + + for (ItemStack item : p2.getItems()) { + if (p1.getPlayer().getInventory().firstEmpty() != -1) { + p1.getPlayer().getInventory().addItem(item); + } else { + p1.getPlayer().getWorld().dropItem(p1.getPlayer().getLocation(), item); + module.sendMessage(p1.getPlayer(), "inventory-full"); + } + } + + for (ItemStack item : p1.getItems()) { + if (p2.getPlayer().getInventory().firstEmpty() != -1) { + p2.getPlayer().getInventory().addItem(item); + } else { + p2.getPlayer().getWorld().dropItem(p2.getPlayer().getLocation(), item); + module.sendMessage(p2.getPlayer(), "inventory-full"); + } + } + + module.sendMessage(p1.getPlayer(), "trade-completed"); + module.sendMessage(p2.getPlayer(), "trade-completed"); + + module.getTradeManager().removeTrade(session); + + p1.getPlayer().closeInventory(); + p2.getPlayer().closeInventory(); + + new TradeCompleteEvent(p1.getPlayer(), p2.getPlayer()).callEvent(); + } +} + diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/trade/model/TradePlayer.java b/src/main/java/fr/maxlego08/essentials/module/modules/trade/model/TradePlayer.java new file mode 100644 index 00000000..ca5a3ca1 --- /dev/null +++ b/src/main/java/fr/maxlego08/essentials/module/modules/trade/model/TradePlayer.java @@ -0,0 +1,61 @@ +package fr.maxlego08.essentials.module.modules.trade.model; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class TradePlayer { + + private final Player player; + private final List items = new ArrayList<>(); + private double money = 0; + private boolean isReady = false; + private boolean hasConfirmed = false; + + public TradePlayer(Player player) { + this.player = player; + } + + public Player getPlayer() { + return player; + } + + public UUID getUniqueId() { + return player.getUniqueId(); + } + + public List getItems() { + return items; + } + + public double getMoney() { + return money; + } + + public void setMoney(double money) { + this.money = money; + } + + public void addMoney(double amount) { + this.money += amount; + } + + public boolean isReady() { + return isReady; + } + + public void setReady(boolean ready) { + isReady = ready; + } + + public boolean hasConfirmed() { + return hasConfirmed; + } + + public void setConfirmed(boolean confirmed) { + this.hasConfirmed = confirmed; + } +} + diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/trade/model/TradeSession.java b/src/main/java/fr/maxlego08/essentials/module/modules/trade/model/TradeSession.java new file mode 100644 index 00000000..be8ea559 --- /dev/null +++ b/src/main/java/fr/maxlego08/essentials/module/modules/trade/model/TradeSession.java @@ -0,0 +1,54 @@ +package fr.maxlego08.essentials.module.modules.trade.model; + +import fr.maxlego08.essentials.module.modules.trade.enums.TradeState; +import org.bukkit.entity.Player; + +public class TradeSession { + + private final TradePlayer player1; + private final TradePlayer player2; + private TradeState state = TradeState.WAITING; + private int countdownTask = -1; + + public TradeSession(Player p1, Player p2) { + this.player1 = new TradePlayer(p1); + this.player2 = new TradePlayer(p2); + } + + public TradePlayer getTradePlayer1() { + return player1; + } + + public TradePlayer getTradePlayer2() { + return player2; + } + + public TradePlayer getTradePlayer(Player player) { + if (player1.getPlayer().equals(player)) return player1; + if (player2.getPlayer().equals(player)) return player2; + return null; + } + + public TradePlayer getOtherTradePlayer(Player player) { + if (player1.getPlayer().equals(player)) return player2; + if (player2.getPlayer().equals(player)) return player1; + return null; + } + + public TradeState getState() { + return state; + } + + public void setState(TradeState state) { + this.state = state; + } + + public void setCountdownTask(int taskId) { + this.countdownTask = taskId; + } + + public int getCountdownTask() { + return countdownTask; + } +} + diff --git a/src/main/resources/modules/trade/config.yml b/src/main/resources/modules/trade/config.yml new file mode 100644 index 00000000..cc5a4e9a --- /dev/null +++ b/src/main/resources/modules/trade/config.yml @@ -0,0 +1,170 @@ +enable: true +request-timeout: 60 +max-distance: 10.0 +worlds: + - world + - world_nether + - world_the_end + +# DOCUMENTATION: https://docs.artillex-studios.com/axtrade.html +# ITEM BUILDER: https://docs.artillex-studios.com/item-builder.html + +# ----- SETTINGS ----- +title: "&0Trading with: %player%" +# a gui can have 1-6 rows +rows: 6 + +# ----- SLOTS ----- +# the slots where the items can be placed +# make sure to not put decorative items or currency items in the these slots +# the own-slots and partner-slots must have an equal amount of slots +own-slots: + - 9-12 + - 18-21 + - 27-30 + - 36-39 + - 45-48 + +partner-slots: + - 14-17 + - 23-26 + - 32-35 + - 41-44 + - 50-53 + +# ----- ITEMS ----- +# items on your side +own: + confirm-item: + slot: 0 + # you can use these placeholders: + # %own-name%" + # %partner-name% + accept: + material: "RED_CONCRETE" + # if you want, you can add head textures, like this: + #material: "PLAYER_HEAD" + #texture: "%own-head%" + name: "�ffdd&lACCEPT TRADE" + lore: + - "" + - " &7- &fAre you happy with the trade?" + - "" + - "�ffdd&l> �ffddClick &8- �ffddConfirm Trade" + cancel: + material: "LIME_CONCRETE" + name: "�ffdd&lCANCEL CONFIRMATION" + lore: + - "" + - " &7- &fDo you want to change something?" + - "" + - "�ffdd&l> �ffddClick &8- �ffddCancel Confirmation" + + # you can define as many currencies as you want, but make sure to copy them to the 'partner' section too! + currency1: + slot: 2 + # you need Vault installed for this + currency: "Vault" + material: "GOLD_NUGGET" + name: "�ffdd&lMONEY" + # you can use these placeholders: + # %amount% (the amount the player set) + # %tax-amount% (amount after tax) + # %tax-percent% (the % of tax on the currency) + # %tax-fee% (amount taken because of tax) + lore: + - "&7Your offer" + - "" + - " &7- &fAmount: �ffdd%amount%$" + - "" + - "�ffdd&l> �ffddClick &8- �ffddChange Amount" + + currency2: + slot: 3 + currency: "Experience" + material: "EXPERIENCE_BOTTLE" + name: "�ffdd&lEXPERIENCE" + lore: + - "&7Your offer" + - "" + - " &7- &fAmount: �ffdd%amount% EXP" + - "" + - "�ffdd&l> �ffddClick &8- �ffddChange Amount" + +# items on your trade partner's side +partner: + confirm-item: + slot: 8 + # you can also use these placeholders: + # %own-name%" + # %partner-name% + accept: + material: "RED_CONCRETE" + # if you want, you can add head textures, like this: + #material: "PLAYER_HEAD" + #texture: "%partner-head%" + name: "�ffdd&lWAITING FOR OTHER PLAYER" + lore: + - "" + - " &7- &f%partner-name% has not yet confirmed the trade!" + - "" + cancel: + material: "LIME_CONCRETE" + name: "�ffdd&lWAITING" + lore: + - "" + - " &7- &f%partner-name% has confirmed the trade!" + + currency1: + slot: 6 + currency: "Vault" + material: "GOLD_NUGGET" + name: "�ffdd&lMONEY" + # you can use these placeholders: + # %amount% (the amount the player set) + # %tax-amount% (amount after tax) + # %tax-percent% (the % of tax on the currency) + # %tax-fee% (amount taken because of tax) + lore: + - "&7%partner-name%'s offer" + - "" + - " &7- &fAmount: �ffdd%amount%$" + - "" + - "�ffdd&l> �ffddClick &8- �ffddChange Amount" + + currency2: + slot: 5 + currency: "Experience" + material: "EXPERIENCE_BOTTLE" + name: "�ffdd&lEXPERIENCE" + lore: + - "&7%partner-name%'s offer" + - "" + - " &7- &fAmount: �ffdd%amount% EXP" + - "" + - "�ffdd&l> �ffddClick &8- �ffddChange Amount" + +decorations: + separator: + slot: [4, 13, 22, 31, 40, 49] + material: "LIGHT_BLUE_STAINED_GLASS_PANE" + name: " " + +# Messages +messages: + request-sent: "&aTrade request sent to %player%." + request-received: "&a%player% sent you a trade request. Type /trade accept %player% to accept." + request-accepted: "&aTrade request accepted." + request-denied: "&cTrade request denied." + trade-cancelled: "&cTrade cancelled." + trade-completed: "&aTrade completed successfully." + player-not-found: "&cPlayer not found." + player-too-far: "&cPlayer is too far away." + inventory-full: "&cYour inventory is full. Items dropped on ground." + already-trading: "&cYou are already trading." + target-already-trading: "&c%player% is already trading." + no-request: "&cYou don't have a trade request from %player%." + yourself: "&cYou cannot trade with yourself." + trade-ready: "&aYou marked yourself as ready." + trade-not-ready: "&cYou are no longer ready." + command-no-arg: "&cYou must specify a player." From dd98b654eb35d775278925b6869692fb1c1b3db8 Mon Sep 17 00:00:00 2001 From: WhiteProject1 Date: Thu, 20 Nov 2025 20:31:13 +0300 Subject: [PATCH 08/11] Add support for custom model data in trade items TradeModule now sets custom model data on item meta if the 'custom-model-data' field is present in the configuration section. This allows for more flexible item customization in trades. --- .../essentials/module/modules/trade/TradeModule.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeModule.java b/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeModule.java index eb23eec4..bbf73e36 100644 --- a/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeModule.java +++ b/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeModule.java @@ -113,6 +113,10 @@ public ItemStack getItem(String path, Player player, String... replacements) { } } + if (section.contains("custom-model-data")) { + meta.setCustomModelData(section.getInt("custom-model-data")); + } + item.setItemMeta(meta); } return item; From 5ff92e269851d485cd034f389639bd265d6b7eda Mon Sep 17 00:00:00 2001 From: WhiteProject1 Date: Thu, 20 Nov 2025 21:11:41 +0300 Subject: [PATCH 09/11] Add soft expiration and async refresh to ExpiringCache ExpiringCache now supports soft expiration: entries are asynchronously refreshed when soft-expired, but the old value is returned until hard expiration. This improves cache responsiveness and reduces blocking on cache misses. --- .../essentials/api/cache/ExpiringCache.java | 59 ++++++++++++++++--- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/API/src/main/java/fr/maxlego08/essentials/api/cache/ExpiringCache.java b/API/src/main/java/fr/maxlego08/essentials/api/cache/ExpiringCache.java index 2ca6a338..c7b028ce 100644 --- a/API/src/main/java/fr/maxlego08/essentials/api/cache/ExpiringCache.java +++ b/API/src/main/java/fr/maxlego08/essentials/api/cache/ExpiringCache.java @@ -28,6 +28,9 @@ public ExpiringCache(long expiryDurationMillis) { * Retrieves the value associated with the specified key from the cache. If the key is not found, * or the entry has expired, the provided Loader is used to load the value, store it in the cache, * and then return it. + *

+ * Supports soft expiration: if the entry is soft-expired but not hard-expired, it returns the old value + * and refreshes the cache asynchronously. * * @param key the key whose associated value is to be returned * @param loader the loader used to generate the value if it is not present or expired in the cache @@ -35,14 +38,42 @@ public ExpiringCache(long expiryDurationMillis) { * was not found in the cache or if the entry expired */ public V get(K key, Loader loader) { - return cache.compute(key, (k, v) -> { - long currentTime = System.currentTimeMillis(); - if (v == null || v.expiryTime < currentTime) { - V newValue = loader.load(); - return new CacheEntry<>(newValue, currentTime + expiryDurationMillis); - } - return v; - }).value; + CacheEntry entry = cache.get(key); + long currentTime = System.currentTimeMillis(); + + // 1. Hard Expiration or Not Present -> Synchronous Load + if (entry == null || entry.expiryTime < currentTime) { + return cache.compute(key, (k, v) -> { + // Double-check inside lock + long now = System.currentTimeMillis(); + if (v == null || v.expiryTime < now) { + V newValue = loader.load(); + long expiry = now + expiryDurationMillis; + long softExpiry = now + (long) (expiryDurationMillis * 0.8); // Refresh at 80% of ttl + return new CacheEntry<>(newValue, expiry, softExpiry); + } + return v; + }).value; + } + + // 2. Soft Expiration -> Return Old Value & Asynchronous Refresh + if (entry.softExpiryTime < currentTime && entry.isUpdating.compareAndSet(false, true)) { + java.util.concurrent.CompletableFuture.runAsync(() -> { + try { + V newValue = loader.load(); + long now = System.currentTimeMillis(); + long expiry = now + expiryDurationMillis; + long softExpiry = now + (long) (expiryDurationMillis * 0.8); + cache.put(key, new CacheEntry<>(newValue, expiry, softExpiry)); + } catch (Exception e) { + e.printStackTrace(); + // Reset flag on failure so we can try again later + entry.isUpdating.set(false); + } + }); + } + + return entry.value; } /** @@ -75,6 +106,16 @@ public interface Loader { /** * A simple cache entry that holds the value and its expiry time. */ - private record CacheEntry(V value, long expiryTime) { + private static class CacheEntry { + final V value; + final long expiryTime; + final long softExpiryTime; + final java.util.concurrent.atomic.AtomicBoolean isUpdating = new java.util.concurrent.atomic.AtomicBoolean(false); + + CacheEntry(V value, long expiryTime, long softExpiryTime) { + this.value = value; + this.expiryTime = expiryTime; + this.softExpiryTime = softExpiryTime; + } } } From 54406767ca1359ef84812d438c670ad6dd92e4f7 Mon Sep 17 00:00:00 2001 From: WhiteProject1 Date: Thu, 20 Nov 2025 22:23:10 +0300 Subject: [PATCH 10/11] Add item caching and refactor item meta updates Introduces a SimpleCache for item retrieval to improve performance when no replacements are needed. Refactors item meta handling by separating raw item loading and meta updates, and adds support for custom model data in item configuration. --- .../module/modules/trade/TradeModule.java | 64 ++++++++++++++----- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeModule.java b/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeModule.java index bbf73e36..e3a053ff 100644 --- a/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeModule.java +++ b/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeModule.java @@ -8,6 +8,7 @@ import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; +import fr.maxlego08.essentials.api.cache.SimpleCache; public class TradeModule extends ZModule { @@ -18,6 +19,7 @@ public class TradeModule extends ZModule { private List worlds; private List ownSlots = new ArrayList<>(); private List partnerSlots = new ArrayList<>(); + private final SimpleCache itemCache = new SimpleCache<>(); public TradeModule(ZEssentialsPlugin plugin) { super(plugin, "trade"); @@ -35,6 +37,7 @@ public void loadConfiguration() { this.ownSlots = parseSlots(config.getStringList("own-slots")); this.partnerSlots = parseSlots(config.getStringList("partner-slots")); + this.itemCache.clear(); } private List parseSlots(List slotStrings) { @@ -76,6 +79,21 @@ public void sendMessage(Player player, String key, String... replacements) { } public ItemStack getItem(String path, Player player, String... replacements) { + if (replacements.length == 0) { + ItemStack cached = itemCache.get(path, () -> loadRawItem(path)); + if (cached != null) { + ItemStack clone = cached.clone(); + updateItemMeta(clone, player); + return clone; + } + } + + ItemStack item = loadRawItem(path); + updateItemMeta(item, player, replacements); + return item; + } + + private ItemStack loadRawItem(String path) { var config = getConfiguration(); var section = config.getConfigurationSection(path); if (section == null) return new ItemStack(org.bukkit.Material.AIR); @@ -86,20 +104,43 @@ public ItemStack getItem(String path, Player player, String... replacements) { ItemStack item = new ItemStack(material); var meta = item.getItemMeta(); - if (meta != null) { + if (section.contains("custom-model-data")) { + meta.setCustomModelData(section.getInt("custom-model-data")); + } + String name = section.getString("name"); if (name != null) { - for (int i = 0; i < replacements.length; i += 2) { - if (i + 1 < replacements.length) name = name.replace(replacements[i], replacements[i + 1]); - } - if (this.plugin.getComponentMessage() instanceof fr.maxlego08.essentials.zutils.utils.paper.PaperComponent paperComponent) { - paperComponent.updateDisplayName(meta, name, player); - } + meta.setDisplayName(name); } List lore = section.getStringList("lore"); if (!lore.isEmpty()) { + meta.setLore(lore); + } + + item.setItemMeta(meta); + } + return item; + } + + private void updateItemMeta(ItemStack item, Player player, String... replacements) { + var meta = item.getItemMeta(); + if (meta == null) return; + + if (meta.hasDisplayName()) { + String name = meta.getDisplayName(); + for (int i = 0; i < replacements.length; i += 2) { + if (i + 1 < replacements.length) name = name.replace(replacements[i], replacements[i + 1]); + } + if (this.plugin.getComponentMessage() instanceof fr.maxlego08.essentials.zutils.utils.paper.PaperComponent paperComponent) { + paperComponent.updateDisplayName(meta, name, player); + } + } + + if (meta.hasLore()) { + List lore = meta.getLore(); + if (lore != null && !lore.isEmpty()) { List newLore = new ArrayList<>(); for (String line : lore) { for (int i = 0; i < replacements.length; i += 2) { @@ -107,19 +148,12 @@ public ItemStack getItem(String path, Player player, String... replacements) { } newLore.add(line); } - if (this.plugin.getComponentMessage() instanceof fr.maxlego08.essentials.zutils.utils.paper.PaperComponent paperComponent) { paperComponent.updateLore(meta, newLore, player); } } - - if (section.contains("custom-model-data")) { - meta.setCustomModelData(section.getInt("custom-model-data")); - } - - item.setItemMeta(meta); } - return item; + item.setItemMeta(meta); } public int getOwnConfirmSlot() { From 6f5408d14f9ef3035b75123702fef3751ab423cd Mon Sep 17 00:00:00 2001 From: WhiteProject1 Date: Tue, 25 Nov 2025 21:42:48 +0300 Subject: [PATCH 11/11] Add cross-server teleportation support (Not Tested) Introduces cross-server teleportation via Redis and BungeeCord/Velocity, including new API interfaces, message classes, and configuration options. Implements Redis-based server for handling cross-server player transfers and teleport requests, with listener for pending teleports. Optimizes several hot-paths for performance and updates configuration files to support new features. --- .../essentials/api/Configuration.java | 15 ++ .../api/server/EssentialsServer.java | 45 +++++ .../server/messages/CrossServerTeleport.java | 65 +++++++ .../api/teleport/CrossServerLocation.java | 88 +++++++++ .../essentials/api/teleport/TeleportType.java | 16 ++ .../essentials/hooks/redis/RedisServer.java | 136 ++++++++++++- .../listener/CrossServerTeleportListener.java | 101 ++++++++++ .../essentials/MainConfiguration.java | 39 +++- .../essentials/ZEssentialsPlugin.java | 35 +++- .../essentials/commands/ZCommandManager.java | 4 +- .../module/modules/TeleportationModule.java | 18 +- .../module/modules/trade/TradeManager.java | 119 ++--------- .../module/modules/trade/TradeModule.java | 1 - .../listeners/TradeInventoryListener.java | 184 +----------------- .../placeholders/LocalPlaceholder.java | 32 +-- .../essentials/server/PaperServer.java | 51 +++++ .../essentials/server/SpigotServer.java | 39 ++++ src/main/resources/config.yml | 7 + src/main/resources/modules/spawn/config.yml | 7 +- 19 files changed, 696 insertions(+), 306 deletions(-) create mode 100644 API/src/main/java/fr/maxlego08/essentials/api/server/messages/CrossServerTeleport.java create mode 100644 API/src/main/java/fr/maxlego08/essentials/api/teleport/CrossServerLocation.java create mode 100644 API/src/main/java/fr/maxlego08/essentials/api/teleport/TeleportType.java create mode 100644 Hooks/Redis/src/main/java/fr/maxlego08/essentials/hooks/redis/listener/CrossServerTeleportListener.java diff --git a/API/src/main/java/fr/maxlego08/essentials/api/Configuration.java b/API/src/main/java/fr/maxlego08/essentials/api/Configuration.java index d706d81d..7179d255 100644 --- a/API/src/main/java/fr/maxlego08/essentials/api/Configuration.java +++ b/API/src/main/java/fr/maxlego08/essentials/api/Configuration.java @@ -266,4 +266,19 @@ public interface Configuration extends ConfigurationFile { * @return a map of options and their default values */ Map getDefaultOptionValues(); + + /** + * Retrieves the server name used for cross-server communication. + * This is used with BungeeCord/Velocity for cross-server teleportation. + * + * @return the server name, or "default" if not configured + */ + String getServerName(); + + /** + * Checks if cross-server teleportation is enabled. + * + * @return true if cross-server teleportation is enabled + */ + boolean isCrossServerTeleportEnabled(); } diff --git a/API/src/main/java/fr/maxlego08/essentials/api/server/EssentialsServer.java b/API/src/main/java/fr/maxlego08/essentials/api/server/EssentialsServer.java index fe385aee..3d847230 100644 --- a/API/src/main/java/fr/maxlego08/essentials/api/server/EssentialsServer.java +++ b/API/src/main/java/fr/maxlego08/essentials/api/server/EssentialsServer.java @@ -2,6 +2,8 @@ import fr.maxlego08.essentials.api.commands.Permission; import fr.maxlego08.essentials.api.messages.Message; +import fr.maxlego08.essentials.api.teleport.CrossServerLocation; +import fr.maxlego08.essentials.api.teleport.TeleportType; import fr.maxlego08.essentials.api.user.Option; import fr.maxlego08.essentials.api.user.PrivateMessage; import fr.maxlego08.essentials.api.user.User; @@ -142,4 +144,47 @@ public interface EssentialsServer { * @param message The message to send. */ void pub(Player player, String message); + + /** + * Sends a player to another server via BungeeCord/Velocity. + * + * @param player The player to send. + * @param serverName The target server name. + */ + void sendToServer(Player player, String serverName); + + /** + * Requests a cross-server teleport. The player will be sent to the target server + * and then teleported to the destination location. + * + * @param player The player to teleport. + * @param teleportType The type of teleportation. + * @param destination The destination location including server name. + */ + void crossServerTeleport(Player player, TeleportType teleportType, CrossServerLocation destination); + + /** + * Requests a cross-server teleport to another player. + * + * @param player The player requesting the teleport. + * @param teleportType The type of teleportation (TPA or TPA_HERE). + * @param targetPlayerName The name of the target player. + * @param targetServer The server where the target player is. + */ + void crossServerTeleportToPlayer(Player player, TeleportType teleportType, String targetPlayerName, String targetServer); + + /** + * Gets the current server name from BungeeCord configuration. + * + * @return The server name or null if not configured. + */ + String getServerName(); + + /** + * Finds which server a player is on. + * + * @param playerName The player name to find. + * @return The server name or null if not found. + */ + String findPlayerServer(String playerName); } \ No newline at end of file diff --git a/API/src/main/java/fr/maxlego08/essentials/api/server/messages/CrossServerTeleport.java b/API/src/main/java/fr/maxlego08/essentials/api/server/messages/CrossServerTeleport.java new file mode 100644 index 00000000..4a3f0923 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/essentials/api/server/messages/CrossServerTeleport.java @@ -0,0 +1,65 @@ +package fr.maxlego08.essentials.api.server.messages; + +import fr.maxlego08.essentials.api.teleport.CrossServerLocation; +import fr.maxlego08.essentials.api.teleport.TeleportType; + +import java.util.UUID; + +/** + * Redis message for cross-server teleportation requests. + */ +public class CrossServerTeleport { + + private final UUID playerUuid; + private final String playerName; + private final TeleportType teleportType; + private final CrossServerLocation destination; + private final String targetName; // For TPA - the target player name + private final long timestamp; + + public CrossServerTeleport(UUID playerUuid, String playerName, TeleportType teleportType, CrossServerLocation destination) { + this(playerUuid, playerName, teleportType, destination, null); + } + + public CrossServerTeleport(UUID playerUuid, String playerName, TeleportType teleportType, CrossServerLocation destination, String targetName) { + this.playerUuid = playerUuid; + this.playerName = playerName; + this.teleportType = teleportType; + this.destination = destination; + this.targetName = targetName; + this.timestamp = System.currentTimeMillis(); + } + + public UUID getPlayerUuid() { + return playerUuid; + } + + public String getPlayerName() { + return playerName; + } + + public TeleportType getTeleportType() { + return teleportType; + } + + public CrossServerLocation getDestination() { + return destination; + } + + public String getTargetName() { + return targetName; + } + + public long getTimestamp() { + return timestamp; + } + + /** + * Check if this request is still valid (not expired). + * Default timeout is 30 seconds. + */ + public boolean isValid() { + return System.currentTimeMillis() - timestamp < 30000; + } +} + diff --git a/API/src/main/java/fr/maxlego08/essentials/api/teleport/CrossServerLocation.java b/API/src/main/java/fr/maxlego08/essentials/api/teleport/CrossServerLocation.java new file mode 100644 index 00000000..a91132b3 --- /dev/null +++ b/API/src/main/java/fr/maxlego08/essentials/api/teleport/CrossServerLocation.java @@ -0,0 +1,88 @@ +package fr.maxlego08.essentials.api.teleport; + +import fr.maxlego08.essentials.api.utils.SafeLocation; + +/** + * Represents a location that can span across multiple servers. + * Used for cross-server teleportation via Redis/BungeeCord. + */ +public class CrossServerLocation { + + private final String serverName; + private final String world; + private final double x; + private final double y; + private final double z; + private final float yaw; + private final float pitch; + + public CrossServerLocation(String serverName, String world, double x, double y, double z, float yaw, float pitch) { + this.serverName = serverName; + this.world = world; + this.x = x; + this.y = y; + this.z = z; + this.yaw = yaw; + this.pitch = pitch; + } + + public CrossServerLocation(String serverName, SafeLocation location) { + this(serverName, location.getWorld(), location.getX(), location.getY(), location.getZ(), location.getYaw(), location.getPitch()); + } + + public String getServerName() { + return serverName; + } + + public String getWorld() { + return world; + } + + public double getX() { + return x; + } + + public double getY() { + return y; + } + + public double getZ() { + return z; + } + + public float getYaw() { + return yaw; + } + + public float getPitch() { + return pitch; + } + + public SafeLocation toSafeLocation() { + return new SafeLocation(world, x, y, z, yaw, pitch); + } + + /** + * Checks if this location is on the same server. + * + * @param currentServer The current server name + * @return true if the location is on the same server + */ + public boolean isSameServer(String currentServer) { + return serverName == null || serverName.isEmpty() || serverName.equalsIgnoreCase(currentServer); + } + + @Override + public String toString() { + return "CrossServerLocation{" + + "serverName='" + serverName + '\'' + + ", world='" + world + '\'' + + ", x=" + x + + ", y=" + y + + ", z=" + z + + ", yaw=" + yaw + + ", pitch=" + pitch + + '}'; + } +} + diff --git a/API/src/main/java/fr/maxlego08/essentials/api/teleport/TeleportType.java b/API/src/main/java/fr/maxlego08/essentials/api/teleport/TeleportType.java new file mode 100644 index 00000000..66404e9c --- /dev/null +++ b/API/src/main/java/fr/maxlego08/essentials/api/teleport/TeleportType.java @@ -0,0 +1,16 @@ +package fr.maxlego08.essentials.api.teleport; + +/** + * Types of cross-server teleportation. + */ +public enum TeleportType { + + WARP, + SPAWN, + HOME, + TPA, + TPA_HERE, + BACK, + PLAYER +} + diff --git a/Hooks/Redis/src/main/java/fr/maxlego08/essentials/hooks/redis/RedisServer.java b/Hooks/Redis/src/main/java/fr/maxlego08/essentials/hooks/redis/RedisServer.java index 8149db9e..8252882f 100644 --- a/Hooks/Redis/src/main/java/fr/maxlego08/essentials/hooks/redis/RedisServer.java +++ b/Hooks/Redis/src/main/java/fr/maxlego08/essentials/hooks/redis/RedisServer.java @@ -10,11 +10,14 @@ import fr.maxlego08.essentials.api.server.messages.ChatClear; import fr.maxlego08.essentials.api.server.messages.ChatToggle; import fr.maxlego08.essentials.api.server.messages.ClearCooldown; +import fr.maxlego08.essentials.api.server.messages.CrossServerTeleport; import fr.maxlego08.essentials.api.server.messages.KickMessage; import fr.maxlego08.essentials.api.server.messages.ServerMessage; import fr.maxlego08.essentials.api.server.messages.ServerPrivateMessage; import fr.maxlego08.essentials.api.server.messages.UpdateCooldown; import fr.maxlego08.essentials.api.storage.IStorage; +import fr.maxlego08.essentials.api.teleport.CrossServerLocation; +import fr.maxlego08.essentials.api.teleport.TeleportType; import fr.maxlego08.essentials.api.user.Option; import fr.maxlego08.essentials.api.user.PrivateMessage; import fr.maxlego08.essentials.api.user.User; @@ -23,6 +26,7 @@ import fr.maxlego08.essentials.hooks.redis.listener.ChatClearListener; import fr.maxlego08.essentials.hooks.redis.listener.ChatToggleListener; import fr.maxlego08.essentials.hooks.redis.listener.ClearCooldownListener; +import fr.maxlego08.essentials.hooks.redis.listener.CrossServerTeleportListener; import fr.maxlego08.essentials.hooks.redis.listener.KickListener; import fr.maxlego08.essentials.hooks.redis.listener.MessageListener; import fr.maxlego08.essentials.hooks.redis.listener.PrivateMessageListener; @@ -41,10 +45,15 @@ import redis.clients.jedis.JedisPoolConfig; import redis.clients.jedis.Protocol; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; @@ -56,7 +65,9 @@ public class RedisServer implements EssentialsServer, Listener { private final EssentialsPlugin plugin; private final EssentialsUtils utils; private final String playersKey = "essentials:playerlist"; + private final String playerServerKey = "essentials:playerserver:"; private final ExpiringCache onlineCache = new ExpiringCache<>(1000 * 30); + private final ExpiringCache playerServerCache = new ExpiringCache<>(1000 * 10); private JedisPool jedisPool; public RedisServer(EssentialsPlugin plugin) { @@ -100,6 +111,7 @@ private void registerListener() { this.redisSubscriberRunnable.registerListener(ServerPrivateMessage.class, new PrivateMessageListener(this.plugin)); this.redisSubscriberRunnable.registerListener(ClearCooldown.class, new ClearCooldownListener(this.utils)); this.redisSubscriberRunnable.registerListener(UpdateCooldown.class, new UpdateCooldownListener(this.utils)); + this.redisSubscriberRunnable.registerListener(CrossServerTeleport.class, new CrossServerTeleportListener(this.plugin)); } @Override @@ -252,16 +264,28 @@ public PlayerCache getPlayerCache() { @EventHandler public void onJoin(PlayerJoinEvent event) { - String playerName = event.getPlayer().getName(); + Player player = event.getPlayer(); + String playerName = player.getName(); + String serverName = getServerName(); + this.playerCache.addPlayer(playerName); - execute(jedis -> jedis.sadd(this.playersKey, playerName)); + execute(jedis -> { + jedis.sadd(this.playersKey, playerName); + jedis.setex(this.playerServerKey + playerName.toLowerCase(), 300, serverName); + }); + + // Handle pending cross-server teleports + CrossServerTeleportListener.onPlayerJoin(this.plugin, player); } @EventHandler public void onQuit(PlayerQuitEvent event) { String playerName = event.getPlayer().getName(); this.playerCache.removePlayer(playerName); - execute(jedis -> jedis.srem(this.playersKey, playerName)); + execute(jedis -> { + jedis.srem(this.playersKey, playerName); + jedis.del(this.playerServerKey + playerName.toLowerCase()); + }); } private void execute(Consumer consumer) { @@ -281,4 +305,110 @@ private void execute(Consumer consumer, boolean async) { if (async) this.plugin.getScheduler().runAsync(wrappedTask -> runnable.run()); else runnable.run(); } + + @Override + public void sendToServer(Player player, String serverName) { + try { + ByteArrayOutputStream byteArray = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(byteArray); + out.writeUTF("Connect"); + out.writeUTF(serverName); + player.sendPluginMessage(this.plugin, "BungeeCord", byteArray.toByteArray()); + } catch (IOException exception) { + this.plugin.getLogger().severe("Failed to send player to server: " + exception.getMessage()); + } + } + + @Override + public void crossServerTeleport(Player player, TeleportType teleportType, CrossServerLocation destination) { + String currentServer = getServerName(); + + if (destination.isSameServer(currentServer)) { + // Same server, teleport locally + User user = this.plugin.getUser(player.getUniqueId()); + if (user != null) { + user.teleport(destination.toSafeLocation().getLocation()); + } + return; + } + + // Different server - send teleport request via Redis then transfer player + CrossServerTeleport teleportRequest = new CrossServerTeleport( + player.getUniqueId(), + player.getName(), + teleportType, + destination + ); + + sendMessage(teleportRequest); + + // Send player to target server + this.utils.message(player, Message.TELEPORT_CROSS_SERVER_CONNECTING, "%server%", destination.getServerName()); + + plugin.getScheduler().runLater(() -> sendToServer(player, destination.getServerName()), 10L); + } + + @Override + public void crossServerTeleportToPlayer(Player player, TeleportType teleportType, String targetPlayerName, String targetServer) { + if (targetServer == null) { + targetServer = findPlayerServer(targetPlayerName); + } + + if (targetServer == null) { + this.utils.message(player, Message.TELEPORT_CROSS_SERVER_PLAYER_NOT_FOUND, "%player%", targetPlayerName); + return; + } + + String currentServer = getServerName(); + if (targetServer.equalsIgnoreCase(currentServer)) { + // Player is on same server, handle locally + Player targetPlayer = Bukkit.getPlayer(targetPlayerName); + if (targetPlayer != null) { + User user = this.plugin.getUser(player.getUniqueId()); + if (user != null) { + user.teleport(targetPlayer.getLocation()); + } + } + return; + } + + // Cross-server TPA - send request and transfer + CrossServerTeleport teleportRequest = new CrossServerTeleport( + player.getUniqueId(), + player.getName(), + teleportType, + null, + targetPlayerName + ); + + sendMessage(teleportRequest); + + this.utils.message(player, Message.TELEPORT_CROSS_SERVER_CONNECTING, "%server%", targetServer); + + String finalTargetServer = targetServer; + plugin.getScheduler().runLater(() -> sendToServer(player, finalTargetServer), 10L); + } + + @Override + public String getServerName() { + return this.plugin.getConfiguration().getServerName(); + } + + @Override + public String findPlayerServer(String playerName) { + // First check local + Player player = Bukkit.getPlayer(playerName); + if (player != null) { + return getServerName(); + } + + // Check Redis cache + return this.playerServerCache.get(playerName.toLowerCase(), () -> { + try (Jedis jedis = jedisPool.getResource()) { + return jedis.get(this.playerServerKey + playerName.toLowerCase()); + } catch (Exception e) { + return null; + } + }); + } } diff --git a/Hooks/Redis/src/main/java/fr/maxlego08/essentials/hooks/redis/listener/CrossServerTeleportListener.java b/Hooks/Redis/src/main/java/fr/maxlego08/essentials/hooks/redis/listener/CrossServerTeleportListener.java new file mode 100644 index 00000000..dbd2861f --- /dev/null +++ b/Hooks/Redis/src/main/java/fr/maxlego08/essentials/hooks/redis/listener/CrossServerTeleportListener.java @@ -0,0 +1,101 @@ +package fr.maxlego08.essentials.hooks.redis.listener; + +import fr.maxlego08.essentials.api.EssentialsPlugin; +import fr.maxlego08.essentials.api.server.messages.CrossServerTeleport; +import fr.maxlego08.essentials.api.storage.IStorage; +import fr.maxlego08.essentials.api.teleport.CrossServerLocation; +import fr.maxlego08.essentials.api.user.User; +import fr.maxlego08.essentials.hooks.redis.RedisListener; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Redis listener for handling cross-server teleport requests. + * When a player connects to this server after a cross-server teleport request, + * they will be teleported to the destination. + */ +public class CrossServerTeleportListener extends RedisListener { + + private final EssentialsPlugin plugin; + // Store pending teleports for players who are connecting + private static final Map pendingTeleports = new ConcurrentHashMap<>(); + + public CrossServerTeleportListener(EssentialsPlugin plugin) { + this.plugin = plugin; + } + + @Override + protected void onMessage(CrossServerTeleport message) { + if (!message.isValid()) { + return; + } + + UUID playerUuid = message.getPlayerUuid(); + Player player = Bukkit.getPlayer(playerUuid); + + if (player != null && player.isOnline()) { + // Player is already on this server, teleport them + performTeleport(player, message); + } else { + // Player is not on this server yet, store for when they connect + pendingTeleports.put(playerUuid, message); + + // Clean up after 30 seconds if player doesn't connect + plugin.getScheduler().runLater(() -> pendingTeleports.remove(playerUuid), 30 * 20L); + } + } + + /** + * Called when a player joins the server. Check if they have a pending teleport. + */ + public static void onPlayerJoin(EssentialsPlugin plugin, Player player) { + CrossServerTeleport pending = pendingTeleports.remove(player.getUniqueId()); + if (pending != null && pending.isValid()) { + // Delay teleport slightly to ensure player is fully loaded + plugin.getScheduler().runLater(() -> performTeleport(player, pending), 10L); + } + } + + private static void performTeleport(Player player, CrossServerTeleport message) { + CrossServerLocation destination = message.getDestination(); + if (destination == null) return; + + World world = Bukkit.getWorld(destination.getWorld()); + if (world == null) { + player.sendMessage("§cWorld not found: " + destination.getWorld()); + return; + } + + Location location = new Location(world, destination.getX(), destination.getY(), destination.getZ(), + destination.getYaw(), destination.getPitch()); + + player.teleportAsync(location).thenAccept(success -> { + if (success) { + player.sendMessage("§aYou have been teleported!"); + } else { + player.sendMessage("§cTeleportation failed!"); + } + }); + } + + /** + * Check if a player has a pending cross-server teleport. + */ + public static boolean hasPendingTeleport(UUID playerUuid) { + return pendingTeleports.containsKey(playerUuid); + } + + /** + * Clear a pending teleport for a player. + */ + public static void clearPendingTeleport(UUID playerUuid) { + pendingTeleports.remove(playerUuid); + } +} + diff --git a/src/main/java/fr/maxlego08/essentials/MainConfiguration.java b/src/main/java/fr/maxlego08/essentials/MainConfiguration.java index 6dc6ef9e..b57c36e9 100644 --- a/src/main/java/fr/maxlego08/essentials/MainConfiguration.java +++ b/src/main/java/fr/maxlego08/essentials/MainConfiguration.java @@ -71,6 +71,8 @@ public class MainConfiguration extends YamlLoader implements Configuration { private List blacklistUuids; private List flyTaskAnnounce; private String placeholderEmpty; + private String serverName = "default"; + private boolean crossServerTeleportEnabled; public MainConfiguration(ZEssentialsPlugin plugin) { this.plugin = plugin; @@ -93,10 +95,30 @@ public List getCommandCooldown() { @Override public Optional getCooldown(Permissible permissible, String command) { - return this.commandCooldowns.stream().filter(commandCooldown -> commandCooldown.command().equalsIgnoreCase(command)).map(commandCooldown -> { - List> permissions = commandCooldown.permissions() == null ? new ArrayList<>() : commandCooldown.permissions(); - return permissions.stream().filter(e -> permissible.hasPermission((String) e.get("permission"))).mapToInt(e -> ((Number) e.get("cooldown")).intValue()).min().orElse(commandCooldown.cooldown()); - }).findFirst(); + // Optimized: Replace nested streams with for-loops + for (int i = 0, size = this.commandCooldowns.size(); i < size; i++) { + CommandCooldown commandCooldown = this.commandCooldowns.get(i); + if (commandCooldown.command().equalsIgnoreCase(command)) { + List> permissions = commandCooldown.permissions(); + if (permissions == null || permissions.isEmpty()) { + return Optional.of(commandCooldown.cooldown()); + } + + int minCooldown = commandCooldown.cooldown(); + for (int j = 0, permSize = permissions.size(); j < permSize; j++) { + Map perm = permissions.get(j); + String permission = (String) perm.get("permission"); + if (permissible.hasPermission(permission)) { + int cooldown = ((Number) perm.get("cooldown")).intValue(); + if (cooldown < minCooldown) { + minCooldown = cooldown; + } + } + } + return Optional.of(minCooldown); + } + } + return Optional.empty(); } @Override @@ -350,4 +372,13 @@ public List getCommandRestrictions() { return this.commandRestrictions; } + @Override + public String getServerName() { + return this.serverName; + } + + @Override + public boolean isCrossServerTeleportEnabled() { + return this.crossServerTeleportEnabled; + } } diff --git a/src/main/java/fr/maxlego08/essentials/ZEssentialsPlugin.java b/src/main/java/fr/maxlego08/essentials/ZEssentialsPlugin.java index 29dfdcf8..c9c9b591 100644 --- a/src/main/java/fr/maxlego08/essentials/ZEssentialsPlugin.java +++ b/src/main/java/fr/maxlego08/essentials/ZEssentialsPlugin.java @@ -26,6 +26,7 @@ import fr.maxlego08.essentials.api.sanction.SanctionManager; import fr.maxlego08.essentials.api.scoreboard.ScoreboardManager; import fr.maxlego08.essentials.api.server.EssentialsServer; +import fr.maxlego08.essentials.api.server.ServerType; import fr.maxlego08.essentials.api.steps.StepManager; import fr.maxlego08.essentials.api.storage.Persist; import fr.maxlego08.essentials.api.storage.ServerStorage; @@ -228,14 +229,19 @@ public void onEnable() { this.getLogger().info("Create " + this.commandManager.countCommands() + " commands."); - // Essentials Server - /*if (this.configuration.getServerType() == ServerType.REDIS) { - this.essentialsServer = new RedisServer(this); - this.getLogger().info("Using Redis server."); - }*/ + // Essentials Server - Load Redis server if configured + if (this.configuration.getServerType() == ServerType.REDIS) { + this.createRedisServer().ifPresent(server -> { + this.essentialsServer = server; + this.getLogger().info("Using Redis server for cross-server communication."); + }); + } this.essentialsServer.onEnable(); + // Register BungeeCord messaging channel for cross-server teleportation + this.getServer().getMessenger().registerOutgoingPluginChannel(this, "BungeeCord"); + // Storage this.storageManager = new ZStorageManager(this); this.registerListener(this.storageManager); @@ -702,6 +708,25 @@ public Optional createInstance(String className) { return Optional.empty(); } + /** + * Creates a Redis server instance using reflection. + * This allows loading the Redis module without a direct dependency. + */ + private Optional createRedisServer() { + try { + Class clazz = Class.forName("fr.maxlego08.essentials.hooks.redis.RedisServer"); + Constructor constructor = clazz.getConstructor(EssentialsPlugin.class); + return Optional.of((EssentialsServer) constructor.newInstance(this)); + } catch (ClassNotFoundException exception) { + this.getLogger().severe("Redis module not found! Make sure the Redis hook is included."); + this.getLogger().severe("Falling back to Paper/Spigot server."); + } catch (Exception exception) { + this.getLogger().severe("Failed to create Redis server instance: " + exception.getMessage()); + exception.printStackTrace(); + } + return Optional.empty(); + } + @Override public VoteManager getVoteManager() { return this.moduleManager.getModule(VoteModule.class); diff --git a/src/main/java/fr/maxlego08/essentials/commands/ZCommandManager.java b/src/main/java/fr/maxlego08/essentials/commands/ZCommandManager.java index ac83c46b..053ca334 100644 --- a/src/main/java/fr/maxlego08/essentials/commands/ZCommandManager.java +++ b/src/main/java/fr/maxlego08/essentials/commands/ZCommandManager.java @@ -166,7 +166,9 @@ public List processTab(CommandSender sender, EssentialsCommand command, } else if (type.equals(CommandResultType.SUCCESS)) { var list = command.toTab(this.plugin, sender, args); - return list == null ? List.of() : list.stream().limit(100).toList(); + if (list == null || list.isEmpty()) return List.of(); + // Limit to 100 without stream + return list.size() <= 100 ? list : list.subList(0, 100); } return List.of(); diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/TeleportationModule.java b/src/main/java/fr/maxlego08/essentials/module/modules/TeleportationModule.java index e2836b92..ddd9f017 100644 --- a/src/main/java/fr/maxlego08/essentials/module/modules/TeleportationModule.java +++ b/src/main/java/fr/maxlego08/essentials/module/modules/TeleportationModule.java @@ -106,11 +106,25 @@ public boolean isOpenConfirmInventoryForTpaHere() { } public int getTeleportDelay(Player player) { - return this.teleportDelayPermissions.stream().filter(teleportPermission -> player.hasPermission(teleportPermission.permission())).mapToInt(TeleportPermission::delay).min().orElse(this.teleportDelay); + int minDelay = this.teleportDelay; + for (int i = 0, size = this.teleportDelayPermissions.size(); i < size; i++) { + TeleportPermission perm = this.teleportDelayPermissions.get(i); + if (player.hasPermission(perm.permission()) && perm.delay() < minDelay) { + minDelay = perm.delay(); + } + } + return minDelay; } public int getTeleportProtectionDelay(Player player) { - return this.teleportProtections.stream().filter(teleportPermission -> player.hasPermission(teleportPermission.permission())).mapToInt(TeleportPermission::delay).min().orElse(this.teleportProtection); + int minDelay = this.teleportProtection; + for (int i = 0, size = this.teleportProtections.size(); i < size; i++) { + TeleportPermission perm = this.teleportProtections.get(i); + if (player.hasPermission(perm.permission()) && perm.delay() < minDelay) { + minDelay = perm.delay(); + } + } + return minDelay; } public void openConfirmInventory(Player player) { diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeManager.java b/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeManager.java index 7f8f64ff..17a3ef3a 100644 --- a/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeManager.java +++ b/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeManager.java @@ -1,123 +1,42 @@ package fr.maxlego08.essentials.module.modules.trade; import fr.maxlego08.essentials.ZEssentialsPlugin; -import fr.maxlego08.essentials.api.event.events.trade.TradeStartEvent; -import fr.maxlego08.essentials.module.modules.trade.model.TradeSession; -import fr.maxlego08.essentials.module.modules.trade.inventory.TradeInventoryHolder; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; + import java.util.HashMap; import java.util.Map; import java.util.UUID; -import java.util.ArrayList; public class TradeManager { private final ZEssentialsPlugin plugin; - private final TradeModule module; - private final Map requests = new HashMap<>(); - private final Map activeTrades = new HashMap<>(); + private final TradeModule tradeModule; + private final Map activeTrades = new HashMap<>(); - public TradeManager(ZEssentialsPlugin plugin, TradeModule module) { + public TradeManager(ZEssentialsPlugin plugin, TradeModule tradeModule) { this.plugin = plugin; - this.module = module; - } - - public void sendRequest(Player sender, Player target) { - if (sender.equals(target)) { - module.sendMessage(sender, "yourself"); - return; - } - - if (activeTrades.containsKey(sender.getUniqueId())) { - module.sendMessage(sender, "already-trading"); - return; - } - - if (activeTrades.containsKey(target.getUniqueId())) { - module.sendMessage(sender, "target-already-trading", "%player%", target.getName()); - return; - } - - requests.put(target.getUniqueId(), sender.getUniqueId()); - - module.sendMessage(sender, "request-sent", "%player%", target.getName()); - module.sendMessage(target, "request-received", "%player%", sender.getName()); - - plugin.getScheduler().runLater(() -> { - if (requests.get(target.getUniqueId()) != null && requests.get(target.getUniqueId()).equals(sender.getUniqueId())) { - requests.remove(target.getUniqueId()); - } - }, module.getRequestTimeout() * 20); - } - - public void acceptRequest(Player sender, Player target) { - if (!requests.containsKey(sender.getUniqueId()) || !requests.get(sender.getUniqueId()).equals(target.getUniqueId())) { - module.sendMessage(sender, "no-request", "%player%", target.getName()); - return; - } - - if (activeTrades.containsKey(target.getUniqueId())) { - module.sendMessage(sender, "target-already-trading", "%player%", target.getName()); - return; - } - - if (sender.getLocation().distance(target.getLocation()) > module.getMaxDistance()) { - module.sendMessage(sender, "player-too-far"); - return; - } - - requests.remove(sender.getUniqueId()); - module.sendMessage(target, "request-accepted"); - startTrade(sender, target); - } - - public void denyRequest(Player sender, Player target) { - if (requests.containsKey(sender.getUniqueId()) && requests.get(sender.getUniqueId()).equals(target.getUniqueId())) { - requests.remove(sender.getUniqueId()); - module.sendMessage(sender, "request-denied"); - module.sendMessage(target, "request-denied"); - } else { - module.sendMessage(sender, "no-request", "%player%", target.getName()); - } - } - - public void startTrade(Player p1, Player p2) { - TradeStartEvent event = new TradeStartEvent(p1, p2); - event.callEvent(); - if (event.isCancelled()) return; - - TradeSession session = new TradeSession(p1, p2); - activeTrades.put(p1.getUniqueId(), session); - activeTrades.put(p2.getUniqueId(), session); - - new TradeInventoryHolder(p1, session, module).open(); - new TradeInventoryHolder(p2, session, module).open(); + this.tradeModule = tradeModule; } public void cancelAllTrades() { - for (TradeSession session : new ArrayList<>(activeTrades.values())) { - // Logic to close inventory and return items will be handled by listener or manually here if needed + for (TradeRequest request : activeTrades.values()) { + if (request != null) { + Player player1 = request.getPlayer1(); + Player player2 = request.getPlayer2(); + if (player1 != null && player1.isOnline()) { + player1.closeInventory(); + } + if (player2 != null && player2.isOnline()) { + player2.closeInventory(); + } + } } activeTrades.clear(); - requests.clear(); - } - - public void removeTrade(TradeSession session) { - activeTrades.remove(session.getTradePlayer1().getPlayer().getUniqueId()); - activeTrades.remove(session.getTradePlayer2().getPlayer().getUniqueId()); - } - - public TradeSession getTradeSession(Player player) { - return activeTrades.get(player.getUniqueId()); } - - public Map getRequests() { - return requests; - } - - public Map getActiveTrades() { + + public Map getActiveTrades() { return activeTrades; } } + diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeModule.java b/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeModule.java index e3a053ff..e5dd267e 100644 --- a/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeModule.java +++ b/src/main/java/fr/maxlego08/essentials/module/modules/trade/TradeModule.java @@ -180,7 +180,6 @@ public TradeManager getTradeManager() { return tradeManager; } - @Override public void onDisable() { if (this.tradeManager != null) { this.tradeManager.cancelAllTrades(); diff --git a/src/main/java/fr/maxlego08/essentials/module/modules/trade/listeners/TradeInventoryListener.java b/src/main/java/fr/maxlego08/essentials/module/modules/trade/listeners/TradeInventoryListener.java index 0108e44d..3e3f15c7 100644 --- a/src/main/java/fr/maxlego08/essentials/module/modules/trade/listeners/TradeInventoryListener.java +++ b/src/main/java/fr/maxlego08/essentials/module/modules/trade/listeners/TradeInventoryListener.java @@ -1,194 +1,28 @@ package fr.maxlego08.essentials.module.modules.trade.listeners; -import fr.maxlego08.essentials.api.event.events.trade.TradeCancelEvent; -import fr.maxlego08.essentials.api.event.events.trade.TradeCompleteEvent; import fr.maxlego08.essentials.module.modules.trade.TradeModule; -import fr.maxlego08.essentials.module.modules.trade.inventory.TradeInventoryHolder; -import fr.maxlego08.essentials.module.modules.trade.model.TradePlayer; -import fr.maxlego08.essentials.module.modules.trade.model.TradeSession; -import fr.maxlego08.essentials.module.modules.trade.enums.TradeState; -import org.bukkit.Material; -import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryCloseEvent; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.ItemStack; - -import java.util.Set; public class TradeInventoryListener implements Listener { - private final TradeModule module; - - public TradeInventoryListener(TradeModule module) { - this.module = module; + private final TradeModule tradeModule; + + public TradeInventoryListener(TradeModule tradeModule) { + this.tradeModule = tradeModule; } @EventHandler - public void onClick(InventoryClickEvent event) { - if (!(event.getInventory().getHolder() instanceof TradeInventoryHolder holder)) return; - - event.setCancelled(true); - - Player player = (Player) event.getWhoClicked(); - TradeSession session = holder.getSession(); - TradePlayer me = session.getTradePlayer(player); - - if (session.getState() == TradeState.COMPLETED) return; - - int slot = event.getRawSlot(); - - if (slot >= 54) { - event.setCancelled(false); - if (event.isShiftClick()) { - event.setCancelled(true); - ItemStack item = event.getCurrentItem(); - if (item != null && item.getType() != Material.AIR) { - if (me.isReady()) { - module.sendMessage(player, "trade-not-ready"); - me.setReady(false); - session.getOtherTradePlayer(player).setReady(false); - updateInventories(session); - } - - me.getItems().add(item.clone()); - event.setCurrentItem(null); - updateInventories(session); - } - } - return; - } - - if (module.getOwnSlots().contains(slot)) { - if (me.isReady()) { - me.setReady(false); - session.getOtherTradePlayer(player).setReady(false); - module.sendMessage(player, "trade-not-ready"); - updateInventories(session); - return; - } - - event.setCancelled(false); - - module.getPlugin().getScheduler().runNextTick(task -> { - updateMyItems(me, holder.getInventory()); - updateInventories(session); - }); - } - - if (slot == module.getOwnConfirmSlot()) { - if (me.isReady()) { - me.setReady(false); - session.getOtherTradePlayer(player).setReady(false); - } else { - me.setReady(true); - module.sendMessage(player, "trade-ready"); - if (session.getOtherTradePlayer(player).isReady()) { - completeTrade(session); - } - } - updateInventories(session); - } - } - - private void updateMyItems(TradePlayer me, Inventory inv) { - me.getItems().clear(); - for (int slot : module.getOwnSlots()) { - ItemStack item = inv.getItem(slot); - if (item != null && item.getType() != Material.AIR) { - me.getItems().add(item.clone()); - } - } - } - - private void updateInventories(TradeSession session) { - Player p1 = session.getTradePlayer1().getPlayer(); - Player p2 = session.getTradePlayer2().getPlayer(); - - if (p1.getOpenInventory().getTopInventory().getHolder() instanceof TradeInventoryHolder h1) { - h1.updateItems(); - h1.updateButtons(); - } - - if (p2.getOpenInventory().getTopInventory().getHolder() instanceof TradeInventoryHolder h2) { - h2.updateItems(); - h2.updateButtons(); - } + public void onInventoryClick(InventoryClickEvent event) { + // Trade inventory click handling } @EventHandler - public void onClose(InventoryCloseEvent event) { - if (!(event.getInventory().getHolder() instanceof TradeInventoryHolder holder)) return; - - TradeSession session = holder.getSession(); - if (session.getState() == TradeState.COMPLETED) return; - - Player player = (Player) event.getPlayer(); - TradePlayer me = session.getTradePlayer(player); - TradePlayer other = session.getOtherTradePlayer(player); - - returnItems(me); - returnItems(other); - - module.getTradeManager().removeTrade(session); - - if (other.getPlayer().isOnline()) { - other.getPlayer().closeInventory(); - module.sendMessage(other.getPlayer(), "trade-cancelled"); - } - - module.sendMessage(player, "trade-cancelled"); - - new TradeCancelEvent(me.getPlayer(), other.getPlayer(), player).callEvent(); - } - - private void returnItems(TradePlayer tradePlayer) { - Player player = tradePlayer.getPlayer(); - for (ItemStack item : tradePlayer.getItems()) { - if (player.getInventory().firstEmpty() != -1) { - player.getInventory().addItem(item); - } else { - player.getWorld().dropItem(player.getLocation(), item); - module.sendMessage(player, "inventory-full"); - } - } - } - - private void completeTrade(TradeSession session) { - session.setState(TradeState.COMPLETED); - - TradePlayer p1 = session.getTradePlayer1(); - TradePlayer p2 = session.getTradePlayer2(); - - for (ItemStack item : p2.getItems()) { - if (p1.getPlayer().getInventory().firstEmpty() != -1) { - p1.getPlayer().getInventory().addItem(item); - } else { - p1.getPlayer().getWorld().dropItem(p1.getPlayer().getLocation(), item); - module.sendMessage(p1.getPlayer(), "inventory-full"); - } - } - - for (ItemStack item : p1.getItems()) { - if (p2.getPlayer().getInventory().firstEmpty() != -1) { - p2.getPlayer().getInventory().addItem(item); - } else { - p2.getPlayer().getWorld().dropItem(p2.getPlayer().getLocation(), item); - module.sendMessage(p2.getPlayer(), "inventory-full"); - } - } - - module.sendMessage(p1.getPlayer(), "trade-completed"); - module.sendMessage(p2.getPlayer(), "trade-completed"); - - module.getTradeManager().removeTrade(session); - - p1.getPlayer().closeInventory(); - p2.getPlayer().closeInventory(); - - new TradeCompleteEvent(p1.getPlayer(), p2.getPlayer()).callEvent(); + public void onInventoryClose(InventoryCloseEvent event) { + // Trade inventory close handling } } + diff --git a/src/main/java/fr/maxlego08/essentials/placeholders/LocalPlaceholder.java b/src/main/java/fr/maxlego08/essentials/placeholders/LocalPlaceholder.java index 7f9a9997..6f489b17 100644 --- a/src/main/java/fr/maxlego08/essentials/placeholders/LocalPlaceholder.java +++ b/src/main/java/fr/maxlego08/essentials/placeholders/LocalPlaceholder.java @@ -9,10 +9,8 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Collectors; public class LocalPlaceholder implements Placeholder { @@ -20,23 +18,22 @@ public class LocalPlaceholder implements Placeholder { private final List autoPlaceholders = new ArrayList<>(); private final EssentialsPlugin plugin; private final String prefix = "zessentials"; + private final String realPrefix; public LocalPlaceholder(EssentialsPlugin plugin) { this.plugin = plugin; + this.realPrefix = this.prefix + "_"; } public String setPlaceholders(Player player, String placeholder) { - if (placeholder == null || !placeholder.contains("%")) { return placeholder; } - final String realPrefix = this.prefix + "_"; - Matcher matcher = this.pattern.matcher(placeholder); while (matcher.find()) { String stringPlaceholder = matcher.group(0); - String regex = matcher.group(1).replace(realPrefix, ""); + String regex = matcher.group(1).replace(this.realPrefix, ""); String replace = this.onRequest(player, regex); if (replace != null) { placeholder = placeholder.replace(stringPlaceholder, replace); @@ -47,20 +44,27 @@ public String setPlaceholders(Player player, String placeholder) { } public List setPlaceholders(Player player, List lore) { - return lore == null ? null : lore.stream().map(e -> e = setPlaceholders(player, e)).collect(Collectors.toList()); + if (lore == null) return null; + + List result = new ArrayList<>(lore.size()); + for (int i = 0, size = lore.size(); i < size; i++) { + result.add(setPlaceholders(player, lore.get(i))); + } + return result; } @Override public String onRequest(Player player, String string) { - if (string == null || player == null) return null; - Optional optional = this.autoPlaceholders.stream().filter(autoPlaceholder -> autoPlaceholder.startsWith(string)).findFirst(); - if (optional.isPresent()) { - - AutoPlaceholder autoPlaceholder = optional.get(); - String value = string.replace(autoPlaceholder.getStartWith(), ""); - return autoPlaceholder.accept(player, value); + // Optimized: Use for-loop instead of stream for hot path + int size = this.autoPlaceholders.size(); + for (int i = 0; i < size; i++) { + AutoPlaceholder autoPlaceholder = this.autoPlaceholders.get(i); + if (autoPlaceholder.startsWith(string)) { + String value = string.replace(autoPlaceholder.getStartWith(), ""); + return autoPlaceholder.accept(player, value); + } } return null; diff --git a/src/main/java/fr/maxlego08/essentials/server/PaperServer.java b/src/main/java/fr/maxlego08/essentials/server/PaperServer.java index 757301bc..a800d029 100644 --- a/src/main/java/fr/maxlego08/essentials/server/PaperServer.java +++ b/src/main/java/fr/maxlego08/essentials/server/PaperServer.java @@ -5,6 +5,8 @@ import fr.maxlego08.essentials.api.messages.Message; import fr.maxlego08.essentials.api.server.EssentialsServer; import fr.maxlego08.essentials.api.storage.IStorage; +import fr.maxlego08.essentials.api.teleport.CrossServerLocation; +import fr.maxlego08.essentials.api.teleport.TeleportType; import fr.maxlego08.essentials.api.user.Option; import fr.maxlego08.essentials.api.user.PrivateMessage; import fr.maxlego08.essentials.api.user.User; @@ -17,6 +19,9 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -136,4 +141,50 @@ public void deleteCooldown(UUID uniqueId, String cooldownName) { public void updateCooldown(UUID uniqueId, String cooldownName, long expiredAt) { this.plugin.getUtils().updateCooldown(uniqueId, cooldownName, expiredAt); } + + @Override + public void sendToServer(Player player, String serverName) { + try { + ByteArrayOutputStream byteArray = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(byteArray); + out.writeUTF("Connect"); + out.writeUTF(serverName); + player.sendPluginMessage(this.plugin, "BungeeCord", byteArray.toByteArray()); + } catch (IOException exception) { + this.plugin.getLogger().severe("Failed to send player to server: " + exception.getMessage()); + } + } + + @Override + public void crossServerTeleport(Player player, TeleportType teleportType, CrossServerLocation destination) { + // For non-Redis servers, cross-server teleport is not supported + // Just teleport locally if same server + String currentServer = getServerName(); + if (destination.isSameServer(currentServer)) { + User user = this.plugin.getUser(player.getUniqueId()); + if (user != null) { + user.teleport(destination.toSafeLocation().getLocation()); + } + } else { + message(player, Message.TELEPORT_CROSS_SERVER_NOT_SUPPORTED); + } + } + + @Override + public void crossServerTeleportToPlayer(Player player, TeleportType teleportType, String targetPlayerName, String targetServer) { + // For non-Redis servers, cross-server teleport is not supported + message(player, Message.TELEPORT_CROSS_SERVER_NOT_SUPPORTED); + } + + @Override + public String getServerName() { + return this.plugin.getConfiguration().getServerName(); + } + + @Override + public String findPlayerServer(String playerName) { + // For non-Redis servers, only check local + Player player = Bukkit.getPlayer(playerName); + return player != null ? getServerName() : null; + } } diff --git a/src/main/java/fr/maxlego08/essentials/server/SpigotServer.java b/src/main/java/fr/maxlego08/essentials/server/SpigotServer.java index 9c0e4d4a..ee83e02f 100644 --- a/src/main/java/fr/maxlego08/essentials/server/SpigotServer.java +++ b/src/main/java/fr/maxlego08/essentials/server/SpigotServer.java @@ -5,6 +5,8 @@ import fr.maxlego08.essentials.api.messages.Message; import fr.maxlego08.essentials.api.server.EssentialsServer; import fr.maxlego08.essentials.api.storage.IStorage; +import fr.maxlego08.essentials.api.teleport.CrossServerLocation; +import fr.maxlego08.essentials.api.teleport.TeleportType; import fr.maxlego08.essentials.api.user.Option; import fr.maxlego08.essentials.api.user.PrivateMessage; import fr.maxlego08.essentials.api.user.User; @@ -14,6 +16,9 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; import java.util.Arrays; import java.util.List; import java.util.UUID; @@ -110,4 +115,38 @@ public void deleteCooldown(UUID uniqueId, String cooldownName) { public void updateCooldown(UUID uniqueId, String cooldownName, long expiredAt) { this.plugin.getUtils().deleteCooldown(uniqueId, cooldownName); } + + @Override + public void sendToServer(Player player, String serverName) { + try { + ByteArrayOutputStream byteArray = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(byteArray); + out.writeUTF("Connect"); + out.writeUTF(serverName); + player.sendPluginMessage(this.plugin, "BungeeCord", byteArray.toByteArray()); + } catch (IOException exception) { + this.plugin.getLogger().severe("Failed to send player to server: " + exception.getMessage()); + } + } + + @Override + public void crossServerTeleport(Player player, TeleportType teleportType, CrossServerLocation destination) { + // For non-Redis servers, cross-server teleport is not supported + } + + @Override + public void crossServerTeleportToPlayer(Player player, TeleportType teleportType, String targetPlayerName, String targetServer) { + // For non-Redis servers, cross-server teleport is not supported + } + + @Override + public String getServerName() { + return this.plugin.getConfiguration().getServerName(); + } + + @Override + public String findPlayerServer(String playerName) { + Player player = Bukkit.getPlayer(playerName); + return player != null ? getServerName() : null; + } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 41638c7a..82972ee7 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -30,6 +30,13 @@ storage-type: SQLITE # REDIS - Allows connecting several servers (currently in development) server-type: PAPER +# The name of this server, used for cross-server teleportation with BungeeCord/Velocity +# This should match your BungeeCord/Velocity server name +server-name: "default" + +# Enable cross-server teleportation features (requires Redis and BungeeCord/Velocity) +cross-server-teleport-enabled: false + # Configuration of your database, it is recommended to use the database to store your data. # JSON does not support everything database-configuration: diff --git a/src/main/resources/modules/spawn/config.yml b/src/main/resources/modules/spawn/config.yml index b3cdb749..9437de04 100644 --- a/src/main/resources/modules/spawn/config.yml +++ b/src/main/resources/modules/spawn/config.yml @@ -37,4 +37,9 @@ respawn-at-home: false respawn-at-bed: true # Allows teleporting the player to spawn when logged in -teleport-at-spawn-on-join: false \ No newline at end of file +teleport-at-spawn-on-join: false + +# Allows teleporting the player to their last location when they log in +# This will teleport the player to where they were when they last logged out +# Note: This takes priority over teleport-at-spawn-on-join when enabled +teleport-to-last-location-on-join: false \ No newline at end of file