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_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.4.0
|
mod_version=0.5.0
|
||||||
mod_group_id=uk.sijbers.bnstoolkit
|
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.
|
* Server-side villager-trade rebalance.
|
||||||
*
|
*
|
||||||
* Wraps every trade listing on {@link VillagerTradesEvent} (and the
|
* <h2>Currency swap</h2>
|
||||||
* wanderer trades) so the resulting {@link MerchantOffer} has its
|
* For every existing trade listing, swaps emerald cost/result for the
|
||||||
* emerald cost-or-result items swapped to Numismatics coin equivalents.
|
* Numismatics coin equivalent (1..7 spurs / 8..63 bevels / 64+ cogs,
|
||||||
|
* round-to-nearest).
|
||||||
*
|
*
|
||||||
* <h2>Denomination choice</h2>
|
* <h2>Supply/demand throttle</h2>
|
||||||
* For each emerald count we pick a single denomination that minimises
|
* Trades whose result is a spur (i.e. the trade pays the player) are
|
||||||
* the rounding error:
|
* wrapped in {@link VillageTrackedMerchantOffer}. Each tile (64×64 block
|
||||||
* <ul>
|
* region) has a per-cost-item demand pool that depletes per trade and
|
||||||
* <li>1..7 emeralds → spurs (1 spur = 1 emerald)</li>
|
* lazily refills over real-world time. When the pool is empty at
|
||||||
* <li>8..63 emeralds → bevels, rounding to nearest (1 bevel = 8 spurs)</li>
|
* offer-build time, the offer comes back pre-locked (uses=maxUses),
|
||||||
* <li>64+ emeralds → cogs, rounding to nearest (1 cog = 64 spurs)</li>
|
* showing "out of stock" in the trade UI until the villager restocks
|
||||||
* </ul>
|
* AND the tile's demand has refilled.
|
||||||
* Round-to-nearest avoids the 10–12% silent price haircut from floor-division.
|
*
|
||||||
* MerchantOffer constraints require a single ItemCost per slot (no
|
* <p>This stops the classic "50-librarian paper farm" cleanly: the
|
||||||
* multi-denom split possible at this layer).
|
* 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>
|
* <h2>Robustness</h2>
|
||||||
* <ul>
|
* Wrap-time {@code inner.getOffer} is try/catch'd. Null offers
|
||||||
* <li>Wraps inner ItemListing.getOffer in try/catch — a misbehaved
|
* propagate. Null cost-A leaves the trade alone. Recipe-viewer mods
|
||||||
* modded listing can't take down the whole profession.</li>
|
* pre-fetch trades with {@code entity=null} at boot — we treat that as
|
||||||
* <li>Null offer returned from inner is propagated cleanly.</li>
|
* "skip tracking" (return the swapped offer plain, no demand binding).
|
||||||
* <li>Null cost-A (theoretically possible from a reflection-built modded
|
|
||||||
* MerchantOffer) is treated as "leave alone".</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
*/
|
||||||
@EventBusSubscriber(modid = BnsToolkit.MOD_ID)
|
@EventBusSubscriber(modid = BnsToolkit.MOD_ID)
|
||||||
public final class VillagerTradeRebalance {
|
public final class VillagerTradeRebalance {
|
||||||
@@ -89,14 +89,19 @@ public final class VillagerTradeRebalance {
|
|||||||
try {
|
try {
|
||||||
base = inner.getOffer(entity, rand);
|
base = inner.getOffer(entity, rand);
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
BnsToolkit.LOG.warn("[bns/trades] inner ItemListing {} threw {} — skipping offer",
|
// Some mods pre-fetch trades with entity=null at boot — vanilla
|
||||||
inner.getClass().getName(), t.toString());
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
if (base == null) return null;
|
if (base == null) return null;
|
||||||
|
|
||||||
ItemCost costA = base.getItemCostA();
|
ItemCost costA = base.getItemCostA();
|
||||||
if (costA == null) return base; // can't swap; leave alone
|
if (costA == null) return base;
|
||||||
Optional<ItemCost> costB = base.getItemCostB();
|
Optional<ItemCost> costB = base.getItemCostB();
|
||||||
ItemStack result = base.getResult();
|
ItemStack result = base.getResult();
|
||||||
|
|
||||||
@@ -104,12 +109,49 @@ public final class VillagerTradeRebalance {
|
|||||||
Optional<ItemCost> newCostB = costB.map(VillagerTradeRebalance::swapIfEmerald);
|
Optional<ItemCost> newCostB = costB.map(VillagerTradeRebalance::swapIfEmerald);
|
||||||
ItemStack newResult = swapIfEmerald(result);
|
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,
|
return new MerchantOffer(newCostA, newCostB, newResult,
|
||||||
base.getUses(), base.getMaxUses(), base.getXp(),
|
uses, maxUses, base.getXp(),
|
||||||
base.getPriceMultiplier(), base.getDemand());
|
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) {
|
private static ItemCost swapIfEmerald(ItemCost cost) {
|
||||||
if (cost == null) return null;
|
if (cost == null) return null;
|
||||||
ItemStack peek = cost.itemStack();
|
ItemStack peek = cost.itemStack();
|
||||||
@@ -118,7 +160,7 @@ public final class VillagerTradeRebalance {
|
|||||||
int count = peek.getCount();
|
int count = peek.getCount();
|
||||||
CoinSwap swap = pickDenom(count);
|
CoinSwap swap = pickDenom(count);
|
||||||
Item coinItem = BuiltInRegistries.ITEM.get(swap.itemId);
|
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);
|
return new ItemCost(coinItem, swap.count);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +178,6 @@ public final class VillagerTradeRebalance {
|
|||||||
private record CoinSwap(ResourceLocation itemId, int count) {}
|
private record CoinSwap(ResourceLocation itemId, int count) {}
|
||||||
|
|
||||||
private static CoinSwap pickDenom(int emeralds) {
|
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 >= 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));
|
if (emeralds >= 8) return new CoinSwap(BEVEL_ID, Math.max(1, (emeralds + 4) / 8));
|
||||||
return new CoinSwap(SPUR_ID, Math.max(1, emeralds));
|
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 Method kjsServerPersistentData;
|
||||||
private static volatile boolean kjsLookupAttempted;
|
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) {
|
if (kjsServerPersistentData == null && !kjsLookupAttempted) {
|
||||||
kjsLookupAttempted = true;
|
kjsLookupAttempted = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user