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_id=bnstoolkit
mod_name=Brass and Sigil Toolkit mod_name=Brass and Sigil Toolkit
mod_license=All Rights Reserved mod_license=All Rights Reserved
mod_version=0.5.1 mod_version=0.6.0
mod_group_id=uk.sijbers.bnstoolkit 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 net.neoforged.neoforge.event.tick.PlayerTickEvent;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import uk.sijbers.bnstoolkit.economy.NumismaticsBridge;
import uk.sijbers.bnstoolkit.network.BnsNetwork; import uk.sijbers.bnstoolkit.network.BnsNetwork;
import uk.sijbers.bnstoolkit.network.ServerEconomyBridge; import uk.sijbers.bnstoolkit.network.ServerEconomyBridge;
@@ -48,6 +49,10 @@ public final class BnsToolkit {
@SubscribeEvent @SubscribeEvent
public void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) { public void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) {
if (event.getEntity() instanceof ServerPlayer sp) { 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"); ServerEconomyBridge.pushSnapshot(sp, "ALL");
LOG.info("[bnstoolkit] pushed initial economy snapshot to {}", sp.getName().getString()); 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 org.lwjgl.glfw.GLFW;
import uk.sijbers.bnstoolkit.BnsToolkit; import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.client.screens.BountyBoardScreen; import uk.sijbers.bnstoolkit.client.screens.BountyBoardScreen;
import uk.sijbers.bnstoolkit.client.screens.CountingHouseScreen;
import uk.sijbers.bnstoolkit.client.screens.HubScreen; import uk.sijbers.bnstoolkit.client.screens.HubScreen;
import uk.sijbers.bnstoolkit.client.screens.PlotPurchaseScreen; import uk.sijbers.bnstoolkit.client.screens.PlotPurchaseScreen;
import uk.sijbers.bnstoolkit.client.screens.QuestLogScreen; import uk.sijbers.bnstoolkit.client.screens.QuestLogScreen;
import uk.sijbers.bnstoolkit.client.screens.ShopBrowserScreen;
import uk.sijbers.bnstoolkit.client.screens.TierUpgradeScreen; import uk.sijbers.bnstoolkit.client.screens.TierUpgradeScreen;
import uk.sijbers.bnstoolkit.network.NetSend; import uk.sijbers.bnstoolkit.network.NetSend;
@@ -114,7 +114,7 @@ public final class BnsKeyBindings {
} }
if (OPEN_SHOP.consumeClick()) { if (OPEN_SHOP.consumeClick()) {
NetSend.requestSnapshot("SELL"); NetSend.requestSnapshot("SELL");
new ShopBrowserScreen().openGui(); new CountingHouseScreen().openGui();
} }
if (OPEN_QUESTLOG.consumeClick()) { if (OPEN_QUESTLOG.consumeClick()) {
NetSend.requestSnapshot("ALL"); NetSend.requestSnapshot("ALL");
@@ -27,7 +27,9 @@ public final class ClientBnsState {
private ClientBnsState() {} private ClientBnsState() {}
public static volatile int tier = 1; 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; public static volatile int effectiveFeePct = 25;
// SellSnapshot: // SellSnapshot:
@@ -2,10 +2,10 @@ package uk.sijbers.bnstoolkit.client;
import uk.sijbers.bnstoolkit.BnsToolkit; import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.client.screens.BountyBoardScreen; import uk.sijbers.bnstoolkit.client.screens.BountyBoardScreen;
import uk.sijbers.bnstoolkit.client.screens.CountingHouseScreen;
import uk.sijbers.bnstoolkit.client.screens.HubScreen; import uk.sijbers.bnstoolkit.client.screens.HubScreen;
import uk.sijbers.bnstoolkit.client.screens.PlotPurchaseScreen; import uk.sijbers.bnstoolkit.client.screens.PlotPurchaseScreen;
import uk.sijbers.bnstoolkit.client.screens.QuestLogScreen; import uk.sijbers.bnstoolkit.client.screens.QuestLogScreen;
import uk.sijbers.bnstoolkit.client.screens.ShopBrowserScreen;
import uk.sijbers.bnstoolkit.client.screens.TierUpgradeScreen; import uk.sijbers.bnstoolkit.client.screens.TierUpgradeScreen;
import uk.sijbers.bnstoolkit.network.NetSend; import uk.sijbers.bnstoolkit.network.NetSend;
@@ -25,7 +25,8 @@ public final class OpenScreenDispatcher {
case "tier" -> { NetSend.requestSnapshot("TIER"); new TierUpgradeScreen().openGui(); } case "tier" -> { NetSend.requestSnapshot("TIER"); new TierUpgradeScreen().openGui(); }
case "bounty" -> { NetSend.requestSnapshot("BOUNTY"); new BountyBoardScreen().openGui(); } case "bounty" -> { NetSend.requestSnapshot("BOUNTY"); new BountyBoardScreen().openGui(); }
case "plot" -> { NetSend.requestSnapshot("PLOT"); new PlotPurchaseScreen().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 "questlog" -> { NetSend.requestSnapshot("ALL"); new QuestLogScreen().openGui(); }
case "hub" -> { NetSend.requestSnapshot("ALL"); new HubScreen().openGui(); } case "hub" -> { NetSend.requestSnapshot("ALL"); new HubScreen().openGui(); }
default -> BnsToolkit.LOG.warn("[bnstoolkit] unknown screen requested: {}", which); 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. // Cache the rendered Component objects, rebuild only when the state changes.
// Keeps allocation off the 60 Hz render path. // Keeps allocation off the 60 Hz render path.
private int cachedTier = -1; private int cachedTier = -1;
private long cachedSpurs = -1L; private long cachedCash = -1L;
private long cachedBank = -1L;
private Component cachedTierLine = Component.empty(); 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) { public static void onRegisterGuiLayers(RegisterGuiLayersEvent event) {
event.registerAbove(VanillaGuiLayers.EXPERIENCE_BAR, LAYER_ID, INSTANCE::render); 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 == null || mc.player == null) return;
if (mc.options.hideGui) 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); CivicTier cfg = CivicTier.byId(ClientBnsState.tier);
cachedTierLine = Component.literal("§" + cfg.colour().getChar() + cfg.name() + "§r"); 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; cachedTier = ClientBnsState.tier;
cachedSpurs = ClientBnsState.spurs; cachedCash = ClientBnsState.cashOnHand;
cachedBank = ClientBnsState.bankBalance;
} }
int x = 4; int x = 4;
int y = 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 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 + h, 0x80000000);
gfx.fill(x, y, x + w, y + 1, 0xFF666666); 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.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, 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; 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 * Not a general shop. Players bring diamonds / netherite / ancient
* shows the selected item's effective price after fee + diminishing * debris and convert them to spurs, credited straight to their bank
* returns. Three quick-sell buttons: 1 / 16 / 64 (or whatever fits in * account. Iron / gold / coal / etc. are NOT here they belong on
* the player's inventory). * player-run Numismatics Vendor blocks at market prices.
* *
* Sell action: sends a "sell <item> <count>" routed C2S packet. Server * Right pane shows current effective price for the selected wealth
* runs the KubeJS-owned /bns sell verb; KubeJS subtracts items and pays * token after the player's tier fee + DR. Quick-exchange buttons cover
* the spurs; server pushes both an updated SellSnapshot AND a fresh * 1 / 16 / 64.
* tier/balance. *
* 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 final class CountingHouseScreen extends BaseBnsScreen {
public ShopBrowserScreen() { public CountingHouseScreen() {
super(Component.translatable("screen.bnstoolkit.shop_browser")); super(Component.translatable("screen.bnstoolkit.counting_house"));
} }
private static ResourceLocation selected = ResourceLocation.parse("minecraft:diamond"); private static ResourceLocation selected = ResourceLocation.parse("minecraft:diamond");
@@ -1,7 +1,6 @@
package uk.sijbers.bnstoolkit.client.screens; package uk.sijbers.bnstoolkit.client.screens;
import dev.ftb.mods.ftblibrary.icon.Color4I; 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.Icons;
import dev.ftb.mods.ftblibrary.ui.Panel; import dev.ftb.mods.ftblibrary.ui.Panel;
import dev.ftb.mods.ftblibrary.ui.SimpleTextButton; import dev.ftb.mods.ftblibrary.ui.SimpleTextButton;
@@ -14,15 +13,17 @@ import uk.sijbers.bnstoolkit.data.CivicTier;
import uk.sijbers.bnstoolkit.network.NetSend; 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. * <p>Status-only. Shows your tier, cash on hand, bank balance, total
* Body: 5 large buttons that route to the sub-screens. Clicking a button * wealth, current shop fee, slot caps, active bounties count, owned
* fires the matching snapshot request and opens the screen — same flow * plots count. The Quest Log button opens the read-only quest overview.
* as the (now mostly-unbound) direct shortcut keys.
* *
* Closing a sub-screen returns to the hub (so this hub becomes a real * <p>Transactional features (Counting House exchange, Bounty Board,
* navigation surface, not a one-shot menu). * 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 { public final class HubScreen extends BaseBnsScreen {
@@ -32,64 +33,75 @@ public final class HubScreen extends BaseBnsScreen {
@Override @Override
public void addWidgets() { public void addWidgets() {
add(new StatusHeader(this)); add(new StatusPanel(this));
add(SimpleTextButton.create(this, Component.literal("Quest Log"), Icons.INFO,
add(makeButton("Civic Tier", Icons.PLAYER, "TIER", () -> new TierUpgradeScreen())); btn -> { NetSend.requestSnapshot("ALL"); new QuestLogScreen().openGui(); }));
add(makeButton("Bazaar Shop", Icons.ADD, "SELL", () -> new ShopBrowserScreen())); add(SimpleTextButton.create(this, Component.literal("Refresh"), Icons.REFRESH,
add(makeButton("Bounty Board", Icons.ACCEPT, "BOUNTY", () -> new BountyBoardScreen())); btn -> NetSend.requestSnapshot("ALL")));
add(makeButton("Plot Office", Icons.FRIENDS, "PLOT", () -> new PlotPurchaseScreen()));
add(makeButton("Quest Log", Icons.INFO, "ALL", () -> new QuestLogScreen()));
add(SimpleTextButton.create(this, Component.literal("Close"), Icons.CLOSE, add(SimpleTextButton.create(this, Component.literal("Close"), Icons.CLOSE,
btn -> closeGui())); btn -> closeGui()));
} }
@Override @Override
public void alignWidgets() { public void alignWidgets() {
// All coordinates here are RELATIVE to the panel's top-left — FTB // Status panel fills most of the body
// Library's renderer adds the panel's own (getX(), getY()) for us. getWidgets().get(0).setPos(8, contentTop());
Widget header = getWidgets().get(0); getWidgets().get(0).setSize(getWidth() - 16, getHeight() - 50);
header.setPos(8, contentTop());
header.setSize(getWidth() - 16, 20);
int cols = 2; // Bottom action row
int btnW = (getWidth() - 24) / cols; int btnY = getHeight() - 22;
int btnH = 22; int btnH = 16;
int gap = 4; Widget quest = getWidgets().get(1);
int top = contentTop() + 28; quest.setSize(72, btnH); quest.setPos(8, btnY);
for (int i = 0; i < 5; i++) { Widget refresh = getWidgets().get(2);
int row = i / cols; refresh.setSize(60, btnH); refresh.setPos(84, btnY);
int col = i % cols; Widget close = getWidgets().get(3);
Widget w = getWidgets().get(1 + i); close.setSize(60, btnH); close.setPos(getWidth() - 68, btnY);
w.setSize(btnW, btnH);
w.setPos(8 + col * (btnW + gap), top + row * (btnH + gap));
} }
Widget close = getWidgets().get(6); /** Status display — pure read-only. */
close.setSize(64, 16); private static final class StatusPanel extends Panel {
close.setPos(getWidth() - 72, getHeight() - 22); StatusPanel(Panel parent) { super(parent); }
}
private SimpleTextButton makeButton(String label, Icon icon, String snapshot, @Override public void addWidgets() {
java.util.function.Supplier<BaseBnsScreen> ctor) {
return SimpleTextButton.create(this, Component.literal(label), icon, btn -> {
NetSend.requestSnapshot(snapshot);
ctor.get().openGui();
});
}
/** Status header — tier, balance, fee at the top of the hub. */
private static final class StatusHeader extends Widget {
StatusHeader(Panel parent) { super(parent); }
@Override public void draw(GuiGraphics gfx, Theme theme, int x, int y, int w, int h) {
Color4I.rgba(0x40FFFFFF).draw(gfx, x, y, w, h);
CivicTier cfg = CivicTier.byId(ClientBnsState.tier); CivicTier cfg = CivicTier.byId(ClientBnsState.tier);
String left = "Tier: §" + cfg.colour().getChar() + cfg.name() + "§r"; long cash = ClientBnsState.cashOnHand;
String right = "Balance: §6" + TierUpgradeScreen.formatSpurs(ClientBnsState.spurs) long bank = ClientBnsState.bankBalance;
+ "§r §7(fee " + ClientBnsState.effectiveFeePct + "%)§r"; long total = cash + bank;
theme.drawString(gfx, Component.literal(left), x + 4, y + 6, Color4I.WHITE, Theme.SHADOW); add(new Row(this, "§e=== Civic Status ==="));
int rightW = theme.getFont().width(right); add(new Row(this, "§7Tier: §" + cfg.colour().getChar() + cfg.name()
theme.drawString(gfx, Component.literal(right), x + w - rightW - 4, y + 6, + " §7(" + ClientBnsState.tier + "/" + CivicTier.MAX + ")"));
Color4I.WHITE, Theme.SHADOW); 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."));
}
@Override public void alignWidgets() {
int y = 0;
for (Widget w : getWidgets()) {
w.setSize(width, 12);
w.setPos(0, y);
y += 12;
}
}
}
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) {
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; import java.util.Map;
/** /**
* Bazaar sell prices. Mirrors SELL_PRICES in * Counting House exchange rates.
* pack/overrides/kubejs/server_scripts/economy/02_sell_shop.js.
* *
* The map is insertion-ordered so the screen grid matches the KubeJS * <p>The Counting House is the realm's wealth exchange — not a general
* config order without separate display ordering. * 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 { public final class SellCatalogue {
private SellCatalogue() {} private SellCatalogue() {}
public static final int DR_STEP = 64; public static final int DR_STEP = 64;
public static final double DR_DECAY = 0.10; 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; public static final Map<ResourceLocation, Long> PRICES;
static { static {
Map<ResourceLocation, Long> m = new LinkedHashMap<>(); Map<ResourceLocation, Long> m = new LinkedHashMap<>();
m.put(ResourceLocation.parse("minecraft:diamond"), 10L); 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: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); m.put(ResourceLocation.parse("minecraft:ancient_debris"), 500L);
PRICES = java.util.Collections.unmodifiableMap(m); 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.Inventory;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import uk.sijbers.bnstoolkit.economy.NumismaticsBridge;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
@@ -34,8 +35,17 @@ public final class SpurBalance {
COIN_VALUES = java.util.Collections.unmodifiableMap(m); 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) { 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(); Inventory inv = player.getInventory();
long total = 0L; long total = 0L;
for (int i = 0; i < inv.getContainerSize(); i++) { for (int i = 0; i < inv.getContainerSize(); i++) {
@@ -62,4 +72,67 @@ public final class SpurBalance {
} }
return total; 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 { public final class BnsNetwork {
private BnsNetwork() {} private BnsNetwork() {}
public static final String VERSION = "0.4"; public static final String VERSION = "0.5";
public static void register(IEventBus modBus) { public static void register(IEventBus modBus) {
modBus.addListener(BnsNetwork::onRegisterPayloads); modBus.addListener(BnsNetwork::onRegisterPayloads);
@@ -11,6 +11,7 @@ import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.data.CivicTier; import uk.sijbers.bnstoolkit.data.CivicTier;
import uk.sijbers.bnstoolkit.data.Plot; import uk.sijbers.bnstoolkit.data.Plot;
import uk.sijbers.bnstoolkit.data.SpurBalance; import uk.sijbers.bnstoolkit.data.SpurBalance;
import uk.sijbers.bnstoolkit.economy.NumismaticsBridge;
import uk.sijbers.bnstoolkit.network.s2c.BountySnapshotPacket; import uk.sijbers.bnstoolkit.network.s2c.BountySnapshotPacket;
import uk.sijbers.bnstoolkit.network.s2c.OpenScreenPacket; import uk.sijbers.bnstoolkit.network.s2c.OpenScreenPacket;
import uk.sijbers.bnstoolkit.network.s2c.PlotSnapshotPacket; import uk.sijbers.bnstoolkit.network.s2c.PlotSnapshotPacket;
@@ -214,9 +215,10 @@ public final class ServerEconomyBridge {
int tier = data.contains("bnsTier") ? data.getInt("bnsTier") : 1; int tier = data.contains("bnsTier") ? data.getInt("bnsTier") : 1;
if (tier < 1 || tier > CivicTier.MAX) tier = 1; if (tier < 1 || tier > CivicTier.MAX) tier = 1;
CivicTier cfg = CivicTier.byId(tier); CivicTier cfg = CivicTier.byId(tier);
long spurs = SpurBalance.totalSpurs(sp); long cash = SpurBalance.inventoryCoins(sp);
long bank = NumismaticsBridge.balanceOf(sp);
int fee = cfg.effectiveShopFeePct(); 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) { 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.BnsToolkit;
import uk.sijbers.bnstoolkit.client.ClientBnsState; 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<>( public static final Type<TierStateUpdatePacket> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "tier_state_update")); ResourceLocation.fromNamespaceAndPath(BnsToolkit.MOD_ID, "tier_state_update"));
public static final StreamCodec<RegistryFriendlyByteBuf, TierStateUpdatePacket> STREAM_CODEC = public static final StreamCodec<RegistryFriendlyByteBuf, TierStateUpdatePacket> STREAM_CODEC =
StreamCodec.composite( StreamCodec.composite(
ByteBufCodecs.VAR_INT, TierStateUpdatePacket::tier, 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, ByteBufCodecs.VAR_INT, TierStateUpdatePacket::effectiveFeePct,
TierStateUpdatePacket::new); TierStateUpdatePacket::new);
@@ -29,7 +37,9 @@ public record TierStateUpdatePacket(int tier, long spurs, int effectiveFeePct) i
public static void handle(TierStateUpdatePacket pkt, IPayloadContext ctx) { public static void handle(TierStateUpdatePacket pkt, IPayloadContext ctx) {
ctx.enqueueWork(() -> { ctx.enqueueWork(() -> {
ClientBnsState.tier = pkt.tier; 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; ClientBnsState.effectiveFeePct = pkt.effectiveFeePct;
}); });
} }
@@ -35,7 +35,7 @@ public final class BnsServerCommands {
private BnsServerCommands() {} private BnsServerCommands() {}
private static final String[] SCREEN_NAMES = { private static final String[] SCREEN_NAMES = {
"hub", "tier", "bounty", "plot", "shop", "questlog" "hub", "tier", "bounty", "plot", "counthouse", "shop", "questlog"
}; };
@SubscribeEvent @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.tier": "Open Tier Ladder (direct)",
"key.bnstoolkit.bounties": "Open Bounty Board (direct)", "key.bnstoolkit.bounties": "Open Bounty Board (direct)",
"key.bnstoolkit.plots": "Open Plot Office (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)", "key.bnstoolkit.questlog": "Open Quest Log (direct)",
"screen.bnstoolkit.hub": "Brass and Sigil", "screen.bnstoolkit.hub": "Brass and Sigil",
@@ -12,5 +12,6 @@
"screen.bnstoolkit.bounty_board": "Bounty Board", "screen.bnstoolkit.bounty_board": "Bounty Board",
"screen.bnstoolkit.quest_log": "Quest Log", "screen.bnstoolkit.quest_log": "Quest Log",
"screen.bnstoolkit.plot_purchase": "Plot Office", "screen.bnstoolkit.plot_purchase": "Plot Office",
"screen.bnstoolkit.shop_browser": "Bazaar" "screen.bnstoolkit.shop_browser": "Counting House",
"screen.bnstoolkit.counting_house": "Counting House"
} }