From 0b90e940795d6c04e32ae3742bb56e0367ca769b Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 7 Jun 2026 16:26:48 +0000 Subject: [PATCH] 0.5.0: per-tile village supply/demand throttle for spur-paying trades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anti-farm system that limits how much income players can extract from villager trading in one location. Designed to make 50-librarian halls diminish into futility without disabling villager trading altogether. Model: - World is partitioned into 64×64 block tiles (no POI lookups, villager-position rounded by integer division) - Each tile has a CompoundTag of {item_id -> remaining_demand} at server.persistentData.bnsVillageDemand[dim:tileX:tileZ] - Demand caps per item are set in VillageDemand.java; paper=20, wheat=25, rotten flesh=15, iron=6, diamond=1, etc. - Demand refills linearly: full cap restored over 1 hour of real time (no scheduled tick — refresh computed lazily on each query from lastUpdated timestamp) - A 1-tile multi-profession hall earns ~150 spurs/hour at saturation. Scaling income requires building halls in distant tiles — that's overworld logistics, not micro-exploit. Implementation: - VillageDemand.java: per-item cap config - VillageDemandTracker.java: lazy-refresh + atomic decrement against the KubeJS server-scope NBT (existing reflection accessor) - VillageTrackedMerchantOffer.java: MerchantOffer subclass that overrides increaseUses() to decrement demand on trade completion - VillagerTradeRebalance.java: at offer-build time, queries current tile demand for the cost item. If saturated (<1 left), the offer comes back with uses=maxUses (vanilla "out of stock" UI). Otherwise wrapped in VillageTrackedMerchantOffer so post-trade decrement fires. - Wandering trader: uses whatever tile he's wandered into. No special case — it's just whichever tile he stands in at trade time. Wandering traders, modded merchants (entity is not Villager): same tracking, same caps. Anything that produces a spur as a trade result is throttled by the tile. Hooks: zero mixins. MerchantOffer.increaseUses() is vanilla-public and fires server-side after the trade settles, so a subclass override is the right hook point. Perf: lazy refresh = no scheduled ticks. Per-trade overhead is one NBT lookup + a few timestamp ops, microseconds. Idle servers do zero work. Co-Authored-By: Claude Opus 4.7 --- gradle.properties | 2 +- .../bnstoolkit/economy/VillageDemand.java | 155 +++++++++++++++++ .../economy/VillageDemandTracker.java | 158 ++++++++++++++++++ .../economy/VillageTrackedMerchantOffer.java | 49 ++++++ .../economy/VillagerTradeRebalance.java | 97 +++++++---- .../network/ServerEconomyBridge.java | 3 +- 6 files changed, 433 insertions(+), 31 deletions(-) create mode 100644 src/main/java/uk/sijbers/bnstoolkit/economy/VillageDemand.java create mode 100644 src/main/java/uk/sijbers/bnstoolkit/economy/VillageDemandTracker.java create mode 100644 src/main/java/uk/sijbers/bnstoolkit/economy/VillageTrackedMerchantOffer.java diff --git a/gradle.properties b/gradle.properties index 3985be3..8fd389d 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.4.0 +mod_version=0.5.0 mod_group_id=uk.sijbers.bnstoolkit diff --git a/src/main/java/uk/sijbers/bnstoolkit/economy/VillageDemand.java b/src/main/java/uk/sijbers/bnstoolkit/economy/VillageDemand.java new file mode 100644 index 0000000..148dea1 --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/economy/VillageDemand.java @@ -0,0 +1,155 @@ +package uk.sijbers.bnstoolkit.economy; + +import net.minecraft.resources.ResourceLocation; + +import java.util.HashMap; +import java.util.Map; + +/** + * Per-tile demand caps for the village supply/demand system. + * + *

Model

+ * The overworld is divided into 64×64 block tiles. Every villager / merchant + * in a tile contributes to and draws from a single "village demand pool" for + * each item type. Pool replenishes linearly — full cap restored over 1 hour + * of real time, no matter the player's offline state. + * + *

Why these numbers

+ * Each cap represents how many trades the village can absorb in 1 hour + * for that input item. With vanilla librarian "24 paper → 1 emerald" trade + * and cap=20, that's 480 paper traded per tile-hour → 20 spurs/tile-hour. + * Multi-profession farms (librarian + farmer + fletcher) across one tile + * cap out at ~150 spurs/hour. Across many spread-out tiles, players can + * scale — but that's overworld logistics, not exploit. + * + * Caps are intentionally generous on farmable junk and tight on rare + * resources (iron/gold/diamond). Diamond at cap=1 means each tile absorbs + * exactly one diamond trade per hour — players still get vanilla + * emerald-trade access to enchanted books etc., but can't grind spurs + * through them. + * + * Tune freely — these numbers are first-draft and will get walked back + * in playtest. + */ +public final class VillageDemand { + private VillageDemand() {} + + public static final int DEFAULT_CAP = 12; + /** Tile edge length in blocks. 64 = 4 chunks square. */ + public static final int TILE_EDGE = 64; + /** Full refresh over 1 hour of real time. */ + public static final double REFRESH_PER_SECOND = 1.0 / 3600.0; + + private static final Map CAPS = new HashMap<>(); + static { + // ─── Crops (farmer trades) ───────────────────────────────── + put("minecraft:wheat", 25); + put("minecraft:potato", 25); + put("minecraft:carrot", 25); + put("minecraft:beetroot", 20); + put("minecraft:pumpkin", 15); + put("minecraft:melon", 15); + put("minecraft:apple", 10); + + // ─── Plant materials (librarian/cartographer/fletcher trades) ── + put("minecraft:paper", 20); + put("minecraft:wheat_seeds", 25); + put("minecraft:sugar_cane", 25); + put("minecraft:bamboo", 20); + + // ─── Animal products (shepherd/leatherworker/fisherman) ──── + put("minecraft:wool", 20); + put("minecraft:string", 25); + put("minecraft:leather", 18); + put("minecraft:rabbit_hide", 12); + put("minecraft:scute", 4); + put("minecraft:feather", 15); + + // ─── Cooked / raw food (butcher / fisherman) ─────────────── + put("minecraft:chicken", 20); + put("minecraft:rabbit", 12); + put("minecraft:porkchop", 18); + put("minecraft:beef", 18); + put("minecraft:mutton", 18); + put("minecraft:cod", 22); + put("minecraft:salmon", 22); + put("minecraft:tropical_fish", 8); + put("minecraft:pufferfish", 8); + put("minecraft:carrot_on_a_stick", 5); + + // ─── Junk drops (cleric — most exploitable historically) ─── + put("minecraft:rotten_flesh", 15); + put("minecraft:gunpowder", 10); + put("minecraft:bone", 12); + put("minecraft:spider_eye", 10); + put("minecraft:ender_pearl", 4); + put("minecraft:blaze_powder", 6); + + // ─── Smelted / mineable (smith trades) ───────────────────── + put("minecraft:coal", 20); + put("minecraft:iron_ingot", 6); + put("minecraft:gold_ingot", 6); + put("minecraft:lapis_lazuli", 12); + put("minecraft:redstone", 12); + put("minecraft:diamond", 1); + put("minecraft:emerald", 4); + put("minecraft:amethyst_shard", 8); + put("minecraft:netherite_ingot", 1); + + // ─── Stone / cobble (mason) ──────────────────────────────── + put("minecraft:stone", 25); + put("minecraft:cobblestone", 25); + put("minecraft:granite", 20); + put("minecraft:diorite", 20); + put("minecraft:andesite", 20); + put("minecraft:terracotta", 15); + put("minecraft:quartz", 12); + put("minecraft:nether_quartz_ore", 8); + + // ─── Building materials (mason / toolsmith) ──────────────── + put("minecraft:sand", 18); + put("minecraft:gravel", 20); + put("minecraft:clay_ball", 18); + put("minecraft:clay", 15); + put("minecraft:flint", 12); + put("minecraft:sticks", 25); + put("minecraft:glass", 15); + put("minecraft:glass_pane", 15); + + // ─── Wood / planks (any profession that accepts wood) ────── + put("minecraft:oak_planks", 25); + put("minecraft:spruce_planks", 25); + put("minecraft:birch_planks", 25); + put("minecraft:jungle_planks", 25); + put("minecraft:acacia_planks", 25); + put("minecraft:dark_oak_planks", 25); + put("minecraft:mangrove_planks", 25); + put("minecraft:cherry_planks", 25); + put("minecraft:crimson_planks", 18); + put("minecraft:warped_planks", 18); + + // ─── Books / writing supplies ────────────────────────────── + put("minecraft:book", 8); + put("minecraft:written_book", 4); + put("minecraft:ink_sac", 10); + put("minecraft:glow_ink_sac", 8); + + // ─── Numismatics coins should NEVER appear as cost (they're + // the result of the trade swap). If for some reason they do, + // set demand to zero — village won't accept its own currency. + put("numismatics:spur", 0); + put("numismatics:bevel", 0); + put("numismatics:sprocket", 0); + put("numismatics:cog", 0); + put("numismatics:crown", 0); + put("numismatics:sun", 0); + } + + private static void put(String id, int cap) { + CAPS.put(ResourceLocation.parse(id), cap); + } + + public static int capFor(ResourceLocation itemId) { + return CAPS.getOrDefault(itemId, DEFAULT_CAP); + } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/economy/VillageDemandTracker.java b/src/main/java/uk/sijbers/bnstoolkit/economy/VillageDemandTracker.java new file mode 100644 index 0000000..8080bf0 --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/economy/VillageDemandTracker.java @@ -0,0 +1,158 @@ +package uk.sijbers.bnstoolkit.economy; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.entity.Entity; +import uk.sijbers.bnstoolkit.BnsToolkit; +import uk.sijbers.bnstoolkit.network.ServerEconomyBridge; + +/** + * Per-tile village supply/demand tracking. + * + *

The world is divided into 64×64 block tiles (independent of biome / + * structure / chunk boundaries). Each tile has a CompoundTag at + * server.persistentData.bnsVillageDemand[dim:tileX:tileZ] + * holding remaining demand per cost-item plus a {@code lastUpdated} + * timestamp. + * + *

Refresh is lazy: no scheduled tick. Each query + * reads {@code lastUpdated}, computes elapsed real-world seconds, and + * adds back the appropriate fraction of each item's cap before + * returning. Cost: O(items already touched in this tile) per query, + * typically < 10 lookups. + * + *

Effect on spur farming: a max-stocked 1-tile villager hall earns + * roughly 60–200 spurs per real-time hour from the spur-paying trade + * subset, depending on which professions are stocked. Beyond that, the + * villagers' trades reach demand-saturation, which {@link + * VillagerTradeRebalance} reflects by locking the trade for the rest of + * the restock cycle (vanilla "out of stock" UI). + * + *

All data is kept in the KubeJS-attached server CompoundTag (via the + * existing {@link ServerEconomyBridge#getKubeJSServerData} reflection), + * so it's auto-persisted with the world save and inspectable from + * KubeJS scripts at {@code Utils.server.persistentData.bnsVillageDemand}. + */ +public final class VillageDemandTracker { + private VillageDemandTracker() {} + + /** Compose the tile key used for storage / lookup. */ + public static String tileKey(ResourceLocation dim, int tileX, int tileZ) { + return dim + ":" + tileX + ":" + tileZ; + } + + public static int tileFor(double worldCoord) { + return (int) Math.floor(worldCoord / VillageDemand.TILE_EDGE); + } + + /** + * Look up CURRENT demand for an item in a tile, refreshing the stored + * value as a side effect (lazy refresh). Does NOT decrement; safe for + * reads at offer-build time. + */ + public static double queryDemand(MinecraftServer server, ResourceLocation dim, + int tileX, int tileZ, ResourceLocation item) { + if (server == null) return VillageDemand.capFor(item); // singleplayer integrated path + CompoundTag tile = getOrCreateTile(server, dim, tileX, tileZ); + long now = System.currentTimeMillis(); + return refreshAndRead(tile, item, now); + } + + /** + * Atomic check + decrement. Returns true if the trade is allowed + * (demand was ≥ 1 before this call and is now ≥ 0). Returns false if + * the tile was saturated — caller should reflect that in the UI + * (e.g. by setting the offer's uses to maxUses). + */ + public static boolean tryConsume(MinecraftServer server, ResourceLocation dim, + int tileX, int tileZ, ResourceLocation item) { + if (server == null) return true; + CompoundTag tile = getOrCreateTile(server, dim, tileX, tileZ); + long now = System.currentTimeMillis(); + double current = refreshAndRead(tile, item, now); + if (current < 1.0) { + // Still update lastUpdated so the next call computes refresh from now. + tile.putDouble(item.toString(), Math.max(0.0, current)); + tile.putLong("lastUpdated", now); + return false; + } + tile.putDouble(item.toString(), current - 1.0); + tile.putLong("lastUpdated", now); + return true; + } + + /** Convenience: tile lookup directly from an entity's position. */ + public static boolean tryConsume(Entity merchant, ResourceLocation item) { + if (merchant == null) return true; + MinecraftServer server = merchant.getServer(); + if (server == null) return true; + ResourceLocation dim = merchant.level().dimension().location(); + return tryConsume(server, dim, tileFor(merchant.getX()), tileFor(merchant.getZ()), item); + } + + public static double queryDemand(Entity merchant, ResourceLocation item) { + if (merchant == null) return VillageDemand.capFor(item); + MinecraftServer server = merchant.getServer(); + if (server == null) return VillageDemand.capFor(item); + ResourceLocation dim = merchant.level().dimension().location(); + return queryDemand(server, dim, tileFor(merchant.getX()), tileFor(merchant.getZ()), item); + } + + // ─── Internal NBT plumbing ─────────────────────────────────────── + + private static CompoundTag getOrCreateTile(MinecraftServer server, ResourceLocation dim, + int tileX, int tileZ) { + CompoundTag serverData = ServerEconomyBridge.getKubeJSServerData(server); + if (!serverData.contains("bnsVillageDemand")) { + serverData.put("bnsVillageDemand", new CompoundTag()); + } + CompoundTag root = serverData.getCompound("bnsVillageDemand"); + String key = tileKey(dim, tileX, tileZ); + if (!root.contains(key)) { + root.put(key, new CompoundTag()); + } + return root.getCompound(key); + } + + /** + * Refresh the stored value for {@code item}, write it back, and return + * the refreshed value. Per-tile {@code lastUpdated} is also advanced + * to {@code now} so subsequent reads of other items in the same tile + * see the same "this query's now" baseline. + */ + private static double refreshAndRead(CompoundTag tile, ResourceLocation item, long now) { + long lastUpdated = tile.contains("lastUpdated") ? tile.getLong("lastUpdated") : 0L; + int cap = VillageDemand.capFor(item); + String key = item.toString(); + + double current; + if (!tile.contains(key)) { + // First touch in this tile for this item — start at full cap. + current = cap; + } else { + current = tile.getDouble(key); + } + + if (lastUpdated > 0) { + double elapsed = (now - lastUpdated) / 1000.0; + if (elapsed > 0) { + double refilled = Math.min(cap, + current + elapsed * cap * VillageDemand.REFRESH_PER_SECOND); + current = refilled; + } + } + + // Write back so callers see the same refreshed value if they're + // about to decrement. + tile.putDouble(key, current); + return current; + } + + /** Diagnostic — log the demand state of a tile. Used by /bns admin if we add it. */ + public static void debugDump(MinecraftServer server, ResourceLocation dim, + int tileX, int tileZ) { + CompoundTag tile = getOrCreateTile(server, dim, tileX, tileZ); + BnsToolkit.LOG.info("[bns/demand] tile {} state: {}", tileKey(dim, tileX, tileZ), tile); + } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/economy/VillageTrackedMerchantOffer.java b/src/main/java/uk/sijbers/bnstoolkit/economy/VillageTrackedMerchantOffer.java new file mode 100644 index 0000000..73b40d8 --- /dev/null +++ b/src/main/java/uk/sijbers/bnstoolkit/economy/VillageTrackedMerchantOffer.java @@ -0,0 +1,49 @@ +package uk.sijbers.bnstoolkit.economy; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.trading.ItemCost; +import net.minecraft.world.item.trading.MerchantOffer; + +import java.util.Optional; + +/** + * MerchantOffer subclass that decrements the {@link VillageDemandTracker} + * pool by 1 every time the player completes a trade. + * + * Vanilla {@code MerchantOffer.increaseUses()} is called server-side + * after {@code MerchantContainer.takeItems} settles the trade — that's + * our hook. No mixin needed. + * + * Construction-time captures the merchant's tile and dimension so the + * offer can decrement the right pool even after the merchant has + * wandered away (relevant for wandering traders) or been unloaded. + */ +public final class VillageTrackedMerchantOffer extends MerchantOffer { + private final transient MinecraftServer server; + private final ResourceLocation dim; + private final int tileX; + private final int tileZ; + private final ResourceLocation costItem; + + public VillageTrackedMerchantOffer(ItemCost costA, Optional costB, ItemStack result, + int uses, int maxUses, int xp, + float priceMultiplier, int demand, + MinecraftServer server, ResourceLocation dim, + int tileX, int tileZ, ResourceLocation costItem) { + super(costA, costB, result, uses, maxUses, xp, priceMultiplier, demand); + this.server = server; + this.dim = dim; + this.tileX = tileX; + this.tileZ = tileZ; + this.costItem = costItem; + } + + @Override + public void increaseUses() { + super.increaseUses(); + // Server may be null in single-player loading flows; tracker no-ops in that case. + VillageDemandTracker.tryConsume(server, dim, tileX, tileZ, costItem); + } +} diff --git a/src/main/java/uk/sijbers/bnstoolkit/economy/VillagerTradeRebalance.java b/src/main/java/uk/sijbers/bnstoolkit/economy/VillagerTradeRebalance.java index 47c8cc9..d77219e 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/economy/VillagerTradeRebalance.java +++ b/src/main/java/uk/sijbers/bnstoolkit/economy/VillagerTradeRebalance.java @@ -22,30 +22,30 @@ import java.util.Optional; /** * Server-side villager-trade rebalance. * - * Wraps every trade listing on {@link VillagerTradesEvent} (and the - * wanderer trades) so the resulting {@link MerchantOffer} has its - * emerald cost-or-result items swapped to Numismatics coin equivalents. + *

Currency swap

+ * For every existing trade listing, swaps emerald cost/result for the + * Numismatics coin equivalent (1..7 spurs / 8..63 bevels / 64+ cogs, + * round-to-nearest). * - *

Denomination choice

- * For each emerald count we pick a single denomination that minimises - * the rounding error: - *
    - *
  • 1..7 emeralds → spurs (1 spur = 1 emerald)
  • - *
  • 8..63 emeralds → bevels, rounding to nearest (1 bevel = 8 spurs)
  • - *
  • 64+ emeralds → cogs, rounding to nearest (1 cog = 64 spurs)
  • - *
- * Round-to-nearest avoids the 10–12% silent price haircut from floor-division. - * MerchantOffer constraints require a single ItemCost per slot (no - * multi-denom split possible at this layer). + *

Supply/demand throttle

+ * Trades whose result is a spur (i.e. the trade pays the player) are + * wrapped in {@link VillageTrackedMerchantOffer}. Each tile (64×64 block + * region) has a per-cost-item demand pool that depletes per trade and + * lazily refills over real-world time. When the pool is empty at + * offer-build time, the offer comes back pre-locked (uses=maxUses), + * showing "out of stock" in the trade UI until the villager restocks + * AND the tile's demand has refilled. + * + *

This stops the classic "50-librarian paper farm" cleanly: the + * villagers still trade, but the whole tile shares one ~20-trade/hour + * paper budget. To scale, players must spread halls across distant + * tiles (interesting overworld logistics, not micro-exploit). * *

Robustness

- *
    - *
  • Wraps inner ItemListing.getOffer in try/catch — a misbehaved - * modded listing can't take down the whole profession.
  • - *
  • Null offer returned from inner is propagated cleanly.
  • - *
  • Null cost-A (theoretically possible from a reflection-built modded - * MerchantOffer) is treated as "leave alone".
  • - *
+ * Wrap-time {@code inner.getOffer} is try/catch'd. Null offers + * propagate. Null cost-A leaves the trade alone. Recipe-viewer mods + * pre-fetch trades with {@code entity=null} at boot — we treat that as + * "skip tracking" (return the swapped offer plain, no demand binding). */ @EventBusSubscriber(modid = BnsToolkit.MOD_ID) public final class VillagerTradeRebalance { @@ -89,14 +89,19 @@ public final class VillagerTradeRebalance { try { base = inner.getOffer(entity, rand); } catch (Throwable t) { - BnsToolkit.LOG.warn("[bns/trades] inner ItemListing {} threw {} — skipping offer", - inner.getClass().getName(), t.toString()); + // Some mods pre-fetch trades with entity=null at boot — vanilla + // listings NPE on entity.level(). Only log when a real villager + // (non-null entity) call fails. + if (entity != null) { + BnsToolkit.LOG.warn("[bns/trades] inner ItemListing {} threw {} — skipping offer", + inner.getClass().getName(), t.toString()); + } return null; } if (base == null) return null; ItemCost costA = base.getItemCostA(); - if (costA == null) return base; // can't swap; leave alone + if (costA == null) return base; Optional costB = base.getItemCostB(); ItemStack result = base.getResult(); @@ -104,12 +109,49 @@ public final class VillagerTradeRebalance { Optional newCostB = costB.map(VillagerTradeRebalance::swapIfEmerald); ItemStack newResult = swapIfEmerald(result); + // If we didn't actually swap the result (vanilla emerald → vanilla + // emerald), no demand tracking needed — this trade just modifies the + // cost side. Skip the tracked offer. + boolean producesSpurs = newResult != result && isSpur(newResult); + + int uses = base.getUses(); + int maxUses = base.getMaxUses(); + + if (producesSpurs && entity != null) { + // Pre-lock check: if this tile has no demand for the cost + // item right now, deliver the offer pre-locked so the trade + // UI shows "out of stock". Comes back to life when the + // villager restocks AND demand has refilled. + ResourceLocation costItemId = BuiltInRegistries.ITEM.getKey(costA.itemStack().getItem()); + double remaining = VillageDemandTracker.queryDemand(entity, costItemId); + if (remaining < 1.0) { + uses = maxUses; // forces isOutOfStock() = true + } + + return new VillageTrackedMerchantOffer( + newCostA, newCostB, newResult, + uses, maxUses, base.getXp(), + base.getPriceMultiplier(), base.getDemand(), + entity.getServer(), + entity.level().dimension().location(), + VillageDemandTracker.tileFor(entity.getX()), + VillageDemandTracker.tileFor(entity.getZ()), + costItemId); + } + + // Non-spur-producing path: plain swapped offer, no tracking. return new MerchantOffer(newCostA, newCostB, newResult, - base.getUses(), base.getMaxUses(), base.getXp(), + uses, maxUses, base.getXp(), base.getPriceMultiplier(), base.getDemand()); } } + private static boolean isSpur(ItemStack s) { + if (s == null || s.isEmpty()) return false; + ResourceLocation id = BuiltInRegistries.ITEM.getKey(s.getItem()); + return SPUR_ID.equals(id) || BEVEL_ID.equals(id) || COG_ID.equals(id); + } + private static ItemCost swapIfEmerald(ItemCost cost) { if (cost == null) return null; ItemStack peek = cost.itemStack(); @@ -118,7 +160,7 @@ public final class VillagerTradeRebalance { int count = peek.getCount(); CoinSwap swap = pickDenom(count); Item coinItem = BuiltInRegistries.ITEM.get(swap.itemId); - if (coinItem == Items.AIR) return cost; // Numismatics not loaded + if (coinItem == Items.AIR) return cost; return new ItemCost(coinItem, swap.count); } @@ -136,9 +178,6 @@ public final class VillagerTradeRebalance { private record CoinSwap(ResourceLocation itemId, int count) {} private static CoinSwap pickDenom(int emeralds) { - // Round-to-nearest so 11 emeralds → 1 bevel (8) not 1 (and lose 3 value), - // 12 emeralds → 2 bevels (16). Trade-off: slight overpay at the boundary, - // but no silent floor haircut. Cap at 1 minimum so cost is never 0. if (emeralds >= 64) return new CoinSwap(COG_ID, Math.max(1, (emeralds + 32) / 64)); if (emeralds >= 8) return new CoinSwap(BEVEL_ID, Math.max(1, (emeralds + 4) / 8)); return new CoinSwap(SPUR_ID, Math.max(1, emeralds)); diff --git a/src/main/java/uk/sijbers/bnstoolkit/network/ServerEconomyBridge.java b/src/main/java/uk/sijbers/bnstoolkit/network/ServerEconomyBridge.java index b7a8e0b..603c3d9 100644 --- a/src/main/java/uk/sijbers/bnstoolkit/network/ServerEconomyBridge.java +++ b/src/main/java/uk/sijbers/bnstoolkit/network/ServerEconomyBridge.java @@ -314,7 +314,8 @@ public final class ServerEconomyBridge { private static volatile Method kjsServerPersistentData; private static volatile boolean kjsLookupAttempted; - private static CompoundTag getKubeJSServerData(MinecraftServer server) { + /** Public so VillageDemandTracker can use the same accessor. */ + public static CompoundTag getKubeJSServerData(MinecraftServer server) { if (kjsServerPersistentData == null && !kjsLookupAttempted) { kjsLookupAttempted = true; try {