From 665c1a844282294de6b3e04c6539fe1ae6bd1f7d Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 8 Jun 2026 17:52:31 +0000 Subject: [PATCH] 0.8.0: JSON plot config + FTB Teams/Chunks integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plot system migrated from static Java + KubeJS to a runtime configurable, FTB-Chunks-integrated system. * Plot definitions now live in JSON at /serverconfig/bnstoolkit/plots.json. Default file written on first server start with the three v0.33 plots. Editable in-place + reloadable via /bns admin plot reload. * New static Plot.REGISTRY replaces Plot.ALL. Loaded on ServerStartedEvent. Plot.all() returns the current snapshot. * New FtbBridge.java — compileOnly deps on ftb-teams + ftb-chunks (always in the pack). Wraps team lookup, chunk claim/unclaim, and the BEFORE_UNCLAIM event. * /bns admin spawn-init creates the Spawn (cyan) + Plots (gold) server teams using configured UUIDs/colors and claims every defined plot for the Plots team. Idempotent. * /bns admin plot define — adds the chunk you're standing in to the plot. Re-run with the same id (different chunks) to make multi-chunk plots. Auto-claims for the Plots team and persists to JSON. * /bns admin plot list / remove / reload commands. * /bns plot buy — pays via NumismaticsBridge.spend (cash+bank combined), unclaims chunks from Plots team, claims for the buyer's personal FTB Teams team. Records ownership in server NBT and the player's bnsOwnedPlots list. * /bns plot release — reverse flow, refunds 50% to bank. * Plot chunk guard: ClaimedChunkEvent.BEFORE_UNCLAIM subscriber rejects any unclaim of a registered plot chunk unless a thread-local SYSTEM_UNCLAIMING flag is set (only set during the release/transfer flows). Players who try to unclaim via the FTB Chunks UI get a chat message redirecting them to /bns plot release. * KubeJS plots.js retired (.retired suffix kept). All logic now in Java; KubeJS no longer participates in plot ownership state. Co-Authored-By: Claude Opus 4.7 --- build.gradle | 14 +- gradle.properties | 2 +- .../client/screens/PlotPurchaseScreen.java | 2 +- .../java/uk/sijbers/bnstoolkit/data/Plot.java | 31 +- .../sijbers/bnstoolkit/data/PlotConfig.java | 196 +++++++++ .../sijbers/bnstoolkit/data/PlotRegistry.java | 77 ++++ .../sijbers/bnstoolkit/economy/FtbBridge.java | 202 +++++++++ .../network/ServerEconomyBridge.java | 2 +- .../bnstoolkit/server/PlotCommands.java | 389 ++++++++++++++++++ 9 files changed, 893 insertions(+), 22 deletions(-) create mode 100644 src/main/java/uk/sijbers/bnstoolkit/data/PlotConfig.java create mode 100644 src/main/java/uk/sijbers/bnstoolkit/data/PlotRegistry.java create mode 100644 src/main/java/uk/sijbers/bnstoolkit/economy/FtbBridge.java create mode 100644 src/main/java/uk/sijbers/bnstoolkit/server/PlotCommands.java diff --git a/build.gradle b/build.gradle index 7e11c4c..74945c6 100644 --- a/build.gradle +++ b/build.gradle @@ -81,15 +81,15 @@ configurations { } dependencies { - // FTB Library: provides the Panel/Widget/Button framework used by every - // bnstoolkit screen. compileOnly because it's shipped in the modpack - // already — we don't want to ship a second copy in our jar. Version - // pinned to the live pack's installed version (2101.1.31). + // FTB Library: Panel/Widget/Button framework for screens. compileOnly "dev.ftb.mods:ftb-library-neoforge:2101.1.31" + // FTB Teams: server-team creation + lookup (Spawn + Plots admin teams). + compileOnly "dev.ftb.mods:ftb-teams-neoforge:2101.1.9" + // FTB Chunks: programmatic claim/unclaim + BEFORE_UNCLAIM event (plot guard). + compileOnly "dev.ftb.mods:ftb-chunks-neoforge:2101.1.14" - // No Numismatics compileOnly dep — we read coin balances by inventory- - // scanning items via their string IDs ("numismatics:spur", etc) which - // doesn't need any of Numismatics' Java API on the classpath. + // Numismatics + KubeJS are accessed reflectively (NumismaticsBridge, + // ServerEconomyBridge) so they don't need compileOnly deps. } var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) { diff --git a/gradle.properties b/gradle.properties index 64e17dc..5dc1287 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,5 +19,5 @@ loader_version_range=[1,) mod_id=bnstoolkit mod_name=Brass and Sigil Toolkit mod_license=All Rights Reserved -mod_version=0.7.0 +mod_version=0.8.0 mod_group_id=uk.sijbers.bnstoolkit diff --git a/src/main/java/uk/sijbers/bnstoolkit/client/screens/PlotPurchaseScreen.java b/src/main/java/uk/sijbers/bnstoolkit/client/screens/PlotPurchaseScreen.java index 4c80fc9..86e3b83 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/client/screens/PlotPurchaseScreen.java +++ b/src/main/java/uk/sijbers/bnstoolkit/client/screens/PlotPurchaseScreen.java @@ -61,7 +61,7 @@ public final class PlotPurchaseScreen extends BaseBnsScreen { @Override public void addWidgets() { int owned = ClientBnsState.ownedPlotIds.size(); add(new RowLabel(this, "§7Slots: " + owned + " / " + ClientBnsState.plotSlotCap)); - for (Plot p : Plot.ALL) add(new PlotRow(this, p)); + for (Plot p : Plot.all()) add(new PlotRow(this, p)); } @Override public void alignWidgets() { int y = 0; diff --git a/src/main/java/uk/sijbers/bnstoolkit/data/Plot.java b/src/main/java/uk/sijbers/bnstoolkit/data/Plot.java index 1cfde7b..4b759f9 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/data/Plot.java +++ b/src/main/java/uk/sijbers/bnstoolkit/data/Plot.java @@ -1,25 +1,32 @@ package uk.sijbers.bnstoolkit.data; +import java.util.Collections; import java.util.List; /** - * Spawn-region plot catalogue. Mirrors PLOTS in - * pack/overrides/kubejs/server_scripts/plots.js. + * One sellable plot in the spawn region. + * + *

Loaded from {@code config/bnstoolkit/plots.json} at server start + * via {@link PlotConfig#load}. Static helpers {@link #ALL} and + * {@link #byId} delegate to whatever {@link #REGISTRY} is currently + * set (initially {@link PlotRegistry#empty()} — populated on server + * start). + * + *

{@code chunks} is a list of {@code [chunkX, chunkZ]} pairs in the + * given {@code dimension}. */ public record Plot(String id, String name, String size, long price, String dimension, List chunks) { - public static final List ALL = List.of( - new Plot("p001", "Market Stall 1", "small", 100L, "minecraft:overworld", - List.of(new int[]{10, 10})), - new Plot("p002", "Corner Lot", "medium", 250L, "minecraft:overworld", - List.of(new int[]{12, 10}, new int[]{12, 11})), - new Plot("p003", "Anchor Plot", "large", 600L, "minecraft:overworld", - List.of(new int[]{14, 10}, new int[]{14, 11}, new int[]{15, 10}, new int[]{15, 11})) - ); + /** Mutable global reference; replaced wholesale on reload. */ + public static volatile PlotRegistry REGISTRY = PlotRegistry.empty(); + + /** Snapshot of currently loaded plots. */ + public static List all() { + return REGISTRY.plots(); + } public static Plot byId(String id) { - for (Plot p : ALL) if (p.id.equals(id)) return p; - return null; + return REGISTRY.byId(id); } public long refundOnRelease() { diff --git a/src/main/java/uk/sijbers/bnstoolkit/data/PlotConfig.java b/src/main/java/uk/sijbers/bnstoolkit/data/PlotConfig.java new file mode 100644 index 0000000..f36716b --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/data/PlotConfig.java @@ -0,0 +1,196 @@ +package uk.sijbers.bnstoolkit.data; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.minecraft.server.MinecraftServer; +import uk.sijbers.bnstoolkit.BnsToolkit; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * On-disk plot configuration loaded from + * {@code config/bnstoolkit/plots.json}. + * + *

Format

+ *
+ * {
+ *   "spawnTeam":  { "name": "Spawn", "color": "#00BFFF", "id": "..." },
+ *   "plotsTeam":  { "name": "Plots", "color": "#FFD700", "id": "..." },
+ *   "plots": [
+ *     {
+ *       "id": "p001",
+ *       "name": "Market Stall 1",
+ *       "size": "small",
+ *       "price": 100,
+ *       "dimension": "minecraft:overworld",
+ *       "chunks": [[10, 10]]
+ *     }
+ *   ]
+ * }
+ * 
+ * + *

If the file is missing on first load, a default is written with the + * three pre-existing spawn plots so the world isn't broken at boot. + * Edits land via {@code /bns admin plot reload} or by re-running the + * relevant {@code /bns admin plot} subcommand. + */ +public final class PlotConfig { + private PlotConfig() {} + + public static final int SCHEMA_VERSION = 1; + public static final String DEFAULT_SPAWN_COLOR = "#00BFFF"; // cyan + public static final String DEFAULT_PLOTS_COLOR = "#FFD700"; // gold + + public record TeamConfig(String name, String displayName, String colorHex, String fixedUuid) {} + + public static Path configPath(MinecraftServer server) { + // /serverconfig/bnstoolkit/plots.json — per-world, so a wipe + // resets it. Falls back to /config/bnstoolkit/plots.json + // if the per-world dir isn't writable. + return server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT) + .resolve("serverconfig").resolve("bnstoolkit").resolve("plots.json"); + } + + /** Load + parse the config. Returns a populated registry. Writes defaults if file missing. */ + public static PlotRegistry load(MinecraftServer server) { + Path path = configPath(server); + try { + if (!Files.exists(path)) { + Files.createDirectories(path.getParent()); + Files.writeString(path, defaultJson()); + BnsToolkit.LOG.info("[bns/plots] wrote default plot config to {}", path); + } + String content = Files.readString(path); + return parse(content); + } catch (Exception e) { + BnsToolkit.LOG.error("[bns/plots] failed to load plot config from {}: {}", path, e.toString()); + return PlotRegistry.empty(); + } + } + + /** Persist the registry back to disk. */ + public static void save(MinecraftServer server, PlotRegistry registry) { + Path path = configPath(server); + try { + Files.createDirectories(path.getParent()); + Files.writeString(path, serialize(registry)); + BnsToolkit.LOG.info("[bns/plots] saved {} plots to {}", registry.plots().size(), path); + } catch (IOException e) { + BnsToolkit.LOG.error("[bns/plots] save failed: {}", e.toString()); + } + } + + private static PlotRegistry parse(String json) { + JsonObject root = JsonParser.parseString(json).getAsJsonObject(); + + TeamConfig spawnTeam = parseTeam(root, "spawnTeam", "Spawn", + DEFAULT_SPAWN_COLOR, "00000001-0000-0000-0000-bnstoolspawn"); + TeamConfig plotsTeam = parseTeam(root, "plotsTeam", "Plots", + DEFAULT_PLOTS_COLOR, "00000002-0000-0000-0000-bnstoolplots"); + + List plots = new ArrayList<>(); + if (root.has("plots")) { + for (JsonElement e : root.getAsJsonArray("plots")) { + try { + JsonObject p = e.getAsJsonObject(); + String id = p.get("id").getAsString(); + String name = p.get("name").getAsString(); + String size = p.has("size") ? p.get("size").getAsString() : "medium"; + long price = p.get("price").getAsLong(); + String dim = p.has("dimension") ? p.get("dimension").getAsString() : "minecraft:overworld"; + List chunks = new ArrayList<>(); + for (JsonElement c : p.getAsJsonArray("chunks")) { + JsonArray arr = c.getAsJsonArray(); + chunks.add(new int[]{ arr.get(0).getAsInt(), arr.get(1).getAsInt() }); + } + plots.add(new Plot(id, name, size, price, dim, chunks)); + } catch (Exception ex) { + BnsToolkit.LOG.warn("[bns/plots] skipping malformed plot entry: {}", ex.toString()); + } + } + } + + return new PlotRegistry(spawnTeam, plotsTeam, Collections.unmodifiableList(plots)); + } + + private static TeamConfig parseTeam(JsonObject root, String key, String defaultName, + String defaultColor, String defaultUuid) { + if (!root.has(key)) return new TeamConfig(defaultName, defaultName, defaultColor, defaultUuid); + JsonObject t = root.getAsJsonObject(key); + String name = t.has("name") ? t.get("name").getAsString() : defaultName; + String display = t.has("displayName") ? t.get("displayName").getAsString() : name; + String color = t.has("color") ? t.get("color").getAsString() : defaultColor; + String uuid = t.has("id") ? t.get("id").getAsString() : defaultUuid; + return new TeamConfig(name, display, color, uuid); + } + + private static String defaultJson() { + // Ships the three pre-existing v0.33 plots so a fresh world has + // something to test against. Matt edits + /bns admin plot reload + // after spawn build is done. + return """ + { + "$schema": "bnstoolkit/plots-v1", + "spawnTeam": { + "name": "Spawn", + "displayName": "Spawn", + "color": "#00BFFF", + "id": "00000001-0000-0000-0000-bnstoolspawn" + }, + "plotsTeam": { + "name": "Plots", + "displayName": "Plots", + "color": "#FFD700", + "id": "00000002-0000-0000-0000-bnstoolplots" + }, + "plots": [ + { "id": "p001", "name": "Market Stall 1", "size": "small", "price": 100, "dimension": "minecraft:overworld", "chunks": [[10, 10]] }, + { "id": "p002", "name": "Corner Lot", "size": "medium", "price": 250, "dimension": "minecraft:overworld", "chunks": [[12, 10], [12, 11]] }, + { "id": "p003", "name": "Anchor Plot", "size": "large", "price": 600, "dimension": "minecraft:overworld", "chunks": [[14, 10], [14, 11], [15, 10], [15, 11]] } + ] + } + """; + } + + private static String serialize(PlotRegistry r) { + StringBuilder sb = new StringBuilder(); + sb.append("{\n \"$schema\": \"bnstoolkit/plots-v1\",\n"); + appendTeam(sb, "spawnTeam", r.spawnTeam(), false); + appendTeam(sb, "plotsTeam", r.plotsTeam(), false); + sb.append(" \"plots\": [\n"); + for (int i = 0; i < r.plots().size(); i++) { + Plot p = r.plots().get(i); + sb.append(" { \"id\": \"").append(p.id()) + .append("\", \"name\": \"").append(p.name()) + .append("\", \"size\": \"").append(p.size()) + .append("\", \"price\": ").append(p.price()) + .append(", \"dimension\": \"").append(p.dimension()) + .append("\", \"chunks\": ["); + for (int j = 0; j < p.chunks().size(); j++) { + int[] c = p.chunks().get(j); + if (j > 0) sb.append(", "); + sb.append('[').append(c[0]).append(", ").append(c[1]).append(']'); + } + sb.append("] }"); + if (i + 1 < r.plots().size()) sb.append(','); + sb.append('\n'); + } + sb.append(" ]\n}\n"); + return sb.toString(); + } + + private static void appendTeam(StringBuilder sb, String key, TeamConfig t, boolean trailingComma) { + sb.append(" \"").append(key).append("\": { ") + .append("\"name\": \"").append(t.name()).append("\", ") + .append("\"displayName\": \"").append(t.displayName()).append("\", ") + .append("\"color\": \"").append(t.colorHex()).append("\", ") + .append("\"id\": \"").append(t.fixedUuid()).append("\" },\n"); + } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/data/PlotRegistry.java b/src/main/java/uk/sijbers/bnstoolkit/data/PlotRegistry.java new file mode 100644 index 0000000..d37f180 --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/data/PlotRegistry.java @@ -0,0 +1,77 @@ +package uk.sijbers.bnstoolkit.data; + +import net.minecraft.core.BlockPos; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Runtime store of loaded plots + the spawn/plots team configs. + * + *

Populated from JSON at server start (see {@link PlotConfig#load}) and + * stored on the global {@link Plot#REGISTRY} reference. Replaces the + * earlier static {@code Plot.ALL} list — that's now a delegating view + * over whatever's currently loaded. + */ +public final class PlotRegistry { + private final PlotConfig.TeamConfig spawnTeam; + private final PlotConfig.TeamConfig plotsTeam; + private final List plots; + + public PlotRegistry(PlotConfig.TeamConfig spawnTeam, + PlotConfig.TeamConfig plotsTeam, + List plots) { + this.spawnTeam = spawnTeam; + this.plotsTeam = plotsTeam; + this.plots = plots; + } + + public static PlotRegistry empty() { + return new PlotRegistry( + new PlotConfig.TeamConfig("Spawn", "Spawn", PlotConfig.DEFAULT_SPAWN_COLOR, + "00000001-0000-0000-0000-bnstoolspawn"), + new PlotConfig.TeamConfig("Plots", "Plots", PlotConfig.DEFAULT_PLOTS_COLOR, + "00000002-0000-0000-0000-bnstoolplots"), + Collections.emptyList()); + } + + public PlotConfig.TeamConfig spawnTeam() { return spawnTeam; } + public PlotConfig.TeamConfig plotsTeam() { return plotsTeam; } + public List plots() { return plots; } + + public Plot byId(String id) { + for (Plot p : plots) if (p.id().equals(id)) return p; + return null; + } + + /** Find the plot containing a given chunk position, or null. */ + public Plot byChunk(int chunkX, int chunkZ, String dimensionId) { + for (Plot p : plots) { + if (!p.dimension().equals(dimensionId)) continue; + for (int[] c : p.chunks()) { + if (c[0] == chunkX && c[1] == chunkZ) return p; + } + } + return null; + } + + public Plot byBlockPos(BlockPos pos, String dimensionId) { + return byChunk(pos.getX() >> 4, pos.getZ() >> 4, dimensionId); + } + + /** Create a new registry with one extra plot. Immutable replace. */ + public PlotRegistry withPlot(Plot p) { + List next = new ArrayList<>(plots); + next.removeIf(existing -> existing.id().equals(p.id())); + next.add(p); + return new PlotRegistry(spawnTeam, plotsTeam, Collections.unmodifiableList(next)); + } + + /** Create a new registry without the plot with the given id. */ + public PlotRegistry withoutPlot(String id) { + List next = new ArrayList<>(plots); + next.removeIf(p -> p.id().equals(id)); + return new PlotRegistry(spawnTeam, plotsTeam, Collections.unmodifiableList(next)); + } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/economy/FtbBridge.java b/src/main/java/uk/sijbers/bnstoolkit/economy/FtbBridge.java new file mode 100644 index 0000000..1e04143 --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/economy/FtbBridge.java @@ -0,0 +1,202 @@ +package uk.sijbers.bnstoolkit.economy; + +import dev.ftb.mods.ftbchunks.api.ChunkTeamData; +import dev.ftb.mods.ftbchunks.api.ClaimResult; +import dev.ftb.mods.ftbchunks.api.ClaimedChunk; +import dev.ftb.mods.ftbchunks.api.ClaimedChunkManager; +import dev.ftb.mods.ftbchunks.api.FTBChunksAPI; +import dev.ftb.mods.ftbchunks.api.event.ClaimedChunkEvent; +import dev.ftb.mods.ftblibrary.icon.Color4I; +import dev.ftb.mods.ftblibrary.math.ChunkDimPos; +import dev.ftb.mods.ftbteams.api.FTBTeamsAPI; +import dev.ftb.mods.ftbteams.api.Team; +import dev.ftb.mods.ftbteams.api.TeamManager; +import dev.architectury.event.CompoundEventResult; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import uk.sijbers.bnstoolkit.BnsToolkit; +import uk.sijbers.bnstoolkit.data.Plot; +import uk.sijbers.bnstoolkit.data.PlotConfig; + +import java.util.Optional; +import java.util.UUID; + +/** + * Type-safe wrapper around FTB Teams + FTB Chunks. + * + *

The mods are always in the BNS pack, so we depend on them at + * compile time (via build.gradle compileOnly) rather than the + * reflection pattern we use for KubeJS / Numismatics. The compileOnly + * choice keeps bnstoolkit's jar small and lets IntelliJ resolve types. + * + *

If FTB Teams/Chunks ever became optional, every public method + * here would need a try/catch around {@code FTBTeamsAPI.api()} etc. + * For now: it's a hard requirement — if either mod is missing, + * bnstoolkit's plot system breaks loudly, which is the correct + * failure mode. + * + *

Plot chunk guard

+ * {@link #installUnclaimGuard()} subscribes to {@link + * ClaimedChunkEvent#BEFORE_UNCLAIM} and rejects any unclaim of a + * registered plot chunk unless the bnstoolkit system flag is set + * (which only happens during a {@code /bns plot release} flow). + */ +public final class FtbBridge { + private FtbBridge() {} + + /** Set by Plot release/transfer paths so the guard lets the unclaim through. */ + private static final ThreadLocal SYSTEM_UNCLAIMING = ThreadLocal.withInitial(() -> false); + + /** Run {@code body} with system-unclaim privilege. Use sparingly. */ + public static T withSystemUnclaim(java.util.function.Supplier body) { + SYSTEM_UNCLAIMING.set(true); + try { return body.get(); } + finally { SYSTEM_UNCLAIMING.set(false); } + } + + public static boolean installUnclaimGuard() { + try { + ClaimedChunkEvent.BEFORE_UNCLAIM.register((src, chunk) -> { + if (Boolean.TRUE.equals(SYSTEM_UNCLAIMING.get())) { + return CompoundEventResult.pass(); + } + ChunkDimPos pos = chunk.getPos(); + String dim = pos.dimension().location().toString(); + Plot plot = Plot.REGISTRY.byChunk(pos.x(), pos.z(), dim); + if (plot == null) return CompoundEventResult.pass(); + // Plot chunks can't be manually unclaimed + try { + src.sendFailure(Component.literal( + "§cChunk is part of plot '" + plot.name() + "'. " + + "Use /bns plot release " + plot.id() + " at the Plot Office to sell it back.")); + } catch (Exception ignored) {} + return CompoundEventResult.interruptFalse(ClaimResult.StandardProblem.NOT_OWNER); + }); + BnsToolkit.LOG.info("[bns/plots] installed FTB Chunks BEFORE_UNCLAIM guard"); + return true; + } catch (Throwable t) { + BnsToolkit.LOG.warn("[bns/plots] couldn't install unclaim guard: {}", t.toString()); + return false; + } + } + + // ─── Teams ─────────────────────────────────────────────────────── + + public static TeamManager teamManager() { + return FTBTeamsAPI.api().getManager(); + } + + public static Optional getTeam(UUID id) { + return teamManager().getTeamByID(id); + } + + public static Optional getTeamByName(String name) { + return teamManager().getTeamByName(name); + } + + public static Optional getPersonalTeam(ServerPlayer player) { + return teamManager().getPlayerTeamForPlayerID(player.getUUID()); + } + + public static Optional getActiveTeam(ServerPlayer player) { + return teamManager().getTeamForPlayer(player); + } + + /** + * Get-or-create a server team (admin-owned, no human owner). Used + * for the Spawn + Plots admin teams. Idempotent — if a team with + * the given {@code teamId} UUID already exists, returns it. + */ + public static Optional ensureServerTeam(CommandSourceStack src, + String name, + String displayName, + Color4I color, + UUID teamId) { + TeamManager mgr = teamManager(); + Optional existing = mgr.getTeamByID(teamId); + if (existing.isPresent()) return existing; + + // Some old packs store these as named server teams — try name lookup too + Optional byName = mgr.getTeamByName(name); + if (byName.isPresent()) return byName; + + try { + Team created = mgr.createServerTeam(src, name, displayName, color, teamId); + BnsToolkit.LOG.info("[bns/plots] created server team {} (id {})", name, teamId); + return Optional.of(created); + } catch (Throwable t) { + BnsToolkit.LOG.warn("[bns/plots] couldn't create server team {}: {}", name, t.toString()); + return Optional.empty(); + } + } + + // ─── Chunks ────────────────────────────────────────────────────── + + public static ClaimedChunkManager chunkManager() { + return FTBChunksAPI.api().getManager(); + } + + public static ChunkTeamData chunkData(Team team) { + return chunkManager().getOrCreateData(team); + } + + public static boolean isClaimed(ResourceKey dim, int chunkX, int chunkZ) { + ChunkDimPos pos = new ChunkDimPos(dim, chunkX, chunkZ); + ClaimedChunk c = chunkManager().getChunk(pos); + return c != null; + } + + public static Team ownerOf(ResourceKey dim, int chunkX, int chunkZ) { + ChunkDimPos pos = new ChunkDimPos(dim, chunkX, chunkZ); + ClaimedChunk c = chunkManager().getChunk(pos); + return c == null ? null : c.getTeamData().getTeam(); + } + + /** Claim a chunk for the given team. Returns success. */ + public static boolean claimFor(Team team, CommandSourceStack src, + ResourceKey dim, int chunkX, int chunkZ) { + try { + ChunkDimPos pos = new ChunkDimPos(dim, chunkX, chunkZ); + ClaimResult r = chunkData(team).claim(src, pos, true); + return r.isSuccess(); + } catch (Throwable t) { + BnsToolkit.LOG.warn("[bns/plots] claimFor failed: {}", t.toString()); + return false; + } + } + + /** Unclaim a chunk currently held by the given team. Uses system privilege. */ + public static boolean unclaimFor(Team team, CommandSourceStack src, + ResourceKey dim, int chunkX, int chunkZ) { + return withSystemUnclaim(() -> { + try { + ChunkDimPos pos = new ChunkDimPos(dim, chunkX, chunkZ); + ClaimResult r = chunkData(team).unclaim(src, pos, true); + return r.isSuccess(); + } catch (Throwable t) { + BnsToolkit.LOG.warn("[bns/plots] unclaimFor failed: {}", t.toString()); + return false; + } + }); + } + + // ─── Color parser ─────────────────────────────────────────────── + + /** Parse "#RRGGBB" or "#AARRGGBB" hex to Color4I. */ + public static Color4I parseColor(String hex) { + try { + String s = hex.startsWith("#") ? hex.substring(1) : hex; + long v = Long.parseLong(s, 16); + if (s.length() == 6) return Color4I.rgb((int) v); + return Color4I.rgba((int) v); + } catch (Exception e) { + BnsToolkit.LOG.warn("[bns/plots] bad color '{}', defaulting to white", hex); + return Color4I.WHITE; + } + } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/ServerEconomyBridge.java b/src/main/java/uk/sijbers/bnstoolkit/network/ServerEconomyBridge.java index f599d9b..7ffd6ed 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/network/ServerEconomyBridge.java +++ b/src/main/java/uk/sijbers/bnstoolkit/network/ServerEconomyBridge.java @@ -277,7 +277,7 @@ public final class ServerEconomyBridge { public static void pushPlotSnapshot(ServerPlayer sp) { Map owners = new HashMap<>(); List ownedIds = new ArrayList<>(); - for (Plot p : Plot.ALL) owners.put(p.id(), ""); + for (Plot p : Plot.all()) owners.put(p.id(), ""); // KubeJS attaches its server-scope persistentData to MinecraftServer via // a mixin (kjs$getPersistentData). Reflective access avoids a hard diff --git a/src/main/java/uk/sijbers/bnstoolkit/server/PlotCommands.java b/src/main/java/uk/sijbers/bnstoolkit/server/PlotCommands.java new file mode 100644 index 0000000..e167231 --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/server/PlotCommands.java @@ -0,0 +1,389 @@ +package uk.sijbers.bnstoolkit.server; + +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import dev.ftb.mods.ftbteams.api.Team; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.StringTag; +import net.minecraft.nbt.Tag; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.Level; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.RegisterCommandsEvent; +import net.neoforged.neoforge.event.server.ServerStartedEvent; +import uk.sijbers.bnstoolkit.BnsToolkit; +import uk.sijbers.bnstoolkit.data.CivicTier; +import uk.sijbers.bnstoolkit.data.Plot; +import uk.sijbers.bnstoolkit.data.PlotConfig; +import uk.sijbers.bnstoolkit.data.PlotRegistry; +import uk.sijbers.bnstoolkit.economy.FtbBridge; +import uk.sijbers.bnstoolkit.economy.NumismaticsBridge; +import uk.sijbers.bnstoolkit.network.ServerEconomyBridge; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Plot-system commands — both admin (define/remove/reload/spawn-init) + * and player (buy/release). Owns plot ownership transitions and the + * FTB Chunks integration. + * + *

Plot definitions live in {@code config/bnstoolkit/plots.json} + * (per-world). The {@link Plot#REGISTRY} static is updated on server + * start, on reload, and on admin define/remove. + * + *

Ownership is tracked in {@code server.persistentData.bnsPlots} + * (the same compound the KubeJS plots.js used) and mirrored to each + * player's NBT as {@code bnsOwnedPlots} for HUD/screen consumption. + */ +@EventBusSubscriber(modid = BnsToolkit.MOD_ID) +public final class PlotCommands { + private PlotCommands() {} + + private static final String OWNERSHIP_KEY = "bnsPlots"; + private static final String PER_PLAYER_KEY = "bnsOwnedPlots"; + + // ─── Lifecycle ─────────────────────────────────────────────────── + + @SubscribeEvent + public static void onServerStarted(ServerStartedEvent event) { + MinecraftServer server = event.getServer(); + PlotRegistry registry = PlotConfig.load(server); + Plot.REGISTRY = registry; + BnsToolkit.LOG.info("[bns/plots] loaded {} plots from config", registry.plots().size()); + // Install the BEFORE_UNCLAIM guard so plot chunks can't be unclaimed + // by players manually. + FtbBridge.installUnclaimGuard(); + } + + @SubscribeEvent + public static void onRegisterCommands(RegisterCommandsEvent event) { + LiteralArgumentBuilder bns = Commands.literal("bns") + // ─── Player verbs ──────────────────────────────────── + .then(Commands.literal("plot") + .then(Commands.literal("buy") + .then(Commands.argument("id", StringArgumentType.word()) + .executes(ctx -> doBuy(ctx.getSource(), StringArgumentType.getString(ctx, "id"))))) + .then(Commands.literal("release") + .then(Commands.argument("id", StringArgumentType.word()) + .executes(ctx -> doRelease(ctx.getSource(), StringArgumentType.getString(ctx, "id")))))) + // ─── Admin verbs ───────────────────────────────────── + .then(Commands.literal("admin") + .requires(src -> src.hasPermission(2)) + .then(Commands.literal("plot") + .then(Commands.literal("define") + .then(Commands.argument("id", StringArgumentType.word()) + .then(Commands.argument("name", StringArgumentType.string()) + .then(Commands.argument("price", IntegerArgumentType.integer(0)) + .executes(ctx -> doDefine(ctx.getSource(), + StringArgumentType.getString(ctx, "id"), + StringArgumentType.getString(ctx, "name"), + IntegerArgumentType.getInteger(ctx, "price")))))) + ) + .then(Commands.literal("list").executes(ctx -> doList(ctx.getSource()))) + .then(Commands.literal("remove") + .then(Commands.argument("id", StringArgumentType.word()) + .executes(ctx -> doRemove(ctx.getSource(), StringArgumentType.getString(ctx, "id"))))) + .then(Commands.literal("reload").executes(ctx -> doReload(ctx.getSource())))) + .then(Commands.literal("spawn-init").executes(ctx -> doSpawnInit(ctx.getSource())))); + event.getDispatcher().register(bns); + BnsToolkit.LOG.info("[bns/plots] registered /bns plot + /bns admin plot/spawn-init"); + } + + // ─── /bns plot buy ───────────────────────────────────────── + + private static int doBuy(CommandSourceStack src, String plotId) { + ServerPlayer sp = src.getPlayer(); + if (sp == null) { src.sendFailure(Component.literal("/bns plot buy must be run as a player")); return 0; } + Plot plot = Plot.byId(plotId); + if (plot == null) { sp.sendSystemMessage(Component.literal("§cUnknown plot: " + plotId)); return 0; } + + // Tier slot cap check + CompoundTag pd = sp.getPersistentData(); + int tier = pd.contains("bnsTier") ? pd.getInt("bnsTier") : 1; + if (tier < 1 || tier > CivicTier.MAX) tier = 1; + int cap = CivicTier.byId(tier).plotSlots(); + int owned = ownedPlotsOf(sp).size(); + if (cap <= 0) { sp.sendSystemMessage(Component.literal("§cYou need tier 3 (Citizen) before owning a plot.")); return 0; } + if (owned >= cap) { + sp.sendSystemMessage(Component.literal("§cAt plot-slot cap (" + owned + " / " + cap + "). Upgrade your tier first.")); + return 0; + } + + // Ownership check (server-scope) + String currentOwner = getOwnerUuid(sp.server, plotId); + if (currentOwner != null) { + sp.sendSystemMessage(Component.literal("§cThat plot is already owned.")); + return 0; + } + + // Pay + if (!NumismaticsBridge.spend(sp, plot.price())) { + sp.sendSystemMessage(Component.literal("§cNot enough spurs. Plot costs " + plot.price())); + return 0; + } + + // Move chunks from Plots team to buyer's personal team + ResourceKey dim = ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, + net.minecraft.resources.ResourceLocation.parse(plot.dimension())); + Optional plotsTeam = FtbBridge.getTeamByName(Plot.REGISTRY.plotsTeam().name()); + Optional buyerTeam = FtbBridge.getPersonalTeam(sp); + if (plotsTeam.isPresent() && buyerTeam.isPresent()) { + for (int[] c : plot.chunks()) { + FtbBridge.unclaimFor(plotsTeam.get(), src, dim, c[0], c[1]); + FtbBridge.claimFor(buyerTeam.get(), src, dim, c[0], c[1]); + } + } else { + BnsToolkit.LOG.warn("[bns/plots] FTB Teams unavailable — purchase recorded but chunks NOT transferred"); + } + + // Record ownership + setOwner(sp.server, plotId, sp.getUUID().toString()); + addOwnedPlot(sp, plotId); + + sp.sendSystemMessage(Component.literal("§aPurchased '" + plot.name() + "' for " + plot.price() + " spurs.")); + BnsToolkit.LOG.info("[bns/plots] {} bought {} for {}", sp.getName().getString(), plotId, plot.price()); + + ServerEconomyBridge.pushTierUpdate(sp); + ServerEconomyBridge.pushPlotSnapshot(sp); + return 1; + } + + // ─── /bns plot release ───────────────────────────────────── + + private static int doRelease(CommandSourceStack src, String plotId) { + ServerPlayer sp = src.getPlayer(); + if (sp == null) { src.sendFailure(Component.literal("/bns plot release must be run as a player")); return 0; } + Plot plot = Plot.byId(plotId); + if (plot == null) { sp.sendSystemMessage(Component.literal("§cUnknown plot: " + plotId)); return 0; } + + String currentOwner = getOwnerUuid(sp.server, plotId); + if (currentOwner == null || !currentOwner.equals(sp.getUUID().toString())) { + sp.sendSystemMessage(Component.literal("§cYou don't own that plot.")); + return 0; + } + + // Move chunks back to Plots team + ResourceKey dim = ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, + net.minecraft.resources.ResourceLocation.parse(plot.dimension())); + Optional plotsTeam = FtbBridge.getTeamByName(Plot.REGISTRY.plotsTeam().name()); + Optional buyerTeam = FtbBridge.getPersonalTeam(sp); + if (plotsTeam.isPresent() && buyerTeam.isPresent()) { + for (int[] c : plot.chunks()) { + FtbBridge.unclaimFor(buyerTeam.get(), src, dim, c[0], c[1]); + FtbBridge.claimFor(plotsTeam.get(), src, dim, c[0], c[1]); + } + } + + // Refund + clear ownership + long refund = plot.refundOnRelease(); + NumismaticsBridge.deposit(sp, refund); + clearOwner(sp.server, plotId); + removeOwnedPlot(sp, plotId); + + sp.sendSystemMessage(Component.literal("§eReleased '" + plot.name() + "'. Refunded " + refund + " spurs to bank.")); + BnsToolkit.LOG.info("[bns/plots] {} released {} (refund {})", sp.getName().getString(), plotId, refund); + + ServerEconomyBridge.pushTierUpdate(sp); + ServerEconomyBridge.pushPlotSnapshot(sp); + return 1; + } + + // ─── /bns admin spawn-init ────────────────────────────────────── + + private static int doSpawnInit(CommandSourceStack src) { + PlotRegistry registry = Plot.REGISTRY; + var spawn = registry.spawnTeam(); + var plotsCfg = registry.plotsTeam(); + + UUID spawnId = UUID.fromString(spawn.fixedUuid()); + UUID plotsId = UUID.fromString(plotsCfg.fixedUuid()); + + Optional spawnTeam = FtbBridge.ensureServerTeam(src, + spawn.name(), spawn.displayName(), FtbBridge.parseColor(spawn.colorHex()), spawnId); + Optional plotsTeam = FtbBridge.ensureServerTeam(src, + plotsCfg.name(), plotsCfg.displayName(), FtbBridge.parseColor(plotsCfg.colorHex()), plotsId); + + if (spawnTeam.isEmpty() || plotsTeam.isEmpty()) { + src.sendFailure(Component.literal("§cFailed to create FTB teams — check log")); + return 0; + } + + // Claim all unsold plots for the Plots team + int claimed = 0, skipped = 0; + for (Plot plot : registry.plots()) { + ResourceKey dim = ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, + net.minecraft.resources.ResourceLocation.parse(plot.dimension())); + String owner = getOwnerUuid(src.getServer(), plot.id()); + if (owner != null) { skipped++; continue; } // already owned, leave it + for (int[] c : plot.chunks()) { + if (FtbBridge.isClaimed(dim, c[0], c[1])) { skipped++; continue; } + if (FtbBridge.claimFor(plotsTeam.get(), src, dim, c[0], c[1])) claimed++; + } + } + final int claimedFinal = claimed; + final int skippedFinal = skipped; + src.sendSuccess(() -> Component.literal( + "§aSpawn init complete. Spawn team: " + spawn.name() + + ", Plots team: " + plotsCfg.name() + + ". Claimed " + claimedFinal + " plot chunks, skipped " + skippedFinal + "."), true); + return 1; + } + + // ─── /bns admin plot define ───────────────── + + private static int doDefine(CommandSourceStack src, String id, String name, int price) { + ServerPlayer sp = src.getPlayer(); + if (sp == null) { src.sendFailure(Component.literal("/bns admin plot define must be run as a player")); return 0; } + int chunkX = sp.blockPosition().getX() >> 4; + int chunkZ = sp.blockPosition().getZ() >> 4; + String dim = sp.level().dimension().location().toString(); + + PlotRegistry registry = Plot.REGISTRY; + Plot existing = registry.byId(id); + List chunks; + String displayName; + String size; + long savePrice = price; + String saveDim = dim; + + if (existing != null) { + // Add this chunk to the existing plot (multi-chunk plots) + chunks = new ArrayList<>(existing.chunks()); + for (int[] c : chunks) { + if (c[0] == chunkX && c[1] == chunkZ) { + sp.sendSystemMessage(Component.literal("§eChunk already in plot " + id)); + return 0; + } + } + chunks.add(new int[]{chunkX, chunkZ}); + displayName = existing.name(); + size = existing.size(); + savePrice = existing.price(); // keep existing price + saveDim = existing.dimension(); + } else { + chunks = new ArrayList<>(); + chunks.add(new int[]{chunkX, chunkZ}); + displayName = name; + size = "medium"; // default; adjust later or via JSON + } + + Plot updated = new Plot(id, displayName, size, savePrice, saveDim, Collections.unmodifiableList(chunks)); + PlotRegistry next = registry.withPlot(updated); + Plot.REGISTRY = next; + PlotConfig.save(src.getServer(), next); + + // Claim chunk for the Plots team + Optional plotsTeam = FtbBridge.getTeamByName(next.plotsTeam().name()); + if (plotsTeam.isPresent()) { + ResourceKey dimKey = sp.level().dimension(); + FtbBridge.claimFor(plotsTeam.get(), src, dimKey, chunkX, chunkZ); + } + + sp.sendSystemMessage(Component.literal("§aPlot '" + id + "' now has " + chunks.size() + " chunks.")); + return 1; + } + + private static int doList(CommandSourceStack src) { + PlotRegistry r = Plot.REGISTRY; + src.sendSuccess(() -> Component.literal("§e=== Plots (" + r.plots().size() + ") ==="), false); + for (Plot p : r.plots()) { + String owner = getOwnerUuid(src.getServer(), p.id()); + String state = owner == null ? "§7for sale" : "§a owned"; + String line = " " + p.id() + " §f" + p.name() + "§r " + + "§6" + p.price() + " spurs§r " + state + + " §8(" + p.chunks().size() + " chunks)§r"; + src.sendSuccess(() -> Component.literal(line), false); + } + return 1; + } + + private static int doRemove(CommandSourceStack src, String id) { + Plot p = Plot.byId(id); + if (p == null) { src.sendFailure(Component.literal("Unknown plot: " + id)); return 0; } + // Unclaim its chunks first + Optional plotsTeam = FtbBridge.getTeamByName(Plot.REGISTRY.plotsTeam().name()); + if (plotsTeam.isPresent()) { + ResourceKey dim = ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, + net.minecraft.resources.ResourceLocation.parse(p.dimension())); + for (int[] c : p.chunks()) { + FtbBridge.unclaimFor(plotsTeam.get(), src, dim, c[0], c[1]); + } + } + PlotRegistry next = Plot.REGISTRY.withoutPlot(id); + Plot.REGISTRY = next; + PlotConfig.save(src.getServer(), next); + clearOwner(src.getServer(), id); // also clear any stale ownership + src.sendSuccess(() -> Component.literal("§eRemoved plot " + id), true); + return 1; + } + + private static int doReload(CommandSourceStack src) { + PlotRegistry r = PlotConfig.load(src.getServer()); + Plot.REGISTRY = r; + src.sendSuccess(() -> Component.literal("§aReloaded — " + r.plots().size() + " plots loaded"), true); + return 1; + } + + // ─── NBT helpers ───────────────────────────────────────────────── + + private static List ownedPlotsOf(ServerPlayer sp) { + List out = new ArrayList<>(); + CompoundTag pd = sp.getPersistentData(); + if (!pd.contains(PER_PLAYER_KEY)) return out; + ListTag list = pd.getList(PER_PLAYER_KEY, Tag.TAG_STRING); + for (int i = 0; i < list.size(); i++) out.add(list.getString(i)); + return out; + } + + private static void addOwnedPlot(ServerPlayer sp, String plotId) { + CompoundTag pd = sp.getPersistentData(); + ListTag list = pd.contains(PER_PLAYER_KEY) ? pd.getList(PER_PLAYER_KEY, Tag.TAG_STRING) : new ListTag(); + list.add(StringTag.valueOf(plotId)); + pd.put(PER_PLAYER_KEY, list); + } + + private static void removeOwnedPlot(ServerPlayer sp, String plotId) { + CompoundTag pd = sp.getPersistentData(); + if (!pd.contains(PER_PLAYER_KEY)) return; + ListTag list = pd.getList(PER_PLAYER_KEY, Tag.TAG_STRING); + for (int i = list.size() - 1; i >= 0; i--) { + if (list.getString(i).equals(plotId)) list.remove(i); + } + pd.put(PER_PLAYER_KEY, list); + } + + private static String getOwnerUuid(MinecraftServer server, String plotId) { + CompoundTag serverData = ServerEconomyBridge.getKubeJSServerData(server); + if (!serverData.contains(OWNERSHIP_KEY)) return null; + CompoundTag map = serverData.getCompound(OWNERSHIP_KEY); + return map.contains(plotId) ? map.getString(plotId) : null; + } + + private static void setOwner(MinecraftServer server, String plotId, String uuid) { + CompoundTag serverData = ServerEconomyBridge.getKubeJSServerData(server); + CompoundTag map = serverData.contains(OWNERSHIP_KEY) ? serverData.getCompound(OWNERSHIP_KEY) : new CompoundTag(); + map.putString(plotId, uuid); + serverData.put(OWNERSHIP_KEY, map); + } + + private static void clearOwner(MinecraftServer server, String plotId) { + CompoundTag serverData = ServerEconomyBridge.getKubeJSServerData(server); + if (!serverData.contains(OWNERSHIP_KEY)) return; + CompoundTag map = serverData.getCompound(OWNERSHIP_KEY); + map.remove(plotId); + serverData.put(OWNERSHIP_KEY, map); + } +}