Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Build & Release

on:
push:
branches: [ main ]
branches: [ main, development ]
tags:
- 'v*'

Expand Down
39 changes: 31 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.*

</div>

Expand All @@ -33,26 +35,29 @@
- **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`

---

## 🧩 Compatibility

| 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

Expand Down Expand Up @@ -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 <n>` | Delete a home (alias: `/delhome`) | `homeforge.use` |
| `/removehome <name>` | Delete a home (alias: `/delhome`) | `homeforge.use` |
| `/homes` | Open the homes GUI | `homeforge.use` |
| `/homes <player>` | View another player's homes | `homeforge.admin.viewother` |
| `/homes add <player> <n>` | Grant extra home slots | `homeforge.admin.extrahomes` |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```

---
Expand Down Expand Up @@ -254,7 +276,8 @@ HomeForge/
│ ├── Home.java
│ └── PlayerData.java
└── utils/
└── MessageUtil.java
├── MessageUtil.java
└── SchedulerUtil.java ← Folia/Paper scheduler abstraction (NEW)
```

---
Expand Down Expand Up @@ -291,4 +314,4 @@ SOFTWARE.

Made with ☕ by [Bhumik Jain](https://github.com/trynafindbhumik)

</div>
</div>
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>io.github.homeforge</groupId>
<artifactId>HomeForge</artifactId>
<version>1.0.0</version>
<version>1.1.0</version>
<packaging>jar</packaging>

<name>HomeForge</name>
Expand Down
173 changes: 124 additions & 49 deletions src/main/java/io/github/homeforge/commands/HomesCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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 <player> — admin view
// /homes <player> — admin view of another player's homes
if (!(sender instanceof Player viewer)) {
MessageUtil.send(sender, plugin.getConfigManager().getPrefix()
+ plugin.getConfigManager().msg("players_only"));
Expand All @@ -66,42 +69,58 @@ 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()
+ plugin.getConfigManager().msg("no_permission"));
return true;
}

// /homes info <player>
if (sub.equals("info")) {
if (args.length < 2) { MessageUtil.send(sender, plugin.getConfigManager().getPrefix() + "&cUsage: /homes info <player>"); return true; }
resolvePlayer(args[1], sender, (uuid, name) -> {
List<Home> 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 <player>");
return true;
}
resolvePlayer(args[1], sender, (uuid, name) -> {
List<Home> 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 <player> <amount>
if (args.length < 3) {
MessageUtil.send(sender, plugin.getConfigManager().getPrefix()
+ "&cUsage: /homes " + sub + " <player> <amount>");
return true;
}

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;
Expand All @@ -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}.
*
* <p>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.</p>
*/
@SuppressWarnings("deprecation")
private void resolvePlayer(String name, CommandSender errorSink, BiConsumer<UUID, String> cb) {
private void resolvePlayer(String name, CommandSender errorSink,
BiConsumer<UUID, String> 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<String> 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();
}
}
}
Loading
Loading