From 03b0e95ec3ad3dfdc04fd85987c244cba4208a62 Mon Sep 17 00:00:00 2001 From: Jarvis <262229858+JARVIS-AT-ROOTCAUSED@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:22:59 -0800 Subject: [PATCH 1/6] feat(realestate): scaffold Towny market command and design plan --- .../supremecore/SupremeCore.java | 2 + .../realestate/RealEstateCommand.java | 50 +++++++++++++++++++ .../realestate/RealEstateDesign.md | 47 +++++++++++++++++ src/main/resources/plugin.yml | 10 ++++ 4 files changed, 109 insertions(+) create mode 100644 src/main/java/net/supremesurvival/supremecore/realestate/RealEstateCommand.java create mode 100644 src/main/java/net/supremesurvival/supremecore/realestate/RealEstateDesign.md diff --git a/src/main/java/net/supremesurvival/supremecore/SupremeCore.java b/src/main/java/net/supremesurvival/supremecore/SupremeCore.java index a3dd486..e0f6fb3 100644 --- a/src/main/java/net/supremesurvival/supremecore/SupremeCore.java +++ b/src/main/java/net/supremesurvival/supremecore/SupremeCore.java @@ -15,6 +15,7 @@ import net.supremesurvival.supremecore.tomes.TomesCommand; import net.supremesurvival.supremecore.mobUtils.HorseInfo; import net.supremesurvival.supremecore.sanguine.Vampire; +import net.supremesurvival.supremecore.realestate.RealEstateCommand; import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.event.Listener; @@ -47,6 +48,7 @@ public void onEnable() { this.getCommand("HorseInfo").setExecutor(new HorseInfo()); this.getCommand("Landmarks").setExecutor(new LandmarkCommand()); this.getCommand("Vampire").setExecutor(vampire); + this.getCommand("RealEstate").setExecutor(new RealEstateCommand()); Morality.enable(); this.getServer().getPluginManager().registerEvents(new Morality(), this); this.getServer().getPluginManager().registerEvents(vampire, this); diff --git a/src/main/java/net/supremesurvival/supremecore/realestate/RealEstateCommand.java b/src/main/java/net/supremesurvival/supremecore/realestate/RealEstateCommand.java new file mode 100644 index 0000000..8801d49 --- /dev/null +++ b/src/main/java/net/supremesurvival/supremecore/realestate/RealEstateCommand.java @@ -0,0 +1,50 @@ +package net.supremesurvival.supremecore.realestate; + +import org.bukkit.ChatColor; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +/** + * Towny real-estate command entrypoint. + * + * NOTE: This is intentionally a thin command shell for PR review so we can align UX + * and policy before wiring into full Towny listing/teleport behavior. + */ +public class RealEstateCommand implements CommandExecutor { + + @Override + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage("This command can only be used by players."); + return true; + } + + if (args.length == 0) { + sendHelp(player); + return true; + } + + if (args.length >= 1 && args[0].equalsIgnoreCase("list")) { + player.sendMessage(ChatColor.GOLD + "[RealEstate]" + ChatColor.GRAY + " Towny market query hook is staged for implementation."); + player.sendMessage(ChatColor.GRAY + "Planned: /realestate list [town] [page]"); + return true; + } + + if (args.length >= 1 && args[0].equalsIgnoreCase("view")) { + player.sendMessage(ChatColor.GOLD + "[RealEstate]" + ChatColor.GRAY + " Plot viewing teleport hook is staged for implementation."); + player.sendMessage(ChatColor.GRAY + "Planned: /realestate view "); + return true; + } + + sendHelp(player); + return true; + } + + private void sendHelp(Player player) { + player.sendMessage(ChatColor.YELLOW + "/realestate list [town] [page]"); + player.sendMessage(ChatColor.YELLOW + "/realestate view "); + } +} diff --git a/src/main/java/net/supremesurvival/supremecore/realestate/RealEstateDesign.md b/src/main/java/net/supremesurvival/supremecore/realestate/RealEstateDesign.md new file mode 100644 index 0000000..fad83bf --- /dev/null +++ b/src/main/java/net/supremesurvival/supremecore/realestate/RealEstateDesign.md @@ -0,0 +1,47 @@ +# Towny Real Estate Hook (Design) + +## Goal +Expose Towny plots for sale via commands and provide safe teleport for plot viewing. + +## Command UX + +- `/realestate list [town] [page]` + - Lists for-sale plots from Towny. + - Output includes listingId, town, world, plot coords, and price. + +- `/realestate view ` + - Teleports player to a safe viewing location for a selected listing. + +## Data Source + +Use Towny API as source of truth: + +- Town list from `TownyUniverse` +- Plot sale state from `TownBlock` (`isForSale` / sale metadata) +- Price from TownBlock sale value + +## Behavior + +- Cache listing snapshots for 10–30 seconds to reduce heavy scans. +- Stable listing ids per refresh window so `view ` works predictably. +- Restrict teleports by world denylist + cooldown + optional warmup. +- Never bypass server protections (combat tag, region policy, etc.). + +## Config Additions (planned) + +```yaml +realestate: + enabled: true + list-page-size: 8 + cache-seconds: 20 + view-cooldown-seconds: 15 + view-warmup-seconds: 0 + allowed-worlds: [] +``` + +## Follow-up Tasks + +1. Add `RealEstateManager` to gather and cache Towny listings. +2. Wire command responses to real listing data. +3. Add teleport safety resolver (surface-safe location). +4. Add permissions and polish formatting. diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index d2d9724..a0e43e7 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -37,6 +37,12 @@ permissions: landmarks.nearest: description: show nearest landmark default: true + realestate.list: + description: list for-sale Towny plots + default: true + realestate.view: + description: teleport to view a Towny listing + default: true commands: HorseInfo: description: Your description @@ -56,3 +62,7 @@ commands: description: manage vampirism curse states usage: /vampire permission: sanguine.manage + RealEstate: + description: list and view Towny plots for sale + usage: /realestate + aliases: [re] From 813315c7dec1dbf82bc986f01351d8e6a82641a2 Mon Sep 17 00:00:00 2001 From: Jarvis <262229858+JARVIS-AT-ROOTCAUSED@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:30:58 -0800 Subject: [PATCH 2/6] feat(realestate): implement Towny sale listing query and view teleport --- .../realestate/RealEstateCommand.java | 92 ++++++++-- .../realestate/RealEstateListing.java | 11 ++ .../realestate/RealEstateManager.java | 163 ++++++++++++++++++ 3 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 src/main/java/net/supremesurvival/supremecore/realestate/RealEstateListing.java create mode 100644 src/main/java/net/supremesurvival/supremecore/realestate/RealEstateManager.java diff --git a/src/main/java/net/supremesurvival/supremecore/realestate/RealEstateCommand.java b/src/main/java/net/supremesurvival/supremecore/realestate/RealEstateCommand.java index 8801d49..af4bd6b 100644 --- a/src/main/java/net/supremesurvival/supremecore/realestate/RealEstateCommand.java +++ b/src/main/java/net/supremesurvival/supremecore/realestate/RealEstateCommand.java @@ -1,19 +1,20 @@ package net.supremesurvival.supremecore.realestate; import org.bukkit.ChatColor; +import org.bukkit.Location; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -/** - * Towny real-estate command entrypoint. - * - * NOTE: This is intentionally a thin command shell for PR review so we can align UX - * and policy before wiring into full Towny listing/teleport behavior. - */ +import java.util.List; +import java.util.Locale; + public class RealEstateCommand implements CommandExecutor { + private static final int PAGE_SIZE = 8; + + private final RealEstateManager manager = new RealEstateManager(); @Override public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { @@ -27,15 +28,73 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command return true; } - if (args.length >= 1 && args[0].equalsIgnoreCase("list")) { - player.sendMessage(ChatColor.GOLD + "[RealEstate]" + ChatColor.GRAY + " Towny market query hook is staged for implementation."); - player.sendMessage(ChatColor.GRAY + "Planned: /realestate list [town] [page]"); + if (args[0].equalsIgnoreCase("list")) { + String townFilter = null; + int page = 1; + + if (args.length >= 2) { + if (isInteger(args[1])) { + page = Math.max(1, Integer.parseInt(args[1])); + } else { + townFilter = args[1]; + } + } + + if (args.length >= 3 && isInteger(args[2])) { + page = Math.max(1, Integer.parseInt(args[2])); + } + + List listings = manager.getListings(townFilter); + if (listings.isEmpty()) { + player.sendMessage(ChatColor.GRAY + "No for-sale plots found" + (townFilter != null ? " for town filter '" + townFilter + "'" : "") + "."); + return true; + } + + int totalPages = (int) Math.ceil((double) listings.size() / PAGE_SIZE); + page = Math.min(page, Math.max(totalPages, 1)); + int start = (page - 1) * PAGE_SIZE; + int end = Math.min(start + PAGE_SIZE, listings.size()); + + player.sendMessage(ChatColor.GOLD + "=== Real Estate Listings (" + page + "/" + totalPages + ") ==="); + for (int i = start; i < end; i++) { + RealEstateListing l = listings.get(i); + player.sendMessage( + ChatColor.YELLOW + "#" + l.id() + ChatColor.DARK_GRAY + " • " + + ChatColor.AQUA + l.townName() + + ChatColor.DARK_GRAY + " • " + + ChatColor.GRAY + l.worldName() + + ChatColor.DARK_GRAY + " @ " + + ChatColor.WHITE + l.centerX() + ", " + l.centerZ() + + ChatColor.DARK_GRAY + " • " + + ChatColor.GREEN + "$" + String.format(Locale.US, "%.2f", l.price()) + ); + } + + player.sendMessage(ChatColor.GRAY + "Use /realestate view to teleport for viewing."); return true; } - if (args.length >= 1 && args[0].equalsIgnoreCase("view")) { - player.sendMessage(ChatColor.GOLD + "[RealEstate]" + ChatColor.GRAY + " Plot viewing teleport hook is staged for implementation."); - player.sendMessage(ChatColor.GRAY + "Planned: /realestate view "); + if (args[0].equalsIgnoreCase("view")) { + if (args.length < 2 || !isInteger(args[1])) { + player.sendMessage(ChatColor.RED + "Usage: /realestate view "); + return true; + } + + int id = Integer.parseInt(args[1]); + RealEstateListing listing = manager.getListingById(id); + if (listing == null) { + player.sendMessage(ChatColor.RED + "Listing #" + id + " not found. Use /realestate list first."); + return true; + } + + Location target = manager.resolveTeleportLocation(listing); + if (target == null) { + player.sendMessage(ChatColor.RED + "Could not resolve a safe viewing location for that listing."); + return true; + } + + player.teleport(target); + player.sendMessage(ChatColor.GOLD + "Viewing listing #" + id + ChatColor.GRAY + " in " + ChatColor.AQUA + listing.townName()); return true; } @@ -47,4 +106,13 @@ private void sendHelp(Player player) { player.sendMessage(ChatColor.YELLOW + "/realestate list [town] [page]"); player.sendMessage(ChatColor.YELLOW + "/realestate view "); } + + private boolean isInteger(String s) { + try { + Integer.parseInt(s); + return true; + } catch (NumberFormatException e) { + return false; + } + } } diff --git a/src/main/java/net/supremesurvival/supremecore/realestate/RealEstateListing.java b/src/main/java/net/supremesurvival/supremecore/realestate/RealEstateListing.java new file mode 100644 index 0000000..3d893e0 --- /dev/null +++ b/src/main/java/net/supremesurvival/supremecore/realestate/RealEstateListing.java @@ -0,0 +1,11 @@ +package net.supremesurvival.supremecore.realestate; + +public record RealEstateListing( + int id, + String townName, + String worldName, + int centerX, + int centerZ, + double price +) { +} diff --git a/src/main/java/net/supremesurvival/supremecore/realestate/RealEstateManager.java b/src/main/java/net/supremesurvival/supremecore/realestate/RealEstateManager.java new file mode 100644 index 0000000..566243c --- /dev/null +++ b/src/main/java/net/supremesurvival/supremecore/realestate/RealEstateManager.java @@ -0,0 +1,163 @@ +package net.supremesurvival.supremecore.realestate; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +/** + * Reflection-based Towny listing adapter so we can iterate quickly across Towny API versions. + */ +public class RealEstateManager { + + private static final long CACHE_MS = 20_000L; + + private List cachedListings = new ArrayList<>(); + private long lastRefresh = 0L; + + public List getListings(String townFilter) { + if (System.currentTimeMillis() - lastRefresh > CACHE_MS || cachedListings.isEmpty()) { + cachedListings = fetchTownyListings(); + lastRefresh = System.currentTimeMillis(); + } + + if (townFilter == null || townFilter.isBlank()) { + return cachedListings; + } + + String needle = townFilter.toLowerCase(Locale.ROOT); + List out = new ArrayList<>(); + for (RealEstateListing listing : cachedListings) { + if (listing.townName().toLowerCase(Locale.ROOT).contains(needle)) { + out.add(listing); + } + } + return out; + } + + public RealEstateListing getListingById(int id) { + for (RealEstateListing listing : getListings(null)) { + if (listing.id() == id) return listing; + } + return null; + } + + public Location resolveTeleportLocation(RealEstateListing listing) { + World world = Bukkit.getWorld(listing.worldName()); + if (world == null) return null; + + int x = listing.centerX(); + int z = listing.centerZ(); + int y = world.getHighestBlockYAt(x, z) + 1; + return new Location(world, x + 0.5, y, z + 0.5); + } + + @SuppressWarnings("unchecked") + private List fetchTownyListings() { + try { + Class universeClass = Class.forName("com.palmergames.bukkit.towny.TownyUniverse"); + Object universe = invokeStatic(universeClass, "getInstance"); + List towns = (List) invoke(universe, "getTowns"); + + List out = new ArrayList<>(); + int id = 1; + for (Object town : towns) { + String townName = String.valueOf(invoke(town, "getName")); + List townBlocks = (List) invoke(town, "getTownBlocks"); + for (Object townBlock : townBlocks) { + boolean forSale = asBoolean(invoke(townBlock, "isForSale")); + if (!forSale) continue; + + Double price = asDouble(invokeSafe(townBlock, "getPlotPrice")); + if (price == null) { + price = asDouble(invokeSafe(townBlock, "getPrice")); + } + if (price == null) price = 0.0; + + Object worldCoord = invokeSafe(townBlock, "getWorldCoord"); + String worldName = "world"; + int blockX = 0; + int blockZ = 0; + + if (worldCoord != null) { + String wn = asString(invokeSafe(worldCoord, "getWorldName")); + if (wn != null) worldName = wn; + + Integer x = asInt(invokeSafe(worldCoord, "getX")); + Integer z = asInt(invokeSafe(worldCoord, "getZ")); + if (x != null && z != null) { + // Towny Coord units are 16x16 by default + blockX = x * 16 + 8; + blockZ = z * 16 + 8; + } + } + + if (blockX == 0 && blockZ == 0) { + // Try alternate method names across versions + Object coord = invokeSafe(townBlock, "getCoord"); + if (coord != null) { + Integer x = asInt(invokeSafe(coord, "getX")); + Integer z = asInt(invokeSafe(coord, "getZ")); + if (x != null && z != null) { + blockX = x * 16 + 8; + blockZ = z * 16 + 8; + } + } + } + + out.add(new RealEstateListing(id++, townName, worldName, blockX, blockZ, price)); + } + } + + out.sort(Comparator.comparingDouble(RealEstateListing::price)); + return out; + } catch (Throwable ignored) { + return Collections.emptyList(); + } + } + + private static Object invoke(Object target, String method) throws Exception { + Method m = target.getClass().getMethod(method); + return m.invoke(target); + } + + private static Object invokeStatic(Class target, String method) throws Exception { + Method m = target.getMethod(method); + return m.invoke(null); + } + + private static Object invokeSafe(Object target, String method) { + if (target == null) return null; + try { + Method m = target.getClass().getMethod(method); + return m.invoke(target); + } catch (Throwable ignored) { + return null; + } + } + + private static boolean asBoolean(Object o) { + return o instanceof Boolean b && b; + } + + private static Double asDouble(Object o) { + if (o instanceof Number n) return n.doubleValue(); + return null; + } + + private static Integer asInt(Object o) { + if (o instanceof Number n) return n.intValue(); + return null; + } + + private static String asString(Object o) { + if (o == null) return null; + return String.valueOf(o); + } +} From 9d46571bbfb97296c8bcf459e6bfa4ca65e217f3 Mon Sep 17 00:00:00 2001 From: Jarvis <262229858+JARVIS-AT-ROOTCAUSED@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:40:14 -0800 Subject: [PATCH 3/6] ci: add GitHub Actions Maven build workflow --- .github/workflows/build.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d1a1fdc --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,32 @@ +name: Build SupremeCore + +on: + push: + branches: + - main + - feat/** + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + + - name: Build with Maven + run: mvn -B clean package + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: SupremeCore-jars + path: target/*.jar From 956de8ab826e7cb3d0f48198381e49cc5d7872de Mon Sep 17 00:00:00 2001 From: Jarvis <262229858+JARVIS-AT-ROOTCAUSED@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:42:52 -0800 Subject: [PATCH 4/6] build: remove unused quests dependency to fix CI resolution --- pom.xml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pom.xml b/pom.xml index acbcb5c..5e40d48 100644 --- a/pom.xml +++ b/pom.xml @@ -122,12 +122,6 @@ 7.0.6-SNAPSHOT provided - - me.blackvein.quests - quests-main - 4.0.2 - provided - me.clip placeholderapi From 667ed1bdec9be34f9112dd489e5b9c20a2e9ec3d Mon Sep 17 00:00:00 2001 From: Jarvis <262229858+JARVIS-AT-ROOTCAUSED@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:44:37 -0800 Subject: [PATCH 5/6] build: bump PlaceholderAPI dependency to available release --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5e40d48..0a1667e 100644 --- a/pom.xml +++ b/pom.xml @@ -125,7 +125,7 @@ me.clip placeholderapi - 2.10.9 + 2.12.2 provided From 9aa713eda2049634c77ae2ab086fa9ed8afa690d Mon Sep 17 00:00:00 2001 From: Jarvis <262229858+JARVIS-AT-ROOTCAUSED@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:49:32 -0800 Subject: [PATCH 6/6] fix(landmarks): correct WorldGuard RegionManager import --- .../supremesurvival/supremecore/landmarks/LandmarkManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/supremesurvival/supremecore/landmarks/LandmarkManager.java b/src/main/java/net/supremesurvival/supremecore/landmarks/LandmarkManager.java index 2dff922..23fd094 100644 --- a/src/main/java/net/supremesurvival/supremecore/landmarks/LandmarkManager.java +++ b/src/main/java/net/supremesurvival/supremecore/landmarks/LandmarkManager.java @@ -4,7 +4,7 @@ import com.sk89q.worldguard.WorldGuard; import com.sk89q.worldguard.protection.regions.ProtectedRegion; import com.sk89q.worldguard.protection.regions.RegionContainer; -import com.sk89q.worldguard.protection.regions.RegionManager; +import com.sk89q.worldguard.protection.managers.RegionManager; import net.supremesurvival.supremecore.commonUtils.Logger; import net.supremesurvival.supremecore.commonUtils.fileHandler.ConfigUtility; import net.supremesurvival.supremecore.commonUtils.fileHandler.FileHandler;