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**
[](https://github.com/trynafindbhumik/HomeForge/actions/workflows/build.yml)
-[](https://papermc.io)
+[](https://papermc.io)
+[](https://papermc.io/software/folia)
[](https://adoptium.net)
[](#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