0.4.0: NBT key fix + hub UI + /bns open + network hardening
Build / build (push) Has been cancelled

Post-review fixes from the parallel code/security/hotkey/NPC audits.

## #35 — NBT keys (CRITICAL: screens were showing fictional data)
ServerEconomyBridge.pushBountySnapshot now reads the actual KubeJS keys:
- bnsBountyPool (compound id->1) — was bnsBounties.pool
- bnsBountyActive (compound id->{progress,target,reward,...}) — was bnsBounties.active
- bnsBountyCompleted (compound id->long cooldown-end-ms) — was bnsBounties.completed
- Filters completed by `cooldown-end > now` so old cooldowns drop off

ServerEconomyBridge.pushPlotSnapshot now reads server-scope state via
KubeJS's MinecraftServer mixin (kjs$getPersistentData) using reflection,
no compileOnly dep on KubeJS. Falls back to empty CompoundTag if the
method isn't there. Resolves owner names from PlayerList for online
players, GameProfileCache for offline.

## #36 — Hub UI + key rebind
Single primary key: B opens HubScreen. Direct shortcut keys (K/J/N/H/M)
all default to UNBOUND so they no longer compete with the ~100 other
pack mods. Audit found M = vanilla Social Interactions + FTB Chunks
fullscreen map (hard conflict, removed); H/N collided with Voice Chat
toggles (minor, removed); K/J were safe (kept rebindable for power users).

New HubScreen routes to the 5 sub-screens via labelled buttons + shows
tier/balance/fee at a glance in the header.

## #37 — /bns open <screen>
Java-registered Brigadier subcommand at RegisterCommandsEvent time,
merges with KubeJS's /bns root. New OpenScreenPacket (S2C) pushes a
screen open to the calling player. Screens: hub, tier, bounty, plot,
shop, questlog. Suggests valid screens via tab-complete.

This unlocks:
- Easy NPC dialogue COMMAND actions calling /bns open shop, etc.
- FTB Quests reward commands
- Command blocks / /trigger objectives
- Future scripted intros / tutorials

Also: patched /srv/fast/brass-sigil-server/server/config/easy_npc/
security.cfg to allow `bns` in executeAsUserCommandAllowList.ALL.

## #38 — Network hardening
- Per-player token bucket on C2S packets (10/s sustain, 20 burst).
- All STRING_UTF8 codecs capped: kind=8, args=80, which=16.
- Verb whitelist replaced with structured parse: args -> tokens ->
  per-verb regex validators -> rebuilt command. No more prefix-match
  surprises if /bns sell syntax expands.
- Per-player in-flight guard on tier upgrades (500ms) closes the
  double-spend race window.
- Bridge logs sanitised (control-char-stripped) so a hostile client
  can't inject newlines/ANSI into server logs.

## Review-driven nits also folded in
- Per-player tickCount (was shared static — N-player skew)
- VillagerTradeRebalance: try/catch around inner.getOffer; null cost-A
  passthrough; round-to-nearest denom split (was floor, ~10% price haircut)
- SpurHud caches Component refs (avoid 120/s allocation)
- ClientBnsState.completedIds now HashSet (O(1) per-frame lookups)
- BountyBoardScreen: deleted the empty `if (isActive)` dead branch
- Wire-format version bumped 0.2 -> 0.4 (added OpenScreenPacket)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-07 09:24:23 +00:00
parent 14dc69f22f
commit 884a8cea0b
17 changed files with 613 additions and 166 deletions
+1 -1
View File
@@ -19,5 +19,5 @@ loader_version_range=[1,)
mod_id=bnstoolkit mod_id=bnstoolkit
mod_name=Brass and Sigil Toolkit mod_name=Brass and Sigil Toolkit
mod_license=All Rights Reserved mod_license=All Rights Reserved
mod_version=0.3.1 mod_version=0.4.0
mod_group_id=uk.sijbers.bnstoolkit mod_group_id=uk.sijbers.bnstoolkit
@@ -53,17 +53,13 @@ public final class BnsToolkit {
} }
} }
private static int tickCounter = 0;
@SubscribeEvent @SubscribeEvent
public void onPlayerTick(PlayerTickEvent.Post event) { public void onPlayerTick(PlayerTickEvent.Post event) {
// 1 Hz balance push so the HUD reflects coin-pickup without polling // 1 Hz balance push so the HUD reflects coin-pickup without polling
// the inventory client-side. // the inventory client-side. Per-player tick counter (not a shared
if (event.getEntity() instanceof ServerPlayer sp) { // static) so the push rate is 1Hz/player and naturally staggered.
tickCounter++; if (event.getEntity() instanceof ServerPlayer sp && sp.tickCount % 20 == 0) {
if (tickCounter % 20 == 0) {
ServerEconomyBridge.pushTierUpdate(sp); ServerEconomyBridge.pushTierUpdate(sp);
} }
} }
}
} }
@@ -10,6 +10,7 @@ import net.neoforged.neoforge.client.settings.KeyConflictContext;
import org.lwjgl.glfw.GLFW; import org.lwjgl.glfw.GLFW;
import uk.sijbers.bnstoolkit.BnsToolkit; import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.client.screens.BountyBoardScreen; import uk.sijbers.bnstoolkit.client.screens.BountyBoardScreen;
import uk.sijbers.bnstoolkit.client.screens.HubScreen;
import uk.sijbers.bnstoolkit.client.screens.PlotPurchaseScreen; import uk.sijbers.bnstoolkit.client.screens.PlotPurchaseScreen;
import uk.sijbers.bnstoolkit.client.screens.QuestLogScreen; import uk.sijbers.bnstoolkit.client.screens.QuestLogScreen;
import uk.sijbers.bnstoolkit.client.screens.ShopBrowserScreen; import uk.sijbers.bnstoolkit.client.screens.ShopBrowserScreen;
@@ -17,66 +18,88 @@ import uk.sijbers.bnstoolkit.client.screens.TierUpgradeScreen;
import uk.sijbers.bnstoolkit.network.NetSend; import uk.sijbers.bnstoolkit.network.NetSend;
/** /**
* Key bindings for the toolkit screens. * Key bindings for the bnstoolkit screens.
* *
* Default keys avoid known FTB Quests / Chunks / Inventory defaults: * <p>Single primary key: <b>B</b> opens the hub screen, which has buttons
* K — Civic tier ladder (replaces vanilla advancements which is L) * routing to each sub-screen. Power users can also bind direct shortcuts
* J — Bounty board * to the 5 sub-screens (defaults are UNBOUND so they don't compete with
* N — Plot map / office * the other ~100 mods in the pack).
* M — Quest log overview
* *
* Picked so they sit on the home row right-hand side and don't clash * <p>Audit context for choosing B as default (2026-06-07):
* with WASD movement / vanilla / FTB defaults. Player can rebind in the * <ul>
* vanilla controls menu like any other mod. * <li>Vanilla 1.21.1: B is free</li>
* <li>FTB Quests / FTB Chunks / FTB Library: B is free</li>
* <li>Numismatics / Easy NPC / JEI / Patchouli: B is free</li>
* <li>Simple Voice Chat, Curios, Ars Nouveau, Better Combat, Sophisticated
* Backpacks: B is free</li>
* </ul>
* Sub-screen keys (K/J/N/H/M) had conflicts — most notably M against
* vanilla Social Interactions + FTB Chunks fullscreen map — so they're
* intentionally UNBOUND now.
*/ */
public final class BnsKeyBindings { public final class BnsKeyBindings {
private BnsKeyBindings() {} private BnsKeyBindings() {}
private static final String CATEGORY = "key.category." + BnsToolkit.MOD_ID; private static final String CATEGORY = "key.category." + BnsToolkit.MOD_ID;
public static final KeyMapping OPEN_HUB = new KeyMapping(
"key." + BnsToolkit.MOD_ID + ".hub",
KeyConflictContext.IN_GAME,
InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_B),
CATEGORY);
// Direct shortcuts — default UNBOUND. Players can bind them in the
// vanilla controls menu under the "Brass and Sigil" category if they
// want speed over discoverability.
public static final KeyMapping OPEN_TIER = new KeyMapping( public static final KeyMapping OPEN_TIER = new KeyMapping(
"key." + BnsToolkit.MOD_ID + ".tier", "key." + BnsToolkit.MOD_ID + ".tier",
KeyConflictContext.IN_GAME, KeyConflictContext.IN_GAME,
InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_K), InputConstants.UNKNOWN,
CATEGORY); CATEGORY);
public static final KeyMapping OPEN_BOUNTIES = new KeyMapping( public static final KeyMapping OPEN_BOUNTIES = new KeyMapping(
"key." + BnsToolkit.MOD_ID + ".bounties", "key." + BnsToolkit.MOD_ID + ".bounties",
KeyConflictContext.IN_GAME, KeyConflictContext.IN_GAME,
InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_J), InputConstants.UNKNOWN,
CATEGORY); CATEGORY);
public static final KeyMapping OPEN_PLOTS = new KeyMapping( public static final KeyMapping OPEN_PLOTS = new KeyMapping(
"key." + BnsToolkit.MOD_ID + ".plots", "key." + BnsToolkit.MOD_ID + ".plots",
KeyConflictContext.IN_GAME, KeyConflictContext.IN_GAME,
InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_N), InputConstants.UNKNOWN,
CATEGORY); CATEGORY);
public static final KeyMapping OPEN_SHOP = new KeyMapping( public static final KeyMapping OPEN_SHOP = new KeyMapping(
"key." + BnsToolkit.MOD_ID + ".shop", "key." + BnsToolkit.MOD_ID + ".shop",
KeyConflictContext.IN_GAME, KeyConflictContext.IN_GAME,
InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_H), InputConstants.UNKNOWN,
CATEGORY); CATEGORY);
public static final KeyMapping OPEN_QUESTLOG = new KeyMapping( public static final KeyMapping OPEN_QUESTLOG = new KeyMapping(
"key." + BnsToolkit.MOD_ID + ".questlog", "key." + BnsToolkit.MOD_ID + ".questlog",
KeyConflictContext.IN_GAME, KeyConflictContext.IN_GAME,
InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_M), InputConstants.UNKNOWN,
CATEGORY); CATEGORY);
public static void onRegisterKeyMappings(RegisterKeyMappingsEvent event) { public static void onRegisterKeyMappings(RegisterKeyMappingsEvent event) {
event.register(OPEN_HUB);
event.register(OPEN_TIER); event.register(OPEN_TIER);
event.register(OPEN_BOUNTIES); event.register(OPEN_BOUNTIES);
event.register(OPEN_PLOTS); event.register(OPEN_PLOTS);
event.register(OPEN_SHOP); event.register(OPEN_SHOP);
event.register(OPEN_QUESTLOG); event.register(OPEN_QUESTLOG);
BnsToolkit.LOG.info("[bnstoolkit] registered 5 key bindings under {}", CATEGORY); BnsToolkit.LOG.info("[bnstoolkit] registered key bindings (B = hub; others unbound by default)");
} }
@SubscribeEvent @SubscribeEvent
public static void onClientTick(ClientTickEvent.Post event) { public static void onClientTick(ClientTickEvent.Post event) {
Minecraft mc = Minecraft.getInstance(); Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.player == null || mc.screen != null) return; if (mc == null || mc.player == null || mc.screen != null) return;
if (OPEN_HUB.consumeClick()) {
NetSend.requestSnapshot("ALL");
new HubScreen().openGui();
}
// Direct shortcuts — only fire if the player actually bound a key
if (OPEN_TIER.consumeClick()) { if (OPEN_TIER.consumeClick()) {
NetSend.requestSnapshot("TIER"); NetSend.requestSnapshot("TIER");
new TierUpgradeScreen().openGui(); new TierUpgradeScreen().openGui();
@@ -6,8 +6,10 @@ import uk.sijbers.bnstoolkit.data.Plot;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* Client-side cache of the player's economy state, populated by S2C * Client-side cache of the player's economy state, populated by S2C
@@ -34,7 +36,7 @@ public final class ClientBnsState {
// BountySnapshot: // BountySnapshot:
public static final List<String> dailyPoolIds = new ArrayList<>(); public static final List<String> dailyPoolIds = new ArrayList<>();
public static final Map<String, ActiveBounty> activeBounties = new HashMap<>(); public static final Map<String, ActiveBounty> activeBounties = new HashMap<>();
public static final List<String> completedIds = new ArrayList<>(); public static final Set<String> completedIds = new HashSet<>();
public static volatile int bountySlotCap = 3; public static volatile int bountySlotCap = 3;
// PlotSnapshot: // PlotSnapshot:
@@ -0,0 +1,34 @@
package uk.sijbers.bnstoolkit.client;
import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.client.screens.BountyBoardScreen;
import uk.sijbers.bnstoolkit.client.screens.HubScreen;
import uk.sijbers.bnstoolkit.client.screens.PlotPurchaseScreen;
import uk.sijbers.bnstoolkit.client.screens.QuestLogScreen;
import uk.sijbers.bnstoolkit.client.screens.ShopBrowserScreen;
import uk.sijbers.bnstoolkit.client.screens.TierUpgradeScreen;
import uk.sijbers.bnstoolkit.network.NetSend;
/**
* Maps the string-keyed S2C OpenScreenPacket payload to the
* corresponding client-side screen open. Lives in the client package
* so the packet class itself doesn't have to import screen classes —
* which keeps the dedicated server from trying to load them.
*/
public final class OpenScreenDispatcher {
private OpenScreenDispatcher() {}
public static void open(String which) {
// Each branch requests the matching snapshot then opens the screen
// so the panel renders with data instead of zeros.
switch (which) {
case "tier" -> { NetSend.requestSnapshot("TIER"); new TierUpgradeScreen().openGui(); }
case "bounty" -> { NetSend.requestSnapshot("BOUNTY"); new BountyBoardScreen().openGui(); }
case "plot" -> { NetSend.requestSnapshot("PLOT"); new PlotPurchaseScreen().openGui(); }
case "shop" -> { NetSend.requestSnapshot("SELL"); new ShopBrowserScreen().openGui(); }
case "questlog" -> { NetSend.requestSnapshot("ALL"); new QuestLogScreen().openGui(); }
case "hub" -> { NetSend.requestSnapshot("ALL"); new HubScreen().openGui(); }
default -> BnsToolkit.LOG.warn("[bnstoolkit] unknown screen requested: {}", which);
}
}
}
@@ -3,7 +3,6 @@ package uk.sijbers.bnstoolkit.client.hud;
import net.minecraft.client.DeltaTracker; import net.minecraft.client.DeltaTracker;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.LayeredDraw;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
import net.neoforged.neoforge.client.event.RegisterGuiLayersEvent; import net.neoforged.neoforge.client.event.RegisterGuiLayersEvent;
@@ -16,10 +15,9 @@ import uk.sijbers.bnstoolkit.data.CivicTier;
/** /**
* Persistent on-screen overlay: civic tier + spurs balance, top-left. * Persistent on-screen overlay: civic tier + spurs balance, top-left.
* *
* Drawn as a NeoForge {@link RegisterGuiLayersEvent} layer rather than a * Registered as a NeoForge gui layer (above the experience bar) so it
* {@code RenderGuiEvent} subscriber so it composes cleanly with the * composes cleanly with the vanilla HUD chain — F1 hide, chat focus,
* vanilla layer system (and hides automatically when chat / inventory * and pause screens all suppress us automatically.
* suppresses HUD).
*/ */
public final class SpurHud { public final class SpurHud {
private SpurHud() {} private SpurHud() {}
@@ -28,6 +26,13 @@ public final class SpurHud {
public static final ResourceLocation LAYER_ID = public static final ResourceLocation LAYER_ID =
ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "spur_hud"); ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "spur_hud");
// Cache the rendered Component objects, rebuild only when the state changes.
// Keeps allocation off the 60 Hz render path.
private int cachedTier = -1;
private long cachedSpurs = -1L;
private Component cachedTierLine = Component.empty();
private Component cachedSpursLine = Component.empty();
public static void onRegisterGuiLayers(RegisterGuiLayersEvent event) { public static void onRegisterGuiLayers(RegisterGuiLayersEvent event) {
event.registerAbove(VanillaGuiLayers.EXPERIENCE_BAR, LAYER_ID, INSTANCE::render); event.registerAbove(VanillaGuiLayers.EXPERIENCE_BAR, LAYER_ID, INSTANCE::render);
BnsToolkit.LOG.info("[bnstoolkit] HUD layer registered as {}", LAYER_ID); BnsToolkit.LOG.info("[bnstoolkit] HUD layer registered as {}", LAYER_ID);
@@ -38,13 +43,18 @@ public final class SpurHud {
if (mc == null || mc.player == null) return; if (mc == null || mc.player == null) return;
if (mc.options.hideGui) return; if (mc.options.hideGui) return;
if (cachedTier != ClientBnsState.tier || cachedSpurs != ClientBnsState.spurs) {
CivicTier cfg = CivicTier.byId(ClientBnsState.tier); CivicTier cfg = CivicTier.byId(ClientBnsState.tier);
String tierLine = "§" + cfg.colour().getChar() + cfg.name() + "§r"; cachedTierLine = Component.literal("§" + cfg.colour().getChar() + cfg.name() + "§r");
String spursLine = "§6" + TierUpgradeScreen.formatSpurs(ClientBnsState.spurs) + " spurs§r"; cachedSpursLine = Component.literal("§6" + TierUpgradeScreen.formatSpurs(ClientBnsState.spurs) + " spurs§r");
cachedTier = ClientBnsState.tier;
cachedSpurs = ClientBnsState.spurs;
}
int x = 4; int x = 4;
int y = 4; int y = 4;
int w = Math.max(mc.font.width(tierLine), mc.font.width(spursLine)) + 8; int textW = Math.max(mc.font.width(cachedTierLine), mc.font.width(cachedSpursLine));
int w = textW + 8;
int h = 24; int h = 24;
gfx.fill(x, y, x + w, y + h, 0x80000000); gfx.fill(x, y, x + w, y + h, 0x80000000);
@@ -53,17 +63,7 @@ public final class SpurHud {
gfx.fill(x, y, x + 1, y + h, 0xFF666666); gfx.fill(x, y, x + 1, y + h, 0xFF666666);
gfx.fill(x + w - 1, y, x + w, y + h, 0xFF666666); gfx.fill(x + w - 1, y, x + w, y + h, 0xFF666666);
gfx.drawString(mc.font, Component.literal(tierLine), x + 4, y + 4, 0xFFFFFFFF, true); gfx.drawString(mc.font, cachedTierLine, x + 4, y + 4, 0xFFFFFFFF, true);
gfx.drawString(mc.font, Component.literal(spursLine), x + 4, y + 14, 0xFFFFFFFF, true); gfx.drawString(mc.font, cachedSpursLine, x + 4, y + 14, 0xFFFFFFFF, true);
} }
// Kept for symmetry with the LayeredDraw.Layer interface in case other
// code wants to call the render directly.
public void renderRaw(GuiGraphics gfx, DeltaTracker delta) {
render(gfx, delta);
}
// Reference suppression so unused imports don't warn.
@SuppressWarnings("unused")
private static final LayeredDraw.Layer UNUSED_REF = null;
} }
@@ -149,7 +149,6 @@ public final class BountyBoardScreen extends BaseBnsScreen {
if (ab != null) add(new HeaderRow(this, "Progress: " + ab.progress() + " / " + ab.target())); if (ab != null) add(new HeaderRow(this, "Progress: " + ab.progress() + " / " + ab.target()));
boolean atCap = ClientBnsState.activeBounties.size() >= ClientBnsState.bountySlotCap; boolean atCap = ClientBnsState.activeBounties.size() >= ClientBnsState.bountySlotCap;
boolean isActive = s == Bounty.State.ACTIVE || s == Bounty.State.READY;
if (s == Bounty.State.AVAILABLE && !atCap) { if (s == Bounty.State.AVAILABLE && !atCap) {
add(SimpleTextButton.create(this, Component.literal("Accept"), Icons.ACCEPT, add(SimpleTextButton.create(this, Component.literal("Accept"), Icons.ACCEPT,
@@ -166,8 +165,6 @@ public final class BountyBoardScreen extends BaseBnsScreen {
if (s == Bounty.State.AVAILABLE && atCap) { if (s == Bounty.State.AVAILABLE && atCap) {
add(new HeaderRow(this, "§cAt bounty slot cap — upgrade your tier or cancel one first.")); add(new HeaderRow(this, "§cAt bounty slot cap — upgrade your tier or cancel one first."));
} }
// ignore isActive flag silently; included for future buttons
if (isActive) { /* visual cue handled via row marker */ }
} }
@Override public void alignWidgets() { @Override public void alignWidgets() {
@@ -0,0 +1,96 @@
package uk.sijbers.bnstoolkit.client.screens;
import dev.ftb.mods.ftblibrary.icon.Color4I;
import dev.ftb.mods.ftblibrary.icon.Icon;
import dev.ftb.mods.ftblibrary.icon.Icons;
import dev.ftb.mods.ftblibrary.ui.Panel;
import dev.ftb.mods.ftblibrary.ui.SimpleTextButton;
import dev.ftb.mods.ftblibrary.ui.Theme;
import dev.ftb.mods.ftblibrary.ui.Widget;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.network.chat.Component;
import uk.sijbers.bnstoolkit.client.ClientBnsState;
import uk.sijbers.bnstoolkit.data.CivicTier;
import uk.sijbers.bnstoolkit.network.NetSend;
/**
* Brass and Sigil hub — the single entry point opened by the B key.
*
* Header: current tier (in tier colour) + spurs balance.
* Body: 5 large buttons that route to the sub-screens. Clicking a button
* fires the matching snapshot request and opens the screen — same flow
* as the (now mostly-unbound) direct shortcut keys.
*
* Closing a sub-screen returns to the hub (so this hub becomes a real
* navigation surface, not a one-shot menu).
*/
public final class HubScreen extends BaseBnsScreen {
public HubScreen() {
super(Component.translatable("screen.bnstoolkit.hub"));
}
@Override
public void addWidgets() {
add(new StatusHeader(this));
add(makeButton("Civic Tier", Icons.PLAYER, "TIER", () -> new TierUpgradeScreen()));
add(makeButton("Bazaar Shop", Icons.ADD, "SELL", () -> new ShopBrowserScreen()));
add(makeButton("Bounty Board", Icons.ACCEPT, "BOUNTY", () -> new BountyBoardScreen()));
add(makeButton("Plot Office", Icons.FRIENDS, "PLOT", () -> new PlotPurchaseScreen()));
add(makeButton("Quest Log", Icons.INFO, "ALL", () -> new QuestLogScreen()));
add(SimpleTextButton.create(this, Component.literal("Close"), Icons.CLOSE,
btn -> closeGui()));
}
@Override
public void alignWidgets() {
// Header across the top
Widget header = getWidgets().get(0);
header.setPos(getX() + 8, contentTop());
header.setSize(getWidth() - 16, 20);
// 5 routing buttons stacked vertically in two columns
int cols = 2;
int btnW = (getWidth() - 24) / cols;
int btnH = 22;
int gap = 4;
int top = contentTop() + 28;
for (int i = 0; i < 5; i++) {
int row = i / cols;
int col = i % cols;
Widget w = getWidgets().get(1 + i);
w.setSize(btnW, btnH);
w.setPos(getX() + 8 + col * (btnW + gap), top + row * (btnH + gap));
}
// Close button bottom-right
Widget close = getWidgets().get(6);
close.setSize(64, 16);
close.setPos(getX() + getWidth() - 72, getY() + getHeight() - 22);
}
private SimpleTextButton makeButton(String label, Icon icon, String snapshot,
java.util.function.Supplier<BaseBnsScreen> ctor) {
return SimpleTextButton.create(this, Component.literal(label), icon, btn -> {
NetSend.requestSnapshot(snapshot);
ctor.get().openGui();
});
}
/** Status header — tier, balance, fee at the top of the hub. */
private static final class StatusHeader extends Widget {
StatusHeader(Panel parent) { super(parent); }
@Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) {
Color4I.rgba(0x40FFFFFF).draw(gfx, x, y, w, h);
CivicTier cfg = CivicTier.byId(ClientBnsState.tier);
String left = "Tier: §" + cfg.colour().getChar() + cfg.name() + "§r";
String right = "Balance: §6" + TierUpgradeScreen.formatSpurs(ClientBnsState.spurs)
+ "§r §7(fee " + ClientBnsState.effectiveFeePct + "%)§r";
theme.drawString(gfx, Component.literal(left), x + 4, y + 6, Color4I.WHITE, Theme.SHADOW);
int rightW = theme.getFont().width(right);
theme.drawString(gfx, Component.literal(right), x + w - rightW - 4, y + 6,
Color4I.WHITE, Theme.SHADOW);
}
}
}
@@ -22,25 +22,30 @@ import java.util.Optional;
/** /**
* Server-side villager-trade rebalance. * Server-side villager-trade rebalance.
* *
* Hooks {@link VillagerTradesEvent} and {@link WandererTradesEvent} at * Wraps every trade listing on {@link VillagerTradesEvent} (and the
* data-pack-reload time. For every existing trade listing, swaps any * wanderer trades) so the resulting {@link MerchantOffer} has its
* emerald cost/result for the Numismatics equivalent (1 spur = 1 * emerald cost-or-result items swapped to Numismatics coin equivalents.
* emerald), so the entire vanilla and modded villager trade table
* routes through the BNS economy without us having to enumerate every
* profession + level by hand.
* *
* Counts >= 8 are upgraded to higher denominations so inventory doesn't * <h2>Denomination choice</h2>
* fill up with raw spurs: * For each emerald count we pick a single denomination that minimises
* 1..7 spurs (1 each) * the rounding error:
* 8..63 -> bevels (1 bevel = 8 spurs) * <ul>
* 64.. -> cogs (1 cog = 64 spurs) * <li>1..7 emeralds → spurs (1 spur = 1 emerald)</li>
* <li>8..63 emeralds → bevels, rounding to nearest (1 bevel = 8 spurs)</li>
* <li>64+ emeralds → cogs, rounding to nearest (1 cog = 64 spurs)</li>
* </ul>
* Round-to-nearest avoids the 1012% silent price haircut from floor-division.
* MerchantOffer constraints require a single ItemCost per slot (no
* multi-denom split possible at this layer).
* *
* Why this lives in bnstoolkit rather than a third-party mod: * <h2>Robustness</h2>
* - The two existing options on Modrinth (numismatics-villager-currency * <ul>
* and data-trades) either require a NeoForge bump (1.1.0 wants * <li>Wraps inner ItemListing.getOffer in try/catch — a misbehaved
* 21.1.230+, we're on 228) or need hand-written JSON for every * modded listing can't take down the whole profession.</li>
* trade. Reusing the event we already need for HUD pushes is * <li>Null offer returned from inner is propagated cleanly.</li>
* simpler and avoids touching the live NeoForge install. * <li>Null cost-A (theoretically possible from a reflection-built modded
* MerchantOffer) is treated as "leave alone".</li>
* </ul>
*/ */
@EventBusSubscriber(modid = BnsToolkit.MOD_ID) @EventBusSubscriber(modid = BnsToolkit.MOD_ID)
public final class VillagerTradeRebalance { public final class VillagerTradeRebalance {
@@ -80,9 +85,18 @@ public final class VillagerTradeRebalance {
private record SpurTradeWrapper(VillagerTrades.ItemListing inner) implements VillagerTrades.ItemListing { private record SpurTradeWrapper(VillagerTrades.ItemListing inner) implements VillagerTrades.ItemListing {
@Override @Override
public MerchantOffer getOffer(net.minecraft.world.entity.Entity entity, net.minecraft.util.RandomSource rand) { public MerchantOffer getOffer(net.minecraft.world.entity.Entity entity, net.minecraft.util.RandomSource rand) {
MerchantOffer base = inner.getOffer(entity, rand); final MerchantOffer base;
try {
base = inner.getOffer(entity, rand);
} catch (Throwable t) {
BnsToolkit.LOG.warn("[bns/trades] inner ItemListing {} threw {} — skipping offer",
inner.getClass().getName(), t.toString());
return null;
}
if (base == null) return null; if (base == null) return null;
ItemCost costA = base.getItemCostA(); ItemCost costA = base.getItemCostA();
if (costA == null) return base; // can't swap; leave alone
Optional<ItemCost> costB = base.getItemCostB(); Optional<ItemCost> costB = base.getItemCostB();
ItemStack result = base.getResult(); ItemStack result = base.getResult();
@@ -104,7 +118,7 @@ public final class VillagerTradeRebalance {
int count = peek.getCount(); int count = peek.getCount();
CoinSwap swap = pickDenom(count); CoinSwap swap = pickDenom(count);
Item coinItem = BuiltInRegistries.ITEM.get(swap.itemId); Item coinItem = BuiltInRegistries.ITEM.get(swap.itemId);
if (coinItem == Items.AIR) return cost; // mod missing — leave vanilla cost if (coinItem == Items.AIR) return cost; // Numismatics not loaded
return new ItemCost(coinItem, swap.count); return new ItemCost(coinItem, swap.count);
} }
@@ -122,8 +136,11 @@ public final class VillagerTradeRebalance {
private record CoinSwap(ResourceLocation itemId, int count) {} private record CoinSwap(ResourceLocation itemId, int count) {}
private static CoinSwap pickDenom(int emeralds) { private static CoinSwap pickDenom(int emeralds) {
if (emeralds >= 64) return new CoinSwap(COG_ID, Math.max(1, emeralds / 64)); // Round-to-nearest so 11 emeralds → 1 bevel (8) not 1 (and lose 3 value),
if (emeralds >= 8) return new CoinSwap(BEVEL_ID, Math.max(1, emeralds / 8)); // 12 emeralds → 2 bevels (16). Trade-off: slight overpay at the boundary,
// but no silent floor haircut. Cap at 1 minimum so cost is never 0.
if (emeralds >= 64) return new CoinSwap(COG_ID, Math.max(1, (emeralds + 32) / 64));
if (emeralds >= 8) return new CoinSwap(BEVEL_ID, Math.max(1, (emeralds + 4) / 8));
return new CoinSwap(SPUR_ID, Math.max(1, emeralds)); return new CoinSwap(SPUR_ID, Math.max(1, emeralds));
} }
} }
@@ -7,6 +7,7 @@ import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.network.c2s.RequestEconomySnapshotPacket; import uk.sijbers.bnstoolkit.network.c2s.RequestEconomySnapshotPacket;
import uk.sijbers.bnstoolkit.network.c2s.RunBnsCommandPacket; import uk.sijbers.bnstoolkit.network.c2s.RunBnsCommandPacket;
import uk.sijbers.bnstoolkit.network.s2c.BountySnapshotPacket; import uk.sijbers.bnstoolkit.network.s2c.BountySnapshotPacket;
import uk.sijbers.bnstoolkit.network.s2c.OpenScreenPacket;
import uk.sijbers.bnstoolkit.network.s2c.PlotSnapshotPacket; import uk.sijbers.bnstoolkit.network.s2c.PlotSnapshotPacket;
import uk.sijbers.bnstoolkit.network.s2c.SellSnapshotPacket; import uk.sijbers.bnstoolkit.network.s2c.SellSnapshotPacket;
import uk.sijbers.bnstoolkit.network.s2c.TierStateUpdatePacket; import uk.sijbers.bnstoolkit.network.s2c.TierStateUpdatePacket;
@@ -31,7 +32,7 @@ import uk.sijbers.bnstoolkit.network.s2c.TierStateUpdatePacket;
public final class BnsNetwork { public final class BnsNetwork {
private BnsNetwork() {} private BnsNetwork() {}
public static final String VERSION = "0.2"; public static final String VERSION = "0.4";
public static void register(IEventBus modBus) { public static void register(IEventBus modBus) {
modBus.addListener(BnsNetwork::onRegisterPayloads); modBus.addListener(BnsNetwork::onRegisterPayloads);
@@ -65,6 +66,10 @@ public final class BnsNetwork {
PlotSnapshotPacket.TYPE, PlotSnapshotPacket.TYPE,
PlotSnapshotPacket.STREAM_CODEC, PlotSnapshotPacket.STREAM_CODEC,
PlotSnapshotPacket::handle); PlotSnapshotPacket::handle);
reg.playToClient(
OpenScreenPacket.TYPE,
OpenScreenPacket.STREAM_CODEC,
OpenScreenPacket::handle);
BnsToolkit.LOG.info("[bnstoolkit/net] registered payloads v{}", VERSION); BnsToolkit.LOG.info("[bnstoolkit/net] registered payloads v{}", VERSION);
} }
@@ -2,98 +2,210 @@ package uk.sijbers.bnstoolkit.network;
import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.CommandSourceStack;
import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag; import net.minecraft.resources.ResourceLocation;
import net.minecraft.nbt.Tag; import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.neoforged.neoforge.network.PacketDistributor; import net.neoforged.neoforge.network.PacketDistributor;
import uk.sijbers.bnstoolkit.BnsToolkit; import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.data.Bounty;
import uk.sijbers.bnstoolkit.data.CivicTier; import uk.sijbers.bnstoolkit.data.CivicTier;
import uk.sijbers.bnstoolkit.data.Plot; import uk.sijbers.bnstoolkit.data.Plot;
import uk.sijbers.bnstoolkit.data.SpurBalance; import uk.sijbers.bnstoolkit.data.SpurBalance;
import uk.sijbers.bnstoolkit.network.s2c.BountySnapshotPacket; import uk.sijbers.bnstoolkit.network.s2c.BountySnapshotPacket;
import uk.sijbers.bnstoolkit.network.s2c.OpenScreenPacket;
import uk.sijbers.bnstoolkit.network.s2c.PlotSnapshotPacket; import uk.sijbers.bnstoolkit.network.s2c.PlotSnapshotPacket;
import uk.sijbers.bnstoolkit.network.s2c.SellSnapshotPacket; import uk.sijbers.bnstoolkit.network.s2c.SellSnapshotPacket;
import uk.sijbers.bnstoolkit.network.s2c.TierStateUpdatePacket; import uk.sijbers.bnstoolkit.network.s2c.TierStateUpdatePacket;
import java.lang.reflect.Method;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
/** /**
* Server-side bridge between the bnstoolkit network packets and the * Server-side bridge between bnstoolkit packets and the KubeJS-owned
* KubeJS-owned economy state. * economy state.
* *
* Reads: pull straight from player.persistentData (the NBT KubeJS writes). * Reads pull straight from player NBT (and the KubeJS-attached server
* Writes: route through the existing /bns chat commands via the server's * NBT for plot ownership). Writes route through the existing /bns chat
* command stack KubeJS is registered as a command-source handler, * verbs via the server's command stack so KubeJS remains the source of
* so this delegates business logic instead of re-implementing it. * truth for economy logic.
* *
* The verb whitelist below is what lets the screens trigger the SAME * <h2>Hardening</h2>
* verbs the player can type — and nothing else. * <ul>
* <li>Per-player token-bucket rate limit (10 commands/sec, burst 20)</li>
* <li>Structured verb whitelist — args are parsed into (verb,
* params) and each param validated against an explicit type, then
* the command string is rebuilt from a trusted template. No
* prefix-startsWith match.</li>
* <li>Identifier inputs (item ids, bounty ids, plot ids, screen names)
* are bounded length + regex-validated.</li>
* <li>Sanitises args before logging (no log-injection).</li>
* <li>Per-player in-flight guard on tier upgrades to close the
* double-spend race window.</li>
* </ul>
*/ */
public final class ServerEconomyBridge { public final class ServerEconomyBridge {
private ServerEconomyBridge() {} private ServerEconomyBridge() {}
private static final Set<String> SAFE_VERBS = Set.of( // ─── Validation patterns ─────────────────────────────────────────
"tier upgrade", // Match Minecraft ResourceLocation: namespace + path. Allow_kebab.
"sell ", // followed by item id + count private static final Pattern ITEM_ID = Pattern.compile("^[a-z0-9_\\-.]+:[a-z0-9_/\\-.]+$");
"bounty accept ", private static final Pattern BOUNTY_ID = Pattern.compile("^[a-z0-9_]{1,32}$");
"bounty cancel ", private static final Pattern PLOT_ID = Pattern.compile("^[a-z0-9_]{1,16}$");
"bounty turnin ", private static final Pattern SCREEN_NAME = Pattern.compile("^[a-z]{2,12}$");
"plot buy ", private static final int MAX_COUNT = 64 * 36; // can't exceed inventory anyway
"plot release "
); // ─── Rate limiting ───────────────────────────────────────────────
// Per-player token bucket: 10 tokens, refill 10/sec. Burst 20 (mass-sell)
// is fine; sustained spam past 10/sec drops silently.
private static final int RATE_CAPACITY = 20;
private static final int RATE_REFILL_PER_SEC = 10;
private static final Map<UUID, RateBucket> BUCKETS = new ConcurrentHashMap<>();
private static final Map<UUID, Long> IN_FLIGHT_TIER = new ConcurrentHashMap<>();
private static boolean takeToken(UUID id) {
long now = System.currentTimeMillis();
RateBucket b = BUCKETS.computeIfAbsent(id, k -> new RateBucket(RATE_CAPACITY, now));
synchronized (b) {
long elapsed = now - b.lastRefillMs;
if (elapsed > 0) {
double add = elapsed * RATE_REFILL_PER_SEC / 1000.0;
b.tokens = Math.min(RATE_CAPACITY, b.tokens + add);
b.lastRefillMs = now;
}
if (b.tokens >= 1.0) {
b.tokens -= 1.0;
return true;
}
return false;
}
}
private static final class RateBucket {
double tokens;
long lastRefillMs;
RateBucket(int initial, long now) { this.tokens = initial; this.lastRefillMs = now; }
}
// ─── Routed commands ───────────────────────────────────────────── // ─── Routed commands ─────────────────────────────────────────────
/** Validates + parses + executes a player-initiated /bns verb. */
public static void runWhitelisted(Player player, String args) { public static void runWhitelisted(Player player, String args) {
if (!(player instanceof ServerPlayer sp)) return; if (!(player instanceof ServerPlayer sp)) return;
if (args == null || args.isEmpty()) return; if (args == null || args.length() > 80) return; // hard cap
boolean ok = false; if (!takeToken(sp.getUUID())) {
for (String prefix : SAFE_VERBS) { BnsToolkit.LOG.debug("[bns/bridge] rate-limited {}", sp.getName().getString());
if (args.equals(prefix.trim()) || args.startsWith(prefix)) { ok = true; break; } return;
} }
if (!ok) {
BnsToolkit.LOG.warn("[bns/bridge] rejected non-whitelisted verb from {}: {}", sp.getName().getString(), args); // Parse `args` into tokens (whitespace-separated). All idents
// already constrained to safe charsets by the regexes below.
String[] parts = args.trim().split("\\s+");
if (parts.length == 0) return;
String safe = null;
switch (parts[0]) {
case "tier":
if (parts.length == 2 && "upgrade".equals(parts[1])) {
safe = handleTierUpgrade(sp);
}
break;
case "sell":
if (parts.length == 3 && ITEM_ID.matcher(parts[1]).matches()) {
int n = parsePositiveInt(parts[2]);
if (n > 0 && n <= MAX_COUNT) safe = "sell " + parts[1] + " " + n;
}
break;
case "bounty":
if (parts.length == 3
&& ("accept".equals(parts[1]) || "cancel".equals(parts[1]) || "turnin".equals(parts[1]))
&& BOUNTY_ID.matcher(parts[2]).matches()) {
safe = "bounty " + parts[1] + " " + parts[2];
}
break;
case "plot":
if (parts.length == 3
&& ("buy".equals(parts[1]) || "release".equals(parts[1]))
&& PLOT_ID.matcher(parts[2]).matches()) {
safe = "plot " + parts[1] + " " + parts[2];
}
break;
}
if (safe == null) {
BnsToolkit.LOG.warn("[bns/bridge] rejected verb from {}: {}",
sp.getName().getString(), sanitize(args));
return; return;
} }
CommandSourceStack src = sp.createCommandSourceStack(); CommandSourceStack src = sp.createCommandSourceStack();
String full = "bns " + args; String full = "bns " + safe;
BnsToolkit.LOG.info("[bns/bridge] {} runs '{}'", sp.getName().getString(), full); BnsToolkit.LOG.info("[bns/bridge] {} runs '{}'", sp.getName().getString(), full);
sp.server.getCommands().performPrefixedCommand(src, full); sp.server.getCommands().performPrefixedCommand(src, full);
// After any state-mutating verb, push a fresh snapshot relevant to it. // Post-mutation snapshot push, matched to the verb.
if (args.startsWith("tier")) { if (safe.startsWith("tier")) {
pushTierUpdate(sp); pushTierUpdate(sp);
} else if (args.startsWith("sell")) { } else if (safe.startsWith("sell")) {
pushTierUpdate(sp); pushTierUpdate(sp);
pushSellSnapshot(sp); pushSellSnapshot(sp);
} else if (args.startsWith("bounty")) { } else if (safe.startsWith("bounty")) {
pushTierUpdate(sp); pushTierUpdate(sp);
pushBountySnapshot(sp); pushBountySnapshot(sp);
} else if (args.startsWith("plot")) { } else if (safe.startsWith("plot")) {
pushTierUpdate(sp); pushTierUpdate(sp);
pushPlotSnapshot(sp); pushPlotSnapshot(sp);
} }
} }
/**
* Tier upgrade guard. KubeJS checks balance then debits — a spammy
* client could race two requests through. Single-player-flight here
* forces one upgrade-attempt at a time; the second arrives, sees
* the guard, and is dropped.
*/
private static String handleTierUpgrade(ServerPlayer sp) {
long now = System.currentTimeMillis();
Long last = IN_FLIGHT_TIER.get(sp.getUUID());
if (last != null && now - last < 500) return null;
IN_FLIGHT_TIER.put(sp.getUUID(), now);
return "tier upgrade";
}
private static int parsePositiveInt(String s) {
if (s.length() > 5) return -1; // 99999 max
try {
int n = Integer.parseInt(s);
return n > 0 ? n : -1;
} catch (NumberFormatException e) {
return -1;
}
}
private static String sanitize(String s) {
if (s == null) return "<null>";
if (s.length() > 64) s = s.substring(0, 64) + "...";
return s.replaceAll("[\\p{Cntrl}]", "?");
}
// ─── Snapshot pushes ───────────────────────────────────────────── // ─── Snapshot pushes ─────────────────────────────────────────────
public static void pushSnapshot(Player player, String kind) { public static void pushSnapshot(Player player, String kind) {
if (!(player instanceof ServerPlayer sp)) return; if (!(player instanceof ServerPlayer sp)) return;
if (!takeToken(sp.getUUID())) return;
switch (kind) { switch (kind) {
case "TIER" -> pushTierUpdate(sp); case "TIER" -> pushTierUpdate(sp);
case "SELL" -> { pushTierUpdate(sp); pushSellSnapshot(sp); } case "SELL" -> { pushTierUpdate(sp); pushSellSnapshot(sp); }
case "BOUNTY" -> { pushTierUpdate(sp); pushBountySnapshot(sp); } case "BOUNTY" -> { pushTierUpdate(sp); pushBountySnapshot(sp); }
case "PLOT" -> { pushTierUpdate(sp); pushPlotSnapshot(sp); } case "PLOT" -> { pushTierUpdate(sp); pushPlotSnapshot(sp); }
default -> { pushTierUpdate(sp); pushSellSnapshot(sp); pushBountySnapshot(sp); pushPlotSnapshot(sp); } case "ALL" -> { pushTierUpdate(sp); pushSellSnapshot(sp); pushBountySnapshot(sp); pushPlotSnapshot(sp); }
default -> BnsToolkit.LOG.debug("[bns/bridge] unknown snapshot kind {}", sanitize(kind));
} }
} }
@@ -121,33 +233,38 @@ public final class ServerEconomyBridge {
public static void pushBountySnapshot(ServerPlayer sp) { public static void pushBountySnapshot(ServerPlayer sp) {
CompoundTag data = sp.getPersistentData(); CompoundTag data = sp.getPersistentData();
List<String> poolIds = new ArrayList<>();
Map<String, BountySnapshotPacket.ActiveEntry> active = new HashMap<>();
List<String> completed = new ArrayList<>();
if (data.contains("bnsBounties")) { // Daily pool: keys of bnsBountyPool compound.
CompoundTag bnt = data.getCompound("bnsBounties"); List<String> poolIds = new ArrayList<>();
if (bnt.contains("pool")) { if (data.contains("bnsBountyPool")) {
ListTag pool = bnt.getList("pool", Tag.TAG_STRING); CompoundTag pool = data.getCompound("bnsBountyPool");
for (int i = 0; i < pool.size(); i++) poolIds.add(pool.getString(i)); for (String key : pool.getAllKeys()) poolIds.add(key);
} }
if (bnt.contains("active")) {
CompoundTag act = bnt.getCompound("active"); // Active set: bnsBountyActive compound of id -> {progress, target, reward, ...}
Map<String, BountySnapshotPacket.ActiveEntry> active = new HashMap<>();
if (data.contains("bnsBountyActive")) {
CompoundTag act = data.getCompound("bnsBountyActive");
for (String id : act.getAllKeys()) { for (String id : act.getAllKeys()) {
CompoundTag e = act.getCompound(id); CompoundTag e = act.getCompound(id);
int progress = e.getInt("progress"); active.put(id, new BountySnapshotPacket.ActiveEntry(
int target = e.getInt("target"); e.getInt("progress"),
long reward = e.getInt("reward"); e.getInt("target"),
active.put(id, new BountySnapshotPacket.ActiveEntry(progress, target, reward)); e.getInt("reward")));
} }
} }
if (bnt.contains("completed")) {
CompoundTag cmp = bnt.getCompound("completed"); // Completed (cooldown) set: bnsBountyCompleted compound of id -> long cooldown-end ms.
for (String id : cmp.getAllKeys()) completed.add(id); // Only include entries still on cooldown (cooldown-end > now).
List<String> completed = new ArrayList<>();
long now = System.currentTimeMillis();
if (data.contains("bnsBountyCompleted")) {
CompoundTag cmp = data.getCompound("bnsBountyCompleted");
for (String id : cmp.getAllKeys()) {
if (cmp.getLong(id) > now) completed.add(id);
} }
} }
// Slot cap from tier
int tier = data.contains("bnsTier") ? data.getInt("bnsTier") : 1; int tier = data.contains("bnsTier") ? data.getInt("bnsTier") : 1;
if (tier < 1 || tier > CivicTier.MAX) tier = 1; if (tier < 1 || tier > CivicTier.MAX) tier = 1;
int slotCap = CivicTier.byId(tier).dailyBountySlots(); int slotCap = CivicTier.byId(tier).dailyBountySlots();
@@ -156,46 +273,77 @@ public final class ServerEconomyBridge {
} }
public static void pushPlotSnapshot(ServerPlayer sp) { public static void pushPlotSnapshot(ServerPlayer sp) {
// Plot ownership is tracked in level.persistentData (world scope), not per-player.
// Read it via the world's saved data CompoundTag the KubeJS code writes to.
Map<String, String> owners = new HashMap<>(); 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(), "");
net.minecraft.server.level.ServerLevel level = sp.serverLevel(); // KubeJS attaches its server-scope persistentData to MinecraftServer via
// KubeJS Persistent Data (world scope) lives at level.getDataStorage with key 'kubejs' // a mixin (kjs$getPersistentData). Reflective access avoids a hard
// but to avoid coupling, read from the per-player bnsOwnedPlots NBT array we set // compileOnly dep on KubeJS; if the method is missing, fall back to
// alongside ownership in plots.js. Fall back to empty. // empty (the screen shows "all free" which is the correct degraded view).
List<String> ownedIds = new ArrayList<>(); CompoundTag serverData = getKubeJSServerData(sp.server);
CompoundTag pdata = sp.getPersistentData(); if (serverData.contains("bnsPlots")) {
if (pdata.contains("bnsOwnedPlots")) { CompoundTag map = serverData.getCompound("bnsPlots");
ListTag list = pdata.getList("bnsOwnedPlots", Tag.TAG_STRING); String myUuid = sp.getUUID().toString();
for (int i = 0; i < list.size(); i++) { for (String plotId : map.getAllKeys()) {
String id = list.getString(i); String uuidStr = map.getString(plotId);
ownedIds.add(id); if (uuidStr.equals(myUuid)) {
owners.put(id, sp.getName().getString()); ownedIds.add(plotId);
owners.put(plotId, sp.getName().getString());
} else {
owners.put(plotId, resolveOwnerName(sp.server, uuidStr));
} }
} }
// Mark plots owned by other players as taken — read everybody's
// persistentData via the level's player list.
for (ServerPlayer other : sp.server.getPlayerList().getPlayers()) {
if (other == sp) continue;
CompoundTag od = other.getPersistentData();
if (!od.contains("bnsOwnedPlots")) continue;
ListTag list = od.getList("bnsOwnedPlots", Tag.TAG_STRING);
for (int i = 0; i < list.size(); i++) {
String id = list.getString(i);
if (!ownedIds.contains(id)) owners.put(id, other.getName().getString());
}
} }
int tier = pdata.contains("bnsTier") ? pdata.getInt("bnsTier") : 1; int tier = sp.getPersistentData().contains("bnsTier") ? sp.getPersistentData().getInt("bnsTier") : 1;
if (tier < 1 || tier > CivicTier.MAX) tier = 1; if (tier < 1 || tier > CivicTier.MAX) tier = 1;
int slotCap = CivicTier.byId(tier).plotSlots(); int slotCap = CivicTier.byId(tier).plotSlots();
PacketDistributor.sendToPlayer(sp, new PlotSnapshotPacket(owners, slotCap, ownedIds)); PacketDistributor.sendToPlayer(sp, new PlotSnapshotPacket(owners, slotCap, ownedIds));
} }
// Defensive: bounties pool format uses the same shape we know about /** Opens a bnstoolkit screen on the calling player (used by /bns open). */
@SuppressWarnings("unused") public static void openScreen(ServerPlayer sp, String which) {
private static Bounty resolveBounty(String id) { return Bounty.byId(id); } if (!SCREEN_NAME.matcher(which).matches()) return;
PacketDistributor.sendToPlayer(sp, new OpenScreenPacket(which));
}
// ─── KubeJS server-data accessor ─────────────────────────────────
private static volatile Method kjsServerPersistentData;
private static volatile boolean kjsLookupAttempted;
private static CompoundTag getKubeJSServerData(MinecraftServer server) {
if (kjsServerPersistentData == null && !kjsLookupAttempted) {
kjsLookupAttempted = true;
try {
kjsServerPersistentData = server.getClass().getMethod("kjs$getPersistentData");
BnsToolkit.LOG.info("[bns/bridge] linked KubeJS server-scope persistentData via mixin");
} catch (NoSuchMethodException e) {
BnsToolkit.LOG.warn("[bns/bridge] KubeJS kjs$getPersistentData not found — plot snapshot will be empty");
}
}
if (kjsServerPersistentData == null) return new CompoundTag();
try {
return (CompoundTag) kjsServerPersistentData.invoke(server);
} catch (Throwable t) {
BnsToolkit.LOG.error("[bns/bridge] failed to read KubeJS server data: {}", t.toString());
return new CompoundTag();
}
}
private static String resolveOwnerName(MinecraftServer server, String uuidStr) {
try {
UUID id = UUID.fromString(uuidStr);
ServerPlayer online = server.getPlayerList().getPlayer(id);
if (online != null) return online.getName().getString();
// Offline: try the game-profile cache for the cached display name.
return server.getProfileCache().get(id)
.map(profile -> profile.getName())
.orElse("uuid:" + uuidStr.substring(0, 8));
} catch (Exception e) {
return "?";
}
}
} }
@@ -28,7 +28,7 @@ public record RequestEconomySnapshotPacket(String kind) implements CustomPacketP
public static final StreamCodec<RegistryFriendlyByteBuf, RequestEconomySnapshotPacket> STREAM_CODEC = public static final StreamCodec<RegistryFriendlyByteBuf, RequestEconomySnapshotPacket> STREAM_CODEC =
StreamCodec.composite( StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, RequestEconomySnapshotPacket::kind, ByteBufCodecs.stringUtf8(8), RequestEconomySnapshotPacket::kind,
RequestEconomySnapshotPacket::new); RequestEconomySnapshotPacket::new);
@Override @Override
@@ -33,7 +33,7 @@ public record RunBnsCommandPacket(String args) implements CustomPacketPayload {
public static final StreamCodec<RegistryFriendlyByteBuf, RunBnsCommandPacket> STREAM_CODEC = public static final StreamCodec<RegistryFriendlyByteBuf, RunBnsCommandPacket> STREAM_CODEC =
StreamCodec.composite( StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, RunBnsCommandPacket::args, ByteBufCodecs.stringUtf8(80), RunBnsCommandPacket::args,
RunBnsCommandPacket::new); RunBnsCommandPacket::new);
@Override @Override
@@ -61,6 +61,8 @@ public record BountySnapshotPacket(
} }
ClientBnsState.completedIds.clear(); ClientBnsState.completedIds.clear();
ClientBnsState.completedIds.addAll(pkt.completedIds); ClientBnsState.completedIds.addAll(pkt.completedIds);
// pkt.completedIds is List by wire format; the client cache is a Set
// so per-frame state lookups in BountyBoardScreen are O(1).
ClientBnsState.bountySlotCap = pkt.slotCap; ClientBnsState.bountySlotCap = pkt.slotCap;
}); });
} }
@@ -0,0 +1,42 @@
package uk.sijbers.bnstoolkit.network.s2c;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.neoforge.network.handling.IPayloadContext;
import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.client.OpenScreenDispatcher;
/**
* S2C: open a bnstoolkit screen on the receiving client.
*
* Server triggers this in response to {@code /bns open <screen>} (which
* NPC dialogue, FTB Quests reward commands, command blocks, etc. can
* invoke). The `which` field selects the screen — see
* {@link OpenScreenDispatcher} for the screen ↔ name mapping.
*
* Decode-side class loading note: this packet must NOT import any
* client-only types directly. Dispatching lives in
* {@link OpenScreenDispatcher}, which itself avoids loading screen
* classes until its dispatch method runs (client-side only).
*/
public record OpenScreenPacket(String which) implements CustomPacketPayload {
public static final Type<OpenScreenPacket> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "open_screen"));
public static final StreamCodec<RegistryFriendlyByteBuf, OpenScreenPacket> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.stringUtf8(16), OpenScreenPacket::which,
OpenScreenPacket::new);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
public static void handle(OpenScreenPacket pkt, IPayloadContext ctx) {
ctx.enqueueWork(() -> OpenScreenDispatcher.open(pkt.which));
}
}
@@ -0,0 +1,83 @@
package uk.sijbers.bnstoolkit.server;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.suggestion.Suggestions;
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.network.ServerEconomyBridge;
import java.util.concurrent.CompletableFuture;
/**
* Bnstoolkit-side /bns subcommands. Brigadier merges this with the
* KubeJS-registered /bns tree so both sets of children appear under the
* same root.
*
* Subcommands provided here:
* /bns open <screen> — push an OpenScreenPacket to the calling player.
* {@code screen} ∈ {hub, tier, bounty, plot, shop, questlog}.
*
* This is the entry point Easy NPC dialogue, FTB Quests reward commands,
* command blocks, and /trigger objectives use to route to a bnstoolkit
* screen. Player-context-only — running from console / a command block
* with a fake source just returns 0 (no harm done).
*/
@EventBusSubscriber(modid = BnsToolkit.MOD_ID)
public final class BnsServerCommands {
private BnsServerCommands() {}
private static final String[] SCREEN_NAMES = {
"hub", "tier", "bounty", "plot", "shop", "questlog"
};
@SubscribeEvent
public static void onRegisterCommands(RegisterCommandsEvent event) {
LiteralArgumentBuilder<CommandSourceStack> bns = Commands.literal("bns")
.then(Commands.literal("open")
.then(Commands.argument("screen", StringArgumentType.word())
.suggests(BnsServerCommands::suggestScreens)
.executes(ctx -> {
String which = StringArgumentType.getString(ctx, "screen");
return openForPlayer(ctx.getSource(), which);
})));
event.getDispatcher().register(bns);
BnsToolkit.LOG.info("[bnstoolkit] registered /bns open <screen> command");
}
private static CompletableFuture<Suggestions> suggestScreens(
com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx,
SuggestionsBuilder builder) {
String prefix = builder.getRemaining().toLowerCase();
for (String s : SCREEN_NAMES) {
if (s.startsWith(prefix)) builder.suggest(s);
}
return builder.buildFuture();
}
private static int openForPlayer(CommandSourceStack src, String which) {
ServerPlayer sp = src.getPlayer();
if (sp == null) {
src.sendFailure(Component.literal("/bns open must be run as a player"));
return 0;
}
// Validate before dispatch — server-side check belt + suspenders with
// the screen-name regex in ServerEconomyBridge.openScreen.
boolean ok = false;
for (String s : SCREEN_NAMES) if (s.equals(which)) { ok = true; break; }
if (!ok) {
src.sendFailure(Component.literal("Unknown screen: " + which
+ ". Options: hub, tier, bounty, plot, shop, questlog"));
return 0;
}
ServerEconomyBridge.openScreen(sp, which);
return 1;
}
}
@@ -1,11 +1,13 @@
{ {
"key.category.bnstoolkit": "Brass and Sigil", "key.category.bnstoolkit": "Brass and Sigil",
"key.bnstoolkit.tier": "Open Tier Ladder", "key.bnstoolkit.hub": "Open Brass and Sigil Hub",
"key.bnstoolkit.bounties": "Open Bounty Board", "key.bnstoolkit.tier": "Open Tier Ladder (direct)",
"key.bnstoolkit.plots": "Open Plot Office", "key.bnstoolkit.bounties": "Open Bounty Board (direct)",
"key.bnstoolkit.shop": "Open Bazaar Shop", "key.bnstoolkit.plots": "Open Plot Office (direct)",
"key.bnstoolkit.questlog": "Open Quest Log", "key.bnstoolkit.shop": "Open Bazaar Shop (direct)",
"key.bnstoolkit.questlog": "Open Quest Log (direct)",
"screen.bnstoolkit.hub": "Brass and Sigil",
"screen.bnstoolkit.tier_upgrade": "Civic Tier", "screen.bnstoolkit.tier_upgrade": "Civic Tier",
"screen.bnstoolkit.bounty_board": "Bounty Board", "screen.bnstoolkit.bounty_board": "Bounty Board",
"screen.bnstoolkit.quest_log": "Quest Log", "screen.bnstoolkit.quest_log": "Quest Log",