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