0.6.0: Counting House refactor — bank-credited wealth exchange
Build / build (push) Has been cancelled

Major economic-design pivot per design conversation:

* Bazaar renamed to "Counting House" — wealth exchange, not general shop
* Players bring wealth-tokens (diamond, netherite_ingot, ancient_debris,
  diamond_block); transactions credit their Numismatics bank account
  directly (not inventory)
* SellCatalogue cut from 9 items to 4; iron/gold/coal/lapis/redstone/
  emerald removed (those belong on player-run Numismatics Vendor blocks)
* Diminishing-returns floor tightened 50% -> 30% so deep-grinders feel
  the friction

Numismatics integration via reflection (NumismaticsBridge):
* Auto-creates personal bank account on PlayerLoggedInEvent
* SpurBalance.totalSpurs() now sums inventory coins + bank balance
* TierStateUpdatePacket carries cashOnHand + bankBalance separately so
  the hub can break them out
* HUD overlay shows three lines: tier / cash / bank — no card lookup,
  always shows total wealth regardless of what's on the player
* /bns sell handled in Java (CountingHouseCommands); KubeJS 02_sell_
  shop.js retired to .retired
* /bns wallet (+ deposit/withdraw) for managing cash<->bank flow
* Wire-version bumped 0.4 -> 0.5

Hub becomes status-only:
* Removed Civic Tier / Bazaar / Bounty / Plot transactional buttons
* Kept Quest Log, Refresh, Close
* Status panel shows tier, cash on hand, bank balance, total wealth,
  fee%, plot slots, bounty slots
* Footer: "Visit spawn NPCs for trades, bounties, plots, tier upgrades"

Screen rename: ShopBrowserScreen -> CountingHouseScreen.
OpenScreenDispatcher accepts both "counthouse" and "shop" keys
(latter as backward-compat alias).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-08 15:32:10 +00:00
parent 1630a8370f
commit 1d38d86a2e
17 changed files with 635 additions and 102 deletions
+1 -1
View File
@@ -19,5 +19,5 @@ loader_version_range=[1,)
mod_id=bnstoolkit
mod_name=Brass and Sigil Toolkit
mod_license=All Rights Reserved
mod_version=0.5.1
mod_version=0.6.0
mod_group_id=uk.sijbers.bnstoolkit
@@ -10,6 +10,7 @@ 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.economy.NumismaticsBridge;
import uk.sijbers.bnstoolkit.network.BnsNetwork;
import uk.sijbers.bnstoolkit.network.ServerEconomyBridge;
@@ -48,6 +49,10 @@ public final class BnsToolkit {
@SubscribeEvent
public void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) {
if (event.getEntity() instanceof ServerPlayer sp) {
// Every player gets a personal Numismatics bank account. After
// this point bnstoolkit treats the bank as canonical — no cards
// required, balance is queryable via the bridge.
NumismaticsBridge.ensureAccount(sp);
ServerEconomyBridge.pushSnapshot(sp, "ALL");
LOG.info("[bnstoolkit] pushed initial economy snapshot to {}", sp.getName().getString());
}
@@ -10,10 +10,10 @@ 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.CountingHouseScreen;
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;
@@ -114,7 +114,7 @@ public final class BnsKeyBindings {
}
if (OPEN_SHOP.consumeClick()) {
NetSend.requestSnapshot("SELL");
new ShopBrowserScreen().openGui();
new CountingHouseScreen().openGui();
}
if (OPEN_QUESTLOG.consumeClick()) {
NetSend.requestSnapshot("ALL");
@@ -27,7 +27,9 @@ public final class ClientBnsState {
private ClientBnsState() {}
public static volatile int tier = 1;
public static volatile long spurs = 0L;
public static volatile long spurs = 0L; // inventory + bank (kept for HUD compatibility)
public static volatile long cashOnHand = 0L; // inventory coins only
public static volatile long bankBalance = 0L; // Numismatics bank balance
public static volatile int effectiveFeePct = 25;
// SellSnapshot:
@@ -2,10 +2,10 @@ package uk.sijbers.bnstoolkit.client;
import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.client.screens.BountyBoardScreen;
import uk.sijbers.bnstoolkit.client.screens.CountingHouseScreen;
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;
@@ -25,7 +25,8 @@ public final class OpenScreenDispatcher {
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 "counthouse", "shop"
-> { NetSend.requestSnapshot("SELL"); new CountingHouseScreen().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);
@@ -29,9 +29,11 @@ public final class SpurHud {
// 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 long cachedCash = -1L;
private long cachedBank = -1L;
private Component cachedTierLine = Component.empty();
private Component cachedSpursLine = Component.empty();
private Component cachedCashLine = Component.empty();
private Component cachedBankLine = Component.empty();
public static void onRegisterGuiLayers(RegisterGuiLayersEvent event) {
event.registerAbove(VanillaGuiLayers.EXPERIENCE_BAR, LAYER_ID, INSTANCE::render);
@@ -43,19 +45,26 @@ public final class SpurHud {
if (mc == null || mc.player == null) return;
if (mc.options.hideGui) return;
if (cachedTier != ClientBnsState.tier || cachedSpurs != ClientBnsState.spurs) {
if (cachedTier != ClientBnsState.tier
|| cachedCash != ClientBnsState.cashOnHand
|| cachedBank != ClientBnsState.bankBalance) {
CivicTier cfg = CivicTier.byId(ClientBnsState.tier);
cachedTierLine = Component.literal("§" + cfg.colour().getChar() + cfg.name() + "§r");
cachedSpursLine = Component.literal("§6" + TierUpgradeScreen.formatSpurs(ClientBnsState.spurs) + " spurs§r");
cachedCashLine = Component.literal(
"§e" + TierUpgradeScreen.formatSpurs(ClientBnsState.cashOnHand) + "§7 cash§r");
cachedBankLine = Component.literal(
"§6" + TierUpgradeScreen.formatSpurs(ClientBnsState.bankBalance) + "§7 bank§r");
cachedTier = ClientBnsState.tier;
cachedSpurs = ClientBnsState.spurs;
cachedCash = ClientBnsState.cashOnHand;
cachedBank = ClientBnsState.bankBalance;
}
int x = 4;
int y = 4;
int textW = Math.max(mc.font.width(cachedTierLine), mc.font.width(cachedSpursLine));
int textW = Math.max(mc.font.width(cachedTierLine),
Math.max(mc.font.width(cachedCashLine), mc.font.width(cachedBankLine)));
int w = textW + 8;
int h = 24;
int h = 34;
gfx.fill(x, y, x + w, y + h, 0x80000000);
gfx.fill(x, y, x + w, y + 1, 0xFF666666);
@@ -64,6 +73,7 @@ public final class SpurHud {
gfx.fill(x + w - 1, y, x + w, y + h, 0xFF666666);
gfx.drawString(mc.font, cachedTierLine, x + 4, y + 4, 0xFFFFFFFF, true);
gfx.drawString(mc.font, cachedSpursLine, x + 4, y + 14, 0xFFFFFFFF, true);
gfx.drawString(mc.font, cachedCashLine, x + 4, y + 14, 0xFFFFFFFF, true);
gfx.drawString(mc.font, cachedBankLine, x + 4, y + 24, 0xFFFFFFFF, true);
}
}
@@ -22,21 +22,25 @@ import uk.sijbers.bnstoolkit.network.NetSend;
import java.util.List;
/**
* Bazaar sell-shop screen.
* The Counting House wealth exchange screen.
*
* 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).
* Not a general shop. Players bring diamonds / netherite / ancient
* debris and convert them to spurs, credited straight to their bank
* account. Iron / gold / coal / etc. are NOT here they belong on
* player-run Numismatics Vendor blocks at market prices.
*
* 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.
* Right pane shows current effective price for the selected wealth
* token after the player's tier fee + DR. Quick-exchange buttons cover
* 1 / 16 / 64.
*
* Reached via the Counting House Clerk NPC at spawn not openable from
* anywhere via keybind. The sell action sends a {@code RunBnsCommand}
* packet which calls Java-side {@code /bns sell}; that handler pulls
* items, computes payout, and deposits to the Numismatics bank.
*/
public final class ShopBrowserScreen extends BaseBnsScreen {
public ShopBrowserScreen() {
super(Component.translatable("screen.bnstoolkit.shop_browser"));
public final class CountingHouseScreen extends BaseBnsScreen {
public CountingHouseScreen() {
super(Component.translatable("screen.bnstoolkit.counting_house"));
}
private static ResourceLocation selected = ResourceLocation.parse("minecraft:diamond");
@@ -1,7 +1,6 @@
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;
@@ -14,15 +13,17 @@ 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.
* Brass and Sigil hub — your civic status card.
*
* 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.
* <p>Status-only. Shows your tier, cash on hand, bank balance, total
* wealth, current shop fee, slot caps, active bounties count, owned
* plots count. The Quest Log button opens the read-only quest overview.
*
* Closing a sub-screen returns to the hub (so this hub becomes a real
* navigation surface, not a one-shot menu).
* <p>Transactional features (Counting House exchange, Bounty Board,
* Tier Upgrade, Plot Office) are not reachable from the hub by design.
* Players visit the corresponding NPC at spawn — that's what makes spawn
* worth caring about. NPCs invoke {@code /bns open <screen>} on the
* clicking player via Easy NPC dialogue.
*/
public final class HubScreen extends BaseBnsScreen {
@@ -32,64 +33,75 @@ public final class HubScreen extends BaseBnsScreen {
@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(new StatusPanel(this));
add(SimpleTextButton.create(this, Component.literal("Quest Log"), Icons.INFO,
btn -> { NetSend.requestSnapshot("ALL"); new QuestLogScreen().openGui(); }));
add(SimpleTextButton.create(this, Component.literal("Refresh"), Icons.REFRESH,
btn -> NetSend.requestSnapshot("ALL")));
add(SimpleTextButton.create(this, Component.literal("Close"), Icons.CLOSE,
btn -> closeGui()));
}
@Override
public void alignWidgets() {
// All coordinates here are RELATIVE to the panel's top-left — FTB
// Library's renderer adds the panel's own (getX(), getY()) for us.
Widget header = getWidgets().get(0);
header.setPos(8, contentTop());
header.setSize(getWidth() - 16, 20);
// Status panel fills most of the body
getWidgets().get(0).setPos(8, contentTop());
getWidgets().get(0).setSize(getWidth() - 16, getHeight() - 50);
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(8 + col * (btnW + gap), top + row * (btnH + gap));
// Bottom action row
int btnY = getHeight() - 22;
int btnH = 16;
Widget quest = getWidgets().get(1);
quest.setSize(72, btnH); quest.setPos(8, btnY);
Widget refresh = getWidgets().get(2);
refresh.setSize(60, btnH); refresh.setPos(84, btnY);
Widget close = getWidgets().get(3);
close.setSize(60, btnH); close.setPos(getWidth() - 68, btnY);
}
/** Status display — pure read-only. */
private static final class StatusPanel extends Panel {
StatusPanel(Panel parent) { super(parent); }
@Override public void addWidgets() {
CivicTier cfg = CivicTier.byId(ClientBnsState.tier);
long cash = ClientBnsState.cashOnHand;
long bank = ClientBnsState.bankBalance;
long total = cash + bank;
add(new Row(this, "§e=== Civic Status ==="));
add(new Row(this, "§7Tier: §" + cfg.colour().getChar() + cfg.name()
+ " §7(" + ClientBnsState.tier + "/" + CivicTier.MAX + ")"));
add(new Row(this, ""));
add(new Row(this, "§e=== Wealth ==="));
add(new Row(this, "§7Cash on hand: §f" + TierUpgradeScreen.formatSpurs(cash) + " spurs"));
add(new Row(this, "§7Bank balance: §f" + TierUpgradeScreen.formatSpurs(bank) + " spurs"));
add(new Row(this, "§7Total wealth: §a" + TierUpgradeScreen.formatSpurs(total) + " spurs"));
add(new Row(this, ""));
add(new Row(this, "§e=== Perks ==="));
add(new Row(this, "§7Exchange fee: §c" + ClientBnsState.effectiveFeePct + "%"));
add(new Row(this, "§7Plot slots: §f" + ClientBnsState.ownedPlotIds.size()
+ " / " + ClientBnsState.plotSlotCap));
add(new Row(this, "§7Bounty slots: §f" + ClientBnsState.activeBounties.size()
+ " / " + ClientBnsState.bountySlotCap));
add(new Row(this, ""));
add(new Row(this, "§8Visit spawn NPCs for trades, bounties, plots, and tier upgrades."));
}
Widget close = getWidgets().get(6);
close.setSize(64, 16);
close.setPos(getWidth() - 72, getHeight() - 22);
@Override public void alignWidgets() {
int y = 0;
for (Widget w : getWidgets()) {
w.setSize(width, 12);
w.setPos(0, y);
y += 12;
}
}
}
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); }
private static final class Row extends Widget {
private final String text;
Row(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) {
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);
theme.drawString(gfx, Component.literal(text), x + 2, y + 2, Color4I.WHITE, Theme.SHADOW);
}
}
}
@@ -6,30 +6,34 @@ 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.
* Counting House exchange rates.
*
* The map is insertion-ordered so the screen grid matches the KubeJS
* config order without separate display ordering.
* <p>The Counting House is the realm's wealth exchange — not a general
* shop. It accepts the universal wealth-store items (diamond, netherite,
* ancient debris) and credits the player's bank account in spurs.
*
* <p>Iron, gold, coal, redstone, mob drops etc. are <em>not</em> here.
* Players who want to sell those operate their own shops with
* Numismatics Vendor blocks — free market, player-set prices.
*
* <p>The diminishing-returns floor stops the diamond-mine-forever
* exploit even though chest loot is finite: per-player DR depletes
* over a sustained grind. Floor 30% means a maxed-out diamond miner
* still earns 3 spurs per diamond instead of 10.
*/
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 double DR_FLOOR = 0.30;
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:diamond_block"), 90L); // 9 diamonds, slight discount
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);
}
@@ -4,6 +4,7 @@ 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 uk.sijbers.bnstoolkit.economy.NumismaticsBridge;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -34,8 +35,17 @@ public final class SpurBalance {
COIN_VALUES = java.util.Collections.unmodifiableMap(m);
}
/** Inventory-only scan (excludes ender chests, curios, backpacks). */
/**
* Total wealth: inventory coins + the player's personal Numismatics
* bank balance. No card lookups required — the bank is canonical and
* always accessible by UUID.
*/
public static long totalSpurs(Player player) {
return inventoryCoins(player) + NumismaticsBridge.balanceOf(player);
}
/** Inventory-only coin sum (no bank). Useful for "cash on hand" displays. */
public static long inventoryCoins(Player player) {
Inventory inv = player.getInventory();
long total = 0L;
for (int i = 0; i < inv.getContainerSize(); i++) {
@@ -62,4 +72,67 @@ public final class SpurBalance {
}
return total;
}
/**
* Remove up to {@code amount} spurs worth of Numismatics coins from
* the player's inventory, starting with the smallest denomination so
* we can pay exact-amount transfers. Returns the actual amount taken
* (may be < amount if the inventory didn't have enough).
*/
public static long takeCoins(Player player, long amount) {
if (amount <= 0) return 0;
long taken = 0;
Inventory inv = player.getInventory();
// Build ordered denomination list (low → high) so we deplete small coins first
var denoms = new java.util.ArrayList<>(COIN_VALUES.entrySet());
denoms.sort(java.util.Map.Entry.comparingByValue());
for (var entry : denoms) {
long denomValue = entry.getValue();
if (taken >= amount) break;
long needed = (long) Math.ceil((amount - taken) / (double) denomValue);
for (int i = 0; i < inv.getContainerSize() && needed > 0 && taken < amount; i++) {
ItemStack stack = inv.getItem(i);
if (stack.isEmpty()) continue;
ResourceLocation id = stack.getItemHolder().unwrapKey().map(k -> k.location()).orElse(null);
if (id == null || !entry.getKey().equals(id)) continue;
int take = (int) Math.min(stack.getCount(), needed);
stack.shrink(take);
taken += (long) take * denomValue;
needed -= take;
}
}
return Math.min(taken, amount);
}
/**
* Add {@code amount} spurs to the player's inventory using the
* largest denomination that fits, smallest-first overflow.
* Drops as items if the inventory is full.
*/
public static void giveCoins(Player player, long amount) {
if (amount <= 0) return;
Inventory inv = player.getInventory();
var denoms = new java.util.ArrayList<>(COIN_VALUES.entrySet());
denoms.sort((a, b) -> Long.compare(b.getValue(), a.getValue())); // high → low
long remaining = amount;
for (var entry : denoms) {
long denomValue = entry.getValue();
int qty = (int) (remaining / denomValue);
if (qty <= 0) continue;
remaining %= denomValue;
net.minecraft.world.item.Item item =
net.minecraft.core.registries.BuiltInRegistries.ITEM.get(entry.getKey());
if (item == null) continue;
int max = item.getDefaultMaxStackSize();
while (qty > 0) {
int give = Math.min(qty, max);
ItemStack stack = new ItemStack(item, give);
if (!inv.add(stack)) {
// Inventory full — drop at the player's feet
player.drop(stack, false);
}
qty -= give;
}
}
}
}
@@ -0,0 +1,179 @@
package uk.sijbers.bnstoolkit.economy;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.data.SpurBalance;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.UUID;
/**
* Reflective adapter for Create: Numismatics' bank API.
*
* <h2>Why reflection</h2>
* Numismatics is always in the pack so a hard compileOnly dep would be
* fine, but reflection keeps bnstoolkit lighter on the build classpath
* and matches the pattern we already use for KubeJS' MinecraftServer
* mixin in {@link uk.sijbers.bnstoolkit.network.ServerEconomyBridge}.
*
* <h2>What we use</h2>
* <ul>
* <li>{@code Numismatics.BANK} — public static {@code GlobalBankManager} singleton</li>
* <li>{@code GlobalBankManager.getAccount(Player)} — null if no account yet</li>
* <li>{@code GlobalBankManager.getOrCreateAccount(UUID, BankAccount.Type)} — auto-create with type=PLAYER</li>
* <li>{@code BankAccount.getBalance() / deposit(int) / deduct(int)} — int-typed balance, base spur denomination</li>
* </ul>
*
* <h2>Lifecycle</h2>
* Players auto-get a personal bank account on first {@code PlayerLoggedInEvent}
* via {@link #ensureAccount(ServerPlayer)}. After that the HUD always reads
* inv + bank ({@link SpurBalance#totalSpurs}) regardless of cards.
*/
public final class NumismaticsBridge {
private NumismaticsBridge() {}
private static final String NUMISMATICS_FQN = "dev.ithundxr.createnumismatics.Numismatics";
private static final String BANK_MGR_FQN = "dev.ithundxr.createnumismatics.content.backend.GlobalBankManager";
private static final String BANK_ACCOUNT_FQN = "dev.ithundxr.createnumismatics.content.backend.BankAccount";
private static final String BANK_TYPE_FQN = "dev.ithundxr.createnumismatics.content.backend.BankAccount$Type";
private static volatile boolean lookupDone = false;
private static volatile Object bankManager; // GlobalBankManager
private static volatile Method getAccountByPlayer; // (Player) -> BankAccount
private static volatile Method getAccountByUuid; // (UUID) -> BankAccount
private static volatile Method getOrCreateAccount; // (UUID, BankAccount.Type) -> BankAccount
private static volatile Method accountGetBalance; // () -> int
private static volatile Method accountDeposit; // (int) -> void
private static volatile Method accountDeductOk; // (int) -> boolean
private static volatile Object typePlayer; // BankAccount.Type.PLAYER enum
private static synchronized void initLookup() {
if (lookupDone) return;
try {
Class<?> num = Class.forName(NUMISMATICS_FQN);
Field f = num.getField("BANK");
bankManager = f.get(null);
Class<?> mgrCls = Class.forName(BANK_MGR_FQN);
getAccountByPlayer = mgrCls.getMethod("getAccount", Class.forName("net.minecraft.world.entity.player.Player"));
getAccountByUuid = mgrCls.getMethod("getAccount", UUID.class);
Class<?> typeCls = Class.forName(BANK_TYPE_FQN);
getOrCreateAccount = mgrCls.getMethod("getOrCreateAccount", UUID.class, typeCls);
// Find Type.PLAYER (default the first enum constant if PLAYER is absent)
Object[] enumValues = typeCls.getEnumConstants();
Object foundPlayer = null;
for (Object v : enumValues) {
if (((Enum<?>) v).name().equals("PLAYER")) { foundPlayer = v; break; }
}
typePlayer = foundPlayer != null ? foundPlayer : (enumValues.length > 0 ? enumValues[0] : null);
Class<?> accountCls = Class.forName(BANK_ACCOUNT_FQN);
accountGetBalance = accountCls.getMethod("getBalance");
accountDeposit = accountCls.getMethod("deposit", int.class);
accountDeductOk = accountCls.getMethod("deduct", int.class);
BnsToolkit.LOG.info("[bns/bank] linked Numismatics bank API (BANK={}, PLAYER type={})",
bankManager != null, typePlayer);
} catch (Throwable t) {
BnsToolkit.LOG.warn("[bns/bank] Numismatics bank API not found ({}) — bank features will no-op", t.toString());
} finally {
lookupDone = true;
}
}
private static boolean ready() {
if (!lookupDone) initLookup();
return bankManager != null && accountGetBalance != null;
}
// ─── Account lookup / creation ───────────────────────────────────
/** Returns the player's existing account, or null if none + lookup failed. */
private static Object lookupAccount(Player player) {
if (!ready()) return null;
try {
Object acc = getAccountByPlayer.invoke(bankManager, player);
return acc;
} catch (Throwable t) {
BnsToolkit.LOG.debug("[bns/bank] getAccount(Player) failed: {}", t.toString());
return null;
}
}
/**
* Ensure the given server player has a personal bank account. Called
* on PlayerLoggedInEvent. No-op + warn if Numismatics is missing.
*/
public static void ensureAccount(ServerPlayer sp) {
if (!ready() || typePlayer == null || getOrCreateAccount == null) return;
try {
Object acc = getAccountByUuid.invoke(bankManager, sp.getUUID());
if (acc == null) {
getOrCreateAccount.invoke(bankManager, sp.getUUID(), typePlayer);
BnsToolkit.LOG.info("[bns/bank] created bank account for {}", sp.getName().getString());
}
} catch (Throwable t) {
BnsToolkit.LOG.warn("[bns/bank] ensureAccount failed for {}: {}",
sp.getName().getString(), t.toString());
}
}
// ─── Balance / mutate ────────────────────────────────────────────
/** Returns the player's bank balance in spurs, or 0 if no account / lookup failed. */
public static long balanceOf(Player player) {
Object acc = lookupAccount(player);
if (acc == null) return 0L;
try {
return ((Number) accountGetBalance.invoke(acc)).longValue();
} catch (Throwable t) {
return 0L;
}
}
/**
* Credit the player's bank with {@code spurs}. Auto-creates the account
* if missing (the Numismatics-managed flow). Returns true on success.
*
* Numismatics' deposit takes int. Clamp/loop if we ever overflow but at
* 2^31-1 spurs = 2 billion, that's deep into Sovereign territory and not
* a near-term concern.
*/
public static boolean deposit(Player player, long spurs) {
if (spurs <= 0) return true;
if (!ready()) return false;
try {
Object acc = lookupAccount(player);
if (acc == null && player instanceof ServerPlayer sp) {
getOrCreateAccount.invoke(bankManager, sp.getUUID(), typePlayer);
acc = lookupAccount(player);
}
if (acc == null) return false;
int amount = (int) Math.min(Integer.MAX_VALUE, spurs);
accountDeposit.invoke(acc, amount);
return true;
} catch (Throwable t) {
BnsToolkit.LOG.warn("[bns/bank] deposit failed: {}", t.toString());
return false;
}
}
/** Try to deduct {@code spurs} from bank. Returns true on success. */
public static boolean deduct(Player player, long spurs) {
if (spurs <= 0) return true;
Object acc = lookupAccount(player);
if (acc == null) return false;
try {
int amount = (int) Math.min(Integer.MAX_VALUE, spurs);
Object result = accountDeductOk.invoke(acc, amount);
return Boolean.TRUE.equals(result);
} catch (Throwable t) {
return false;
}
}
}
@@ -32,7 +32,7 @@ import uk.sijbers.bnstoolkit.network.s2c.TierStateUpdatePacket;
public final class BnsNetwork {
private BnsNetwork() {}
public static final String VERSION = "0.4";
public static final String VERSION = "0.5";
public static void register(IEventBus modBus) {
modBus.addListener(BnsNetwork::onRegisterPayloads);
@@ -11,6 +11,7 @@ import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.data.CivicTier;
import uk.sijbers.bnstoolkit.data.Plot;
import uk.sijbers.bnstoolkit.data.SpurBalance;
import uk.sijbers.bnstoolkit.economy.NumismaticsBridge;
import uk.sijbers.bnstoolkit.network.s2c.BountySnapshotPacket;
import uk.sijbers.bnstoolkit.network.s2c.OpenScreenPacket;
import uk.sijbers.bnstoolkit.network.s2c.PlotSnapshotPacket;
@@ -214,9 +215,10 @@ public final class ServerEconomyBridge {
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);
long cash = SpurBalance.inventoryCoins(sp);
long bank = NumismaticsBridge.balanceOf(sp);
int fee = cfg.effectiveShopFeePct();
PacketDistributor.sendToPlayer(sp, new TierStateUpdatePacket(tier, spurs, fee));
PacketDistributor.sendToPlayer(sp, new TierStateUpdatePacket(tier, cash, bank, fee));
}
public static void pushSellSnapshot(ServerPlayer sp) {
@@ -9,15 +9,23 @@ import net.neoforged.neoforge.network.handling.IPayloadContext;
import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.client.ClientBnsState;
/** Server -> client: civic tier (1..13), spurs balance, effective shop fee%. */
public record TierStateUpdatePacket(int tier, long spurs, int effectiveFeePct) implements CustomPacketPayload {
/**
* S2C: civic tier (1..13), cash on hand (inventory coins), bank balance
* (Numismatics personal account), and the effective Counting House fee%.
*
* Cash + bank are sent separately so the hub UI can break them out
* ("on hand" vs "in bank") while the HUD overlay sums them for the
* "total wealth" display.
*/
public record TierStateUpdatePacket(int tier, long cashOnHand, long bankBalance, int effectiveFeePct) implements CustomPacketPayload {
public static final Type<TierStateUpdatePacket> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "tier_state_update"));
public static final StreamCodec<RegistryFriendlyByteBuf, TierStateUpdatePacket> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.VAR_INT, TierStateUpdatePacket::tier,
ByteBufCodecs.VAR_LONG, TierStateUpdatePacket::spurs,
ByteBufCodecs.VAR_LONG, TierStateUpdatePacket::cashOnHand,
ByteBufCodecs.VAR_LONG, TierStateUpdatePacket::bankBalance,
ByteBufCodecs.VAR_INT, TierStateUpdatePacket::effectiveFeePct,
TierStateUpdatePacket::new);
@@ -29,7 +37,9 @@ public record TierStateUpdatePacket(int tier, long spurs, int effectiveFeePct) i
public static void handle(TierStateUpdatePacket pkt, IPayloadContext ctx) {
ctx.enqueueWork(() -> {
ClientBnsState.tier = pkt.tier;
ClientBnsState.spurs = pkt.spurs;
ClientBnsState.cashOnHand = pkt.cashOnHand;
ClientBnsState.bankBalance = pkt.bankBalance;
ClientBnsState.spurs = pkt.cashOnHand + pkt.bankBalance; // kept for HUD compatibility
ClientBnsState.effectiveFeePct = pkt.effectiveFeePct;
});
}
@@ -35,7 +35,7 @@ public final class BnsServerCommands {
private BnsServerCommands() {}
private static final String[] SCREEN_NAMES = {
"hub", "tier", "bounty", "plot", "shop", "questlog"
"hub", "tier", "bounty", "plot", "counthouse", "shop", "questlog"
};
@SubscribeEvent
@@ -0,0 +1,230 @@
package uk.sijbers.bnstoolkit.server;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
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.data.CivicTier;
import uk.sijbers.bnstoolkit.data.SellCatalogue;
import uk.sijbers.bnstoolkit.data.SpurBalance;
import uk.sijbers.bnstoolkit.economy.NumismaticsBridge;
import uk.sijbers.bnstoolkit.network.ServerEconomyBridge;
/**
* Counting House commands — the wealth-exchange + wallet inspection
* verbs that used to live in KubeJS.
*
* <ul>
* <li>{@code /bns sell <item> <count>} — exchange wealth-token items
* for spurs, credited straight to the player's Numismatics bank
* account. NPC dialogue at the Counting House Clerk fires this on
* the clicking player.</li>
* <li>{@code /bns wallet} — show inventory cash, bank balance, total.</li>
* <li>{@code /bns wallet deposit <amount>} — move coins from inventory
* to bank.</li>
* <li>{@code /bns wallet withdraw <amount>} — bank to inventory as
* Numismatics coins (uses the largest denomination that fits).</li>
* </ul>
*
* The KubeJS-side {@code /bns sell} was retired alongside this commit —
* Java is now the source of truth so we can credit the bank atomically.
*/
@EventBusSubscriber(modid = BnsToolkit.MOD_ID)
public final class CountingHouseCommands {
private CountingHouseCommands() {}
@SubscribeEvent
public static void onRegisterCommands(RegisterCommandsEvent event) {
LiteralArgumentBuilder<CommandSourceStack> bns = Commands.literal("bns")
.then(Commands.literal("sell")
.then(Commands.argument("item", StringArgumentType.string())
.then(Commands.argument("count", IntegerArgumentType.integer(1, 64 * 36))
.executes(ctx -> doSell(
ctx.getSource(),
StringArgumentType.getString(ctx, "item"),
IntegerArgumentType.getInteger(ctx, "count"))))))
.then(Commands.literal("wallet")
.executes(ctx -> walletStatus(ctx.getSource()))
.then(Commands.literal("deposit")
.then(Commands.argument("amount", IntegerArgumentType.integer(1))
.executes(ctx -> walletDeposit(
ctx.getSource(),
IntegerArgumentType.getInteger(ctx, "amount")))))
.then(Commands.literal("withdraw")
.then(Commands.argument("amount", IntegerArgumentType.integer(1))
.executes(ctx -> walletWithdraw(
ctx.getSource(),
IntegerArgumentType.getInteger(ctx, "amount"))))));
event.getDispatcher().register(bns);
BnsToolkit.LOG.info("[bnstoolkit] registered Counting House + wallet commands");
}
// ─── /bns sell ───────────────────────────────────────────────────
private static int doSell(CommandSourceStack src, String itemId, int count) {
ServerPlayer sp = src.getPlayer();
if (sp == null) {
src.sendFailure(Component.literal("/bns sell must be run as a player"));
return 0;
}
ResourceLocation item;
try {
item = ResourceLocation.parse(itemId);
} catch (Exception e) {
sp.sendSystemMessage(Component.literal("§cInvalid item id: " + itemId));
return 0;
}
if (!SellCatalogue.PRICES.containsKey(item)) {
sp.sendSystemMessage(Component.literal("§cThe Counting House doesn't exchange '" + itemId
+ "'. Try diamonds, netherite, or ancient debris."));
return 0;
}
// Inventory count
int have = SpurBalance.countItem(sp, item);
if (have < count) {
sp.sendSystemMessage(Component.literal("§cYou only have " + have + "x " + itemId
+ " (need " + count + ")."));
return 0;
}
// Tier-based fee + lifetime DR
CompoundTag pd = sp.getPersistentData();
int tier = pd.contains("bnsTier") ? pd.getInt("bnsTier") : 1;
if (tier < 1 || tier > CivicTier.MAX) tier = 1;
int feePct = CivicTier.byId(tier).effectiveShopFeePct();
long lifetimeSold = readSold(pd, item);
long payout = SellCatalogue.effectivePayout(item, lifetimeSold, count, feePct);
if (payout <= 0) {
sp.sendSystemMessage(Component.literal("§cPayout would be 0 spurs — sell more later or upgrade tier."));
return 0;
}
// Pull items
int taken = takeFromInventory(sp.getInventory(), item, count);
if (taken != count) {
sp.sendSystemMessage(Component.literal("§cFailed to remove items — try again."));
return 0;
}
writeSold(pd, item, lifetimeSold + count);
if (!NumismaticsBridge.deposit(sp, payout)) {
// Refund items if deposit failed (shouldn't happen post-login since ensureAccount ran)
sp.getInventory().add(new ItemStack(
net.minecraft.core.registries.BuiltInRegistries.ITEM.get(item), count));
sp.sendSystemMessage(Component.literal("§cBank unavailable — items refunded."));
return 0;
}
sp.sendSystemMessage(Component.literal("§6Sold " + count + "x " + itemId
+ " for §a" + payout + " spurs§r§6 → bank."));
BnsToolkit.LOG.info("[bns/counthouse] {} sold {}x {} for {} spurs (lifetime now {})",
sp.getName().getString(), count, itemId, payout, lifetimeSold + count);
// Push fresh snapshots so the HUD updates instantly
ServerEconomyBridge.pushTierUpdate(sp);
ServerEconomyBridge.pushSellSnapshot(sp);
return 1;
}
// ─── /bns wallet ─────────────────────────────────────────────────
private static int walletStatus(CommandSourceStack src) {
ServerPlayer sp = src.getPlayer();
if (sp == null) {
src.sendFailure(Component.literal("/bns wallet must be run as a player"));
return 0;
}
long cash = SpurBalance.inventoryCoins(sp);
long bank = NumismaticsBridge.balanceOf(sp);
sp.sendSystemMessage(Component.literal("§6═══ Wallet ═══"));
sp.sendSystemMessage(Component.literal("§eCash on hand: §f" + cash + " spurs"));
sp.sendSystemMessage(Component.literal("§eBank balance: §f" + bank + " spurs"));
sp.sendSystemMessage(Component.literal("§eTotal wealth: §a" + (cash + bank) + " spurs"));
return 1;
}
private static int walletDeposit(CommandSourceStack src, int amount) {
ServerPlayer sp = src.getPlayer();
if (sp == null) return 0;
long cash = SpurBalance.inventoryCoins(sp);
if (cash < amount) {
sp.sendSystemMessage(Component.literal("§cNot enough cash on hand (have " + cash + ", need " + amount + ")"));
return 0;
}
// Take coins from inventory (using SpurBalance helper — needs to be added)
long taken = SpurBalance.takeCoins(sp, amount);
if (taken < amount) {
// Refund any partial (shouldn't happen — SpurBalance.takeCoins is atomic-ish)
if (taken > 0) NumismaticsBridge.deposit(sp, taken);
sp.sendSystemMessage(Component.literal("§cCoin transfer failed."));
return 0;
}
if (!NumismaticsBridge.deposit(sp, amount)) {
sp.sendSystemMessage(Component.literal("§cBank unavailable."));
return 0;
}
sp.sendSystemMessage(Component.literal("§aDeposited " + amount + " spurs to bank."));
ServerEconomyBridge.pushTierUpdate(sp);
return 1;
}
private static int walletWithdraw(CommandSourceStack src, int amount) {
ServerPlayer sp = src.getPlayer();
if (sp == null) return 0;
long bank = NumismaticsBridge.balanceOf(sp);
if (bank < amount) {
sp.sendSystemMessage(Component.literal("§cNot enough in bank (have " + bank + ", need " + amount + ")"));
return 0;
}
if (!NumismaticsBridge.deduct(sp, amount)) {
sp.sendSystemMessage(Component.literal("§cBank deduction failed."));
return 0;
}
SpurBalance.giveCoins(sp, amount);
sp.sendSystemMessage(Component.literal("§aWithdrew " + amount + " spurs as coins to inventory."));
ServerEconomyBridge.pushTierUpdate(sp);
return 1;
}
// ─── NBT helpers (parity with KubeJS bnsSales compound) ─────────
private static long readSold(CompoundTag pd, ResourceLocation item) {
if (!pd.contains("bnsSales")) return 0L;
CompoundTag map = pd.getCompound("bnsSales");
String key = item.toString();
return map.contains(key) ? map.getInt(key) : 0L;
}
private static void writeSold(CompoundTag pd, ResourceLocation item, long value) {
CompoundTag map = pd.contains("bnsSales") ? pd.getCompound("bnsSales") : new CompoundTag();
map.putInt(item.toString(), (int) Math.min(Integer.MAX_VALUE, value));
pd.put("bnsSales", map);
}
private static int takeFromInventory(Inventory inv, ResourceLocation itemId, int count) {
int remaining = count;
for (int i = 0; i < inv.getContainerSize() && remaining > 0; i++) {
ItemStack stack = inv.getItem(i);
if (stack.isEmpty()) continue;
ResourceLocation id = net.minecraft.core.registries.BuiltInRegistries.ITEM.getKey(stack.getItem());
if (!itemId.equals(id)) continue;
int take = Math.min(stack.getCount(), remaining);
stack.shrink(take);
remaining -= take;
}
return count - remaining;
}
}
@@ -4,7 +4,7 @@
"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.shop": "Open Counting House (direct)",
"key.bnstoolkit.questlog": "Open Quest Log (direct)",
"screen.bnstoolkit.hub": "Brass and Sigil",
@@ -12,5 +12,6 @@
"screen.bnstoolkit.bounty_board": "Bounty Board",
"screen.bnstoolkit.quest_log": "Quest Log",
"screen.bnstoolkit.plot_purchase": "Plot Office",
"screen.bnstoolkit.shop_browser": "Bazaar"
"screen.bnstoolkit.shop_browser": "Counting House",
"screen.bnstoolkit.counting_house": "Counting House"
}