0.5.0: per-tile village supply/demand throttle for spur-paying trades
Build / build (push) Has been cancelled
Build / build (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <h2>Model</h2>
|
||||
* 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.
|
||||
*
|
||||
* <h2>Why these numbers</h2>
|
||||
* Each cap represents how many <i>trades</i> 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<ResourceLocation, Integer> 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>The world is divided into 64×64 block tiles (independent of biome /
|
||||
* structure / chunk boundaries). Each tile has a CompoundTag at
|
||||
* <code>server.persistentData.bnsVillageDemand[dim:tileX:tileZ]</code>
|
||||
* holding remaining demand per cost-item plus a {@code lastUpdated}
|
||||
* timestamp.
|
||||
*
|
||||
* <p>Refresh is <strong>lazy</strong>: 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.
|
||||
*
|
||||
* <p>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).
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
@@ -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<ItemCost> 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <h2>Currency swap</h2>
|
||||
* 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).
|
||||
*
|
||||
* <h2>Denomination choice</h2>
|
||||
* For each emerald count we pick a single denomination that minimises
|
||||
* the rounding error:
|
||||
* <ul>
|
||||
* <li>1..7 emeralds → spurs (1 spur = 1 emerald)</li>
|
||||
* <li>8..63 emeralds → bevels, rounding to nearest (1 bevel = 8 spurs)</li>
|
||||
* <li>64+ emeralds → cogs, rounding to nearest (1 cog = 64 spurs)</li>
|
||||
* </ul>
|
||||
* 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).
|
||||
* <h2>Supply/demand throttle</h2>
|
||||
* 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.
|
||||
*
|
||||
* <p>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).
|
||||
*
|
||||
* <h2>Robustness</h2>
|
||||
* <ul>
|
||||
* <li>Wraps inner ItemListing.getOffer in try/catch — a misbehaved
|
||||
* modded listing can't take down the whole profession.</li>
|
||||
* <li>Null offer returned from inner is propagated cleanly.</li>
|
||||
* <li>Null cost-A (theoretically possible from a reflection-built modded
|
||||
* MerchantOffer) is treated as "leave alone".</li>
|
||||
* </ul>
|
||||
* 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) {
|
||||
// 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<ItemCost> costB = base.getItemCostB();
|
||||
ItemStack result = base.getResult();
|
||||
|
||||
@@ -104,12 +109,49 @@ public final class VillagerTradeRebalance {
|
||||
Optional<ItemCost> 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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user