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_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
@@ -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) {
// 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);
}
}
}
}
@@ -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
* <p>Single primary key: <b>B</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.
* <p>Audit context for choosing B as default (2026-06-07):
* <ul>
* <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 {
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();
@@ -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<String> dailyPoolIds = new ArrayList<>();
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;
// 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.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;
if (cachedTier != ClientBnsState.tier || cachedSpurs != ClientBnsState.spurs) {
CivicTier cfg = CivicTier.byId(ClientBnsState.tier);
String tierLine = "§" + cfg.colour().getChar() + cfg.name() + "§r";
String spursLine = "§6" + TierUpgradeScreen.formatSpurs(ClientBnsState.spurs) + " spurs§r";
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;
}
@@ -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() {
@@ -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.
*
* 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)
* <h2>Denomination choice</h2>
* For each emerald count we pick a single denomination that minimises
* the rounding error:
* <ul>
* <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:
* - 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.
* <h2>Robustness</h2>
* <ul>
* <li>Wraps inner ItemListing.getOffer in try/catch — a misbehaved
* modded listing can't take down the whole profession.</li>
* <li>Null offer returned from inner is propagated cleanly.</li>
* <li>Null cost-A (theoretically possible from a reflection-built modded
* MerchantOffer) is treated as "leave alone".</li>
* </ul>
*/
@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<ItemCost> 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));
}
}
@@ -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);
}
@@ -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.
* <h2>Hardening</h2>
* <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 {
private ServerEconomyBridge() {}
private static final Set<String> 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<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 ─────────────────────────────────────────────
/** 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 "<null>";
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<String> poolIds = new ArrayList<>();
Map<String, BountySnapshotPacket.ActiveEntry> active = new HashMap<>();
List<String> 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));
// Daily pool: keys of bnsBountyPool compound.
List<String> poolIds = new ArrayList<>();
if (data.contains("bnsBountyPool")) {
CompoundTag pool = data.getCompound("bnsBountyPool");
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()) {
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);
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<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;
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<String, String> owners = new HashMap<>();
List<String> 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<String> 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());
// 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));
}
}
// 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;
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 "?";
}
}
}
@@ -28,7 +28,7 @@ public record RequestEconomySnapshotPacket(String kind) implements CustomPacketP
public static final StreamCodec<RegistryFriendlyByteBuf, RequestEconomySnapshotPacket> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, RequestEconomySnapshotPacket::kind,
ByteBufCodecs.stringUtf8(8), RequestEconomySnapshotPacket::kind,
RequestEconomySnapshotPacket::new);
@Override
@@ -33,7 +33,7 @@ public record RunBnsCommandPacket(String args) implements CustomPacketPayload {
public static final StreamCodec<RegistryFriendlyByteBuf, RunBnsCommandPacket> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, RunBnsCommandPacket::args,
ByteBufCodecs.stringUtf8(80), RunBnsCommandPacket::args,
RunBnsCommandPacket::new);
@Override
@@ -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;
});
}
@@ -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.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",