diff --git a/gradle.properties b/gradle.properties
index 0c0bb20..3985be3 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -19,5 +19,5 @@ loader_version_range=[1,)
mod_id=bnstoolkit
mod_name=Brass and Sigil Toolkit
mod_license=All Rights Reserved
-mod_version=0.3.1
+mod_version=0.4.0
mod_group_id=uk.sijbers.bnstoolkit
diff --git a/src/main/java/uk/sijbers/bnstoolkit/BnsToolkit.java b/src/main/java/uk/sijbers/bnstoolkit/BnsToolkit.java
index f961d1b..4b57132 100644
--- a/src/main/java/uk/sijbers/bnstoolkit/BnsToolkit.java
+++ b/src/main/java/uk/sijbers/bnstoolkit/BnsToolkit.java
@@ -53,17 +53,13 @@ public final class BnsToolkit {
}
}
- private static int tickCounter = 0;
-
@SubscribeEvent
public void onPlayerTick(PlayerTickEvent.Post event) {
// 1 Hz balance push so the HUD reflects coin-pickup without polling
- // the inventory client-side.
- if (event.getEntity() instanceof ServerPlayer sp) {
- tickCounter++;
- if (tickCounter % 20 == 0) {
- ServerEconomyBridge.pushTierUpdate(sp);
- }
+ // the inventory client-side. Per-player tick counter (not a shared
+ // static) so the push rate is 1Hz/player and naturally staggered.
+ if (event.getEntity() instanceof ServerPlayer sp && sp.tickCount % 20 == 0) {
+ ServerEconomyBridge.pushTierUpdate(sp);
}
}
}
diff --git a/src/main/java/uk/sijbers/bnstoolkit/client/BnsKeyBindings.java b/src/main/java/uk/sijbers/bnstoolkit/client/BnsKeyBindings.java
index be71029..5de9dd4 100644
--- a/src/main/java/uk/sijbers/bnstoolkit/client/BnsKeyBindings.java
+++ b/src/main/java/uk/sijbers/bnstoolkit/client/BnsKeyBindings.java
@@ -10,6 +10,7 @@ import net.neoforged.neoforge.client.settings.KeyConflictContext;
import org.lwjgl.glfw.GLFW;
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;
@@ -17,66 +18,88 @@ import uk.sijbers.bnstoolkit.client.screens.TierUpgradeScreen;
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:
- * K — Civic tier ladder (replaces vanilla advancements which is L)
- * J — Bounty board
- * N — Plot map / office
- * M — Quest log overview
+ *
Single primary key: B opens the hub screen, which has buttons
+ * routing to each sub-screen. Power users can also bind direct shortcuts
+ * to the 5 sub-screens (defaults are UNBOUND so they don't compete with
+ * the other ~100 mods in the pack).
*
- * Picked so they sit on the home row right-hand side and don't clash
- * with WASD movement / vanilla / FTB defaults. Player can rebind in the
- * vanilla controls menu like any other mod.
+ *
Audit context for choosing B as default (2026-06-07):
+ *
+ * - Vanilla 1.21.1: B is free
+ * - FTB Quests / FTB Chunks / FTB Library: B is free
+ * - Numismatics / Easy NPC / JEI / Patchouli: B is free
+ * - Simple Voice Chat, Curios, Ars Nouveau, Better Combat, Sophisticated
+ * Backpacks: B is free
+ *
+ * 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 {
private BnsKeyBindings() {}
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(
"key." + BnsToolkit.MOD_ID + ".tier",
KeyConflictContext.IN_GAME,
- InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_K),
+ InputConstants.UNKNOWN,
CATEGORY);
public static final KeyMapping OPEN_BOUNTIES = new KeyMapping(
"key." + BnsToolkit.MOD_ID + ".bounties",
KeyConflictContext.IN_GAME,
- InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_J),
+ InputConstants.UNKNOWN,
CATEGORY);
public static final KeyMapping OPEN_PLOTS = new KeyMapping(
"key." + BnsToolkit.MOD_ID + ".plots",
KeyConflictContext.IN_GAME,
- InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_N),
+ InputConstants.UNKNOWN,
CATEGORY);
public static final KeyMapping OPEN_SHOP = new KeyMapping(
"key." + BnsToolkit.MOD_ID + ".shop",
KeyConflictContext.IN_GAME,
- InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_H),
+ InputConstants.UNKNOWN,
CATEGORY);
public static final KeyMapping OPEN_QUESTLOG = new KeyMapping(
"key." + BnsToolkit.MOD_ID + ".questlog",
KeyConflictContext.IN_GAME,
- InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_M),
+ InputConstants.UNKNOWN,
CATEGORY);
public static void onRegisterKeyMappings(RegisterKeyMappingsEvent event) {
+ event.register(OPEN_HUB);
event.register(OPEN_TIER);
event.register(OPEN_BOUNTIES);
event.register(OPEN_PLOTS);
event.register(OPEN_SHOP);
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
public static void onClientTick(ClientTickEvent.Post event) {
Minecraft mc = Minecraft.getInstance();
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()) {
NetSend.requestSnapshot("TIER");
new TierUpgradeScreen().openGui();
diff --git a/src/main/java/uk/sijbers/bnstoolkit/client/ClientBnsState.java b/src/main/java/uk/sijbers/bnstoolkit/client/ClientBnsState.java
index 9c848ac..eea78d0 100644
--- a/src/main/java/uk/sijbers/bnstoolkit/client/ClientBnsState.java
+++ b/src/main/java/uk/sijbers/bnstoolkit/client/ClientBnsState.java
@@ -6,8 +6,10 @@ import uk.sijbers.bnstoolkit.data.Plot;
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Set;
/**
* Client-side cache of the player's economy state, populated by S2C
@@ -34,7 +36,7 @@ public final class ClientBnsState {
// BountySnapshot:
public static final List dailyPoolIds = new ArrayList<>();
public static final Map activeBounties = new HashMap<>();
- public static final List completedIds = new ArrayList<>();
+ public static final Set completedIds = new HashSet<>();
public static volatile int bountySlotCap = 3;
// PlotSnapshot:
diff --git a/src/main/java/uk/sijbers/bnstoolkit/client/OpenScreenDispatcher.java b/src/main/java/uk/sijbers/bnstoolkit/client/OpenScreenDispatcher.java
new file mode 100644
index 0000000..af8517b
--- /dev/null
+++ b/src/main/java/uk/sijbers/bnstoolkit/client/OpenScreenDispatcher.java
@@ -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);
+ }
+ }
+}
diff --git a/src/main/java/uk/sijbers/bnstoolkit/client/hud/SpurHud.java b/src/main/java/uk/sijbers/bnstoolkit/client/hud/SpurHud.java
index 1895ff5..960152c 100644
--- a/src/main/java/uk/sijbers/bnstoolkit/client/hud/SpurHud.java
+++ b/src/main/java/uk/sijbers/bnstoolkit/client/hud/SpurHud.java
@@ -3,7 +3,6 @@ package uk.sijbers.bnstoolkit.client.hud;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
-import net.minecraft.client.gui.LayeredDraw;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
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.
*
- * Drawn as a NeoForge {@link RegisterGuiLayersEvent} layer rather than a
- * {@code RenderGuiEvent} subscriber so it composes cleanly with the
- * vanilla layer system (and hides automatically when chat / inventory
- * suppresses HUD).
+ * Registered as a NeoForge gui layer (above the experience bar) so it
+ * composes cleanly with the vanilla HUD chain — F1 hide, chat focus,
+ * and pause screens all suppress us automatically.
*/
public final class SpurHud {
private SpurHud() {}
@@ -28,6 +26,13 @@ public final class SpurHud {
public static final ResourceLocation LAYER_ID =
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) {
event.registerAbove(VanillaGuiLayers.EXPERIENCE_BAR, LAYER_ID, INSTANCE::render);
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.options.hideGui) return;
- CivicTier cfg = CivicTier.byId(ClientBnsState.tier);
- String tierLine = "§" + cfg.colour().getChar() + cfg.name() + "§r";
- String spursLine = "§6" + TierUpgradeScreen.formatSpurs(ClientBnsState.spurs) + " spurs§r";
+ if (cachedTier != ClientBnsState.tier || cachedSpurs != ClientBnsState.spurs) {
+ CivicTier cfg = CivicTier.byId(ClientBnsState.tier);
+ cachedTierLine = Component.literal("§" + cfg.colour().getChar() + cfg.name() + "§r");
+ cachedSpursLine = Component.literal("§6" + TierUpgradeScreen.formatSpurs(ClientBnsState.spurs) + " spurs§r");
+ cachedTier = ClientBnsState.tier;
+ cachedSpurs = ClientBnsState.spurs;
+ }
int x = 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;
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 + 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, Component.literal(spursLine), x + 4, y + 14, 0xFFFFFFFF, true);
+ gfx.drawString(mc.font, cachedTierLine, x + 4, y + 4, 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;
}
diff --git a/src/main/java/uk/sijbers/bnstoolkit/client/screens/BountyBoardScreen.java b/src/main/java/uk/sijbers/bnstoolkit/client/screens/BountyBoardScreen.java
index e0dbaf0..8fd907d 100644
--- a/src/main/java/uk/sijbers/bnstoolkit/client/screens/BountyBoardScreen.java
+++ b/src/main/java/uk/sijbers/bnstoolkit/client/screens/BountyBoardScreen.java
@@ -149,7 +149,6 @@ public final class BountyBoardScreen extends BaseBnsScreen {
if (ab != null) add(new HeaderRow(this, "Progress: " + ab.progress() + " / " + ab.target()));
boolean atCap = ClientBnsState.activeBounties.size() >= ClientBnsState.bountySlotCap;
- boolean isActive = s == Bounty.State.ACTIVE || s == Bounty.State.READY;
if (s == Bounty.State.AVAILABLE && !atCap) {
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) {
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() {
diff --git a/src/main/java/uk/sijbers/bnstoolkit/client/screens/HubScreen.java b/src/main/java/uk/sijbers/bnstoolkit/client/screens/HubScreen.java
new file mode 100644
index 0000000..8475af6
--- /dev/null
+++ b/src/main/java/uk/sijbers/bnstoolkit/client/screens/HubScreen.java
@@ -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 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);
+ }
+ }
+}
diff --git a/src/main/java/uk/sijbers/bnstoolkit/economy/VillagerTradeRebalance.java b/src/main/java/uk/sijbers/bnstoolkit/economy/VillagerTradeRebalance.java
index 00fb39e..47c8cc9 100644
--- a/src/main/java/uk/sijbers/bnstoolkit/economy/VillagerTradeRebalance.java
+++ b/src/main/java/uk/sijbers/bnstoolkit/economy/VillagerTradeRebalance.java
@@ -22,25 +22,30 @@ import java.util.Optional;
/**
* Server-side villager-trade rebalance.
*
- * Hooks {@link VillagerTradesEvent} and {@link WandererTradesEvent} at
- * data-pack-reload time. For every existing trade listing, swaps any
- * emerald cost/result for the Numismatics equivalent (1 spur = 1
- * 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.
+ * Wraps every trade listing on {@link VillagerTradesEvent} (and the
+ * wanderer trades) so the resulting {@link MerchantOffer} has its
+ * emerald cost-or-result items swapped to Numismatics coin equivalents.
*
- * Counts >= 8 are upgraded to higher denominations so inventory doesn't
- * fill up with raw spurs:
- * 1..7 spurs (1 each)
- * 8..63 -> bevels (1 bevel = 8 spurs)
- * 64.. -> cogs (1 cog = 64 spurs)
+ * Denomination choice
+ * For each emerald count we pick a single denomination that minimises
+ * the rounding error:
+ *
+ * - 1..7 emeralds → spurs (1 spur = 1 emerald)
+ * - 8..63 emeralds → bevels, rounding to nearest (1 bevel = 8 spurs)
+ * - 64+ emeralds → cogs, rounding to nearest (1 cog = 64 spurs)
+ *
+ * Round-to-nearest avoids the 10–12% 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:
- * - The two existing options on Modrinth (numismatics-villager-currency
- * and data-trades) either require a NeoForge bump (1.1.0 wants
- * 21.1.230+, we're on 228) or need hand-written JSON for every
- * trade. Reusing the event we already need for HUD pushes is
- * simpler and avoids touching the live NeoForge install.
+ * Robustness
+ *
+ * - Wraps inner ItemListing.getOffer in try/catch — a misbehaved
+ * modded listing can't take down the whole profession.
+ * - Null offer returned from inner is propagated cleanly.
+ * - Null cost-A (theoretically possible from a reflection-built modded
+ * MerchantOffer) is treated as "leave alone".
+ *
*/
@EventBusSubscriber(modid = BnsToolkit.MOD_ID)
public final class VillagerTradeRebalance {
@@ -80,9 +85,18 @@ public final class VillagerTradeRebalance {
private record SpurTradeWrapper(VillagerTrades.ItemListing inner) implements VillagerTrades.ItemListing {
@Override
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;
+
ItemCost costA = base.getItemCostA();
+ if (costA == null) return base; // can't swap; leave alone
Optional costB = base.getItemCostB();
ItemStack result = base.getResult();
@@ -104,7 +118,7 @@ public final class VillagerTradeRebalance {
int count = peek.getCount();
CoinSwap swap = pickDenom(count);
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);
}
@@ -122,8 +136,11 @@ public final class VillagerTradeRebalance {
private record CoinSwap(ResourceLocation itemId, int count) {}
private static CoinSwap pickDenom(int emeralds) {
- if (emeralds >= 64) return new CoinSwap(COG_ID, Math.max(1, emeralds / 64));
- if (emeralds >= 8) return new CoinSwap(BEVEL_ID, Math.max(1, emeralds / 8));
+ // Round-to-nearest so 11 emeralds → 1 bevel (8) not 1 (and lose 3 value),
+ // 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));
}
}
diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/BnsNetwork.java b/src/main/java/uk/sijbers/bnstoolkit/network/BnsNetwork.java
index 2d35cac..84719fb 100644
--- a/src/main/java/uk/sijbers/bnstoolkit/network/BnsNetwork.java
+++ b/src/main/java/uk/sijbers/bnstoolkit/network/BnsNetwork.java
@@ -7,6 +7,7 @@ import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.network.c2s.RequestEconomySnapshotPacket;
import uk.sijbers.bnstoolkit.network.c2s.RunBnsCommandPacket;
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.SellSnapshotPacket;
import uk.sijbers.bnstoolkit.network.s2c.TierStateUpdatePacket;
@@ -31,7 +32,7 @@ import uk.sijbers.bnstoolkit.network.s2c.TierStateUpdatePacket;
public final class BnsNetwork {
private BnsNetwork() {}
- public static final String VERSION = "0.2";
+ public static final String VERSION = "0.4";
public static void register(IEventBus modBus) {
modBus.addListener(BnsNetwork::onRegisterPayloads);
@@ -65,6 +66,10 @@ public final class BnsNetwork {
PlotSnapshotPacket.TYPE,
PlotSnapshotPacket.STREAM_CODEC,
PlotSnapshotPacket::handle);
+ reg.playToClient(
+ OpenScreenPacket.TYPE,
+ OpenScreenPacket.STREAM_CODEC,
+ OpenScreenPacket::handle);
BnsToolkit.LOG.info("[bnstoolkit/net] registered payloads v{}", VERSION);
}
diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/ServerEconomyBridge.java b/src/main/java/uk/sijbers/bnstoolkit/network/ServerEconomyBridge.java
index 365a77f..b7a8e0b 100644
--- a/src/main/java/uk/sijbers/bnstoolkit/network/ServerEconomyBridge.java
+++ b/src/main/java/uk/sijbers/bnstoolkit/network/ServerEconomyBridge.java
@@ -2,98 +2,210 @@ package uk.sijbers.bnstoolkit.network;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.nbt.CompoundTag;
-import net.minecraft.nbt.ListTag;
-import net.minecraft.nbt.Tag;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import net.neoforged.neoforge.network.PacketDistributor;
import uk.sijbers.bnstoolkit.BnsToolkit;
-import uk.sijbers.bnstoolkit.data.Bounty;
import uk.sijbers.bnstoolkit.data.CivicTier;
import uk.sijbers.bnstoolkit.data.Plot;
import uk.sijbers.bnstoolkit.data.SpurBalance;
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.SellSnapshotPacket;
import uk.sijbers.bnstoolkit.network.s2c.TierStateUpdatePacket;
+import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
-import java.util.HashSet;
import java.util.List;
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
- * KubeJS-owned economy state.
+ * Server-side bridge between bnstoolkit packets and the KubeJS-owned
+ * economy state.
*
- * Reads: pull straight from player.persistentData (the NBT KubeJS writes).
- * Writes: route through the existing /bns chat commands via the server's
- * command stack — KubeJS is registered as a command-source handler,
- * so this delegates business logic instead of re-implementing it.
+ * Reads pull straight from player NBT (and the KubeJS-attached server
+ * NBT for plot ownership). Writes route through the existing /bns chat
+ * verbs via the server's command stack so KubeJS remains the source of
+ * truth for economy logic.
*
- * The verb whitelist below is what lets the screens trigger the SAME
- * verbs the player can type — and nothing else.
+ * Hardening
+ *
+ * - Per-player token-bucket rate limit (10 commands/sec, burst 20)
+ * - 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.
+ * - Identifier inputs (item ids, bounty ids, plot ids, screen names)
+ * are bounded length + regex-validated.
+ * - Sanitises args before logging (no log-injection).
+ * - Per-player in-flight guard on tier upgrades to close the
+ * double-spend race window.
+ *
*/
public final class ServerEconomyBridge {
private ServerEconomyBridge() {}
- private static final Set SAFE_VERBS = Set.of(
- "tier upgrade",
- "sell ", // followed by item id + count
- "bounty accept ",
- "bounty cancel ",
- "bounty turnin ",
- "plot buy ",
- "plot release "
- );
+ // ─── Validation patterns ─────────────────────────────────────────
+ // Match Minecraft ResourceLocation: namespace + path. Allow_kebab.
+ private static final Pattern ITEM_ID = Pattern.compile("^[a-z0-9_\\-.]+:[a-z0-9_/\\-.]+$");
+ private static final Pattern BOUNTY_ID = Pattern.compile("^[a-z0-9_]{1,32}$");
+ private static final Pattern PLOT_ID = Pattern.compile("^[a-z0-9_]{1,16}$");
+ private static final Pattern SCREEN_NAME = Pattern.compile("^[a-z]{2,12}$");
+ private static final int MAX_COUNT = 64 * 36; // can't exceed inventory anyway
+
+ // ─── 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 BUCKETS = new ConcurrentHashMap<>();
+ private static final Map 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 ─────────────────────────────────────────────
+ /** Validates + parses + executes a player-initiated /bns verb. */
public static void runWhitelisted(Player player, String args) {
if (!(player instanceof ServerPlayer sp)) return;
- if (args == null || args.isEmpty()) return;
+ if (args == null || args.length() > 80) return; // hard cap
- boolean ok = false;
- for (String prefix : SAFE_VERBS) {
- if (args.equals(prefix.trim()) || args.startsWith(prefix)) { ok = true; break; }
+ if (!takeToken(sp.getUUID())) {
+ BnsToolkit.LOG.debug("[bns/bridge] rate-limited {}", sp.getName().getString());
+ 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;
}
CommandSourceStack src = sp.createCommandSourceStack();
- String full = "bns " + args;
+ String full = "bns " + safe;
BnsToolkit.LOG.info("[bns/bridge] {} runs '{}'", sp.getName().getString(), full);
sp.server.getCommands().performPrefixedCommand(src, full);
- // After any state-mutating verb, push a fresh snapshot relevant to it.
- if (args.startsWith("tier")) {
+ // Post-mutation snapshot push, matched to the verb.
+ if (safe.startsWith("tier")) {
pushTierUpdate(sp);
- } else if (args.startsWith("sell")) {
+ } else if (safe.startsWith("sell")) {
pushTierUpdate(sp);
pushSellSnapshot(sp);
- } else if (args.startsWith("bounty")) {
+ } else if (safe.startsWith("bounty")) {
pushTierUpdate(sp);
pushBountySnapshot(sp);
- } else if (args.startsWith("plot")) {
+ } else if (safe.startsWith("plot")) {
pushTierUpdate(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 "";
+ if (s.length() > 64) s = s.substring(0, 64) + "...";
+ return s.replaceAll("[\\p{Cntrl}]", "?");
+ }
+
// ─── Snapshot pushes ─────────────────────────────────────────────
public static void pushSnapshot(Player player, String kind) {
if (!(player instanceof ServerPlayer sp)) return;
+ if (!takeToken(sp.getUUID())) return;
switch (kind) {
case "TIER" -> pushTierUpdate(sp);
case "SELL" -> { pushTierUpdate(sp); pushSellSnapshot(sp); }
case "BOUNTY" -> { pushTierUpdate(sp); pushBountySnapshot(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) {
CompoundTag data = sp.getPersistentData();
- List poolIds = new ArrayList<>();
- Map active = new HashMap<>();
- List completed = new ArrayList<>();
- if (data.contains("bnsBounties")) {
- CompoundTag bnt = data.getCompound("bnsBounties");
- if (bnt.contains("pool")) {
- ListTag pool = bnt.getList("pool", Tag.TAG_STRING);
- for (int i = 0; i < pool.size(); i++) poolIds.add(pool.getString(i));
- }
- if (bnt.contains("active")) {
- CompoundTag act = bnt.getCompound("active");
- for (String id : act.getAllKeys()) {
- CompoundTag e = act.getCompound(id);
- int progress = e.getInt("progress");
- int target = e.getInt("target");
- long reward = e.getInt("reward");
- active.put(id, new BountySnapshotPacket.ActiveEntry(progress, target, reward));
- }
- }
- if (bnt.contains("completed")) {
- CompoundTag cmp = bnt.getCompound("completed");
- for (String id : cmp.getAllKeys()) completed.add(id);
+ // Daily pool: keys of bnsBountyPool compound.
+ List poolIds = new ArrayList<>();
+ if (data.contains("bnsBountyPool")) {
+ CompoundTag pool = data.getCompound("bnsBountyPool");
+ for (String key : pool.getAllKeys()) poolIds.add(key);
+ }
+
+ // Active set: bnsBountyActive compound of id -> {progress, target, reward, ...}
+ Map active = new HashMap<>();
+ if (data.contains("bnsBountyActive")) {
+ CompoundTag act = data.getCompound("bnsBountyActive");
+ for (String id : act.getAllKeys()) {
+ CompoundTag e = act.getCompound(id);
+ active.put(id, new BountySnapshotPacket.ActiveEntry(
+ e.getInt("progress"),
+ e.getInt("target"),
+ e.getInt("reward")));
+ }
+ }
+
+ // Completed (cooldown) set: bnsBountyCompleted compound of id -> long cooldown-end ms.
+ // Only include entries still on cooldown (cooldown-end > now).
+ List 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;
if (tier < 1 || tier > CivicTier.MAX) tier = 1;
int slotCap = CivicTier.byId(tier).dailyBountySlots();
@@ -156,46 +273,77 @@ public final class ServerEconomyBridge {
}
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 owners = new HashMap<>();
+ List ownedIds = new ArrayList<>();
for (Plot p : Plot.ALL) owners.put(p.id(), "");
- net.minecraft.server.level.ServerLevel level = sp.serverLevel();
- // KubeJS Persistent Data (world scope) lives at level.getDataStorage with key 'kubejs'
- // but to avoid coupling, read from the per-player bnsOwnedPlots NBT array we set
- // alongside ownership in plots.js. Fall back to empty.
- List ownedIds = new ArrayList<>();
- CompoundTag pdata = sp.getPersistentData();
- if (pdata.contains("bnsOwnedPlots")) {
- ListTag list = pdata.getList("bnsOwnedPlots", Tag.TAG_STRING);
- for (int i = 0; i < list.size(); i++) {
- String id = list.getString(i);
- ownedIds.add(id);
- owners.put(id, sp.getName().getString());
- }
- }
- // 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());
+ // KubeJS attaches its server-scope persistentData to MinecraftServer via
+ // a mixin (kjs$getPersistentData). Reflective access avoids a hard
+ // compileOnly dep on KubeJS; if the method is missing, fall back to
+ // empty (the screen shows "all free" which is the correct degraded view).
+ CompoundTag serverData = getKubeJSServerData(sp.server);
+ if (serverData.contains("bnsPlots")) {
+ CompoundTag map = serverData.getCompound("bnsPlots");
+ String myUuid = sp.getUUID().toString();
+ for (String plotId : map.getAllKeys()) {
+ String uuidStr = map.getString(plotId);
+ if (uuidStr.equals(myUuid)) {
+ ownedIds.add(plotId);
+ owners.put(plotId, sp.getName().getString());
+ } else {
+ owners.put(plotId, resolveOwnerName(sp.server, uuidStr));
+ }
}
}
- 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;
int slotCap = CivicTier.byId(tier).plotSlots();
PacketDistributor.sendToPlayer(sp, new PlotSnapshotPacket(owners, slotCap, ownedIds));
}
- // Defensive: bounties pool format uses the same shape we know about
- @SuppressWarnings("unused")
- private static Bounty resolveBounty(String id) { return Bounty.byId(id); }
+ /** Opens a bnstoolkit screen on the calling player (used by /bns open). */
+ public static void openScreen(ServerPlayer sp, String which) {
+ 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 "?";
+ }
+ }
}
diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RequestEconomySnapshotPacket.java b/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RequestEconomySnapshotPacket.java
index 15c7093..0dc4b48 100644
--- a/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RequestEconomySnapshotPacket.java
+++ b/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RequestEconomySnapshotPacket.java
@@ -28,7 +28,7 @@ public record RequestEconomySnapshotPacket(String kind) implements CustomPacketP
public static final StreamCodec STREAM_CODEC =
StreamCodec.composite(
- ByteBufCodecs.STRING_UTF8, RequestEconomySnapshotPacket::kind,
+ ByteBufCodecs.stringUtf8(8), RequestEconomySnapshotPacket::kind,
RequestEconomySnapshotPacket::new);
@Override
diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RunBnsCommandPacket.java b/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RunBnsCommandPacket.java
index dca2ba8..a65b3b0 100644
--- a/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RunBnsCommandPacket.java
+++ b/src/main/java/uk/sijbers/bnstoolkit/network/c2s/RunBnsCommandPacket.java
@@ -33,7 +33,7 @@ public record RunBnsCommandPacket(String args) implements CustomPacketPayload {
public static final StreamCodec STREAM_CODEC =
StreamCodec.composite(
- ByteBufCodecs.STRING_UTF8, RunBnsCommandPacket::args,
+ ByteBufCodecs.stringUtf8(80), RunBnsCommandPacket::args,
RunBnsCommandPacket::new);
@Override
diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/s2c/BountySnapshotPacket.java b/src/main/java/uk/sijbers/bnstoolkit/network/s2c/BountySnapshotPacket.java
index 37f0d48..fa86111 100644
--- a/src/main/java/uk/sijbers/bnstoolkit/network/s2c/BountySnapshotPacket.java
+++ b/src/main/java/uk/sijbers/bnstoolkit/network/s2c/BountySnapshotPacket.java
@@ -61,6 +61,8 @@ public record BountySnapshotPacket(
}
ClientBnsState.completedIds.clear();
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;
});
}
diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/s2c/OpenScreenPacket.java b/src/main/java/uk/sijbers/bnstoolkit/network/s2c/OpenScreenPacket.java
new file mode 100644
index 0000000..3185ada
--- /dev/null
+++ b/src/main/java/uk/sijbers/bnstoolkit/network/s2c/OpenScreenPacket.java
@@ -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 } (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 TYPE = new Type<>(
+ ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "open_screen"));
+
+ public static final StreamCodec 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));
+ }
+}
diff --git a/src/main/java/uk/sijbers/bnstoolkit/server/BnsServerCommands.java b/src/main/java/uk/sijbers/bnstoolkit/server/BnsServerCommands.java
new file mode 100644
index 0000000..7ab114f
--- /dev/null
+++ b/src/main/java/uk/sijbers/bnstoolkit/server/BnsServerCommands.java
@@ -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 — 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 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 command");
+ }
+
+ private static CompletableFuture suggestScreens(
+ com.mojang.brigadier.context.CommandContext 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;
+ }
+}
diff --git a/src/main/resources/assets/bnstoolkit/lang/en_us.json b/src/main/resources/assets/bnstoolkit/lang/en_us.json
index 91f8238..0fff03c 100644
--- a/src/main/resources/assets/bnstoolkit/lang/en_us.json
+++ b/src/main/resources/assets/bnstoolkit/lang/en_us.json
@@ -1,11 +1,13 @@
{
"key.category.bnstoolkit": "Brass and Sigil",
- "key.bnstoolkit.tier": "Open Tier Ladder",
- "key.bnstoolkit.bounties": "Open Bounty Board",
- "key.bnstoolkit.plots": "Open Plot Office",
- "key.bnstoolkit.shop": "Open Bazaar Shop",
- "key.bnstoolkit.questlog": "Open Quest Log",
+ "key.bnstoolkit.hub": "Open Brass and Sigil Hub",
+ "key.bnstoolkit.tier": "Open Tier Ladder (direct)",
+ "key.bnstoolkit.bounties": "Open Bounty Board (direct)",
+ "key.bnstoolkit.plots": "Open Plot Office (direct)",
+ "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.bounty_board": "Bounty Board",
"screen.bnstoolkit.quest_log": "Quest Log",