diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c3b7d2..b768ca1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: Build & Release on: push: - branches: [ main ] + branches: [ main, development ] tags: - 'v*' diff --git a/README.md b/README.md index 07bba05..ac56b71 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,16 @@ # 🏠 HomeForge -**A modern, feature-rich sethome plugin for Paper 26.1** +**A modern, feature-rich sethome plugin for Paper 1.21.11+ and Folia** [![Build](https://github.com/trynafindbhumik/HomeForge/actions/workflows/build.yml/badge.svg)](https://github.com/trynafindbhumik/HomeForge/actions/workflows/build.yml) -[![Paper](https://img.shields.io/badge/Paper-26.1-blue)](https://papermc.io) +[![Paper](https://img.shields.io/badge/Paper-1.21.11+-blue)](https://papermc.io) +[![Folia](https://img.shields.io/badge/Folia-Supported-brightgreen)](https://papermc.io/software/folia) [![Java](https://img.shields.io/badge/Java-21-orange)](https://adoptium.net) [![License](https://img.shields.io/badge/License-MIT-green)](#license) *Set homes. Teleport instantly. Manage everything from a clean GUI.* +*Now fully compatible with Folia's regionized multithreading.* @@ -33,6 +35,7 @@ - **Tab completion** β€” all commands suggest home names and player names - **100% async** β€” all database I/O off the main thread, zero TPS impact - **Fully configurable messages** β€” every chat message is customizable +- **βœ… Folia compatible** β€” all schedulers migrated to `EntityScheduler`, `RegionScheduler`, `AsyncScheduler`, and `GlobalRegionScheduler` --- @@ -40,19 +43,21 @@ | Requirement | Version | |---|---| -| Server | Paper 26.1 (Minecraft 26.1 / 1.21.11+) | +| Server | Paper 1.21.11+ **or** Folia (any recent build) | | Java | 21 or higher | | Dependencies | None β€” SQLite & HikariCP downloaded automatically | > Works with Java and Bedrock (Geyser) players. The GUI uses single-click so Bedrock players on mobile/console have full access to all features. +> **Folia note:** HomeForge is explicitly marked `folia-supported: true` and has been fully rewritten to use region-aware schedulers. It is safe to drop into any Folia server without modification. + --- ## πŸ“₯ Installation 1. πŸ‘‰ [Download Latest](https://github.com/trynafindbhumik/HomeForge/releases/latest) 2. Drop it into your server's `plugins/` folder -3. Start the server β€” Paper downloads SQLite and HikariCP automatically +3. Start the server β€” Paper/Folia downloads SQLite and HikariCP automatically 4. Edit `plugins/HomeForge/config.yml` to your liking 5. Run `/hfreload` to apply changes without restarting @@ -85,7 +90,7 @@ Also set `settings.server_name` to each server's name as configured in BungeeCor |---|---|---| | `/sethome [name]` | Set or update a home at your location | `homeforge.use` | | `/home [name]` | Teleport to a home (primary if no name given) | `homeforge.use` | -| `/removehome ` | Delete a home (alias: `/delhome`) | `homeforge.use` | +| `/removehome ` | Delete a home (alias: `/delhome`) | `homeforge.use` | | `/homes` | Open the homes GUI | `homeforge.use` | | `/homes ` | View another player's homes | `homeforge.admin.viewother` | | `/homes add ` | Grant extra home slots | `homeforge.admin.extrahomes` | @@ -171,6 +176,23 @@ All chat messages are configurable under `messages:` in `config.yml`. Supports ` --- +## πŸ”€ Folia Threading Model + +HomeForge uses the correct scheduler for every operation: + +| Scheduler | Used for | +|---|---| +| `AsyncScheduler` | All database I/O (SQLite / MySQL) | +| `GlobalRegionScheduler` | Chat messages, future completion callbacks | +| `EntityScheduler` | Inventory opens, teleport delay timers, post-teleport effects, join delay | +| `RegionScheduler` | Location-based block operations | + +The teleport delay countdown runs on the `EntityScheduler` so it correctly follows the player if they cross a region boundary during the countdown. Post-teleport sound and particle effects are dispatched back onto the player's entity region after `teleportAsync` completes. All `CompletableFuture` callbacks that open inventories are re-dispatched onto the player's entity region before calling `openInventory`. + +> `folia-supported: true` is declared in `plugin.yml`. The plugin also works identically on regular Paper β€” the new scheduler APIs are available in Paper 1.19.4+ and behave as single-threaded equivalents. + +--- + ## πŸ—ƒοΈ Database Schema ```sql @@ -210,7 +232,7 @@ CREATE TABLE Players ( git clone https://github.com/trynafindbhumik/HomeForge.git cd HomeForge mvn clean package -# Output: target/HomeForge-1.0.0.jar +# Output: target/HomeForge-1.1.0.jar ``` --- @@ -254,7 +276,8 @@ HomeForge/ β”‚ β”œβ”€β”€ Home.java β”‚ └── PlayerData.java └── utils/ - └── MessageUtil.java + β”œβ”€β”€ MessageUtil.java + └── SchedulerUtil.java ← Folia/Paper scheduler abstraction (NEW) ``` --- @@ -291,4 +314,4 @@ SOFTWARE. Made with β˜• by [Bhumik Jain](https://github.com/trynafindbhumik) - + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 02974da..24fba9e 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.homeforge HomeForge - 1.0.0 + 1.1.0 jar HomeForge diff --git a/src/main/java/io/github/homeforge/commands/HomesCommand.java b/src/main/java/io/github/homeforge/commands/HomesCommand.java index 9cc1e98..cec605b 100644 --- a/src/main/java/io/github/homeforge/commands/HomesCommand.java +++ b/src/main/java/io/github/homeforge/commands/HomesCommand.java @@ -5,6 +5,7 @@ import io.github.homeforge.models.Home; import io.github.homeforge.models.PlayerData; import io.github.homeforge.utils.MessageUtil; +import io.github.homeforge.utils.SchedulerUtil; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.command.Command; @@ -27,6 +28,8 @@ public class HomesCommand implements CommandExecutor, TabCompleter { public HomesCommand(HomeForge plugin) { this.plugin = plugin; } + // Command dispatch + @Override public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @NotNull String label, @NotNull String[] args) { @@ -50,12 +53,12 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, String sub = args[0].toLowerCase(); - // Admin sub-commands + // Admin sub-commands (add / remove / set / info) if (ADMIN_SUBS.contains(sub)) { return handleAdmin(sender, sub, args); } - // /homes β€” admin view + // /homes β€” admin view of another player's homes if (!(sender instanceof Player viewer)) { MessageUtil.send(sender, plugin.getConfigManager().getPrefix() + plugin.getConfigManager().msg("players_only")); @@ -66,10 +69,13 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, + plugin.getConfigManager().msg("no_permission")); return true; } - resolvePlayer(args[0], sender, (uuid, name) -> openGUI(viewer, uuid, name)); + resolvePlayer(args[0], sender, + (uuid, name) -> openGUI(viewer, uuid, name)); return true; } + // Admin sub-command handler + private boolean handleAdmin(CommandSender sender, String sub, String[] args) { if (!sender.hasPermission("homeforge.admin.extrahomes")) { MessageUtil.send(sender, plugin.getConfigManager().getPrefix() @@ -77,22 +83,33 @@ private boolean handleAdmin(CommandSender sender, String sub, String[] args) { return true; } + // /homes info if (sub.equals("info")) { - if (args.length < 2) { MessageUtil.send(sender, plugin.getConfigManager().getPrefix() + "&cUsage: /homes info "); return true; } - resolvePlayer(args[1], sender, (uuid, name) -> { - List homes = plugin.getHomeManager().getHomes(uuid.toString()); - PlayerData data = plugin.getHomeManager().getPlayerData(uuid.toString()); - Player online = Bukkit.getPlayer(uuid); - int limit = online != null ? plugin.getHomeManager().getHomeLimit(online) - : plugin.getConfigManager().getDefaultHomeLimit() + (int) data.getExtraHomes(); + if (args.length < 2) { MessageUtil.send(sender, plugin.getConfigManager().getPrefix() - + plugin.getConfigManager().msg("homes_info"), - "%player%", name, "%homes%", String.valueOf(homes.size()), - "%max%", String.valueOf(limit), "%extra%", String.valueOf(data.getExtraHomes())); + + "&cUsage: /homes info "); + return true; + } + resolvePlayer(args[1], sender, (uuid, name) -> { + List homes = plugin.getHomeManager().getHomes(uuid.toString()); + PlayerData data = plugin.getHomeManager().getPlayerData(uuid.toString()); + Player online = Bukkit.getPlayer(uuid); + int limit = online != null + ? plugin.getHomeManager().getHomeLimit(online) + : plugin.getConfigManager().getDefaultHomeLimit() + + (int) data.getExtraHomes(); + MessageUtil.send(sender, + plugin.getConfigManager().getPrefix() + + plugin.getConfigManager().msg("homes_info"), + "%player%", name, + "%homes%", String.valueOf(homes.size()), + "%max%", String.valueOf(limit), + "%extra%", String.valueOf(data.getExtraHomes())); }); return true; } + // /homes add|remove|set if (args.length < 3) { MessageUtil.send(sender, plugin.getConfigManager().getPrefix() + "&cUsage: /homes " + sub + " "); @@ -100,8 +117,10 @@ private boolean handleAdmin(CommandSender sender, String sub, String[] args) { } long amount; - try { amount = Long.parseLong(args[2]); if (amount < 0) throw new NumberFormatException(); } - catch (NumberFormatException e) { + try { + amount = Long.parseLong(args[2]); + if (amount < 0) throw new NumberFormatException(); + } catch (NumberFormatException e) { MessageUtil.send(sender, plugin.getConfigManager().getPrefix() + plugin.getConfigManager().msg("invalid_amount")); return true; @@ -110,67 +129,123 @@ private boolean handleAdmin(CommandSender sender, String sub, String[] args) { final long finalAmount = amount; resolvePlayer(args[1], sender, (uuid, name) -> { switch (sub) { - case "add" -> plugin.getHomeManager().addExtraHomes(uuid.toString(), finalAmount) - .thenAccept(d -> MessageUtil.send(sender, plugin.getConfigManager().getPrefix() - + plugin.getConfigManager().msg("extra_homes_added"), - "%player%", name, "%amount%", String.valueOf(finalAmount), "%total%", String.valueOf(d.getExtraHomes()))); - case "remove" -> plugin.getHomeManager().removeExtraHomes(uuid.toString(), finalAmount) - .thenAccept(d -> MessageUtil.send(sender, plugin.getConfigManager().getPrefix() - + plugin.getConfigManager().msg("extra_homes_removed"), - "%player%", name, "%amount%", String.valueOf(finalAmount), "%total%", String.valueOf(d.getExtraHomes()))); - case "set" -> plugin.getHomeManager().setExtraHomes(uuid.toString(), finalAmount) - .thenAccept(d -> MessageUtil.send(sender, plugin.getConfigManager().getPrefix() - + plugin.getConfigManager().msg("extra_homes_set"), - "%player%", name, "%amount%", String.valueOf(finalAmount))); + case "add" -> plugin.getHomeManager() + .addExtraHomes(uuid.toString(), finalAmount) + .thenAccept(d -> MessageUtil.send(sender, + plugin.getConfigManager().getPrefix() + + plugin.getConfigManager().msg("extra_homes_added"), + "%player%", name, + "%amount%", String.valueOf(finalAmount), + "%total%", String.valueOf(d.getExtraHomes()))); + + case "remove" -> plugin.getHomeManager() + .removeExtraHomes(uuid.toString(), finalAmount) + .thenAccept(d -> MessageUtil.send(sender, + plugin.getConfigManager().getPrefix() + + plugin.getConfigManager().msg("extra_homes_removed"), + "%player%", name, + "%amount%", String.valueOf(finalAmount), + "%total%", String.valueOf(d.getExtraHomes()))); + + case "set" -> plugin.getHomeManager() + .setExtraHomes(uuid.toString(), finalAmount) + .thenAccept(d -> MessageUtil.send(sender, + plugin.getConfigManager().getPrefix() + + plugin.getConfigManager().msg("extra_homes_set"), + "%player%", name, + "%amount%", String.valueOf(finalAmount))); } }); return true; } + // GUI helpers + + /** + * Load {@code ownerUuid}'s homes asynchronously, then open the GUI on + * {@code viewer}'s entity region thread (required for inventory opens on Folia). + */ private void openGUI(Player viewer, UUID ownerUuid, String ownerName) { - plugin.getHomeManager().loadHomesAsync(ownerUuid.toString()).thenAccept(homes -> { - if (homes.isEmpty()) { - MessageUtil.send(viewer, plugin.getConfigManager().getPrefix() - + plugin.getConfigManager().msg( - viewer.getUniqueId().equals(ownerUuid) ? "no_homes" : "no_home_other")); - return; - } - new HomesGUI(plugin, viewer, ownerUuid, ownerName, homes, 0).open(viewer); - }); + plugin.getHomeManager().loadHomesAsync(ownerUuid.toString()) + .thenAccept(homes -> { + // loadHomesAsync completes on the global region thread. + // Opening an inventory requires the player's entity region. + SchedulerUtil.runOnPlayer(plugin, viewer, () -> { + if (homes.isEmpty()) { + MessageUtil.send(viewer, + plugin.getConfigManager().getPrefix() + + plugin.getConfigManager().msg( + viewer.getUniqueId().equals(ownerUuid) + ? "no_homes" + : "no_home_other")); + return; + } + new HomesGUI(plugin, viewer, ownerUuid, ownerName, homes, 0) + .open(viewer); + }); + }); } + // Player resolution (online β†’ offline lookup is async) + + /** + * Resolve a player name to a UUID + display name, then invoke {@code cb}. + * + *

If the player is online the callback fires synchronously on the calling + * thread. For offline players the lookup is dispatched to an async thread + * and the callback fires on the global region thread.

+ */ @SuppressWarnings("deprecation") - private void resolvePlayer(String name, CommandSender errorSink, BiConsumer cb) { + private void resolvePlayer(String name, CommandSender errorSink, + BiConsumer cb) { + // Online player — fast path, no thread switch needed. Player online = Bukkit.getPlayer(name); - if (online != null) { cb.accept(online.getUniqueId(), online.getName()); return; } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + if (online != null) { + cb.accept(online.getUniqueId(), online.getName()); + return; + } + + // Offline player — Bukkit.getOfflinePlayer blocks on name→UUID look-up. + SchedulerUtil.runAsync(plugin, () -> { OfflinePlayer offline = Bukkit.getOfflinePlayer(name); if (!offline.hasPlayedBefore()) { - plugin.getServer().getScheduler().runTask(plugin, () -> - MessageUtil.send(errorSink, plugin.getConfigManager().getPrefix() - + plugin.getConfigManager().msg("player_not_found"), "%player%", name)); + SchedulerUtil.runGlobal(plugin, () -> + MessageUtil.send(errorSink, + plugin.getConfigManager().getPrefix() + + plugin.getConfigManager() + .msg("player_not_found"), + "%player%", name)); return; } - String resolvedName = offline.getName() != null ? offline.getName() : name; - plugin.getServer().getScheduler().runTask(plugin, () -> + String resolvedName = offline.getName() != null + ? offline.getName() : name; + // Deliver callback on global region; openGUI then uses entity + // scheduler internally for the inventory open. + SchedulerUtil.runGlobal(plugin, () -> cb.accept(offline.getUniqueId(), resolvedName)); }); } + // Tab completion + @Override public List onTabComplete(@NotNull CommandSender sender, @NotNull Command cmd, @NotNull String label, @NotNull String[] args) { if (args.length == 1) { String typed = args[0].toLowerCase(); - return Stream.concat(ADMIN_SUBS.stream(), + return Stream.concat( + ADMIN_SUBS.stream(), Bukkit.getOnlinePlayers().stream().map(Player::getName)) - .filter(s -> s.toLowerCase().startsWith(typed)).collect(Collectors.toList()); + .filter(s -> s.toLowerCase().startsWith(typed)) + .collect(Collectors.toList()); } if (args.length == 2 && ADMIN_SUBS.contains(args[0].toLowerCase())) { String typed = args[1].toLowerCase(); - return Bukkit.getOnlinePlayers().stream().map(Player::getName) - .filter(n -> n.toLowerCase().startsWith(typed)).collect(Collectors.toList()); + return Bukkit.getOnlinePlayers().stream() + .map(Player::getName) + .filter(n -> n.toLowerCase().startsWith(typed)) + .collect(Collectors.toList()); } return List.of(); } -} +} \ No newline at end of file diff --git a/src/main/java/io/github/homeforge/commands/ImportHomesCommand.java b/src/main/java/io/github/homeforge/commands/ImportHomesCommand.java index 9407a11..37dbf66 100644 --- a/src/main/java/io/github/homeforge/commands/ImportHomesCommand.java +++ b/src/main/java/io/github/homeforge/commands/ImportHomesCommand.java @@ -3,6 +3,7 @@ import io.github.homeforge.HomeForge; import io.github.homeforge.models.Home; import io.github.homeforge.utils.MessageUtil; +import io.github.homeforge.utils.SchedulerUtil; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; @@ -14,8 +15,14 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Level; -// /importhomes essentials -// Reads EssentialsX player YAML files and imports all homes into HomeForge. +/** + * /importhomes essentials + * + * Reads EssentialsX player YAML files and imports all homes into HomeForge. + * The heavy file I/O is performed on an async thread via {@link SchedulerUtil#runAsync}; + * the result message is delivered on the global region so it is always safe to + * send to any CommandSender (player or console). + */ public class ImportHomesCommand implements CommandExecutor { private final HomeForge plugin; @@ -38,23 +45,28 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, return true; } - File essentialsDir = new File(plugin.getDataFolder().getParentFile(), "Essentials/userdata"); + File essentialsDir = new File( + plugin.getDataFolder().getParentFile(), "Essentials/userdata"); + if (!essentialsDir.exists() || !essentialsDir.isDirectory()) { MessageUtil.send(sender, plugin.getConfigManager().getPrefix() - + MessageUtil.replace(plugin.getConfigManager().msg("import_failed"), + + MessageUtil.replace( + plugin.getConfigManager().msg("import_failed"), "%error%", "EssentialsX userdata folder not found")); return true; } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + // All file I/O runs async; result is delivered on the global region. + SchedulerUtil.runAsync(plugin, () -> { AtomicInteger count = new AtomicInteger(0); - String serverName = plugin.getConfigManager().getServerName(); + String serverName = plugin.getConfigManager().getServerName(); File[] files = essentialsDir.listFiles(f -> f.getName().endsWith(".yml")); if (files == null || files.length == 0) { - plugin.getServer().getScheduler().runTask(plugin, () -> - MessageUtil.send(sender, plugin.getConfigManager().getPrefix() - + plugin.getConfigManager().msg("import_nothing"))); + SchedulerUtil.runGlobal(plugin, () -> + MessageUtil.send(sender, + plugin.getConfigManager().getPrefix() + + plugin.getConfigManager().msg("import_nothing"))); return; } @@ -66,16 +78,19 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, YamlConfiguration yaml; try { yaml = YamlConfiguration.loadConfiguration(file); } catch (Exception e) { - plugin.getLogger().log(Level.WARNING, "Skipping " + file.getName(), e); + plugin.getLogger().log(Level.WARNING, + "Skipping " + file.getName(), e); continue; } - ConfigurationSection homesSection = yaml.getConfigurationSection("homes"); + ConfigurationSection homesSection = + yaml.getConfigurationSection("homes"); if (homesSection == null) continue; for (String homeName : homesSection.getKeys(false)) { if (plugin.getHomeManager().getHome(uuidStr, homeName) != null) continue; - ConfigurationSection hs = homesSection.getConfigurationSection(homeName); + ConfigurationSection hs = + homesSection.getConfigurationSection(homeName); if (hs == null) continue; try { Home h = new Home(); @@ -94,17 +109,20 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, if (plugin.getDatabase().insertHome(h)) count.incrementAndGet(); } catch (Exception e) { plugin.getLogger().log(Level.WARNING, - "Failed to import home '" + homeName + "' for " + uuidStr, e); + "Failed to import home '" + homeName + + "' for " + uuidStr, e); } } } int total = count.get(); - plugin.getServer().getScheduler().runTask(plugin, () -> - MessageUtil.send(sender, plugin.getConfigManager().getPrefix() - + plugin.getConfigManager().msg(total == 0 ? "import_nothing" : "import_success"), + SchedulerUtil.runGlobal(plugin, () -> + MessageUtil.send(sender, + plugin.getConfigManager().getPrefix() + + plugin.getConfigManager().msg( + total == 0 ? "import_nothing" : "import_success"), "%count%", String.valueOf(total))); }); return true; } -} +} \ No newline at end of file diff --git a/src/main/java/io/github/homeforge/gui/HomeSettingsGUI.java b/src/main/java/io/github/homeforge/gui/HomeSettingsGUI.java index fa4ef4f..fd3bfe7 100644 --- a/src/main/java/io/github/homeforge/gui/HomeSettingsGUI.java +++ b/src/main/java/io/github/homeforge/gui/HomeSettingsGUI.java @@ -3,6 +3,7 @@ import io.github.homeforge.HomeForge; import io.github.homeforge.models.Home; import io.github.homeforge.utils.MessageUtil; +import io.github.homeforge.utils.SchedulerUtil; import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.entity.Player; @@ -15,6 +16,16 @@ import static io.github.homeforge.gui.HomesGUI.makeItem; +/** + * Per-home management GUI: teleport, relocate, set primary, change symbol, delete. + * + *

Folia note: clicks arrive on the player's entity region, so all + * direct GUI opens ({@link #open}) are safe without an extra dispatch. + * However, {@code CompletableFuture#thenAccept} callbacks produced by + * {@link io.github.homeforge.managers.HomeManager} complete on the global + * region thread β€” any code in those callbacks that touches the player (opens + * inventories, etc.) must be re-dispatched via {@link SchedulerUtil#runOnPlayer}.

+ */ public class HomeSettingsGUI implements InventoryHolder { private static final int SLOT_TELEPORT = 11; @@ -40,6 +51,8 @@ public HomeSettingsGUI(HomeForge plugin, Player viewer, UUID ownerUuid, this.parentGUI = parentGUI; } + // Build & open (must be called on the player's entity region) + public void open(Player viewer) { this.inventory = build(viewer); viewer.openInventory(inventory); @@ -47,7 +60,8 @@ public void open(Player viewer) { private Inventory build(Player viewer) { String title = MessageUtil.replace( - plugin.getConfigManager().getGuiSettingsTitle(), "%name%", home.getName()); + plugin.getConfigManager().getGuiSettingsTitle(), + "%name%", home.getName()); Inventory inv = Bukkit.createInventory(this, 27, MessageUtil.colorize(title)); ItemStack fill = makeItem(Material.GRAY_STAINED_GLASS_PANE, " "); @@ -81,60 +95,99 @@ private Inventory build(Player viewer) { if (isOwner || isAdmin) inv.setItem(SLOT_DELETE, makeItem(Material.BARRIER, "&cDelete Home", - List.of("&7Permanently remove &b" + home.getName() + "&7.", "", "&cCannot be undone!"))); + List.of("&7Permanently remove &b" + home.getName() + "&7.", + "", "&cCannot be undone!"))); - inv.setItem(SLOT_BACK, makeItem(Material.ARROW, "&7← Back", List.of("&7Return to home list."))); + inv.setItem(SLOT_BACK, makeItem(Material.ARROW, "&7← Back", + List.of("&7Return to home list."))); this.inventory = inv; return inv; } + // Click handler (called from GUIListener, already on the player's region) + public void handleClick(Player viewer, int slot) { boolean isOwner = viewer.getUniqueId().equals(ownerUuid); boolean isAdmin = viewer.hasPermission("homeforge.admin.editother"); switch (slot) { + + // Teleport case SLOT_TELEPORT -> { viewer.closeInventory(); plugin.getTeleportManager().teleportToHome(viewer, home); } + + // Update location case SLOT_UPDATE_LOC -> { if (!isOwner && !isAdmin) return; viewer.closeInventory(); - plugin.getHomeManager().updateHomeLocation(viewer, home.getName()) - .thenAccept(ok -> { if (ok) MessageUtil.send(viewer, - plugin.getConfigManager().getPrefix() - + plugin.getConfigManager().msg("home_location_updated"), - "%name%", home.getName()); }); + plugin.getHomeManager() + .updateHomeLocation(viewer, home.getName()) + .thenAccept(ok -> { + // thenAccept fires on global region β€” message only, safe. + if (ok) MessageUtil.send(viewer, + plugin.getConfigManager().getPrefix() + + plugin.getConfigManager() + .msg("home_location_updated"), + "%name%", home.getName()); + }); } + + // Set primary case SLOT_SET_PRIMARY -> { if (!isOwner || isPrimary()) return; - plugin.getHomeManager().setPrimaryHome(ownerUuid.toString(), home.getName()) - .thenAccept(ok -> { if (ok) { - MessageUtil.send(viewer, plugin.getConfigManager().getPrefix() - + plugin.getConfigManager().msg("primary_home_set"), "%name%", home.getName()); - open(viewer); - }}); + plugin.getHomeManager() + .setPrimaryHome(ownerUuid.toString(), home.getName()) + .thenAccept(ok -> { + if (!ok) return; + // Message is safe on global region. + MessageUtil.send(viewer, + plugin.getConfigManager().getPrefix() + + plugin.getConfigManager() + .msg("primary_home_set"), + "%name%", home.getName()); + // Re-opening the inventory must happen on the + // player's entity region, not the global region. + SchedulerUtil.runOnPlayer(plugin, viewer, + () -> open(viewer)); + }); } + + // Change symbol case SLOT_CHANGE_SYMBOL -> { if (!isOwner && !isAdmin) return; - new SymbolPickerGUI(plugin, viewer, ownerUuid, home, this).open(viewer); + new SymbolPickerGUI(plugin, viewer, ownerUuid, home, this) + .open(viewer); } + + // Delete case SLOT_DELETE -> { if (!isOwner && !isAdmin) return; viewer.closeInventory(); - plugin.getHomeManager().removeHome(ownerUuid.toString(), home.getName()) - .thenAccept(ok -> { if (ok) MessageUtil.send(viewer, - plugin.getConfigManager().getPrefix() - + plugin.getConfigManager().msg("home_removed"), - "%name%", home.getName()); }); + plugin.getHomeManager() + .removeHome(ownerUuid.toString(), home.getName()) + .thenAccept(ok -> { + // Message only β€” safe on global region. + if (ok) MessageUtil.send(viewer, + plugin.getConfigManager().getPrefix() + + plugin.getConfigManager() + .msg("home_removed"), + "%name%", home.getName()); + }); } + + // Back case SLOT_BACK -> parentGUI.open(viewer); } } + // Helpers + private boolean isPrimary() { - Long ph = plugin.getHomeManager().getPlayerData(ownerUuid.toString()).getPrimaryHome(); + Long ph = plugin.getHomeManager() + .getPlayerData(ownerUuid.toString()).getPrimaryHome(); return ph != null && ph == home.getId(); } @@ -142,4 +195,4 @@ private boolean isPrimary() { public Home getHome() { return home; } public UUID getOwnerUuid() { return ownerUuid; } public HomesGUI getParentGUI() { return parentGUI; } -} +} \ No newline at end of file diff --git a/src/main/java/io/github/homeforge/gui/SymbolPickerGUI.java b/src/main/java/io/github/homeforge/gui/SymbolPickerGUI.java index 36a7200..9ed358f 100644 --- a/src/main/java/io/github/homeforge/gui/SymbolPickerGUI.java +++ b/src/main/java/io/github/homeforge/gui/SymbolPickerGUI.java @@ -3,6 +3,7 @@ import io.github.homeforge.HomeForge; import io.github.homeforge.models.Home; import io.github.homeforge.utils.MessageUtil; +import io.github.homeforge.utils.SchedulerUtil; import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.entity.Player; @@ -15,6 +16,13 @@ import static io.github.homeforge.gui.HomesGUI.makeItem; +/** + * Icon-picker GUI β€” lets the player choose a Material as the home's symbol. + * + *

Folia note: The {@code updateSymbol} future completes on the global + * region thread. Re-opening the parent settings GUI must be dispatched onto the + * player's entity region via {@link SchedulerUtil#runOnPlayer}.

+ */ public class SymbolPickerGUI implements InventoryHolder { private static final Material[] SYMBOLS = { @@ -62,6 +70,8 @@ public SymbolPickerGUI(HomeForge plugin, Player viewer, UUID ownerUuid, this.parent = parent; } + // Build & open (must be called on the player's entity region) + public void open(Player viewer) { this.inventory = build(); viewer.openInventory(inventory); @@ -69,7 +79,8 @@ public void open(Player viewer) { private Inventory build() { Inventory inv = Bukkit.createInventory(this, 54, - MessageUtil.colorize(plugin.getConfigManager().getGuiSymbolPickerTitle())); + MessageUtil.colorize( + plugin.getConfigManager().getGuiSymbolPickerTitle())); ItemStack fill = makeItem(Material.GRAY_STAINED_GLASS_PANE, " "); for (int i = 0; i < 54; i++) inv.setItem(i, fill); @@ -80,30 +91,50 @@ private Inventory build() { List.of("&7Set as icon for &b" + home.getName() + "&7."))); } - inv.setItem(BACK_SLOT, makeItem(Material.ARROW, "&7← Back", List.of("&7Return to home settings."))); + inv.setItem(BACK_SLOT, makeItem(Material.ARROW, "&7← Back", + List.of("&7Return to home settings."))); this.inventory = inv; return inv; } + // Click handler (called from GUIListener, already on the player's region) + public void handleClick(Player viewer, int slot) { - if (slot == BACK_SLOT) { parent.open(viewer); return; } + if (slot == BACK_SLOT) { + parent.open(viewer); + return; + } if (slot < 0 || slot >= SYMBOLS.length) return; + Material chosen = SYMBOLS[slot]; home.setSymbol(chosen.name()); - plugin.getHomeManager().updateSymbol(ownerUuid.toString(), home.getName(), chosen.name()) + + plugin.getHomeManager() + .updateSymbol(ownerUuid.toString(), home.getName(), chosen.name()) .thenAccept(ok -> { - if (ok) MessageUtil.send(viewer, plugin.getConfigManager().getPrefix() - + plugin.getConfigManager().msg("symbol_changed"), "%name%", home.getName()); - parent.open(viewer); + // thenAccept fires on the global region thread. + // Send message here (safe from any thread), then re-open + // the parent GUI on the player's entity region. + if (ok) MessageUtil.send(viewer, + plugin.getConfigManager().getPrefix() + + plugin.getConfigManager().msg("symbol_changed"), + "%name%", home.getName()); + + SchedulerUtil.runOnPlayer(plugin, viewer, + () -> parent.open(viewer)); }); } + // Helpers + private static String fmt(String name) { StringBuilder sb = new StringBuilder(); for (String w : name.split("_")) - sb.append(Character.toUpperCase(w.charAt(0))).append(w.substring(1).toLowerCase()).append(" "); + sb.append(Character.toUpperCase(w.charAt(0))) + .append(w.substring(1).toLowerCase()) + .append(" "); return sb.toString().trim(); } @Override public Inventory getInventory() { return inventory; } -} +} \ No newline at end of file diff --git a/src/main/java/io/github/homeforge/listeners/PlayerListener.java b/src/main/java/io/github/homeforge/listeners/PlayerListener.java index ef6330e..b53f6fd 100644 --- a/src/main/java/io/github/homeforge/listeners/PlayerListener.java +++ b/src/main/java/io/github/homeforge/listeners/PlayerListener.java @@ -2,6 +2,7 @@ import io.github.homeforge.HomeForge; import io.github.homeforge.models.Home; +import io.github.homeforge.utils.SchedulerUtil; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -18,14 +19,25 @@ public class PlayerListener implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void onJoin(PlayerJoinEvent e) { Player player = e.getPlayer(); + + // Load homes/player-data from DB (async) into cache (global region). plugin.getHomeManager().loadPlayer(player); - Long pendingHomeId = plugin.getTeleportManager().consumePendingCrossServer(player.getUniqueId()); + // Cross-server teleport: if this player arrived via BungeeCord with a + // pending home ID, teleport them a few ticks after login so the world + // has fully loaded. Use the entity scheduler so the task tracks the + // player's region, which may differ from the spawn region. + Long pendingHomeId = plugin.getTeleportManager() + .consumePendingCrossServer(player.getUniqueId()); + if (pendingHomeId != null) { final long homeId = pendingHomeId; - plugin.getServer().getScheduler().runTaskLater(plugin, () -> { - Home home = plugin.getHomeManager().getDatabase().getHomeById(homeId); - if (home != null) plugin.getTeleportManager().teleportToHome(player, home); + SchedulerUtil.runOnPlayerLater(plugin, player, () -> { + Home home = plugin.getHomeManager() + .getDatabase().getHomeById(homeId); + if (home != null) { + plugin.getTeleportManager().teleportToHome(player, home); + } }, 5L); } } @@ -37,4 +49,4 @@ public void onQuit(PlayerQuitEvent e) { plugin.getTeleportManager().clearCooldown(player.getUniqueId()); plugin.getHomeManager().unloadPlayer(player); } -} +} \ No newline at end of file diff --git a/src/main/java/io/github/homeforge/managers/HomeManager.java b/src/main/java/io/github/homeforge/managers/HomeManager.java index edb1dde..2510051 100644 --- a/src/main/java/io/github/homeforge/managers/HomeManager.java +++ b/src/main/java/io/github/homeforge/managers/HomeManager.java @@ -4,6 +4,7 @@ import io.github.homeforge.database.Database; import io.github.homeforge.models.Home; import io.github.homeforge.models.PlayerData; +import io.github.homeforge.utils.SchedulerUtil; import org.bukkit.entity.Player; import java.util.*; @@ -18,23 +19,30 @@ public class HomeManager { private final HomeForge plugin; private final Database db; - private final Map> homeCache = new ConcurrentHashMap<>(); - private final Map dataCache = new ConcurrentHashMap<>(); + // Both maps are ConcurrentHashMap β€” safe for async reads from DB thread + // and synchronous writes dispatched back to the global region. + private final Map> homeCache = new ConcurrentHashMap<>(); + private final Map dataCache = new ConcurrentHashMap<>(); public HomeManager(HomeForge plugin, Database db) { this.plugin = plugin; this.db = db; } - // Cache + // Cache lifecycle + public void loadPlayer(Player player) { String uuid = player.getUniqueId().toString(); - runAsync(() -> { - List homes = db.getHomesForPlayer(uuid); - PlayerData data = db.getPlayerData(uuid); + // Always load from DB off-thread; results written back on global region. + SchedulerUtil.runAsync(plugin, () -> { + List homes = db.getHomesForPlayer(uuid); + PlayerData data = db.getPlayerData(uuid); if (data == null) data = new PlayerData(uuid, null, 0); - homeCache.put(uuid, new ArrayList<>(homes)); - dataCache.put(uuid, data); + final PlayerData finalData = data; + SchedulerUtil.runGlobal(plugin, () -> { + homeCache.put(uuid, new ArrayList<>(homes)); + dataCache.put(uuid, finalData); + }); }); } @@ -44,7 +52,8 @@ public void unloadPlayer(Player player) { dataCache.remove(uuid); } - // Reads + // Synchronous reads (must be called from a region or global region thread) + public List getHomes(String uuid) { return homeCache.computeIfAbsent(uuid, db::getHomesForPlayer); } @@ -77,7 +86,7 @@ public int getHomeLimit(Player player) { return plugin.getConfigManager().getMaxHomeLimit() + (int) getPlayerData(player.getUniqueId().toString()).getExtraHomes(); } - int max = plugin.getConfigManager().getMaxHomeLimit(); + int max = plugin.getConfigManager().getMaxHomeLimit(); int base = 0; if (plugin.getConfigManager().stackPermissionLimits()) { for (int i = 1; i <= max; i++) @@ -91,19 +100,20 @@ public int getHomeLimit(Player player) { return base + (int) getPlayerData(player.getUniqueId().toString()).getExtraHomes(); } - // Async writes + // Async writes β€” DB work on async thread; future completes on global region + public CompletableFuture setHome(Player player, String name, boolean overwrite) { CompletableFuture future = new CompletableFuture<>(); String uuid = player.getUniqueId().toString(); String serverName = plugin.getConfigManager().getServerName(); - runAsync(() -> { + SchedulerUtil.runAsync(plugin, () -> { Home existing = getHome(uuid, name); if (existing != null) { - if (!overwrite) { complete(future, false); return; } + if (!overwrite) { completeSync(future, false); return; } existing.updateLocation(player.getLocation()); existing.setServerName(serverName.isBlank() ? null : serverName); db.updateHome(existing); - complete(future, true); + completeSync(future, true); } else { Home h = new Home(); h.setId(db.nextHomeId()); h.setOwner(uuid); @@ -113,7 +123,7 @@ public CompletableFuture setHome(Player player, String name, boolean ov h.updateLocation(player.getLocation()); db.insertHome(h); homeCache.computeIfAbsent(uuid, u -> new ArrayList<>()).add(h); - complete(future, true); + completeSync(future, true); } }); return future; @@ -121,9 +131,9 @@ public CompletableFuture setHome(Player player, String name, boolean ov public CompletableFuture removeHome(String uuid, String name) { CompletableFuture future = new CompletableFuture<>(); - runAsync(() -> { + SchedulerUtil.runAsync(plugin, () -> { Home h = getHome(uuid, name); - if (h == null) { complete(future, false); return; } + if (h == null) { completeSync(future, false); return; } db.deleteHome(h.getId()); PlayerData data = getPlayerData(uuid); if (data.getPrimaryHome() != null && data.getPrimaryHome() == h.getId()) { @@ -132,20 +142,20 @@ public CompletableFuture removeHome(String uuid, String name) { } List cached = homeCache.get(uuid); if (cached != null) cached.removeIf(x -> x.getId() == h.getId()); - complete(future, true); + completeSync(future, true); }); return future; } public CompletableFuture setPrimaryHome(String uuid, String name) { CompletableFuture future = new CompletableFuture<>(); - runAsync(() -> { + SchedulerUtil.runAsync(plugin, () -> { Home h = getHome(uuid, name); - if (h == null) { complete(future, false); return; } + if (h == null) { completeSync(future, false); return; } PlayerData data = getPlayerData(uuid); data.setPrimaryHome(h.getId()); db.upsertPlayerData(data); - complete(future, true); + completeSync(future, true); }); return future; } @@ -153,59 +163,86 @@ public CompletableFuture setPrimaryHome(String uuid, String name) { public CompletableFuture updateHomeLocation(Player player, String name) { CompletableFuture future = new CompletableFuture<>(); String uuid = player.getUniqueId().toString(); - runAsync(() -> { + SchedulerUtil.runAsync(plugin, () -> { Home h = getHome(uuid, name); - if (h == null) { complete(future, false); return; } + if (h == null) { completeSync(future, false); return; } h.updateLocation(player.getLocation()); h.setServerName(plugin.getConfigManager().getServerName().isBlank() ? null : plugin.getConfigManager().getServerName()); db.updateHome(h); - complete(future, true); + completeSync(future, true); }); return future; } public CompletableFuture updateSymbol(String uuid, String name, String symbol) { CompletableFuture future = new CompletableFuture<>(); - runAsync(() -> { + SchedulerUtil.runAsync(plugin, () -> { Home h = getHome(uuid, name); - if (h == null) { complete(future, false); return; } + if (h == null) { completeSync(future, false); return; } h.setSymbol(symbol); h.setLastUsed(System.currentTimeMillis()); db.updateHome(h); - complete(future, true); + completeSync(future, true); }); return future; } public CompletableFuture addExtraHomes(String uuid, long amount) { CompletableFuture f = new CompletableFuture<>(); - runAsync(() -> { PlayerData d = getPlayerData(uuid); d.setExtraHomes(Math.max(0, d.getExtraHomes() + amount)); db.upsertPlayerData(d); runSync(() -> f.complete(d)); }); + SchedulerUtil.runAsync(plugin, () -> { + PlayerData d = getPlayerData(uuid); + d.setExtraHomes(Math.max(0, d.getExtraHomes() + amount)); + db.upsertPlayerData(d); + SchedulerUtil.runGlobal(plugin, () -> f.complete(d)); + }); return f; } public CompletableFuture removeExtraHomes(String uuid, long amount) { CompletableFuture f = new CompletableFuture<>(); - runAsync(() -> { PlayerData d = getPlayerData(uuid); d.setExtraHomes(Math.max(0, d.getExtraHomes() - amount)); db.upsertPlayerData(d); runSync(() -> f.complete(d)); }); + SchedulerUtil.runAsync(plugin, () -> { + PlayerData d = getPlayerData(uuid); + d.setExtraHomes(Math.max(0, d.getExtraHomes() - amount)); + db.upsertPlayerData(d); + SchedulerUtil.runGlobal(plugin, () -> f.complete(d)); + }); return f; } public CompletableFuture setExtraHomes(String uuid, long amount) { CompletableFuture f = new CompletableFuture<>(); - runAsync(() -> { PlayerData d = getPlayerData(uuid); d.setExtraHomes(Math.max(0, amount)); db.upsertPlayerData(d); runSync(() -> f.complete(d)); }); + SchedulerUtil.runAsync(plugin, () -> { + PlayerData d = getPlayerData(uuid); + d.setExtraHomes(Math.max(0, amount)); + db.upsertPlayerData(d); + SchedulerUtil.runGlobal(plugin, () -> f.complete(d)); + }); return f; } + /** + * Load (or return cached) homes for {@code uuid} asynchronously. + * The returned future completes on the global region thread. + */ public CompletableFuture> loadHomesAsync(String uuid) { CompletableFuture> f = new CompletableFuture<>(); - runAsync(() -> { List h = homeCache.computeIfAbsent(uuid, db::getHomesForPlayer); runSync(() -> f.complete(h)); }); + SchedulerUtil.runAsync(plugin, () -> { + List homes = homeCache.computeIfAbsent(uuid, db::getHomesForPlayer); + SchedulerUtil.runGlobal(plugin, () -> f.complete(homes)); + }); return f; } // Internals - private void runAsync(Runnable r) { plugin.getServer().getScheduler().runTaskAsynchronously(plugin, r); } - private void runSync(Runnable r) { plugin.getServer().getScheduler().runTask(plugin, r); } - private void complete(CompletableFuture f, boolean v) { runSync(() -> f.complete(v)); } + + /** + * Complete {@code future} with {@code value} on the global region thread. + * Called from an async thread after DB work is done. + */ + private void completeSync(CompletableFuture future, boolean value) { + SchedulerUtil.runGlobal(plugin, () -> future.complete(value)); + } public Database getDatabase() { return db; } -} +} \ No newline at end of file diff --git a/src/main/java/io/github/homeforge/managers/TeleportManager.java b/src/main/java/io/github/homeforge/managers/TeleportManager.java index 6b93768..2ec42d2 100644 --- a/src/main/java/io/github/homeforge/managers/TeleportManager.java +++ b/src/main/java/io/github/homeforge/managers/TeleportManager.java @@ -5,30 +5,55 @@ import io.github.homeforge.HomeForge; import io.github.homeforge.models.Home; import io.github.homeforge.utils.MessageUtil; +import io.github.homeforge.utils.SchedulerUtil; +import io.papermc.paper.threadedregions.scheduler.ScheduledTask; import org.bukkit.Location; import org.bukkit.Particle; import org.bukkit.Sound; import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitTask; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +/** + * Handles home teleportation including cooldowns, movement-checked delays, + * cross-server BungeeCord forwarding, and sound/particle effects. + * + *

Folia thread model: + *

    + *
  • Teleport delay timers use the {@code EntityScheduler} so they tick on the + * region owning the player, following them if the region splits/merges.
  • + *
  • Post-teleport effects are dispatched back onto the player's entity scheduler + * inside the {@code teleportAsync} callback.
  • + *
  • Cooldown and pending-teleport maps are {@link ConcurrentHashMap} because + * they may be read from multiple region threads simultaneously.
  • + *
+ *

+ */ public class TeleportManager { private final HomeForge plugin; - private final Map pendingTeleports = new ConcurrentHashMap<>(); - private final Map teleportOrigins = new ConcurrentHashMap<>(); - private final Map cooldowns = new ConcurrentHashMap<>(); - private final Map pendingCrossServer = new ConcurrentHashMap<>(); + + // ScheduledTask replaces BukkitTask β€” works on both Paper and Folia. + private final Map pendingTeleports = new ConcurrentHashMap<>(); + private final Map teleportOrigins = new ConcurrentHashMap<>(); + private final Map cooldowns = new ConcurrentHashMap<>(); + private final Map pendingCrossServer = new ConcurrentHashMap<>(); public TeleportManager(HomeForge plugin) { this.plugin = plugin; } + // Public API + + /** + * Initiate a teleport to {@code home} for {@code player}. + * Must be called from the player's entity region (event / command context). + */ public void teleportToHome(Player player, Home home) { - // Cooldown + + // --- Cooldown check --- int cooldownSec = plugin.getConfigManager().getHomeCooldown(); if (cooldownSec > 0 && !player.hasPermission("homeforge.cooldown.bypass")) { Long last = cooldowns.get(player.getUniqueId()); @@ -43,21 +68,23 @@ public void teleportToHome(Player player, Home home) { } } - // Already teleporting + // --- Already in a delayed teleport --- if (pendingTeleports.containsKey(player.getUniqueId())) { MessageUtil.send(player, plugin.getConfigManager().getPrefix() + plugin.getConfigManager().msg("delayed_teleport_already_in_progress")); return; } - // Cross-server + // --- Cross-server redirect --- String curServer = plugin.getConfigManager().getServerName(); String homeServer = home.getServerName(); - if (homeServer != null && !homeServer.isBlank() && !homeServer.equalsIgnoreCase(curServer)) { + if (homeServer != null && !homeServer.isBlank() + && !homeServer.equalsIgnoreCase(curServer)) { sendToServer(player, home); return; } + // --- Resolve destination --- Location dest = home.toBukkitLocation(); if (dest == null) { player.sendMessage(MessageUtil.colorize(plugin.getConfigManager().getPrefix() @@ -66,86 +93,157 @@ public void teleportToHome(Player player, Home home) { } int delay = plugin.getConfigManager().getTeleportDelay(); - if (delay <= 0) doTeleport(player, dest, home.getName()); - else startDelayedTeleport(player, dest, home.getName(), delay); + if (delay <= 0) { + doTeleport(player, dest, home.getName()); + } else { + startDelayedTeleport(player, dest, home.getName(), delay); + } } - private void startDelayedTeleport(Player player, Location dest, String name, int delaySeconds) { - teleportOrigins.put(player.getUniqueId(), player.getLocation().clone()); + // Delayed teleport β€” entity scheduler keeps it pinned to the player + + private void startDelayedTeleport(Player player, Location dest, + String name, int delaySeconds) { + UUID uuid = player.getUniqueId(); + teleportOrigins.put(uuid, player.getLocation().clone()); + MessageUtil.send(player, plugin.getConfigManager().getPrefix() + plugin.getConfigManager().msg("delayed_teleport"), "%seconds%", String.valueOf(delaySeconds)); + // Mutable countdown wrapped in an array so the lambda can capture it. int[] countdown = {delaySeconds}; - BukkitTask task = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> { + + /* + * runAtFixedRate on the EntityScheduler β€” fires every 20 ticks (1 s) + * on whatever region currently owns the player. + * initialDelayTicks = 20 so the first decrement happens 1 s after scheduling. + */ + ScheduledTask task = SchedulerUtil.runTimerOnPlayer(plugin, player, scheduledTask -> { countdown[0]--; + + // Movement cancellation check if (plugin.getConfigManager().checkMovementOnDelay()) { - Location origin = teleportOrigins.get(player.getUniqueId()); - if (origin != null && origin.distanceSquared(player.getLocation()) > 0.0225) { + Location origin = teleportOrigins.get(uuid); + if (origin != null + && origin.distanceSquared(player.getLocation()) > 0.0225) { MessageUtil.send(player, plugin.getConfigManager().getPrefix() + plugin.getConfigManager().msg("delayed_teleport_cancel_movement")); - cancelTeleport(player); + scheduledTask.cancel(); + pendingTeleports.remove(uuid); + teleportOrigins.remove(uuid); return; } } + + // Countdown finished β€” teleport! if (countdown[0] <= 0) { - pendingTeleports.remove(player.getUniqueId()); - teleportOrigins.remove(player.getUniqueId()); + scheduledTask.cancel(); + pendingTeleports.remove(uuid); + teleportOrigins.remove(uuid); doTeleport(player, dest, name); } }, 20L, 20L); - pendingTeleports.put(player.getUniqueId(), task); + + pendingTeleports.put(uuid, task); } + // Actual teleport + post-teleport effects + + /** + * Execute the teleport. {@code player.teleportAsync} is async internally; + * effects are dispatched back onto the player's entity scheduler inside the + * completion callback so they run on the correct region thread. + */ private void doTeleport(Player player, Location dest, String name) { MessageUtil.send(player, plugin.getConfigManager().getPrefix() + plugin.getConfigManager().msg("home_teleport"), "%name%", name); + player.teleportAsync(dest).thenAccept(success -> { if (!success) return; - plugin.getServer().getScheduler().runTask(plugin, () -> { + // Post-teleport effects must run on the player's region thread. + SchedulerUtil.runOnPlayer(plugin, player, () -> { cooldowns.put(player.getUniqueId(), System.currentTimeMillis()); + if (plugin.getConfigManager().playSoundOnTeleport()) - player.playSound(player.getLocation(), Sound.ENTITY_ENDERMAN_TELEPORT, 1f, 1f); + player.playSound(player.getLocation(), + Sound.ENTITY_ENDERMAN_TELEPORT, 1f, 1f); + if (plugin.getConfigManager().playParticleOnTeleport()) player.getWorld().spawnParticle(Particle.PORTAL, - player.getLocation().add(0,1,0), 30, 0.3, 0.5, 0.3, 0.1); + player.getLocation().add(0, 1, 0), + 30, 0.3, 0.5, 0.3, 0.1); }); }); } + // Cross-server (BungeeCord) + private void sendToServer(Player player, Home home) { if (!plugin.getConfigManager().isBungeeCordEnabled()) return; + MessageUtil.send(player, plugin.getConfigManager().getPrefix() + plugin.getConfigManager().msg("home_cross_server"), "%server%", home.getServerName(), "%name%", home.getName()); + try { + // Forward message carrying the home ID to the target server. ByteArrayDataOutput fwd = ByteStreams.newDataOutput(); - fwd.writeUTF("Forward"); fwd.writeUTF(home.getServerName()); + fwd.writeUTF("Forward"); + fwd.writeUTF(home.getServerName()); fwd.writeUTF("HomeForge"); + ByteArrayDataOutput payload = ByteStreams.newDataOutput(); payload.writeUTF(player.getUniqueId().toString()); payload.writeLong(home.getId()); byte[] pb = payload.toByteArray(); - fwd.writeShort(pb.length); fwd.write(pb); + fwd.writeShort(pb.length); + fwd.write(pb); + player.sendPluginMessage(plugin, "BungeeCord", fwd.toByteArray()); } catch (Exception e) { - plugin.getLogger().warning("Failed to send BungeeCord Forward: " + e.getMessage()); + plugin.getLogger().warning( + "Failed to send BungeeCord Forward: " + e.getMessage()); } + + // Then connect the player. ByteArrayDataOutput connect = ByteStreams.newDataOutput(); - connect.writeUTF("Connect"); connect.writeUTF(home.getServerName()); + connect.writeUTF("Connect"); + connect.writeUTF(home.getServerName()); player.sendPluginMessage(plugin, "BungeeCord", connect.toByteArray()); } + // Lifecycle helpers + + /** + * Cancel any pending delayed teleport for {@code player} (e.g. on disconnect). + */ public void cancelTeleport(Player player) { - BukkitTask task = pendingTeleports.remove(player.getUniqueId()); + ScheduledTask task = pendingTeleports.remove(player.getUniqueId()); if (task != null) task.cancel(); teleportOrigins.remove(player.getUniqueId()); } - public boolean hasPendingTeleport(UUID uuid) { return pendingTeleports.containsKey(uuid); } - public Location getTeleportOrigin(UUID uuid) { return teleportOrigins.get(uuid); } - public void clearCooldown(UUID uuid) { cooldowns.remove(uuid); } - public void cancelAllTeleports() { pendingTeleports.values().forEach(BukkitTask::cancel); pendingTeleports.clear(); teleportOrigins.clear(); } - public void registerPendingCrossServer(UUID u, long homeId) { pendingCrossServer.put(u, homeId); } - public Long consumePendingCrossServer(UUID u) { return pendingCrossServer.remove(u); } -} + /** + * Cancel ALL pending teleports β€” called on plugin disable. + */ + public void cancelAllTeleports() { + pendingTeleports.values().forEach(ScheduledTask::cancel); + pendingTeleports.clear(); + teleportOrigins.clear(); + } + + // Accessors + + public boolean hasPendingTeleport(UUID uuid) { return pendingTeleports.containsKey(uuid); } + public Location getTeleportOrigin(UUID uuid) { return teleportOrigins.get(uuid); } + public void clearCooldown(UUID uuid) { cooldowns.remove(uuid); } + + public void registerPendingCrossServer(UUID uuid, long homeId) { + pendingCrossServer.put(uuid, homeId); + } + + public Long consumePendingCrossServer(UUID uuid) { + return pendingCrossServer.remove(uuid); + } +} \ No newline at end of file diff --git a/src/main/java/io/github/homeforge/utils/SchedulerUtil.java b/src/main/java/io/github/homeforge/utils/SchedulerUtil.java new file mode 100644 index 0000000..a938dc2 --- /dev/null +++ b/src/main/java/io/github/homeforge/utils/SchedulerUtil.java @@ -0,0 +1,114 @@ +package io.github.homeforge.utils; + +import io.github.homeforge.HomeForge; +import io.papermc.paper.threadedregions.scheduler.ScheduledTask; +import org.bukkit.Location; +import org.bukkit.entity.Player; + +import java.util.function.Consumer; + +/** + * Thread-safe scheduler utility compatible with both Paper and Folia. + * + *

Paper 1.19.4+ ships the same scheduler API that Folia introduced, so every + * method here works without any runtime branching β€” Paper just executes region/entity + * tasks on its single main thread, while Folia executes them on the correct region + * thread.

+ * + *

Scheduler selection guide: + *

    + *
  • {@link #runAsync} β€” I/O, database, anything off-tick.
  • + *
  • {@link #runGlobal} β€” Weather, game-rules, chat messages to players, + * anything not tied to a specific region.
  • + *
  • {@link #runAtLocation} β€” Block/chunk mutations at a known location.
  • + *
  • {@link #runOnPlayer} β€” Inventory opens, teleports, anything that touches + * a player entity (follows the player across regions).
  • + *
+ *

+ */ +public final class SchedulerUtil { + + private SchedulerUtil() {} + + // Async β€” runs off any tick thread + + /** + * Run {@code task} asynchronously as soon as possible. + * Safe for blocking I/O (database queries, file reads, etc.). + */ + public static void runAsync(HomeForge plugin, Runnable task) { + plugin.getServer().getAsyncScheduler() + .runNow(plugin, t -> task.run()); + } + + // Global region β€” one-shot and repeating + + /** + * Execute {@code task} on the next tick of the global region. + * Use for server-wide state (messages, game rules, console commands). + */ + public static void runGlobal(HomeForge plugin, Runnable task) { + plugin.getServer().getGlobalRegionScheduler() + .execute(plugin, task); + } + + /** + * Schedule {@code task} to run on the global region after {@code delayTicks}. + */ + public static void runGlobalLater(HomeForge plugin, Runnable task, long delayTicks) { + plugin.getServer().getGlobalRegionScheduler() + .runDelayed(plugin, t -> task.run(), delayTicks); + } + + // Region β€” tied to a world location + + /** + * Execute {@code task} on the region that owns {@code location}. + * Use for block/chunk mutations at a specific location. + */ + public static void runAtLocation(HomeForge plugin, Location location, Runnable task) { + plugin.getServer().getRegionScheduler() + .execute(plugin, location, task); + } + + // Entity / Player β€” follows the entity across regions + + /** + * Run {@code task} on the next tick of the region owning {@code player}. + * Required for: opening inventories, reading player location, teleporting. + * + * @param retired optional callback if the player has already left the server; pass {@code null} to ignore. + */ + public static void runOnPlayer(HomeForge plugin, Player player, + Runnable task, Runnable retired) { + player.getScheduler().run(plugin, t -> task.run(), retired); + } + + /** Convenience overload β€” retired callback is a no-op. */ + public static void runOnPlayer(HomeForge plugin, Player player, Runnable task) { + runOnPlayer(plugin, player, task, null); + } + + /** + * Run {@code task} on the player's region after {@code delayTicks} ticks. + */ + public static void runOnPlayerLater(HomeForge plugin, Player player, + Runnable task, long delayTicks) { + player.getScheduler().runDelayed(plugin, t -> task.run(), null, delayTicks); + } + + /** + * Schedule a repeating task on the player's region. + * + * @param task receives the {@link ScheduledTask} so it can cancel itself. + * @param initialDelayTicks ticks to wait before first execution (minimum 1). + * @param periodTicks ticks between subsequent executions. + * @return the {@link ScheduledTask} β€” keep a reference to cancel it later. + */ + public static ScheduledTask runTimerOnPlayer(HomeForge plugin, Player player, + Consumer task, + long initialDelayTicks, long periodTicks) { + return player.getScheduler() + .runAtFixedRate(plugin, task, null, initialDelayTicks, periodTicks); + } +} \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 1b8d07b..8fb36d0 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,12 +1,16 @@ name: HomeForge version: '${project.version}' main: io.github.homeforge.HomeForge -description: A modern sethome plugin for Paper 26.1 +description: A modern sethome plugin for Paper 26.1 and Folia author: Bhumik Jain website: https://github.com/trynafindbhumik/HomeForge api-version: '1.21.11' -# SQLite and HikariCP downloaded by Paper at startup β€” no shading needed +# Required: tells Folia this plugin has been explicitly written +# to be region-thread-safe (uses EntityScheduler, RegionScheduler, etc.) +folia-supported: true + +# SQLite and HikariCP downloaded by Paper/Folia at startup β€” no shading needed libraries: - org.xerial:sqlite-jdbc:3.49.1.0 - com.zaxxer:HikariCP:6.2.1 @@ -112,4 +116,4 @@ permissions: homeforge.admin.import: description: Use /importhomes. - default: op + default: op \ No newline at end of file