From 884a8cea0b3b559be2d0830fcdf95407bd513c93 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 7 Jun 2026 09:24:23 +0000 Subject: [PATCH] 0.4.0: NBT key fix + hub UI + /bns open + network hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- gradle.properties | 2 +- .../uk/sijbers/bnstoolkit/BnsToolkit.java | 12 +- .../bnstoolkit/client/BnsKeyBindings.java | 53 ++- .../bnstoolkit/client/ClientBnsState.java | 4 +- .../client/OpenScreenDispatcher.java | 34 ++ .../bnstoolkit/client/hud/SpurHud.java | 42 +-- .../client/screens/BountyBoardScreen.java | 3 - .../bnstoolkit/client/screens/HubScreen.java | 96 ++++++ .../economy/VillagerTradeRebalance.java | 59 ++-- .../bnstoolkit/network/BnsNetwork.java | 7 +- .../network/ServerEconomyBridge.java | 324 +++++++++++++----- .../c2s/RequestEconomySnapshotPacket.java | 2 +- .../network/c2s/RunBnsCommandPacket.java | 2 +- .../network/s2c/BountySnapshotPacket.java | 2 + .../network/s2c/OpenScreenPacket.java | 42 +++ .../bnstoolkit/server/BnsServerCommands.java | 83 +++++ .../assets/bnstoolkit/lang/en_us.json | 12 +- 17 files changed, 613 insertions(+), 166 deletions(-) create mode 100644 src/main/java/uk/sijbers/bnstoolkit/client/OpenScreenDispatcher.java create mode 100644 src/main/java/uk/sijbers/bnstoolkit/client/screens/HubScreen.java create mode 100644 src/main/java/uk/sijbers/bnstoolkit/network/s2c/OpenScreenPacket.java create mode 100644 src/main/java/uk/sijbers/bnstoolkit/server/BnsServerCommands.java 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 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",