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. + * + *
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 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).
*
* 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:
- *
- *
- * 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.
+ *
+ * Robustness
- *
- *
+ * 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