0.2.0: feature-complete UI — 5 FTB-Library screens + HUD overlay
Build / build (push) Has been cancelled

All five screens implemented with FTB Library widgets (Panel/Widget/
SimpleTextButton). Layout matches FTB Quests/Chunks for visual
consistency with the rest of the pack.

Screens:
- TierUpgradeScreen   — 13-row ladder, current tier highlighted,
                        Upgrade button to next tier with cost/affordability check
- ShopBrowserScreen   — 9-cell item grid + detail pane, quick-sell
                        buttons (1/16/64) with live DR-aware price preview
- BountyBoardScreen   — daily pool + active set, accept/cancel/turn-in
                        per state, slot-cap indicator
- PlotPurchaseScreen  — plot list + detail pane, buy/release with
                        ownership colour-coding and tier-gated buy
- QuestLogScreen      — at-a-glance tier+balance summary, active
                        bounties with progress bars, owned plots

HUD overlay: top-left tier name + spurs balance, registered as a
NeoForge GUI layer above the experience bar. 1Hz server push keeps
the balance current without polling client-side inventory.

Network: replaces single smoke-test packet with 7-packet contract:
- C2S: RequestEconomySnapshot, RunBnsCommand (whitelisted)
- S2C: TierStateUpdate, SellSnapshot, BountySnapshot, PlotSnapshot, Toast

Server-side bridge (ServerEconomyBridge) reads player NBT and
Numismatics-by-item-id-string for balance, routes write verbs
through the existing KubeJS /bns chat commands so KubeJS remains the
source of truth for economy logic.

Key bindings (rebindable in vanilla controls menu):
- K  Civic tier ladder
- J  Bounty board
- N  Plot office
- H  Bazaar shop
- M  Quest log

FTB Library + Architectury maven repos wired in build.gradle as
compileOnly; the jar stays small since FTB Library is already in pack.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-07 02:33:53 +00:00
parent 9892b0e737
commit afe7d38827
30 changed files with 1888 additions and 241 deletions
+13 -11
View File
@@ -19,17 +19,16 @@ sourceSets.main.resources {
}
repositories {
// FTB Library / FTB Chunks / FTB Quests live here. Used compileOnly
// when we wire screens to the FTB UI framework (M1+).
maven {
name = 'FTB'
url = 'https://maven.ftb.dev/releases'
content { includeGroup 'dev.ftb.mods' }
}
// Numismatics + Create dependencies live on Modrinth's maven mirror.
// Architectury (transitive of FTB Library)
maven {
name = 'ModrinthMaven'
url = 'https://api.modrinth.com/maven'
name = 'Architectury'
url = 'https://maven.architectury.dev/'
content { includeGroup 'dev.architectury' }
}
}
@@ -82,12 +81,15 @@ configurations {
}
dependencies {
// TODO(matt M1): wire FTB Library compileOnly dep when first FTB-UI screen lands.
// Candidate coord (verify version pinning against pack):
// compileOnly "dev.ftb.mods:ftb-library-neoforge:2101.1.31"
//
// TODO(matt M2): Numismatics compileOnly dep for the shop block integration.
// Modrinth maven path: maven.modrinth/numismatics/<version>
// FTB Library: provides the Panel/Widget/Button framework used by every
// bnstoolkit screen. compileOnly because it's shipped in the modpack
// already — we don't want to ship a second copy in our jar. Version
// pinned to the live pack's installed version (2101.1.31).
compileOnly "dev.ftb.mods:ftb-library-neoforge:2101.1.31"
// No Numismatics compileOnly dep — we read coin balances by inventory-
// scanning items via their string IDs ("numismatics:spur", etc) which
// doesn't need any of Numismatics' Java API on the classpath.
}
var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) {
+1 -1
View File
@@ -19,5 +19,5 @@ loader_version_range=[1,)
mod_id=bnstoolkit
mod_name=Brass and Sigil Toolkit
mod_license=All Rights Reserved
mod_version=0.1.1
mod_version=0.2.0
mod_group_id=uk.sijbers.bnstoolkit
@@ -1,22 +1,31 @@
package uk.sijbers.bnstoolkit;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.Mod;
import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
import net.neoforged.neoforge.event.tick.PlayerTickEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import uk.sijbers.bnstoolkit.network.BnsNetwork;
import uk.sijbers.bnstoolkit.network.ServerEconomyBridge;
/**
* Brass and Sigil Toolkit — common (client+server) mod entry.
*
* Mod ID matches {@link #MOD_ID}. The matching value lives in
* gradle.properties (mod_id=bnstoolkit) and is interpolated into
* src/main/templates/META-INF/neoforge.mods.toml at build time.
* Two server-side responsibilities live here:
* 1. Push a full economy snapshot to every player on login so screens
* have data the first time they're opened.
* 2. Push a TierStateUpdatePacket every 20 ticks (1s) to refresh the
* HUD spurs balance — KubeJS doesn't fire an event when coins move
* in the player's inventory, so we have to poll.
*
* Scope (M0): empty scaffold. Registers no items, blocks, or screens yet.
* Just loads cleanly on client and dedicated server so the deploy pipeline
* works end-to-end before we start adding real surface area.
* The polling tick is cheap (one SpurBalance.totalSpurs sweep over 41
* inventory slots) and avoids forcing the player to interact with a
* shop/quest to see their wallet update.
*/
@Mod(BnsToolkit.MOD_ID)
public final class BnsToolkit {
@@ -29,14 +38,32 @@ public final class BnsToolkit {
BnsNetwork.register(modBus);
modBus.addListener(this::onCommonSetup);
// NOTE: do NOT call NeoForge.EVENT_BUS.register(this) here — NeoForge
// 21.1+ throws if the registered class has zero @SubscribeEvent methods.
// Add the register() call back together with the first @SubscribeEvent
// method (likely PlayerEvent.PlayerLoggedInEvent in M1).
NeoForge.EVENT_BUS.register(this);
}
private void onCommonSetup(final FMLCommonSetupEvent event) {
LOG.info("[bnstoolkit] common setup complete (M0 scaffold — no surface yet)");
LOG.info("[bnstoolkit] common setup complete");
}
@SubscribeEvent
public void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) {
if (event.getEntity() instanceof ServerPlayer sp) {
ServerEconomyBridge.pushSnapshot(sp, "ALL");
LOG.info("[bnstoolkit] pushed initial economy snapshot to {}", sp.getName().getString());
}
}
private static int tickCounter = 0;
@SubscribeEvent
public void onPlayerTick(PlayerTickEvent.Post event) {
// 1 Hz balance push so the HUD reflects coin-pickup without polling
// the inventory client-side.
if (event.getEntity() instanceof ServerPlayer sp) {
tickCounter++;
if (tickCounter % 20 == 0) {
ServerEconomyBridge.pushTierUpdate(sp);
}
}
}
}
@@ -4,15 +4,10 @@ import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.Mod;
import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent;
import net.neoforged.neoforge.common.NeoForge;
import uk.sijbers.bnstoolkit.client.BnsKeyBindings;
import uk.sijbers.bnstoolkit.client.hud.SpurHud;
/**
* Client-only mod entry. Hosts key bindings + screen registration so the
* dedicated server never loads them.
*
* Activated via the {@code dist = Dist.CLIENT} parameter on the @Mod
* annotation — NeoForge will skip this class entirely on the server side.
*/
@Mod(value = BnsToolkit.MOD_ID, dist = Dist.CLIENT)
public final class BnsToolkitClient {
@@ -20,11 +15,14 @@ public final class BnsToolkitClient {
BnsToolkit.LOG.info("[bnstoolkit] client entry constructed");
modBus.addListener(this::onClientSetup);
modBus.addListener(BnsKeyBindings::onRegisterKeyMappings);
modBus.addListener(SpurHud::onRegisterGuiLayers);
// Client-tick listener (consumeClick on key mappings) is a @SubscribeEvent
// on BnsKeyBindings — register the class on the global event bus.
NeoForge.EVENT_BUS.register(BnsKeyBindings.class);
}
private void onClientSetup(final FMLClientSetupEvent event) {
// TODO(matt M1): register screens here when bnstoolkit ships its first GUI.
// MenuScreens.register(BnsMenus.TIER_UPGRADE.get(), TierUpgradeScreen::new);
BnsToolkit.LOG.info("[bnstoolkit] client setup complete");
}
}
@@ -2,21 +2,32 @@ package uk.sijbers.bnstoolkit.client;
import com.mojang.blaze3d.platform.InputConstants;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.Minecraft;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.neoforge.client.event.RegisterKeyMappingsEvent;
import net.neoforged.neoforge.client.event.ClientTickEvent;
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.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;
/**
* Key bindings for the toolkit screens. All keys are CLIENT-only and live
* under a "Brass and Sigil" category in the controls menu.
* Key bindings for the toolkit screens.
*
* Default assignments are placeholders — picked to avoid known FTB
* Library / FTB Chunks / Numismatics defaults. Players can rebind in the
* 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
*
* 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.
*
* Wiring: each binding gets polled in a {@code ClientTickEvent} handler
* once the corresponding screen is implemented; M0 keeps them inert.
*/
public final class BnsKeyBindings {
private BnsKeyBindings() {}
@@ -26,25 +37,65 @@ public final class BnsKeyBindings {
public static final KeyMapping OPEN_TIER = new KeyMapping(
"key." + BnsToolkit.MOD_ID + ".tier",
KeyConflictContext.IN_GAME,
InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_T),
InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_K),
CATEGORY);
public static final KeyMapping OPEN_BOUNTIES = new KeyMapping(
"key." + BnsToolkit.MOD_ID + ".bounties",
KeyConflictContext.IN_GAME,
InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_B),
InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_J),
CATEGORY);
public static final KeyMapping OPEN_PLOTS = new KeyMapping(
"key." + BnsToolkit.MOD_ID + ".plots",
KeyConflictContext.IN_GAME,
InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_P),
InputConstants.Type.KEYSYM.getOrCreate(GLFW.GLFW_KEY_N),
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),
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),
CATEGORY);
public static void onRegisterKeyMappings(RegisterKeyMappingsEvent event) {
event.register(OPEN_TIER);
event.register(OPEN_BOUNTIES);
event.register(OPEN_PLOTS);
BnsToolkit.LOG.info("[bnstoolkit] registered 3 key bindings under {}", CATEGORY);
event.register(OPEN_SHOP);
event.register(OPEN_QUESTLOG);
BnsToolkit.LOG.info("[bnstoolkit] registered 5 key bindings under {}", CATEGORY);
}
@SubscribeEvent
public static void onClientTick(ClientTickEvent.Post event) {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.player == null || mc.screen != null) return;
if (OPEN_TIER.consumeClick()) {
NetSend.requestSnapshot("TIER");
new TierUpgradeScreen().openGui();
}
if (OPEN_BOUNTIES.consumeClick()) {
NetSend.requestSnapshot("BOUNTY");
new BountyBoardScreen().openGui();
}
if (OPEN_PLOTS.consumeClick()) {
NetSend.requestSnapshot("PLOT");
new PlotPurchaseScreen().openGui();
}
if (OPEN_SHOP.consumeClick()) {
NetSend.requestSnapshot("SELL");
new ShopBrowserScreen().openGui();
}
if (OPEN_QUESTLOG.consumeClick()) {
NetSend.requestSnapshot("ALL");
new QuestLogScreen().openGui();
}
}
}
@@ -0,0 +1,70 @@
package uk.sijbers.bnstoolkit.client;
import net.minecraft.resources.ResourceLocation;
import uk.sijbers.bnstoolkit.data.Bounty;
import uk.sijbers.bnstoolkit.data.Plot;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Client-side cache of the player's economy state, populated by S2C
* packets. Read by the HUD overlay every frame and by every open screen.
*
* The server pushes:
* - {@link uk.sijbers.bnstoolkit.network.s2c.TierStateUpdatePacket} — tier + balance
* - {@link uk.sijbers.bnstoolkit.network.s2c.SellSnapshotPacket} — lifetime-sold map + effective fee
* - {@link uk.sijbers.bnstoolkit.network.s2c.BountySnapshotPacket} — today's pool + active set
* - {@link uk.sijbers.bnstoolkit.network.s2c.PlotSnapshotPacket} — ownership map
*
* All access is single-threaded (client tick / render thread) so no locks.
*/
public final class ClientBnsState {
private ClientBnsState() {}
public static volatile int tier = 1;
public static volatile long spurs = 0L;
public static volatile int effectiveFeePct = 25;
// SellSnapshot:
public static final Map<ResourceLocation, Long> lifetimeSold = new HashMap<>();
// 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 volatile int bountySlotCap = 3;
// PlotSnapshot:
public static final Map<String, String> plotOwners = new HashMap<>(); // plot id -> owner name (or "" if free)
public static volatile int plotSlotCap = 0;
public static final List<String> ownedPlotIds = new ArrayList<>();
public static long lifetimeSoldOf(ResourceLocation item) {
Long v = lifetimeSold.get(item);
return v == null ? 0L : v;
}
public static Bounty.State stateOf(String bountyId) {
if (completedIds.contains(bountyId)) return Bounty.State.COOLDOWN;
ActiveBounty ab = activeBounties.get(bountyId);
if (ab == null) return Bounty.State.AVAILABLE;
return ab.progress >= ab.target ? Bounty.State.READY : Bounty.State.ACTIVE;
}
public static boolean ownsPlot(String plotId) {
return ownedPlotIds.contains(plotId);
}
public static String ownerOf(String plotId) {
return plotOwners.getOrDefault(plotId, "");
}
public static Plot plotById(String id) {
return Plot.byId(id);
}
public record ActiveBounty(int progress, int target, long reward) {}
}
@@ -1,30 +1,69 @@
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;
import net.neoforged.neoforge.client.gui.VanillaGuiLayers;
import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.client.ClientBnsState;
import uk.sijbers.bnstoolkit.client.screens.TierUpgradeScreen;
import uk.sijbers.bnstoolkit.data.CivicTier;
/**
* Tiny on-screen overlay showing the player's spurs balance + civic tier.
* Persistent on-screen overlay: civic tier + spurs balance, top-left.
*
* Spec ref: docs/bnstoolkit-architecture.md §4.6 (HUD overlay)
*
* TODO(matt M4): wire this up via {@code RegisterGuiLayersEvent} when the
* NeoForge gui-layer registration lands. The signature here matches the
* {@code LayeredDraw.Layer} contract (a render method taking GuiGraphics +
* DeltaTracker), so the eventual implementation only has to add the
* "implements" clause and the registration call.
*
* Position default: top-left, below buff icons. Player can disable in mod
* settings (no settings screen yet — comes with the FTB Library swap).
* 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).
*/
public final class SpurHud {
public static final SpurHud INSTANCE = new SpurHud();
private SpurHud() {}
public void render(GuiGraphics graphics, DeltaTracker delta) {
// TODO(matt M4): draw "<tier-icon> <tier-name> <spurs> spurs"
// Data source: client-side cache populated by S2C TierStateUpdate
// packets the server emits whenever balance or tier changes.
public static final SpurHud INSTANCE = new SpurHud();
public static final ResourceLocation LAYER_ID =
ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "spur_hud");
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);
}
public void render(GuiGraphics gfx, DeltaTracker delta) {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.player == null) return;
if (mc.options.hideGui) return;
CivicTier cfg = CivicTier.byId(ClientBnsState.tier);
String tierLine = "§" + cfg.colour().getChar() + cfg.name() + "§r";
String spursLine = "§6" + TierUpgradeScreen.formatSpurs(ClientBnsState.spurs) + " spurs§r";
int x = 4;
int y = 4;
int w = Math.max(mc.font.width(tierLine), mc.font.width(spursLine)) + 8;
int h = 24;
gfx.fill(x, y, x + w, y + h, 0x80000000);
gfx.fill(x, y, x + w, y + 1, 0xFF666666);
gfx.fill(x, y + h - 1, x + w, y + h, 0xFF666666);
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);
}
// 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;
}
@@ -1,37 +1,57 @@
package uk.sijbers.bnstoolkit.client.screens;
import dev.ftb.mods.ftblibrary.icon.Color4I;
import dev.ftb.mods.ftblibrary.ui.BaseScreen;
import dev.ftb.mods.ftblibrary.ui.Theme;
import dev.ftb.mods.ftblibrary.ui.WidgetType;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/**
* Shared base for all toolkit screens.
* Shared parent for every bnstoolkit screen.
*
* TODO(matt M1): swap parent to {@code dev.ftb.mods.ftblibrary.ui.BaseScreen}
* once the FTB Library compileOnly dependency is wired in build.gradle. The
* vanilla {@link Screen} parent is a stand-in so M0 compiles without FTB
* Library on the classpath and we can ship a "loads cleanly" mod first.
*
* When the swap lands, each subclass moves from
* {@code render(GuiGraphics, ...)} -> the FTB Lib panel/widget tree
* Visual styling (background tile, title bar, close button) comes for
* free from FTB Library, matching FTB Quests / FTB Chunks look-and-feel.
* Inherits FTB Library's BaseScreen — that brings in the panel-style
* chrome, scroll handling, modal dialog support, and consistent visual
* theming with FTB Quests / FTB Chunks. We override drawBackground to
* add a title bar on top of the standard FTB gui panel.
*/
public abstract class BaseBnsScreen extends Screen {
public abstract class BaseBnsScreen extends BaseScreen {
public static final int WIDTH = 360;
public static final int HEIGHT = 220;
protected final Component screenTitle;
protected BaseBnsScreen(Component title) {
super(title);
this.screenTitle = title;
setWidth(WIDTH);
setHeight(HEIGHT);
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
this.renderBackground(graphics, mouseX, mouseY, partialTick);
super.render(graphics, mouseX, mouseY, partialTick);
// Centred title — placeholder styling.
graphics.drawCenteredString(this.font, this.title, this.width / 2, 16, 0xFFFFFFFF);
}
@Override
public boolean isPauseScreen() {
public boolean doesGuiPauseGame() {
return false;
}
@Override
public boolean drawDefaultBackground(GuiGraphics graphics) {
return true; // dim the world behind
}
@Override
public void drawBackground(GuiGraphics graphics, Theme theme, int x, int y, int w, int h) {
theme.drawGui(graphics, x, y, w, h, WidgetType.NORMAL);
// Title bar — darker strip across the top.
Color4I.rgba(0x80000000).draw(graphics, x + 2, y + 2, w - 4, 16);
theme.drawString(graphics, screenTitle, x + 8, y + 6, Color4I.WHITE, Theme.SHADOW);
}
/** Top y of the content area (below title bar). */
protected int contentTop() {
return getY() + 20;
}
/** Bottom y of the content area (above action row). */
protected int contentBottom() {
return getY() + getHeight() - 24;
}
}
@@ -1,29 +1,196 @@
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 dev.ftb.mods.ftblibrary.ui.input.MouseButton;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.network.chat.Component;
import uk.sijbers.bnstoolkit.client.ClientBnsState;
import uk.sijbers.bnstoolkit.data.Bounty;
import uk.sijbers.bnstoolkit.network.NetSend;
/**
* Bounty board screen — replaces /bns bounty list / accept / turnin.
* Bounty board screen.
*
* Spec ref: docs/bnstoolkit-architecture.md §4.3 (BountyBoardScreen)
* Left: scrollable list of today's 5-bounty pool, plus any active bounties
* not currently in the pool (so you don't lose your in-progress hunt at
* day-rollover).
*
* TODO(matt M3): Layout to confirm tomorrow:
* - Left pane: list of 5 available bounties for today (refreshes every 24h
* server-side; the screen just renders what the server sent)
* - Right pane: detail for the highlighted bounty — target entity, count,
* payout, state (AVAILABLE / ACTIVE / READY / COOLDOWN)
* - Bottom row: ACCEPT / CANCEL / TURN IN buttons (enable based on state)
* - Slot indicator: "Active bounties: 2/5" using the player's tier cap
*
* Two C2S packets drive it:
* - RequestBountyList (board open / refresh)
* - RequestBountyAction (accept | cancel | turnin)
* One S2C: BountyBoardSnapshot.
* Right: detail for the highlighted bounty — name, target entity, count
* needed, reward, current progress. Bottom row: ACCEPT / CANCEL /
* TURN IN buttons (enabled by state).
*/
public final class BountyBoardScreen extends BaseBnsScreen {
private static String selected = null;
public BountyBoardScreen() {
super(Component.translatable("screen.bnstoolkit.bounty_board"));
}
// TODO(matt M3): init() + widgets
@Override
public void addWidgets() {
add(new BountyList(this));
add(new BountyDetail(this));
add(SimpleTextButton.create(this,
Component.literal("Refresh"),
Icons.REFRESH,
btn -> NetSend.requestSnapshot("BOUNTY")));
}
@Override
public void alignWidgets() {
getWidgets().get(0).setPos(getX() + 8, contentTop());
getWidgets().get(0).setSize(170, getHeight() - 30);
getWidgets().get(1).setPos(getX() + 186, contentTop());
getWidgets().get(1).setSize(getWidth() - 194, getHeight() - 30);
Widget rf = getWidgets().get(2);
rf.setSize(60, 16);
rf.setPos(getX() + getWidth() - 68, getY() + getHeight() - 22);
}
private static final class BountyList extends Panel {
BountyList(Panel parent) { super(parent); }
@Override public void addWidgets() {
// Today's daily pool first, then any active not already in pool
java.util.LinkedHashSet<String> show = new java.util.LinkedHashSet<>();
show.addAll(ClientBnsState.dailyPoolIds);
show.addAll(ClientBnsState.activeBounties.keySet());
int activeCount = ClientBnsState.activeBounties.size();
add(new HeaderRow(this, "Active: " + activeCount + " / " + ClientBnsState.bountySlotCap));
for (String id : show) {
Bounty b = Bounty.byId(id);
if (b != null) add(new BountyRow(this, b));
}
if (show.isEmpty()) {
add(new HeaderRow(this, "§7No bounties available — try Refresh"));
}
}
@Override public void alignWidgets() {
int y = 0;
for (Widget w : getWidgets()) {
w.setSize(width, 14);
w.setPos(0, y);
y += 14;
}
}
}
private static final class HeaderRow extends Widget {
private final String text;
HeaderRow(Panel parent, String text) { super(parent); this.text = text; }
@Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) {
theme.drawString(gfx, Component.literal(text), x + 2, y + 3, Color4I.GRAY, Theme.SHADOW);
}
}
private static final class BountyRow extends Widget {
private final Bounty b;
BountyRow(Panel parent, Bounty b) { super(parent); this.b = b; }
@Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) {
Bounty.State s = ClientBnsState.stateOf(b.id());
boolean sel = b.id().equals(selected);
if (sel) Color4I.rgba(0x80FFD700).draw(gfx, x, y, w, h);
else if (isMouseOver) Color4I.rgba(0x40FFFFFF).draw(gfx, x, y, w, h);
String prefix = switch (s) {
case ACTIVE -> "§e●§r ";
case READY -> "§a✓§r ";
case COOLDOWN -> "§8○§r ";
default -> "§7○§r ";
};
String suffix = switch (s) {
case ACTIVE -> {
ClientBnsState.ActiveBounty ab = ClientBnsState.activeBounties.get(b.id());
yield ab == null ? "" : " §7(" + ab.progress() + "/" + ab.target() + ")§r";
}
case READY -> " §aREADY§r";
case COOLDOWN -> " §8cooldown§r";
default -> " §6" + b.reward() + "§r";
};
theme.drawString(gfx, Component.literal(prefix + b.name() + suffix), x + 2, y + 3,
Color4I.WHITE, Theme.SHADOW);
}
@Override public boolean mousePressed(MouseButton button) {
if (isMouseOver) {
selected = b.id();
TierUpgradeScreen.playClick();
return true;
}
return false;
}
}
private static final class BountyDetail extends Panel {
BountyDetail(Panel parent) { super(parent); }
@Override public void addWidgets() {
if (selected == null) {
add(new HeaderRow(this, "Select a bounty on the left."));
return;
}
Bounty b = Bounty.byId(selected);
if (b == null) {
add(new HeaderRow(this, "§cUnknown bounty: " + selected));
return;
}
Bounty.State s = ClientBnsState.stateOf(selected);
ClientBnsState.ActiveBounty ab = ClientBnsState.activeBounties.get(selected);
add(new HeaderRow(this, "§e" + b.name()));
add(new HeaderRow(this, "§7Target: " + b.targetEntity()));
add(new HeaderRow(this, "§7Count needed: " + b.count()));
add(new HeaderRow(this, "§6Reward: " + b.reward() + " spurs"));
add(new HeaderRow(this, "Status: " + statusColour(s)));
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,
btn -> { NetSend.runBns("bounty accept " + b.id()); TierUpgradeScreen.playClick(); }));
}
if (s == Bounty.State.ACTIVE) {
add(SimpleTextButton.create(this, Component.literal("Cancel"), Icons.CANCEL,
btn -> { NetSend.runBns("bounty cancel " + b.id()); TierUpgradeScreen.playClick(); }));
}
if (s == Bounty.State.READY) {
add(SimpleTextButton.create(this, Component.literal("Turn In"), Icons.ACCEPT,
btn -> { NetSend.runBns("bounty turnin " + b.id()); TierUpgradeScreen.playClick(); }));
}
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() {
int y = 0;
for (Widget w : getWidgets()) {
if (w instanceof HeaderRow) {
w.setSize(width, 12);
w.setPos(0, y);
y += 12;
} else {
w.setSize(width, 18);
w.setPos(0, height - 18);
}
}
}
private static String statusColour(Bounty.State s) {
return switch (s) {
case AVAILABLE -> "§7available";
case ACTIVE -> "§eactive";
case READY -> "§aready to turn in";
case COOLDOWN -> "§8on cooldown";
};
}
}
}
@@ -1,32 +1,170 @@
package uk.sijbers.bnstoolkit.client.screens;
import dev.ftb.mods.ftblibrary.icon.Color4I;
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 dev.ftb.mods.ftblibrary.ui.input.MouseButton;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.network.chat.Component;
import uk.sijbers.bnstoolkit.client.ClientBnsState;
import uk.sijbers.bnstoolkit.data.Plot;
import uk.sijbers.bnstoolkit.network.NetSend;
/**
* Plot purchase + management screen — replaces /bns plot list / buy / release.
* Spawn-region plot purchase screen.
*
* Spec ref: docs/bnstoolkit-architecture.md §4.4 (PlotPurchaseScreen)
* Left: list of all plots in the spawn region (currently 3), state-coded
* by ownership. Right: detail for the selected plot — name, size, chunks,
* price, refund preview if owned. Bottom buttons: BUY or RELEASE.
*
* TODO(matt M3): Layout to confirm tomorrow:
* - Top-down minimap of the spawn plot region, plots coloured by state
* (OWN / AVAILABLE / TAKEN_BY_OTHER), hover for owner + price
* - Right pane: highlighted plot details — id, chunk bounds, price,
* owner, release refund preview
* - Bottom: BUY / RELEASE buttons (price + refund displayed inline)
* - Slot indicator: "Plots owned: 1/2" using the player's tier cap
*
* Two C2S packets:
* - RequestPlotMap (open / refresh)
* - RequestPlotAction (buy | release, with plot id)
* One S2C: PlotMapSnapshot.
*
* Open trigger options (decide tomorrow): keybind P, an in-game block at
* the spawn building, or both. Both is easy — they're not exclusive.
* No minimap widget in M1 — the chunk list shows you exactly where each
* plot sits. A real map view (with bluemap thumbnail or live render) is
* planned in a follow-up.
*/
public final class PlotPurchaseScreen extends BaseBnsScreen {
private static String selected = null;
public PlotPurchaseScreen() {
super(Component.translatable("screen.bnstoolkit.plot_purchase"));
}
// TODO(matt M3): init() + map widget + action buttons
@Override
public void addWidgets() {
add(new PlotList(this));
add(new PlotDetail(this));
add(SimpleTextButton.create(this,
Component.literal("Refresh"),
Icons.REFRESH,
btn -> NetSend.requestSnapshot("PLOT")));
}
@Override
public void alignWidgets() {
getWidgets().get(0).setPos(getX() + 8, contentTop());
getWidgets().get(0).setSize(170, getHeight() - 30);
getWidgets().get(1).setPos(getX() + 186, contentTop());
getWidgets().get(1).setSize(getWidth() - 194, getHeight() - 30);
Widget rf = getWidgets().get(2);
rf.setSize(60, 16);
rf.setPos(getX() + getWidth() - 68, getY() + getHeight() - 22);
}
private static final class PlotList extends Panel {
PlotList(Panel parent) { super(parent); }
@Override public void addWidgets() {
int owned = ClientBnsState.ownedPlotIds.size();
add(new RowLabel(this, "§7Slots: " + owned + " / " + ClientBnsState.plotSlotCap));
for (Plot p : Plot.ALL) add(new PlotRow(this, p));
}
@Override public void alignWidgets() {
int y = 0;
for (Widget w : getWidgets()) {
w.setSize(width, 14);
w.setPos(0, y);
y += 14;
}
}
}
private static final class PlotRow extends Widget {
private final Plot p;
PlotRow(Panel parent, Plot p) { super(parent); this.p = p; }
@Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) {
boolean sel = p.id().equals(selected);
String owner = ClientBnsState.ownerOf(p.id());
boolean isMine = ClientBnsState.ownsPlot(p.id());
boolean taken = !owner.isEmpty() && !isMine;
if (sel) Color4I.rgba(0x80FFD700).draw(gfx, x, y, w, h);
else if (isMouseOver) Color4I.rgba(0x40FFFFFF).draw(gfx, x, y, w, h);
String state = isMine ? "§a★ yours§r" :
taken ? "§c● " + owner + "§r" :
"§7○ free§r";
String line = "§e" + p.name() + "§r §6(" + p.price() + ")§r " + state;
theme.drawString(gfx, Component.literal(line), x + 2, y + 3, Color4I.WHITE, Theme.SHADOW);
}
@Override public boolean mousePressed(MouseButton button) {
if (isMouseOver) {
selected = p.id();
TierUpgradeScreen.playClick();
return true;
}
return false;
}
}
private static final class RowLabel extends Widget {
private final String text;
RowLabel(Panel parent, String text) { super(parent); this.text = text; }
@Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) {
theme.drawString(gfx, Component.literal(text), x + 2, y + 3, Color4I.GRAY, Theme.SHADOW);
}
}
private static final class PlotDetail extends Panel {
PlotDetail(Panel parent) { super(parent); }
@Override public void addWidgets() {
if (selected == null) {
add(new RowLabel(this, "Select a plot on the left."));
return;
}
Plot p = Plot.byId(selected);
if (p == null) {
add(new RowLabel(this, "§cUnknown plot: " + selected));
return;
}
String owner = ClientBnsState.ownerOf(p.id());
boolean isMine = ClientBnsState.ownsPlot(p.id());
boolean taken = !owner.isEmpty() && !isMine;
boolean atCap = ClientBnsState.ownedPlotIds.size() >= ClientBnsState.plotSlotCap;
add(new RowLabel(this, "§e" + p.name()));
add(new RowLabel(this, "§7Size: " + p.size()));
add(new RowLabel(this, "§7Dimension: " + p.dimension()));
add(new RowLabel(this, "§7Chunks: " + chunksToString(p)));
add(new RowLabel(this, "§6Price: " + p.price() + " spurs"));
if (isMine) {
add(new RowLabel(this, "§aOwned by you (release refund: " + p.refundOnRelease() + ")"));
add(SimpleTextButton.create(this, Component.literal("Release"), Icons.CANCEL,
btn -> { NetSend.runBns("plot release " + p.id()); TierUpgradeScreen.playClick(); }));
} else if (taken) {
add(new RowLabel(this, "§cTaken by " + owner));
} else if (atCap) {
add(new RowLabel(this, "§cAt plot-slot cap — upgrade your tier"));
} else if (ClientBnsState.plotSlotCap <= 0) {
add(new RowLabel(this, "§cYou need tier 3 (Citizen) for a plot"));
} else {
add(SimpleTextButton.create(this, Component.literal("Buy"), Icons.ACCEPT,
btn -> { NetSend.runBns("plot buy " + p.id()); TierUpgradeScreen.playClick(); }));
}
}
@Override public void alignWidgets() {
int y = 0;
for (Widget w : getWidgets()) {
if (w instanceof RowLabel) {
w.setSize(width, 12);
w.setPos(0, y);
y += 12;
} else {
w.setSize(width, 18);
w.setPos(0, height - 18);
}
}
}
private static String chunksToString(Plot p) {
StringBuilder sb = new StringBuilder();
for (int[] cc : p.chunks()) {
if (sb.length() > 0) sb.append(", ");
sb.append('[').append(cc[0]).append(',').append(cc[1]).append(']');
}
return sb.toString();
}
}
}
@@ -1,29 +1,114 @@
package uk.sijbers.bnstoolkit.client.screens;
import dev.ftb.mods.ftblibrary.icon.Color4I;
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.Bounty;
import uk.sijbers.bnstoolkit.data.CivicTier;
import uk.sijbers.bnstoolkit.data.Plot;
import uk.sijbers.bnstoolkit.network.NetSend;
import java.util.Map;
/**
* Quest log — the "What am I doing right now?" pane.
* Read-only "what am I doing right now" overview.
*
* Spec ref: docs/bnstoolkit-architecture.md §4.2 (QuestLogScreen)
*
* TODO(matt M2): Layout to confirm tomorrow:
* - Tab bar at top: ACTIVE BOUNTIES | FTB QUESTS | PLOT TIMERS
* - Active Bounties tab: same data as Bounty Board's right pane but
* filtered to ACTIVE state, with kill-progress bars
* - FTB Quests tab: read-only mirror of the player's current chapter,
* showing in-progress quests + reward previews
* - Plot Timers tab: list of owned plots with any pending refund/release
* timers (when implemented)
*
* Refresh model: server pushes a QuestLogSnapshot whenever any tracked
* counter changes (kill count, quest reward, plot ownership). Screen re-
* renders from the latest snapshot.
* Shows the player's tier + balance summary at the top, then three
* sections: active bounties with progress bars, owned plots, and the
* shop fee preview. FTB Quests integration deferred — when the quest
* book lands we'll add a fourth section that mirrors the player's
* current chapter progress here.
*/
public final class QuestLogScreen extends BaseBnsScreen {
public QuestLogScreen() {
super(Component.translatable("screen.bnstoolkit.quest_log"));
}
// TODO(matt M2): init() + tab widgets
@Override
public void addWidgets() {
add(new SummaryPanel(this));
add(SimpleTextButton.create(this, Component.literal("Refresh"), Icons.REFRESH,
btn -> NetSend.requestSnapshot("ALL")));
}
@Override
public void alignWidgets() {
getWidgets().get(0).setPos(getX() + 8, contentTop());
getWidgets().get(0).setSize(getWidth() - 16, getHeight() - 30);
Widget rf = getWidgets().get(1);
rf.setSize(60, 16);
rf.setPos(getX() + getWidth() - 68, getY() + getHeight() - 22);
}
private static final class SummaryPanel extends Panel {
SummaryPanel(Panel parent) { super(parent); }
@Override public void addWidgets() {
CivicTier cfg = CivicTier.byId(ClientBnsState.tier);
add(new Header(this, "§e=== Status ==="));
add(new Header(this, "Tier: §" + cfg.colour().getChar() + cfg.name() + "§r ("
+ ClientBnsState.tier + " / " + CivicTier.MAX + ")"));
add(new Header(this, "Balance: §6" + TierUpgradeScreen.formatSpurs(ClientBnsState.spurs) + " spurs§r"));
add(new Header(this, "Shop fee: §c" + ClientBnsState.effectiveFeePct + "%§r"));
add(new Header(this, ""));
add(new Header(this, "§e=== Active bounties ==="));
if (ClientBnsState.activeBounties.isEmpty()) {
add(new Header(this, "§7none — visit the Bounty Board (B)"));
} else {
for (Map.Entry<String, ClientBnsState.ActiveBounty> e : ClientBnsState.activeBounties.entrySet()) {
Bounty b = Bounty.byId(e.getKey());
if (b == null) continue;
ClientBnsState.ActiveBounty ab = e.getValue();
String bar = progressBar(ab.progress(), ab.target(), 14);
String line = " " + b.name() + " " + bar + " §7" + ab.progress() + "/" + ab.target() + "§r";
add(new Header(this, line));
}
}
add(new Header(this, ""));
add(new Header(this, "§e=== Owned plots ==="));
if (ClientBnsState.ownedPlotIds.isEmpty()) {
add(new Header(this, "§7none — visit the Plot Office (P)"));
} else {
for (String pid : ClientBnsState.ownedPlotIds) {
Plot p = Plot.byId(pid);
if (p != null) add(new Header(this, " §a★§r " + p.name() + " §7(" + p.size() + ")§r"));
}
}
}
@Override public void alignWidgets() {
int y = 0;
for (Widget w : getWidgets()) {
w.setSize(width, 12);
w.setPos(0, y);
y += 12;
}
}
private static String progressBar(int progress, int target, int barWidth) {
int filled = target > 0 ? Math.min(barWidth, (progress * barWidth) / target) : 0;
StringBuilder sb = new StringBuilder("§a");
for (int i = 0; i < filled; i++) sb.append('█');
sb.append("§8");
for (int i = filled; i < barWidth; i++) sb.append('▒');
sb.append("§r");
return sb.toString();
}
}
private static final class Header extends Widget {
private final String text;
Header(Panel parent, String text) { super(parent); this.text = text; }
@Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) {
theme.drawString(gfx, Component.literal(text), x, y + 2, Color4I.WHITE, Theme.SHADOW);
}
}
}
@@ -1,32 +1,172 @@
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.icon.ItemIcon;
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 dev.ftb.mods.ftblibrary.ui.input.MouseButton;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import uk.sijbers.bnstoolkit.client.ClientBnsState;
import uk.sijbers.bnstoolkit.data.SellCatalogue;
import uk.sijbers.bnstoolkit.network.NetSend;
import java.util.List;
/**
* Bazaar shop browser — replaces /bns sell list / sell <item> [count].
* Bazaar sell-shop screen.
*
* Spec ref: docs/bnstoolkit-architecture.md §4.5 (ShopBrowserScreen)
* Grid of 9 sell-list items. Click an item to select it. The right pane
* shows the selected item's effective price after fee + diminishing
* returns. Three quick-sell buttons: 1 / 16 / 64 (or whatever fits in
* the player's inventory).
*
* TODO(matt M2): Layout to confirm tomorrow:
* - Grid of sell-list items (9 in the current catalogue)
* - Per cell: item icon, base price, effective price after DR + tier fee,
* "you've sold X" counter
* - Click cell -> count slider (1 / 16 / 64 / max-in-inventory) and SELL button
* - Header: current spurs + effective fee%
* - Future: BUY tab when buy-prices land (Phase 7 dynamic exchange)
*
* Two C2S packets:
* - RequestShopCatalogue (open / refresh)
* - RequestShopSell (item id, count)
* One S2C: ShopCatalogueSnapshot, plus per-sale ShopSellResult.
*
* Open trigger options: keybind, a Numismatics-style shop block at the
* Bazaar building, or both.
* Sell action: sends a "sell <item> <count>" routed C2S packet. Server
* runs the KubeJS-owned /bns sell verb; KubeJS subtracts items and pays
* the spurs; server pushes both an updated SellSnapshot AND a fresh
* tier/balance.
*/
public final class ShopBrowserScreen extends BaseBnsScreen {
public ShopBrowserScreen() {
super(Component.translatable("screen.bnstoolkit.shop_browser"));
}
// TODO(matt M2): init() + item grid + sell widget
private static ResourceLocation selected = ResourceLocation.parse("minecraft:diamond");
@Override
public void addWidgets() {
add(new ItemGrid(this));
add(new SellDetail(this));
add(SimpleTextButton.create(this,
Component.literal("Refresh"),
Icons.REFRESH,
btn -> NetSend.requestSnapshot("SELL")));
}
@Override
public void alignWidgets() {
getWidgets().get(0).setPos(getX() + 8, contentTop());
getWidgets().get(0).setSize(170, getHeight() - 30);
getWidgets().get(1).setPos(getX() + 186, contentTop());
getWidgets().get(1).setSize(getWidth() - 194, getHeight() - 30);
Widget rf = getWidgets().get(2);
rf.setSize(60, 16);
rf.setPos(getX() + getWidth() - 68, getY() + getHeight() - 22);
}
// ─── Item grid ───────────────────────────────────────────────────
private static final class ItemGrid extends Panel {
ItemGrid(Panel parent) { super(parent); }
@Override public void addWidgets() {
for (ResourceLocation id : SellCatalogue.PRICES.keySet()) add(new ItemCell(this, id));
}
@Override public void alignWidgets() {
int cols = 5, cell = 32, gap = 2;
int i = 0;
for (Widget w : getWidgets()) {
int col = i % cols;
int row = i / cols;
w.setSize(cell, cell);
w.setPos(col * (cell + gap), row * (cell + gap));
i++;
}
}
}
private static final class ItemCell extends Widget {
private final ResourceLocation itemId;
private final ItemStack stack;
ItemCell(Panel parent, ResourceLocation itemId) {
super(parent);
this.itemId = itemId;
Item item = BuiltInRegistries.ITEM.get(itemId);
this.stack = item == null ? ItemStack.EMPTY : new ItemStack(item);
}
@Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) {
boolean isSelected = itemId.equals(selected);
Color4I bg = isSelected ? Color4I.rgba(0x80FFD700) :
isMouseOver ? Color4I.rgba(0x40FFFFFF) : Color4I.rgba(0x40000000);
bg.draw(gfx, x, y, w, h);
theme.drawString(gfx, Component.empty(), x, y, Color4I.WHITE, 0);
// Render the item icon centered
if (!stack.isEmpty()) {
gfx.renderItem(stack, x + 8, y + 8);
}
}
@Override public boolean mousePressed(MouseButton button) {
if (isMouseOver) {
selected = itemId;
TierUpgradeScreen.playClick();
return true;
}
return false;
}
}
// ─── Detail pane ─────────────────────────────────────────────────
private static final class SellDetail extends Panel {
SellDetail(Panel parent) { super(parent); }
@Override public void addWidgets() {
long base = SellCatalogue.PRICES.getOrDefault(selected, 0L);
long sold = ClientBnsState.lifetimeSoldOf(selected);
int feePct = ClientBnsState.effectiveFeePct;
long perUnit = SellCatalogue.singleUnitPriceDisplay(selected, sold, feePct);
add(new LabelRow(this, "§e" + selected.toString() + "§r"));
add(new LabelRow(this, ""));
add(new LabelRow(this, "List price: §6" + base + "§r"));
add(new LabelRow(this, "Sold lifetime: §7" + sold + "§r"));
add(new LabelRow(this, "Effective fee: §c" + feePct + "%§r"));
add(new LabelRow(this, "You get: §a" + perUnit + " spurs / unit§r"));
add(new LabelRow(this, ""));
for (int n : new int[]{1, 16, 64}) {
final int count = n;
add(SimpleTextButton.create(this,
Component.literal("Sell " + count),
Icon.empty(),
btn -> {
NetSend.runBns("sell " + selected + " " + count);
TierUpgradeScreen.playClick();
}));
}
}
@Override public void alignWidgets() {
int yCursor = 0;
int btnY = height - 20;
int btnX = 0;
for (Widget w : getWidgets()) {
if (w instanceof LabelRow) {
w.setSize(width, 12);
w.setPos(0, yCursor);
yCursor += 12;
} else {
int bw = (width - 4) / 3;
w.setSize(bw, 18);
w.setPos(btnX, btnY);
btnX += bw + 2;
}
}
}
}
private static final class LabelRow extends Widget {
private final String text;
LabelRow(Panel parent, String text) { super(parent); this.text = text; }
@Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) {
theme.drawString(gfx, Component.literal(text), x, y + 2, Color4I.WHITE, Theme.SHADOW);
}
}
}
@@ -1,28 +1,178 @@
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 dev.ftb.mods.ftblibrary.ui.WidgetLayout;
import net.minecraft.ChatFormatting;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.resources.sounds.SimpleSoundInstance;
import net.minecraft.client.Minecraft;
import net.minecraft.network.chat.Component;
import net.minecraft.sounds.SoundEvents;
import uk.sijbers.bnstoolkit.client.ClientBnsState;
import uk.sijbers.bnstoolkit.data.CivicTier;
import uk.sijbers.bnstoolkit.network.NetSend;
/**
* Tier ladder + upgrade screen.
* Civic tier ladder + upgrade button.
*
* Spec ref: docs/bnstoolkit-architecture.md §4.1 (TierUpgradeScreen)
* Left side: 13 rows for the tiers. Current tier row gets a highlighted
* background and a "▶" marker. Each row shows: tier number, name in its
* rank colour, cost in spurs, summary perks.
*
* TODO(matt M1): Visual layout to confirm with you tomorrow:
* - 13 rows, one per civic tier (Peasant -> Sovereign)
* - Current tier row highlighted
* - Each row: rank colour name, cost in spurs, perks summary
* (chunks / plot-slots / bounty-slots / shop-fee)
* - "Upgrade to <next>" button bottom-right (only enabled if affordable)
* - Click pre-confirms cost + shows current balance, second click commits
*
* Backend wiring: button click sends a C2S RequestTierUpgrade packet.
* Server replies with TierUpgradeResult (success/failure + new tier + new
* balance). Same backend the /bns tier upgrade chat command already uses.
* Right side: detail card for the *next* tier (or current if you're at
* the top) plus the Upgrade button. Button is disabled when you can't
* afford it or you're at Sovereign.
*/
public final class TierUpgradeScreen extends BaseBnsScreen {
public TierUpgradeScreen() {
super(Component.translatable("screen.bnstoolkit.tier_upgrade"));
}
// TODO(matt M1): override init() to lay out rows + button widgets.
@Override
public void addWidgets() {
add(new LadderPanel(this));
add(new DetailPanel(this));
add(SimpleTextButton.create(this,
Component.literal("Refresh"),
Icons.REFRESH,
btn -> NetSend.requestSnapshot("TIER")));
}
@Override
public void alignWidgets() {
// ladder panel
getWidgets().get(0).setPos(getX() + 8, contentTop());
getWidgets().get(0).setSize(180, getHeight() - 28);
// detail panel
getWidgets().get(1).setPos(getX() + 196, contentTop());
getWidgets().get(1).setSize(getWidth() - 204, getHeight() - 50);
// refresh button bottom-right
Widget rf = getWidgets().get(2);
rf.setSize(60, 16);
rf.setPos(getX() + getWidth() - 68, getY() + getHeight() - 22);
}
// ─── Sub-panels ──────────────────────────────────────────────────
private static final class LadderPanel extends Panel {
LadderPanel(Panel parent) { super(parent); }
@Override public void addWidgets() {
for (CivicTier t : CivicTier.LADDER) add(new LadderRow(this, t));
}
@Override public void alignWidgets() {
WidgetLayout.VERTICAL.align(this);
}
}
private static final class LadderRow extends Widget {
private final CivicTier cfg;
LadderRow(Panel parent, CivicTier cfg) {
super(parent);
this.cfg = cfg;
setSize(parent.width, 13);
}
@Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) {
boolean current = ClientBnsState.tier == cfg.id();
if (current) {
Color4I.rgba(0x80FFD700).draw(gfx, x, y, w, h);
} else if (isMouseOver) {
Color4I.rgba(0x40FFFFFF).draw(gfx, x, y, w, h);
}
String marker = current ? "" : " ";
String line = String.format("%s %2d. %s§r §7%s spurs",
marker, cfg.id(),
"§" + colourCode(cfg.colour()) + cfg.name(),
formatSpurs(cfg.cost()));
theme.drawString(gfx, Component.literal(line), x + 4, y + 2,
current ? Color4I.WHITE : Color4I.GRAY, Theme.SHADOW);
}
}
private static final class DetailPanel extends Panel {
DetailPanel(Panel parent) { super(parent); }
@Override public void addWidgets() {
int currentTier = ClientBnsState.tier;
boolean atMax = currentTier >= CivicTier.MAX;
if (atMax) {
add(new InfoLabel(this, "You have reached the Sovereign tier."));
add(new InfoLabel(this, "Nothing more to ascend to."));
return;
}
CivicTier next = CivicTier.byId(currentTier + 1);
long cost = next.cost();
boolean canAfford = ClientBnsState.spurs >= cost;
add(new InfoLabel(this, String.format("Next: §%c%s§r", colourCode(next.colour()), next.name())));
add(new InfoLabel(this, "Cost: §6" + formatSpurs(cost) + " spurs§r"));
add(new InfoLabel(this, "You have: " + (canAfford ? "§a" : "§c") + formatSpurs(ClientBnsState.spurs) + "§r"));
add(new InfoLabel(this, ""));
add(new InfoLabel(this, "Perks at " + next.name() + ":"));
add(new InfoLabel(this, " • Chunks: " + next.wildernessChunks()));
add(new InfoLabel(this, " • Plot slots: " + next.plotSlots()));
add(new InfoLabel(this, " • Daily bounties: " + next.dailyBountySlots()));
add(new InfoLabel(this, " • Shop fee: " + next.effectiveShopFeePct() + "%"));
SimpleTextButton upgrade = SimpleTextButton.create(this,
Component.literal("Upgrade to " + next.name()),
Icon.empty(),
btn -> {
if (!canAfford) {
playClick();
return;
}
NetSend.runBns("tier upgrade");
playClick();
});
add(upgrade);
}
@Override public void alignWidgets() {
int y = 0;
for (Widget w : getWidgets()) {
if (w instanceof InfoLabel) {
w.setSize(width, 12);
w.setPos(0, y);
y += 12;
} else {
w.setSize(width, 18);
w.setPos(0, height - 18);
}
}
}
}
private static final class InfoLabel extends Widget {
private final String text;
InfoLabel(Panel parent, String text) { super(parent); this.text = text; }
@Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) {
theme.drawString(gfx, Component.literal(text), x, y + 2, Color4I.WHITE, Theme.SHADOW);
}
}
// ─── helpers ────────────────────────────────────────────────────
private static char colourCode(ChatFormatting c) {
return c.getChar();
}
public static String formatSpurs(long n) {
if (n >= 1_000_000) return String.format("%.1fM", n / 1_000_000.0);
if (n >= 10_000) return String.format("%dk", n / 1000);
if (n >= 1_000) return String.format("%.1fk", n / 1000.0);
return Long.toString(n);
}
static void playClick() {
Minecraft mc = Minecraft.getInstance();
if (mc != null) mc.getSoundManager().play(
SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK.value(), 1.0F));
}
}
@@ -0,0 +1,45 @@
package uk.sijbers.bnstoolkit.data;
import java.util.List;
/**
* Bounty catalogue. Mirrors BOUNTY_POOL in
* pack/overrides/kubejs/server_scripts/economy/03_bounty_engine.js.
*
* Static — the pool of 20 doesn't change at runtime. Per-player daily
* selection (5 of these), active set, completion state all live in the
* player's persistentData NBT and arrive via the BountyBoardSnapshot
* S2C packet.
*/
public record Bounty(String id, String name, String targetEntity, int count, long reward, String source) {
public static final List<Bounty> POOL = List.of(
new Bounty("b_zombie_10", "Slay 10 Zombies", "minecraft:zombie", 10, 15L, "Adventurer's Guild"),
new Bounty("b_skeleton_10", "Slay 10 Skeletons", "minecraft:skeleton", 10, 15L, "Adventurer's Guild"),
new Bounty("b_spider_8", "Slay 8 Spiders", "minecraft:spider", 8, 12L, "Adventurer's Guild"),
new Bounty("b_creeper_5", "Slay 5 Creepers", "minecraft:creeper", 5, 20L, "Adventurer's Guild"),
new Bounty("b_drowned_8", "Slay 8 Drowned", "minecraft:drowned", 8, 18L, "Adventurer's Guild"),
new Bounty("b_husk_8", "Slay 8 Husks", "minecraft:husk", 8, 18L, "Adventurer's Guild"),
new Bounty("b_blaze_5", "Slay 5 Blazes", "minecraft:blaze", 5, 50L, "Adventurer's Guild"),
new Bounty("b_piglin_8", "Slay 8 Piglins", "minecraft:piglin", 8, 40L, "Adventurer's Guild"),
new Bounty("b_magma_6", "Slay 6 Magma Cubes", "minecraft:magma_cube", 6, 30L, "Adventurer's Guild"),
new Bounty("b_ghast_3", "Slay 3 Ghasts", "minecraft:ghast", 3, 60L, "Adventurer's Guild"),
new Bounty("b_plunderer_5", "Hunt 5 Plunderers", "supplementaries:plunderer", 5, 75L, "Adventurer's Guild"),
new Bounty("b_witch_3", "Slay 3 Witches", "minecraft:witch", 3, 80L, "Adventurer's Guild"),
new Bounty("b_enderman_5", "Slay 5 Endermen", "minecraft:enderman", 5, 90L, "Adventurer's Guild"),
new Bounty("b_wither_skel_3", "Slay 3 Wither Skeletons","minecraft:wither_skeleton", 3, 150L, "Adventurer's Guild"),
new Bounty("b_shulker_3", "Slay 3 Shulkers", "minecraft:shulker", 3, 200L, "Adventurer's Guild"),
new Bounty("b_warden_1", "Slay the Warden", "minecraft:warden", 1, 800L, "Adventurer's Guild"),
new Bounty("b_voidworm_1", "Destroy a Void Worm", "alexsmobs:void_worm", 1, 1200L, "Adventurer's Guild"),
new Bounty("b_warped_mosco_1", "Slay a Warped Mosco", "alexsmobs:warped_mosco", 1, 700L, "Adventurer's Guild"),
new Bounty("b_farseer_1", "Slay the Farseer", "alexsmobs:farseer", 1, 900L, "Adventurer's Guild"),
new Bounty("b_skreecher_1", "Slay a Skreecher", "alexsmobs:skreecher", 1, 400L, "Adventurer's Guild")
);
public static Bounty byId(String id) {
for (Bounty b : POOL) if (b.id.equals(id)) return b;
return null;
}
public enum State { AVAILABLE, ACTIVE, READY, COOLDOWN }
}
@@ -0,0 +1,53 @@
package uk.sijbers.bnstoolkit.data;
import net.minecraft.ChatFormatting;
import java.util.List;
/**
* 13-tier civic ladder. Mirrors TIER_CONFIG in
* pack/overrides/kubejs/server_scripts/economy/00_tier_system.js.
*
* Source of truth is still the KubeJS table — this Java copy exists so the
* client-side screens can render the ladder without round-tripping every
* row to the server. The two must be kept in sync; if a value changes
* here, change it there too.
*/
public record CivicTier(
int id,
String name,
long cost,
int wildernessChunks,
int plotSlots,
int dailyBountySlots,
int shopFeeDiscount,
ChatFormatting colour) {
public static final List<CivicTier> LADDER = List.of(
new CivicTier( 1, "Peasant", 0L, 9, 0, 3, 0, ChatFormatting.GRAY),
new CivicTier( 2, "Farmer", 200L, 18, 0, 3, 0, ChatFormatting.GRAY),
new CivicTier( 3, "Citizen", 750L, 35, 1, 3, 0, ChatFormatting.WHITE),
new CivicTier( 4, "Merchant", 2500L, 60, 1, 4, -2, ChatFormatting.YELLOW),
new CivicTier( 5, "Knight", 6500L, 100, 2, 4, -5, ChatFormatting.GREEN),
new CivicTier( 6, "Baron", 15000L, 150, 2, 5, -7, ChatFormatting.AQUA),
new CivicTier( 7, "Viscount", 35000L, 220, 3, 5, -9, ChatFormatting.BLUE),
new CivicTier( 8, "Earl", 75000L, 300, 3, 6, -11, ChatFormatting.DARK_PURPLE),
new CivicTier( 9, "Marquess", 150000L, 400, 4, 6, -13, ChatFormatting.GOLD),
new CivicTier(10, "Duke", 300000L, 525, 5, 7, -15, ChatFormatting.GOLD),
new CivicTier(11, "Archduke", 700000L, 700, 6, 7, -17, ChatFormatting.RED),
new CivicTier(12, "Grand Duke", 1500000L, 900, 7, 8, -19, ChatFormatting.RED),
new CivicTier(13, "Sovereign", 10000000L, 1500, 10, 10, -25, ChatFormatting.LIGHT_PURPLE)
);
public static final int MAX = 13;
public static final int BAZAAR_BASE_FEE_PCT = 25;
public static CivicTier byId(int id) {
if (id < 1 || id > MAX) return LADDER.get(0);
return LADDER.get(id - 1);
}
public int effectiveShopFeePct() {
return Math.max(0, BAZAAR_BASE_FEE_PCT + shopFeeDiscount);
}
}
@@ -0,0 +1,28 @@
package uk.sijbers.bnstoolkit.data;
import java.util.List;
/**
* Spawn-region plot catalogue. Mirrors PLOTS in
* pack/overrides/kubejs/server_scripts/plots.js.
*/
public record Plot(String id, String name, String size, long price, String dimension, List<int[]> chunks) {
public static final List<Plot> ALL = List.of(
new Plot("p001", "Market Stall 1", "small", 100L, "minecraft:overworld",
List.of(new int[]{10, 10})),
new Plot("p002", "Corner Lot", "medium", 250L, "minecraft:overworld",
List.of(new int[]{12, 10}, new int[]{12, 11})),
new Plot("p003", "Anchor Plot", "large", 600L, "minecraft:overworld",
List.of(new int[]{14, 10}, new int[]{14, 11}, new int[]{15, 10}, new int[]{15, 11}))
);
public static Plot byId(String id) {
for (Plot p : ALL) if (p.id.equals(id)) return p;
return null;
}
public long refundOnRelease() {
return price / 2;
}
}
@@ -0,0 +1,60 @@
package uk.sijbers.bnstoolkit.data;
import net.minecraft.resources.ResourceLocation;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Bazaar sell prices. Mirrors SELL_PRICES in
* pack/overrides/kubejs/server_scripts/economy/02_sell_shop.js.
*
* The map is insertion-ordered so the screen grid matches the KubeJS
* config order without separate display ordering.
*/
public final class SellCatalogue {
private SellCatalogue() {}
public static final int DR_STEP = 64;
public static final double DR_DECAY = 0.10;
public static final double DR_FLOOR = 0.50;
public static final Map<ResourceLocation, Long> PRICES;
static {
Map<ResourceLocation, Long> m = new LinkedHashMap<>();
m.put(ResourceLocation.parse("minecraft:diamond"), 10L);
m.put(ResourceLocation.parse("minecraft:netherite_ingot"), 100L);
m.put(ResourceLocation.parse("minecraft:emerald"), 1L);
m.put(ResourceLocation.parse("minecraft:gold_ingot"), 2L);
m.put(ResourceLocation.parse("minecraft:iron_ingot"), 1L);
m.put(ResourceLocation.parse("minecraft:lapis_lazuli"), 1L);
m.put(ResourceLocation.parse("minecraft:redstone"), 1L);
m.put(ResourceLocation.parse("minecraft:coal"), 1L);
m.put(ResourceLocation.parse("minecraft:ancient_debris"), 500L);
PRICES = java.util.Collections.unmodifiableMap(m);
}
/**
* Effective per-unit payout matching the KubeJS formula:
* midSteps = (sold + count/2) / DR_STEP
* drMult = max(DR_FLOOR, 1 - midSteps * DR_DECAY)
* payout = floor(base * drMult * (1 - feePct/100) * count)
*/
public static long effectivePayout(ResourceLocation item, long lifetimeSold, long count, int feePct) {
Long base = PRICES.get(item);
if (base == null) return 0L;
long midSteps = (lifetimeSold + count / 2L) / DR_STEP;
double drMult = Math.max(DR_FLOOR, 1.0 - midSteps * DR_DECAY);
double net = base * drMult * (1.0 - feePct / 100.0);
return (long) Math.floor(net * count);
}
/** Display-only single-unit price for a player at their current sold count + fee. */
public static long singleUnitPriceDisplay(ResourceLocation item, long lifetimeSold, int feePct) {
Long base = PRICES.get(item);
if (base == null) return 0L;
long steps = lifetimeSold / DR_STEP;
double drMult = Math.max(DR_FLOOR, 1.0 - steps * DR_DECAY);
return (long) Math.floor(base * drMult * (1.0 - feePct / 100.0));
}
}
@@ -0,0 +1,65 @@
package uk.sijbers.bnstoolkit.data;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Sums a player's inventory into a "spurs" balance using the Numismatics
* coin denomination table.
*
* Denominations match the Numismatics mod (1 spur = 1 base unit):
* spur = 1, bevel = 8, sprocket = 16, cog = 64, crown = 512, sun = 4096
*
* Mirrors the COIN_VALUES table in pack/overrides/kubejs/server_scripts/plots.js.
* Lives here in Java so the HUD can render the balance every frame without
* touching the server.
*/
public final class SpurBalance {
private SpurBalance() {}
public static final Map<ResourceLocation, Long> COIN_VALUES;
static {
Map<ResourceLocation, Long> m = new LinkedHashMap<>();
m.put(ResourceLocation.parse("numismatics:spur"), 1L);
m.put(ResourceLocation.parse("numismatics:bevel"), 8L);
m.put(ResourceLocation.parse("numismatics:sprocket"), 16L);
m.put(ResourceLocation.parse("numismatics:cog"), 64L);
m.put(ResourceLocation.parse("numismatics:crown"), 512L);
m.put(ResourceLocation.parse("numismatics:sun"), 4096L);
COIN_VALUES = java.util.Collections.unmodifiableMap(m);
}
/** Inventory-only scan (excludes ender chests, curios, backpacks). */
public static long totalSpurs(Player player) {
Inventory inv = player.getInventory();
long total = 0L;
for (int i = 0; i < inv.getContainerSize(); i++) {
ItemStack stack = inv.getItem(i);
if (stack.isEmpty()) continue;
ResourceLocation id = stack.getItemHolder().unwrapKey().map(k -> k.location()).orElse(null);
if (id == null) continue;
Long value = COIN_VALUES.get(id);
if (value == null) continue;
total += (long) stack.getCount() * value;
}
return total;
}
/** Count of a specific raw item id in the player's inventory (for sell screen prechecks). */
public static int countItem(Player player, ResourceLocation itemId) {
Inventory inv = player.getInventory();
int total = 0;
for (int i = 0; i < inv.getContainerSize(); i++) {
ItemStack stack = inv.getItem(i);
if (stack.isEmpty()) continue;
ResourceLocation id = stack.getItemHolder().unwrapKey().map(k -> k.location()).orElse(null);
if (itemId.equals(id)) total += stack.getCount();
}
return total;
}
}
@@ -4,29 +4,33 @@ import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
import net.neoforged.neoforge.network.registration.PayloadRegistrar;
import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.network.c2s.RequestTierStatePacket;
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.PlotSnapshotPacket;
import uk.sijbers.bnstoolkit.network.s2c.SellSnapshotPacket;
import uk.sijbers.bnstoolkit.network.s2c.TierStateUpdatePacket;
import uk.sijbers.bnstoolkit.network.s2c.ToastPacket;
/**
* Central registration for the toolkit's S2C and C2S packets.
*
* Spec ref: docs/bnstoolkit-architecture.md §5 (network contract).
* Two C2S packets cover the entire client-server contract:
* - {@link RequestEconomySnapshotPacket} — "send me my <kind> snapshot"
* - {@link RunBnsCommandPacket} — "run this whitelisted /bns verb"
*
* M0 ships one round-trip pair as a smoke-test of the wiring:
* - C2S {@link RequestTierStatePacket} ("hey server, what's my tier+balance?")
* - S2C {@link TierStateUpdatePacket} (answer + future push channel)
* Four S2C snapshots feed the screens + HUD:
* - {@link TierStateUpdatePacket} tier + balance + fee
* - {@link SellSnapshotPacket} — lifetime-sold per item
* - {@link BountySnapshotPacket} — daily pool + active + completed + cap
* - {@link PlotSnapshotPacket} — owners + cap + my owned ids
*
* The rest of the contract (bounty / plot / shop / quest log) will land in
* M1..M4 — each milestone adds its own packet pair under the matching
* sub-package.
*
* Versioning: bumped via the version() call below whenever the wire format
* changes incompatibly. Same protocol pattern FTB uses.
* Plus a fire-and-forget {@link ToastPacket} for command result UX.
*/
public final class BnsNetwork {
private BnsNetwork() {}
public static final String VERSION = "0.1";
public static final String VERSION = "0.2";
public static void register(IEventBus modBus) {
modBus.addListener(BnsNetwork::onRegisterPayloads);
@@ -35,18 +39,35 @@ public final class BnsNetwork {
private static void onRegisterPayloads(RegisterPayloadHandlersEvent event) {
final PayloadRegistrar reg = event.registrar(BnsToolkit.MOD_ID).versioned(VERSION);
// C2S: request my current tier + balance.
reg.playToServer(
RequestTierStatePacket.TYPE,
RequestTierStatePacket.STREAM_CODEC,
RequestTierStatePacket::handle);
RequestEconomySnapshotPacket.TYPE,
RequestEconomySnapshotPacket.STREAM_CODEC,
RequestEconomySnapshotPacket::handle);
reg.playToServer(
RunBnsCommandPacket.TYPE,
RunBnsCommandPacket.STREAM_CODEC,
RunBnsCommandPacket::handle);
// S2C: server pushes tier + balance (either in response to a request
// or unsolicited when something changes).
reg.playToClient(
TierStateUpdatePacket.TYPE,
TierStateUpdatePacket.STREAM_CODEC,
TierStateUpdatePacket::handle);
reg.playToClient(
SellSnapshotPacket.TYPE,
SellSnapshotPacket.STREAM_CODEC,
SellSnapshotPacket::handle);
reg.playToClient(
BountySnapshotPacket.TYPE,
BountySnapshotPacket.STREAM_CODEC,
BountySnapshotPacket::handle);
reg.playToClient(
PlotSnapshotPacket.TYPE,
PlotSnapshotPacket.STREAM_CODEC,
PlotSnapshotPacket::handle);
reg.playToClient(
ToastPacket.TYPE,
ToastPacket.STREAM_CODEC,
ToastPacket::handle);
BnsToolkit.LOG.info("[bnstoolkit/net] registered payloads v{}", VERSION);
}
@@ -0,0 +1,21 @@
package uk.sijbers.bnstoolkit.network;
import net.neoforged.neoforge.network.PacketDistributor;
import uk.sijbers.bnstoolkit.network.c2s.RequestEconomySnapshotPacket;
import uk.sijbers.bnstoolkit.network.c2s.RunBnsCommandPacket;
/**
* Client-side senders for the two C2S packets. Centralised here so the
* screens don't all have to know the packet types.
*/
public final class NetSend {
private NetSend() {}
public static void requestSnapshot(String kind) {
PacketDistributor.sendToServer(new RequestEconomySnapshotPacket(kind));
}
public static void runBns(String args) {
PacketDistributor.sendToServer(new RunBnsCommandPacket(args));
}
}
@@ -0,0 +1,206 @@
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.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.PlotSnapshotPacket;
import uk.sijbers.bnstoolkit.network.s2c.SellSnapshotPacket;
import uk.sijbers.bnstoolkit.network.s2c.TierStateUpdatePacket;
import uk.sijbers.bnstoolkit.network.s2c.ToastPacket;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Server-side bridge between the bnstoolkit network 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.
*
* The verb whitelist below is what lets the screens trigger the SAME
* verbs the player can type — and nothing else.
*/
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 "
);
// ─── Routed commands ─────────────────────────────────────────────
public static void runWhitelisted(Player player, String args) {
if (!(player instanceof ServerPlayer sp)) return;
if (args == null || args.isEmpty()) return;
boolean ok = false;
for (String prefix : SAFE_VERBS) {
if (args.equals(prefix.trim()) || args.startsWith(prefix)) { ok = true; break; }
}
if (!ok) {
BnsToolkit.LOG.warn("[bns/bridge] rejected non-whitelisted verb from {}: {}", sp.getName().getString(), args);
return;
}
CommandSourceStack src = sp.createCommandSourceStack();
String full = "bns " + args;
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")) {
pushTierUpdate(sp);
} else if (args.startsWith("sell")) {
pushTierUpdate(sp);
pushSellSnapshot(sp);
} else if (args.startsWith("bounty")) {
pushTierUpdate(sp);
pushBountySnapshot(sp);
} else if (args.startsWith("plot")) {
pushTierUpdate(sp);
pushPlotSnapshot(sp);
}
}
// ─── Snapshot pushes ─────────────────────────────────────────────
public static void pushSnapshot(Player player, String kind) {
if (!(player instanceof ServerPlayer sp)) 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); }
}
}
public static void pushTierUpdate(ServerPlayer sp) {
CompoundTag data = sp.getPersistentData();
int tier = data.contains("bnsTier") ? data.getInt("bnsTier") : 1;
if (tier < 1 || tier > CivicTier.MAX) tier = 1;
CivicTier cfg = CivicTier.byId(tier);
long spurs = SpurBalance.totalSpurs(sp);
int fee = cfg.effectiveShopFeePct();
PacketDistributor.sendToPlayer(sp, new TierStateUpdatePacket(tier, spurs, fee));
}
public static void pushSellSnapshot(ServerPlayer sp) {
CompoundTag data = sp.getPersistentData();
Map<String, Long> sold = new HashMap<>();
if (data.contains("bnsSales")) {
CompoundTag map = data.getCompound("bnsSales");
for (String key : map.getAllKeys()) {
sold.put(key, (long) map.getInt(key));
}
}
PacketDistributor.sendToPlayer(sp, new SellSnapshotPacket(sold));
}
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));
}
if (bnt.contains("active")) {
CompoundTag act = bnt.getCompound("active");
for (String id : act.getAllKeys()) {
CompoundTag e = act.getCompound(id);
int progress = e.getInt("progress");
int target = e.getInt("target");
long reward = e.getInt("reward");
active.put(id, new BountySnapshotPacket.ActiveEntry(progress, target, reward));
}
}
if (bnt.contains("completed")) {
CompoundTag cmp = bnt.getCompound("completed");
for (String id : cmp.getAllKeys()) completed.add(id);
}
}
// 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();
PacketDistributor.sendToPlayer(sp, new BountySnapshotPacket(poolIds, active, completed, slotCap));
}
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<>();
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());
}
}
// 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;
if (tier < 1 || tier > CivicTier.MAX) tier = 1;
int slotCap = CivicTier.byId(tier).plotSlots();
PacketDistributor.sendToPlayer(sp, new PlotSnapshotPacket(owners, slotCap, ownedIds));
}
public static void toast(ServerPlayer sp, int status, String title, String body) {
PacketDistributor.sendToPlayer(sp, new ToastPacket(status, title, body));
}
// Defensive: bounties pool format uses the same shape we know about
@SuppressWarnings("unused")
private static Bounty resolveBounty(String id) { return Bounty.byId(id); }
}
@@ -0,0 +1,42 @@
package uk.sijbers.bnstoolkit.network.c2s;
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.network.ServerEconomyBridge;
/**
* Client -> server: "Open my <kind> screen, send me the current snapshot."
*
* Kinds:
* TIER — push TierStateUpdatePacket
* SELL — push SellSnapshotPacket
* BOUNTY — push BountySnapshotPacket
* PLOT — push PlotSnapshotPacket
* ALL — push all four (e.g. on first login)
*
* Sending an enum int is more compact than a string but the count is
* tiny so we just use a String for debuggability.
*/
public record RequestEconomySnapshotPacket(String kind) implements CustomPacketPayload {
public static final Type<RequestEconomySnapshotPacket> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "request_snapshot"));
public static final StreamCodec<RegistryFriendlyByteBuf, RequestEconomySnapshotPacket> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, RequestEconomySnapshotPacket::kind,
RequestEconomySnapshotPacket::new);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
public static void handle(RequestEconomySnapshotPacket pkt, IPayloadContext ctx) {
ctx.enqueueWork(() -> ServerEconomyBridge.pushSnapshot(ctx.player(), pkt.kind));
}
}
@@ -1,42 +0,0 @@
package uk.sijbers.bnstoolkit.network.c2s;
import net.minecraft.network.RegistryFriendlyByteBuf;
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;
/**
* Client -> server: "Send me my tier + balance."
*
* Empty payload (no fields). The server identifies the player from
* {@link IPayloadContext#player()}. The response is a S2C
* {@code TierStateUpdatePacket}.
*
* TODO(matt M1): once the KubeJS bridge ships, the handler will:
* 1. Read player.persistentData.bnsTier (the KubeJS-owned NBT)
* 2. Sum Numismatics coin slots into a spurs total
* 3. Send a TierStateUpdatePacket back to the same player
*
* For now the handler just logs the request — proves the wire works.
*/
public record RequestTierStatePacket() implements CustomPacketPayload {
public static final Type<RequestTierStatePacket> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "request_tier_state"));
public static final StreamCodec<RegistryFriendlyByteBuf, RequestTierStatePacket> STREAM_CODEC =
StreamCodec.unit(new RequestTierStatePacket());
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
public static void handle(RequestTierStatePacket pkt, IPayloadContext ctx) {
ctx.enqueueWork(() -> {
// TODO(matt M1): read NBT + Numismatics balance, reply with TierStateUpdatePacket
BnsToolkit.LOG.info("[bnstoolkit/net] RequestTierState from {}", ctx.player().getName().getString());
});
}
}
@@ -0,0 +1,47 @@
package uk.sijbers.bnstoolkit.network.c2s;
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.network.ServerEconomyBridge;
/**
* Client -> server: invoke one of the existing KubeJS-owned /bns
* subcommands on behalf of the player. Handler validates the verb against
* a whitelist, then runs it via the dedicated server's command stack so
* KubeJS picks it up exactly as if the player typed it.
*
* Examples sent by the screens:
* "tier upgrade"
* "sell minecraft:diamond 8"
* "bounty accept b_zombie_10"
* "bounty cancel b_zombie_10"
* "bounty turnin b_zombie_10"
* "plot buy p001"
* "plot release p001"
*
* The verb whitelist is enforced server-side in
* {@link ServerEconomyBridge#runWhitelisted}.
*/
public record RunBnsCommandPacket(String args) implements CustomPacketPayload {
public static final Type<RunBnsCommandPacket> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "run_bns"));
public static final StreamCodec<RegistryFriendlyByteBuf, RunBnsCommandPacket> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, RunBnsCommandPacket::args,
RunBnsCommandPacket::new);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
public static void handle(RunBnsCommandPacket pkt, IPayloadContext ctx) {
ctx.enqueueWork(() -> ServerEconomyBridge.runWhitelisted(ctx.player(), pkt.args));
}
}
@@ -0,0 +1,67 @@
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.ClientBnsState;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* S2C: today's bounty pool selection (5 ids) + the player's active set
* with kill progress + completed (cooldown) ids + the tier-derived slot cap.
*/
public record BountySnapshotPacket(
List<String> poolIds,
Map<String, ActiveEntry> active,
List<String> completedIds,
int slotCap) implements CustomPacketPayload {
public record ActiveEntry(int progress, int target, long reward) {}
public static final Type<BountySnapshotPacket> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "bounty_snapshot"));
private static final StreamCodec<RegistryFriendlyByteBuf, ActiveEntry> ENTRY_CODEC =
StreamCodec.composite(
ByteBufCodecs.VAR_INT, ActiveEntry::progress,
ByteBufCodecs.VAR_INT, ActiveEntry::target,
ByteBufCodecs.VAR_LONG, ActiveEntry::reward,
ActiveEntry::new);
public static final StreamCodec<RegistryFriendlyByteBuf, BountySnapshotPacket> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.STRING_UTF8.apply(ByteBufCodecs.list()), BountySnapshotPacket::poolIds,
ByteBufCodecs.map(HashMap::new, ByteBufCodecs.STRING_UTF8, ENTRY_CODEC), BountySnapshotPacket::active,
ByteBufCodecs.STRING_UTF8.apply(ByteBufCodecs.list()), BountySnapshotPacket::completedIds,
ByteBufCodecs.VAR_INT, BountySnapshotPacket::slotCap,
BountySnapshotPacket::new);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
public static void handle(BountySnapshotPacket pkt, IPayloadContext ctx) {
ctx.enqueueWork(() -> {
ClientBnsState.dailyPoolIds.clear();
ClientBnsState.dailyPoolIds.addAll(pkt.poolIds);
ClientBnsState.activeBounties.clear();
for (Map.Entry<String, ActiveEntry> e : pkt.active.entrySet()) {
ActiveEntry v = e.getValue();
ClientBnsState.activeBounties.put(e.getKey(),
new ClientBnsState.ActiveBounty(v.progress, v.target, v.reward));
}
ClientBnsState.completedIds.clear();
ClientBnsState.completedIds.addAll(pkt.completedIds);
ClientBnsState.bountySlotCap = pkt.slotCap;
});
}
}
@@ -0,0 +1,54 @@
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.ClientBnsState;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* S2C: ownership state for every plot id, the player's slot cap (from tier),
* and the list of plot ids the player currently owns.
*
* `owners` value of "" means "available". Non-empty = current owner's name.
*/
public record PlotSnapshotPacket(
Map<String, String> owners,
int slotCap,
List<String> ownedIds) implements CustomPacketPayload {
public static final Type<PlotSnapshotPacket> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "plot_snapshot"));
public static final StreamCodec<RegistryFriendlyByteBuf, PlotSnapshotPacket> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.map(HashMap::new, ByteBufCodecs.STRING_UTF8, ByteBufCodecs.STRING_UTF8),
PlotSnapshotPacket::owners,
ByteBufCodecs.VAR_INT,
PlotSnapshotPacket::slotCap,
ByteBufCodecs.STRING_UTF8.apply(ByteBufCodecs.list()),
PlotSnapshotPacket::ownedIds,
PlotSnapshotPacket::new);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
public static void handle(PlotSnapshotPacket pkt, IPayloadContext ctx) {
ctx.enqueueWork(() -> {
ClientBnsState.plotOwners.clear();
ClientBnsState.plotOwners.putAll(pkt.owners);
ClientBnsState.plotSlotCap = pkt.slotCap;
ClientBnsState.ownedPlotIds.clear();
ClientBnsState.ownedPlotIds.addAll(pkt.ownedIds);
});
}
}
@@ -0,0 +1,45 @@
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.ClientBnsState;
import java.util.Map;
/**
* S2C: lifetime-sold counts per item id, used by the shop screen to show
* accurate per-unit prices after diminishing returns.
*
* Wire format: map of {item id string -> lifetime sold count}.
*/
public record SellSnapshotPacket(Map<String, Long> sold) implements CustomPacketPayload {
public static final Type<SellSnapshotPacket> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "sell_snapshot"));
public static final StreamCodec<RegistryFriendlyByteBuf, SellSnapshotPacket> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.map(java.util.HashMap::new, ByteBufCodecs.STRING_UTF8, ByteBufCodecs.VAR_LONG),
SellSnapshotPacket::sold,
SellSnapshotPacket::new);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
public static void handle(SellSnapshotPacket pkt, IPayloadContext ctx) {
ctx.enqueueWork(() -> {
ClientBnsState.lifetimeSold.clear();
for (Map.Entry<String, Long> e : pkt.sold.entrySet()) {
try {
ClientBnsState.lifetimeSold.put(ResourceLocation.parse(e.getKey()), e.getValue());
} catch (Exception ignored) {}
}
});
}
}
@@ -7,20 +7,10 @@ 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.ClientBnsState;
/**
* Server -> client: pushes the player's current civic tier (1..13) and
* spurs balance.
*
* Sent in response to {@code RequestTierStatePacket} AND unsolicited
* whenever either value changes server-side (so the HUD updates in real
* time without polling).
*
* TODO(matt M1): client handler will cache the values in a static slot
* read by {@link uk.sijbers.bnstoolkit.client.hud.SpurHud} and any open
* screen.
*/
public record TierStateUpdatePacket(int tier, long spurs) implements CustomPacketPayload {
/** Server -> client: civic tier (1..13), spurs balance, effective shop fee%. */
public record TierStateUpdatePacket(int tier, long spurs, int effectiveFeePct) implements CustomPacketPayload {
public static final Type<TierStateUpdatePacket> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "tier_state_update"));
@@ -28,6 +18,7 @@ public record TierStateUpdatePacket(int tier, long spurs) implements CustomPacke
StreamCodec.composite(
ByteBufCodecs.VAR_INT, TierStateUpdatePacket::tier,
ByteBufCodecs.VAR_LONG, TierStateUpdatePacket::spurs,
ByteBufCodecs.VAR_INT, TierStateUpdatePacket::effectiveFeePct,
TierStateUpdatePacket::new);
@Override
@@ -37,8 +28,9 @@ public record TierStateUpdatePacket(int tier, long spurs) implements CustomPacke
public static void handle(TierStateUpdatePacket pkt, IPayloadContext ctx) {
ctx.enqueueWork(() -> {
// TODO(matt M1): stash in a client-side state holder for HUD + screens.
BnsToolkit.LOG.debug("[bnstoolkit/net] TierStateUpdate tier={} spurs={}", pkt.tier, pkt.spurs);
ClientBnsState.tier = pkt.tier;
ClientBnsState.spurs = pkt.spurs;
ClientBnsState.effectiveFeePct = pkt.effectiveFeePct;
});
}
}
@@ -0,0 +1,54 @@
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 net.minecraft.client.Minecraft;
import net.minecraft.client.gui.components.toasts.SystemToast;
import net.minecraft.network.chat.Component;
import uk.sijbers.bnstoolkit.BnsToolkit;
/**
* S2C: short toast notification to the player.
*
* Used by the server-side bridge to report results of routed /bns
* commands the player triggered from a screen (e.g. "Upgrade succeeded",
* "Insufficient funds", "Bounty turned in: +800 spurs").
*
* Status codes: 0=info, 1=success, 2=failure. The client maps these to
* SystemToast colour bands.
*/
public record ToastPacket(int status, String title, String body) implements CustomPacketPayload {
public static final Type<ToastPacket> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "toast"));
public static final StreamCodec<RegistryFriendlyByteBuf, ToastPacket> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.VAR_INT, ToastPacket::status,
ByteBufCodecs.STRING_UTF8, ToastPacket::title,
ByteBufCodecs.STRING_UTF8, ToastPacket::body,
ToastPacket::new);
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
public static void handle(ToastPacket pkt, IPayloadContext ctx) {
ctx.enqueueWork(() -> {
Minecraft mc = Minecraft.getInstance();
if (mc == null) return;
SystemToast.SystemToastId id = switch (pkt.status) {
case 1 -> SystemToast.SystemToastId.PERIODIC_NOTIFICATION;
case 2 -> SystemToast.SystemToastId.PACK_LOAD_FAILURE;
default -> SystemToast.SystemToastId.NARRATOR_TOGGLE;
};
mc.getToasts().addToast(SystemToast.multiline(mc, id,
Component.literal(pkt.title),
Component.literal(pkt.body)));
});
}
}
@@ -2,11 +2,13 @@
"key.category.bnstoolkit": "Brass and Sigil",
"key.bnstoolkit.tier": "Open Tier Ladder",
"key.bnstoolkit.bounties": "Open Bounty Board",
"key.bnstoolkit.plots": "Open Plot Map",
"key.bnstoolkit.plots": "Open Plot Office",
"key.bnstoolkit.shop": "Open Bazaar Shop",
"key.bnstoolkit.questlog": "Open Quest Log",
"screen.bnstoolkit.tier_upgrade": "Civic Tier",
"screen.bnstoolkit.bounty_board": "Bounty Board",
"screen.bnstoolkit.quest_log": "Quest Log",
"screen.bnstoolkit.plot_purchase": "Plots",
"screen.bnstoolkit.plot_purchase": "Plot Office",
"screen.bnstoolkit.shop_browser": "Bazaar"
}