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);
+ }
+}