0.8.0: JSON plot config + FTB Teams/Chunks integration
Build / build (push) Has been cancelled

Plot system migrated from static Java + KubeJS to a runtime
configurable, FTB-Chunks-integrated system.

* Plot definitions now live in JSON at
  <world>/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 <id> <name> <price> — 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 <id> — 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 <id> — 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 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-08 17:52:31 +00:00
parent 4a9edc15c1
commit 665c1a8442
9 changed files with 893 additions and 22 deletions
+7 -7
View File
@@ -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) {
+1 -1
View File
@@ -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
@@ -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;
@@ -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.
*
* <p>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).
*
* <p>{@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<int[]> chunks) {
public static final List<Plot> 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<Plot> 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() {
@@ -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}.
*
* <h2>Format</h2>
* <pre>
* {
* "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]]
* }
* ]
* }
* </pre>
*
* <p>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) {
// <world>/serverconfig/bnstoolkit/plots.json — per-world, so a wipe
// resets it. Falls back to <server>/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<Plot> 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<int[]> 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");
}
}
@@ -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.
*
* <p>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<Plot> plots;
public PlotRegistry(PlotConfig.TeamConfig spawnTeam,
PlotConfig.TeamConfig plotsTeam,
List<Plot> 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<Plot> 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<Plot> 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<Plot> next = new ArrayList<>(plots);
next.removeIf(p -> p.id().equals(id));
return new PlotRegistry(spawnTeam, plotsTeam, Collections.unmodifiableList(next));
}
}
@@ -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.
*
* <p>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.
*
* <p>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.
*
* <h2>Plot chunk guard</h2>
* {@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<Boolean> SYSTEM_UNCLAIMING = ThreadLocal.withInitial(() -> false);
/** Run {@code body} with system-unclaim privilege. Use sparingly. */
public static <T> T withSystemUnclaim(java.util.function.Supplier<T> 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<Team> getTeam(UUID id) {
return teamManager().getTeamByID(id);
}
public static Optional<Team> getTeamByName(String name) {
return teamManager().getTeamByName(name);
}
public static Optional<Team> getPersonalTeam(ServerPlayer player) {
return teamManager().getPlayerTeamForPlayerID(player.getUUID());
}
public static Optional<Team> 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<Team> ensureServerTeam(CommandSourceStack src,
String name,
String displayName,
Color4I color,
UUID teamId) {
TeamManager mgr = teamManager();
Optional<Team> existing = mgr.getTeamByID(teamId);
if (existing.isPresent()) return existing;
// Some old packs store these as named server teams — try name lookup too
Optional<Team> 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<Level> 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<Level> 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<Level> 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<Level> 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;
}
}
}
@@ -277,7 +277,7 @@ public final class ServerEconomyBridge {
public static void pushPlotSnapshot(ServerPlayer sp) {
Map<String, String> owners = new HashMap<>();
List<String> 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
@@ -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.
*
* <p>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.
*
* <p>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<CommandSourceStack> 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 <id> ─────────────────────────────────────────
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<Level> dim = ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION,
net.minecraft.resources.ResourceLocation.parse(plot.dimension()));
Optional<Team> plotsTeam = FtbBridge.getTeamByName(Plot.REGISTRY.plotsTeam().name());
Optional<Team> 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 <id> ─────────────────────────────────────
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<Level> dim = ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION,
net.minecraft.resources.ResourceLocation.parse(plot.dimension()));
Optional<Team> plotsTeam = FtbBridge.getTeamByName(Plot.REGISTRY.plotsTeam().name());
Optional<Team> 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<Team> spawnTeam = FtbBridge.ensureServerTeam(src,
spawn.name(), spawn.displayName(), FtbBridge.parseColor(spawn.colorHex()), spawnId);
Optional<Team> 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<Level> 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 <id> <name> <price> ─────────────────
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<int[]> 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<Team> plotsTeam = FtbBridge.getTeamByName(next.plotsTeam().name());
if (plotsTeam.isPresent()) {
ResourceKey<Level> 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<Team> plotsTeam = FtbBridge.getTeamByName(Plot.REGISTRY.plotsTeam().name());
if (plotsTeam.isPresent()) {
ResourceKey<Level> 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<String> ownedPlotsOf(ServerPlayer sp) {
List<String> 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);
}
}