From 10e9980bfd20f42d3d96b98a3b53326b9e0f419b Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 7 Jun 2026 02:38:49 +0000 Subject: [PATCH] 0.3.0: villager trade rebalance via VillagerTradesEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hooks NeoForge's VillagerTradesEvent + WandererTradesEvent at data-pack reload time and wraps every existing ItemListing so the resulting MerchantOffer has its emerald-cost-or-result items swapped to Numismatics coin equivalents: 1..7 emeralds -> spurs (1:1) 8..63 emeralds -> bevels (1 bevel = 8 spurs) 64+ emeralds -> cogs (1 cog = 64 spurs) Lives in bnstoolkit instead of a third-party mod because the obvious choice — Modrinth's 'numismatics-villager-currency' — requires NeoForge 21.1.230+ and we're on 21.1.228. The two-page Java wrapper here is simpler than the alternatives (writing every trade override JSON via 'data-trades') AND doesn't push us into a runtime upgrade. Includes wanderer trades. Modded villager professions are picked up automatically since we just wrap the event's trade list as-is. Co-Authored-By: Claude Opus 4.7 --- gradle.properties | 2 +- .../economy/VillagerTradeRebalance.java | 129 ++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 src/main/java/uk/sijbers/bnstoolkit/economy/VillagerTradeRebalance.java diff --git a/gradle.properties b/gradle.properties index 2057962..895d99b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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.2.0 +mod_version=0.3.0 mod_group_id=uk.sijbers.bnstoolkit diff --git a/src/main/java/uk/sijbers/bnstoolkit/economy/VillagerTradeRebalance.java b/src/main/java/uk/sijbers/bnstoolkit/economy/VillagerTradeRebalance.java new file mode 100644 index 0000000..00fb39e --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/economy/VillagerTradeRebalance.java @@ -0,0 +1,129 @@ +package uk.sijbers.bnstoolkit.economy; + +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.npc.VillagerTrades; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.trading.ItemCost; +import net.minecraft.world.item.trading.MerchantOffer; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.village.VillagerTradesEvent; +import net.neoforged.neoforge.event.village.WandererTradesEvent; +import uk.sijbers.bnstoolkit.BnsToolkit; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Server-side villager-trade rebalance. + * + * Hooks {@link VillagerTradesEvent} and {@link WandererTradesEvent} at + * data-pack-reload time. For every existing trade listing, swaps any + * emerald cost/result for the Numismatics equivalent (1 spur = 1 + * emerald), so the entire vanilla and modded villager trade table + * routes through the BNS economy without us having to enumerate every + * profession + level by hand. + * + * Counts >= 8 are upgraded to higher denominations so inventory doesn't + * fill up with raw spurs: + * 1..7 spurs (1 each) + * 8..63 -> bevels (1 bevel = 8 spurs) + * 64.. -> cogs (1 cog = 64 spurs) + * + * Why this lives in bnstoolkit rather than a third-party mod: + * - The two existing options on Modrinth (numismatics-villager-currency + * and data-trades) either require a NeoForge bump (1.1.0 wants + * 21.1.230+, we're on 228) or need hand-written JSON for every + * trade. Reusing the event we already need for HUD pushes is + * simpler and avoids touching the live NeoForge install. + */ +@EventBusSubscriber(modid = BnsToolkit.MOD_ID) +public final class VillagerTradeRebalance { + private VillagerTradeRebalance() {} + + private static final ResourceLocation SPUR_ID = ResourceLocation.parse("numismatics:spur"); + private static final ResourceLocation BEVEL_ID = ResourceLocation.parse("numismatics:bevel"); + private static final ResourceLocation COG_ID = ResourceLocation.parse("numismatics:cog"); + private static final ResourceLocation EMERALD_ID = ResourceLocation.parse("minecraft:emerald"); + + @SubscribeEvent + public static void onVillagerTrades(VillagerTradesEvent event) { + Int2ObjectMap> trades = event.getTrades(); + for (Int2ObjectMap.Entry> e : trades.int2ObjectEntrySet()) { + List orig = e.getValue(); + List wrapped = new ArrayList<>(orig.size()); + for (VillagerTrades.ItemListing inner : orig) wrapped.add(new SpurTradeWrapper(inner)); + e.setValue(wrapped); + } + BnsToolkit.LOG.info("[bns/trades] rebalanced trades for profession {}", event.getType().name()); + } + + @SubscribeEvent + public static void onWandererTrades(WandererTradesEvent event) { + rewrapList(event.getGenericTrades()); + rewrapList(event.getRareTrades()); + BnsToolkit.LOG.info("[bns/trades] rebalanced wanderer trades"); + } + + private static void rewrapList(List list) { + List copy = new ArrayList<>(list); + list.clear(); + for (VillagerTrades.ItemListing inner : copy) list.add(new SpurTradeWrapper(inner)); + } + + /** Per-listing wrapper that rewrites the offer's emerald slots on each call. */ + private record SpurTradeWrapper(VillagerTrades.ItemListing inner) implements VillagerTrades.ItemListing { + @Override + public MerchantOffer getOffer(net.minecraft.world.entity.Entity entity, net.minecraft.util.RandomSource rand) { + MerchantOffer base = inner.getOffer(entity, rand); + if (base == null) return null; + ItemCost costA = base.getItemCostA(); + Optional costB = base.getItemCostB(); + ItemStack result = base.getResult(); + + ItemCost newCostA = swapIfEmerald(costA); + Optional newCostB = costB.map(VillagerTradeRebalance::swapIfEmerald); + ItemStack newResult = swapIfEmerald(result); + + return new MerchantOffer(newCostA, newCostB, newResult, + base.getUses(), base.getMaxUses(), base.getXp(), + base.getPriceMultiplier(), base.getDemand()); + } + } + + private static ItemCost swapIfEmerald(ItemCost cost) { + if (cost == null) return null; + ItemStack peek = cost.itemStack(); + ResourceLocation id = BuiltInRegistries.ITEM.getKey(peek.getItem()); + if (!EMERALD_ID.equals(id)) return cost; + int count = peek.getCount(); + CoinSwap swap = pickDenom(count); + Item coinItem = BuiltInRegistries.ITEM.get(swap.itemId); + if (coinItem == Items.AIR) return cost; // mod missing — leave vanilla cost + return new ItemCost(coinItem, swap.count); + } + + private static ItemStack swapIfEmerald(ItemStack stack) { + if (stack == null || stack.isEmpty()) return stack; + ResourceLocation id = BuiltInRegistries.ITEM.getKey(stack.getItem()); + if (!EMERALD_ID.equals(id)) return stack; + int count = stack.getCount(); + CoinSwap swap = pickDenom(count); + Item coinItem = BuiltInRegistries.ITEM.get(swap.itemId); + if (coinItem == Items.AIR) return stack; + return new ItemStack(coinItem, swap.count); + } + + private record CoinSwap(ResourceLocation itemId, int count) {} + + private static CoinSwap pickDenom(int emeralds) { + if (emeralds >= 64) return new CoinSwap(COG_ID, Math.max(1, emeralds / 64)); + if (emeralds >= 8) return new CoinSwap(BEVEL_ID, Math.max(1, emeralds / 8)); + return new CoinSwap(SPUR_ID, Math.max(1, emeralds)); + } +}