0.7.0: combined cash+bank spend + auto-bound card on first login
Build / build (push) Has been cancelled

Two polish features that complete the "one number" wealth UX:

1. NumismaticsBridge.spend(player, amount): combined deduction. Pulls
   inventory coins first (most immediate), then bank for the remainder.
   Atomic — if total is short, nothing is removed. Used by /bns tier
   upgrade (KubeJS calls Java.loadClass). Future plot/bounty spend
   operations should call this too.

2. NumismaticsBridge.ensureBoundCard: on first login, the player gets a
   white_card pre-bound to their own bank account via the
   numismatics:card_account_id DataComponent. Means a player visiting
   a Numismatics Vendor block can pay from their bank without manually
   crafting + binding a card first. If they die and lose it, the next
   login re-issues.

Bounty pool: dropped 4 Alex's Mobs entries; added 6 vanilla replacements
(Breeze, Bogged, Evoker, Vindicator, Piglin Brute, Ravager). All
high-tier targets are vanilla 1.21.x mobs now.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-08 17:20:09 +00:00
parent 8cc2e45011
commit 4a9edc15c1
4 changed files with 124 additions and 6 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.6.2 mod_version=0.7.0
mod_group_id=uk.sijbers.bnstoolkit mod_group_id=uk.sijbers.bnstoolkit
@@ -53,6 +53,7 @@ public final class BnsToolkit {
// this point bnstoolkit treats the bank as canonical — no cards // this point bnstoolkit treats the bank as canonical — no cards
// required, balance is queryable via the bridge. // required, balance is queryable via the bridge.
NumismaticsBridge.ensureAccount(sp); NumismaticsBridge.ensureAccount(sp);
NumismaticsBridge.ensureBoundCard(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());
} }
@@ -30,10 +30,12 @@ public record Bounty(String id, String name, String targetEntity, int count, lon
new Bounty("b_wither_skel_3", "Slay 3 Wither Skeletons","minecraft:wither_skeleton", 3, 150L, "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_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_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_breeze_3", "Hunt 3 Breezes", "minecraft:breeze", 3, 250L, "Adventurer's Guild"),
new Bounty("b_warped_mosco_1", "Slay a Warped Mosco", "alexsmobs:warped_mosco", 1, 700L, "Adventurer's Guild"), new Bounty("b_bogged_5", "Slay 5 Bogged", "minecraft:bogged", 5, 120L, "Adventurer's Guild"),
new Bounty("b_farseer_1", "Slay the Farseer", "alexsmobs:farseer", 1, 900L, "Adventurer's Guild"), new Bounty("b_evoker_2", "Slay 2 Evokers", "minecraft:evoker", 2, 350L, "Adventurer's Guild"),
new Bounty("b_skreecher_1", "Slay a Skreecher", "alexsmobs:skreecher", 1, 400L, "Adventurer's Guild") new Bounty("b_vindicator_4", "Slay 4 Vindicators", "minecraft:vindicator", 4, 180L, "Adventurer's Guild"),
new Bounty("b_piglin_brute_2", "Slay 2 Piglin Brutes", "minecraft:piglin_brute", 2, 400L, "Adventurer's Guild"),
new Bounty("b_ravager_1", "Hunt down a Ravager", "minecraft:ravager", 1, 500L, "Adventurer's Guild")
); );
public static Bounty byId(String id) { public static Bounty byId(String id) {
@@ -1,7 +1,13 @@
package uk.sijbers.bnstoolkit.economy; package uk.sijbers.bnstoolkit.economy;
import net.minecraft.core.component.DataComponentType;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
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.Item;
import net.minecraft.world.item.ItemStack;
import uk.sijbers.bnstoolkit.BnsToolkit; import uk.sijbers.bnstoolkit.BnsToolkit;
import uk.sijbers.bnstoolkit.data.SpurBalance; import uk.sijbers.bnstoolkit.data.SpurBalance;
@@ -176,4 +182,113 @@ public final class NumismaticsBridge {
return false; return false;
} }
} }
/**
* Combined cash+bank spend. Pulls inventory coins first (most direct),
* then deducts the remainder from the player's bank. Returns true if
* the full amount was successfully removed, false if the player didn't
* have enough (in which case NOTHING is removed — atomic).
*
* This is the canonical "pay X spurs" operation. /bns tier upgrade,
* /bns plot buy, and any future spend op should call this.
*/
public static boolean spend(Player player, long spurs) {
if (spurs <= 0) return true;
long cash = SpurBalance.inventoryCoins(player);
long bank = balanceOf(player);
if (cash + bank < spurs) return false;
long fromCash = Math.min(cash, spurs);
long fromBank = spurs - fromCash;
// Try the bank deduction first — that's the more likely to fail
// ("account locked", "missing capability"). If bank fails, the
// inventory coins haven't been touched yet.
if (fromBank > 0 && !deduct(player, fromBank)) return false;
// Take from inventory after bank succeeded.
if (fromCash > 0) {
long taken = SpurBalance.takeCoins(player, fromCash);
if (taken < fromCash) {
// Race / impossibility — refund bank if needed and bail.
long refund = fromCash - taken;
BnsToolkit.LOG.warn("[bns/bank] inventory short by {} during spend; refunding bank", refund);
deposit(player, fromBank + refund);
return false;
}
}
return true;
}
// ─── Card auto-issue ─────────────────────────────────────────────
private static final ResourceLocation DEFAULT_CARD_ID =
ResourceLocation.parse("numismatics:white_card");
private static final ResourceLocation CARD_ACCOUNT_COMPONENT_ID =
ResourceLocation.parse("numismatics:card_account_id");
/**
* On first login (or if their card was lost), give the player a
* white_card pre-bound to their own bank account. Eliminates the
* "I need a card to spend at Vendor blocks" friction without
* forcing players to learn the card system.
*
* Detection: if the player has any bound card already (CardItem with
* the CARD_ACCOUNT_ID component set to their UUID), no-op.
* Otherwise: hand them a fresh bound card.
*
* Note: cards are normal items; they drop on death like anything
* else. We don't keep-inventory force; if they lose it, this method
* re-issues on next login (which is mild friction but at least
* recoverable).
*/
public static void ensureBoundCard(ServerPlayer sp) {
try {
UUID myUuid = sp.getUUID();
Inventory inv = sp.getInventory();
// Resolve the component type once
DataComponentType<?> componentType = BuiltInRegistries.DATA_COMPONENT_TYPE.get(CARD_ACCOUNT_COMPONENT_ID);
if (componentType == null) {
BnsToolkit.LOG.debug("[bns/bank] CARD_ACCOUNT_ID component not registered — skipping card issue");
return;
}
// Scan inventory for an existing bound card
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;
// Cards are tagged with "_card" suffix in Numismatics
if (!id.getNamespace().equals("numismatics") || !id.getPath().endsWith("_card")) continue;
if (id.getPath().endsWith("_id_card")) continue; // skip ID cards
Object boundUuid = stack.get(componentType);
if (myUuid.equals(boundUuid)) {
return; // already has a bound card
}
}
// Issue a new bound white_card
Item cardItem = BuiltInRegistries.ITEM.get(DEFAULT_CARD_ID);
if (cardItem == null) {
BnsToolkit.LOG.debug("[bns/bank] numismatics:white_card not registered — skipping card issue");
return;
}
ItemStack newCard = new ItemStack(cardItem, 1);
// The set() signature is set(DataComponentType<T>, T) — UUID for this component.
@SuppressWarnings("unchecked")
DataComponentType<UUID> typed = (DataComponentType<UUID>) componentType;
newCard.set(typed, myUuid);
if (!inv.add(newCard)) {
// Inventory full — drop at feet
sp.drop(newCard, false);
}
BnsToolkit.LOG.info("[bns/bank] issued bound card to {}", sp.getName().getString());
} catch (Throwable t) {
BnsToolkit.LOG.warn("[bns/bank] ensureBoundCard failed for {}: {}",
sp.getName().getString(), t.toString());
}
}
} }