0.5.0: per-tile village supply/demand throttle for spur-paying trades
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:
Matt
2026-06-07 16:26:48 +00:00
parent 884a8cea0b
commit 0b90e94079
6 changed files with 433 additions and 31 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.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 &lt; 10 lookups.
*
* <p>Effect on spur farming: a max-stocked 1-tile villager hall earns
* roughly 60200 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 1012% 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 {