From b8cb0dc5912a2bba4a01e6edd2b87e1c38b207a7 Mon Sep 17 00:00:00 2001 From: JustinasLa Date: Wed, 30 Sep 2026 19:12:02 +0200 Subject: [PATCH] feat: credit distilled brews taken from brewing stands BreweryX distils with no player attached, so brew_distill credits the player who takes a distilled brew out of a brewing stand's bottle slots. A tag on the brew keeps each one from counting twice. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../activitytf/listeners/BreweryListener.java | 108 ++++++++++++++++++ src/main/resources/config.yml | 15 +++ .../listeners/BreweryListenerTest.java | 48 ++++++++ 3 files changed, 171 insertions(+) diff --git a/src/main/java/net/tfminecraft/activitytf/listeners/BreweryListener.java b/src/main/java/net/tfminecraft/activitytf/listeners/BreweryListener.java index 704340e..fc17d94 100644 --- a/src/main/java/net/tfminecraft/activitytf/listeners/BreweryListener.java +++ b/src/main/java/net/tfminecraft/activitytf/listeners/BreweryListener.java @@ -4,16 +4,47 @@ import com.dre.brewery.Brew; import com.dre.brewery.api.events.IngedientAddEvent; import com.dre.brewery.api.events.brew.BrewModifyEvent; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.java.JavaPlugin; import net.tfminecraft.activitytf.managers.ActivityManager; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + public class BreweryListener implements Listener { + static final NamespacedKey DISTILLED = new NamespacedKey("activity", "brew_distilled"); + + // Clicks that take the clicked item out of its slot. + static final Set TAKES = EnumSet.of( + InventoryAction.PICKUP_ALL, InventoryAction.PICKUP_SOME, InventoryAction.PICKUP_HALF, + InventoryAction.PICKUP_ONE, InventoryAction.MOVE_TO_OTHER_INVENTORY, InventoryAction.HOTBAR_SWAP, + InventoryAction.SWAP_WITH_CURSOR, InventoryAction.DROP_ALL_SLOT, InventoryAction.DROP_ONE_SLOT, + InventoryAction.PICKUP_ALL_INTO_BUNDLE, InventoryAction.PICKUP_SOME_INTO_BUNDLE); + private final ActivityManager manager; + // Nonce of every tag written by a take that has not been confirmed yet, and who made it. + private final Map pending = new HashMap<>(); + public BreweryListener(ActivityManager manager) { this.manager = manager; } @@ -47,4 +78,81 @@ private static int cookedMinutes(Brew brew) { BIngredients ingredients = brew == null ? null : brew.getIngredients(); return ingredients == null ? 0 : ingredients.getCookedTime(); } + + // BreweryX distils on a timer with no player attached, so the credit goes to whoever + // takes a distilled brew out of a brewing stand's bottle slots. The tag written onto + // the brew keeps it from counting again when it is put back and taken out. + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onContainerTake(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player player) || !TAKES.contains(event.getAction())) { + return; + } + Inventory clicked = event.getClickedInventory(); + ItemStack item = event.getCurrentItem(); + if (clicked == null || clicked != event.getView().getTopInventory() + || item == null || item.getType() != Material.POTION) { + return; + } + if (clicked.getType() == InventoryType.BREWING && event.getSlot() < 3) { + Brew brew = Brew.get(item); + if (brew != null && brew.getDistillRuns() > 0) { + creditOnce(player, clicked, event.getSlot(), item, DISTILLED, "brew_distill"); + } + } + } + + private void creditOnce(Player player, Inventory inventory, int slot, ItemStack item, + NamespacedKey key, String activityId) { + ItemMeta meta = item.getItemMeta(); + if (meta == null) { + return; + } + PersistentDataContainer data = meta.getPersistentDataContainer(); + Long previous = data.has(key, PersistentDataType.LONG) ? data.get(key, PersistentDataType.LONG) : null; + if (previous != null && pending.remove(previous) != null) { + // Someone else's take of this brew is still unconfirmed: this click replaces it. + data.remove(key); + } + long nonce = ThreadLocalRandom.current().nextLong(); + if (!markOnce(data, key, nonce)) { + return; + } + // The clicked item mirrors the slot, so the tag travels with the brew. + item.setItemMeta(meta); + pending.put(nonce, player.getUniqueId()); + // The click fires before the move happens, and a quick-move into a full inventory + // leaves the brew where it was. Credit only once it has left the slot; otherwise + // take the tag back off so a later take can still count. + Bukkit.getScheduler().runTask(JavaPlugin.getProvidingPlugin(BreweryListener.class), () -> { + UUID taker = pending.remove(nonce); + if (taker == null) { + return; // replaced by a later take + } + ItemStack left = inventory.getItem(slot); + ItemMeta leftMeta = left == null ? null : left.getItemMeta(); + if (leftMeta != null && unmarkIfStill(leftMeta.getPersistentDataContainer(), key, nonce)) { + left.setItemMeta(leftMeta); + inventory.setItem(slot, left); + return; + } + manager.recordAction(taker, activityId, 1); + }); + } + + static boolean markOnce(PersistentDataContainer data, NamespacedKey key, long nonce) { + if (data.has(key)) { + return false; + } + data.set(key, PersistentDataType.LONG, nonce); + return true; + } + + // True, and the tag removed, when this is still the brew marked with this nonce. + static boolean unmarkIfStill(PersistentDataContainer data, NamespacedKey key, long nonce) { + if (!data.has(key, PersistentDataType.LONG) || data.get(key, PersistentDataType.LONG) != nonce) { + return false; + } + data.remove(key); + return true; + } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 10569cb..218e942 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -337,6 +337,21 @@ activities: every: 3 points: 1 daily-cap: 2 + # BreweryX: distilled brews taken out of a brewing stand, each brew once. Where + # TFMCCore binds brewing stands to a station on right-click, only a shift-right-click + # with an empty hand opens the vanilla stand BreweryX distils in. + # Disabled for now: uncomment to offer distilling as an activity (the listener is in place). + #brew_distill: + # display: "#e6ca40&lDistill Brews" + # description: + # - "&7Take &a%every% &7distilled brews out of a brewing stand" + # - "&7(shift-right-click it with an empty hand)." + # - "&7Earn &a%points% &7activity points per completion." + # - "&7" + # material: BREWING_STAND + # every: 3 + # points: 1 + # daily-cap: 1 # RPCharacters: profession upgrades purchased profession_upgrade: display: "#e6ca40&lProfession Upgrade" diff --git a/src/test/java/net/tfminecraft/activitytf/listeners/BreweryListenerTest.java b/src/test/java/net/tfminecraft/activitytf/listeners/BreweryListenerTest.java index c7c213b..d2dd004 100644 --- a/src/test/java/net/tfminecraft/activitytf/listeners/BreweryListenerTest.java +++ b/src/test/java/net/tfminecraft/activitytf/listeners/BreweryListenerTest.java @@ -6,17 +6,23 @@ import net.tfminecraft.activitytf.models.ActivityDef; import org.bukkit.Material; import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryAction; import org.bukkit.event.player.PlayerEvent; +import org.bukkit.persistence.PersistentDataContainer; import org.junit.jupiter.api.Test; import org.objenesis.ObjenesisStd; import java.lang.reflect.Field; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Proxy; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class BreweryListenerTest { @@ -107,4 +113,46 @@ void onlyCookedBottlesAreCredited() { listener.recordBottle(stubPlayer(uuid), 12); assertEquals(2, manager.tasks(uuid).count("brew_bottle")); } + + private static PersistentDataContainer stubData() { + Map values = new HashMap<>(); + InvocationHandler handler = (proxy, method, args) -> switch (method.getName()) { + case "has" -> values.containsKey(args[0]); + case "get" -> values.get(args[0]); + case "set" -> values.put(args[0], args[2]); + case "remove" -> values.remove(args[0]); + default -> throw new UnsupportedOperationException( + "unexpected call to PersistentDataContainer#" + method.getName()); + }; + return (PersistentDataContainer) Proxy.newProxyInstance( + BreweryListenerTest.class.getClassLoader(), new Class[]{PersistentDataContainer.class}, handler); + } + + @Test + void aBrewIsOnlyMarkedTheFirstTime() { + PersistentDataContainer data = stubData(); + assertTrue(BreweryListener.markOnce(data, BreweryListener.DISTILLED, 7L)); + assertFalse(BreweryListener.markOnce(data, BreweryListener.DISTILLED, 8L)); + } + + @Test + void aBrewThatNeverLeftItsSlotLosesOnlyItsOwnTag() { + PersistentDataContainer data = stubData(); + BreweryListener.markOnce(data, BreweryListener.DISTILLED, 7L); + assertFalse(BreweryListener.unmarkIfStill(data, BreweryListener.DISTILLED, 8L)); + assertTrue(BreweryListener.unmarkIfStill(data, BreweryListener.DISTILLED, 7L)); + assertTrue(BreweryListener.markOnce(data, BreweryListener.DISTILLED, 9L)); + } + + @Test + void onlyClicksThatTakeTheItemCount() { + assertTrue(BreweryListener.TAKES.contains(InventoryAction.PICKUP_ALL)); + assertTrue(BreweryListener.TAKES.contains(InventoryAction.MOVE_TO_OTHER_INVENTORY)); + assertTrue(BreweryListener.TAKES.contains(InventoryAction.HOTBAR_SWAP)); + assertTrue(BreweryListener.TAKES.contains(InventoryAction.PICKUP_ALL_INTO_BUNDLE)); + assertTrue(BreweryListener.TAKES.contains(InventoryAction.PICKUP_SOME_INTO_BUNDLE)); + assertFalse(BreweryListener.TAKES.contains(InventoryAction.PLACE_ALL)); + assertFalse(BreweryListener.TAKES.contains(InventoryAction.NOTHING)); + assertFalse(BreweryListener.TAKES.contains(InventoryAction.CLONE_STACK)); + } }