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