diff --git a/docs/fluid-bottles.md b/docs/fluid-bottles.md index caf01604..cc93bb82 100644 --- a/docs/fluid-bottles.md +++ b/docs/fluid-bottles.md @@ -17,6 +17,15 @@ MMDLib ships the tag empty, so packs and mods decide what can be bottled. Vanilla's own fluids (water, lava) are always left to vanilla, even if tagged: a water bottle is still vanilla's water potion. +## Experience fluids + +Fluids in NeoForge's `#c:experience` tag, from whatever mod adds them, are a +special case: a glass bottle takes **250 mB** (the tag's convention) and becomes +vanilla's **Bottle o' Enchanting**, not an MMDLib bottle. They don't need the +`#mmdlib:bottleable` tag. Right-clicking a source and filling from tanks both +work, as above. It is one way only: a Bottle o' Enchanting can't be poured back +into a tank, its XP comes back by throwing it. + ## What works * **Right-click a source block** with a glass bottle to fill it. The source block @@ -43,8 +52,11 @@ the usual priority are seen first. `zone.moddev.mc.lib.common.fluid.FluidBottles` holds the tag (`BOTTLEABLE`), the capacity, the item (`BOTTLE`) and its fluid component (`CONTENT`). `FluidBottleItem.filled(fluid)` makes a full bottle, and -`FluidBottleItem.contents(stack)` reads one. `FluidBottles.bottleable(fluid)` is -the single rule for what can be bottled. +`FluidBottleItem.contents(stack)` reads one. `FluidBottles.capacity(fluid)` is +the single rule for what can be bottled and how much a bottle takes (0 for +neither), built on `bottleable(fluid)` and `experience(fluid)`; +`FluidBottles.filled(fluid)` gives what a full glass bottle becomes. `FluidBottleSmoke` (game tests) checks filling from the world, water left to -vanilla, untagged fluids refused, and filling and emptying through tanks. +vanilla, untagged fluids refused, filling and emptying through tanks, and +experience fluids becoming Bottles o' Enchanting (molten gold stands in for one). diff --git a/src/main/java/zone/moddev/mc/lib/common/fluid/EmptyBottleFluidHandler.java b/src/main/java/zone/moddev/mc/lib/common/fluid/EmptyBottleFluidHandler.java index f4560ac8..74774b3a 100644 --- a/src/main/java/zone/moddev/mc/lib/common/fluid/EmptyBottleFluidHandler.java +++ b/src/main/java/zone/moddev/mc/lib/common/fluid/EmptyBottleFluidHandler.java @@ -6,9 +6,9 @@ import net.neoforged.neoforge.fluids.capability.IFluidHandlerItem; /** - * The fluid capability of an empty glass bottle: it takes exactly {@link FluidBottles#CAPACITY} of - * a bottleable fluid at once and becomes a {@link FluidBottleItem}, the way NeoForge's bucket - * wrapper turns an empty bucket into a filled one. Anything else is refused, so other fluid + * The fluid capability of an empty glass bottle: it takes exactly {@link FluidBottles#capacity} of + * a bottleable or experience fluid at once and becomes {@link FluidBottles#filled}, the way + * NeoForge's bucket wrapper turns an empty bucket into a filled one. Anything else is refused, so other fluid * handling of glass bottles is left alone. */ final class EmptyBottleFluidHandler implements IFluidHandlerItem { @@ -35,22 +35,22 @@ public FluidStack getFluidInTank(int tank) { @Override public int getTankCapacity(int tank) { - return FluidBottles.CAPACITY; + // Not knowing the fluid, the larger of the two: experience bottles take less. + return Math.max(FluidBottles.CAPACITY, FluidBottles.EXPERIENCE_CAPACITY); } @Override public boolean isFluidValid(int tank, FluidStack stack) { - return FluidBottles.bottleable(stack.getFluid()); + return FluidBottles.capacity(stack.getFluid()) > 0; } @Override public int fill(FluidStack resource, FluidAction action) { // A single bottle, filled whole: a stack of bottles is filled one at a time by FluidUtil. - if (container.getCount() != 1 - || resource.getAmount() < FluidBottles.CAPACITY - || !FluidBottles.bottleable(resource.getFluid())) return 0; - if (action.execute()) container = FluidBottleItem.filled(resource.getFluid()); - return FluidBottles.CAPACITY; + var capacity = FluidBottles.capacity(resource.getFluid()); + if (container.getCount() != 1 || capacity == 0 || resource.getAmount() < capacity) return 0; + if (action.execute()) container = FluidBottles.filled(resource.getFluid()); + return capacity; } @Override diff --git a/src/main/java/zone/moddev/mc/lib/common/fluid/FluidBottles.java b/src/main/java/zone/moddev/mc/lib/common/fluid/FluidBottles.java index 46ef750b..5ca07601 100644 --- a/src/main/java/zone/moddev/mc/lib/common/fluid/FluidBottles.java +++ b/src/main/java/zone/moddev/mc/lib/common/fluid/FluidBottles.java @@ -15,6 +15,7 @@ import net.minecraft.stats.Stats; import net.minecraft.tags.TagKey; import net.minecraft.world.InteractionResult; +import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemUtils; import net.minecraft.world.item.Items; import net.minecraft.world.level.material.Fluid; @@ -26,6 +27,7 @@ import net.neoforged.neoforge.capabilities.Capabilities; import net.neoforged.neoforge.capabilities.RegisterCapabilitiesEvent; import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.common.Tags; import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; import net.neoforged.neoforge.fluids.SimpleFluidContent; import net.neoforged.neoforge.fluids.capability.templates.FluidHandlerItemStack; @@ -46,6 +48,11 @@ * * * Vanilla's own fluids (water, lava) are always left to vanilla, tagged or not. + * + *

Experience fluids ({@code #c:experience}, whatever mod adds them) are a special case: a glass + * bottle takes {@link #EXPERIENCE_CAPACITY} of one and becomes vanilla's Bottle o' Enchanting, no + * {@link #BOTTLEABLE} tag needed. That is one way only: the XP comes back out of a thrown bottle, + * not out of the item. */ public final class FluidBottles { private static final Logger LOGGER = LogUtils.getLogger(); @@ -57,6 +64,12 @@ public final class FluidBottles { /** Millibuckets in a bottle: a third of a bucket, the common convention. */ public static final int CAPACITY = 333; + /** + * Millibuckets of experience fluid in a Bottle o' Enchanting: the {@link Tags.Fluids#EXPERIENCE} + * convention. + */ + public static final int EXPERIENCE_CAPACITY = 250; + private static final DeferredRegister.DataComponents COMPONENTS = DeferredRegister.createDataComponents(Registries.DATA_COMPONENT_TYPE, MMDLib.MODID); @@ -90,6 +103,25 @@ public static boolean bottleable(Fluid fluid) { && fluid.defaultFluidState().is(BOTTLEABLE); } + /** Whether {@code fluid} is an experience fluid, which bottles into a Bottle o' Enchanting. */ + public static boolean experience(Fluid fluid) { + return fluid.defaultFluidState().is(Tags.Fluids.EXPERIENCE); + } + + /** Millibuckets a glass bottle takes of {@code fluid}, or 0 if it can't hold it. */ + public static int capacity(Fluid fluid) { + if (experience(fluid)) return EXPERIENCE_CAPACITY; + return bottleable(fluid) ? CAPACITY : 0; + } + + /** + * What a glass bottle full of {@code fluid} becomes: a Bottle o' Enchanting for experience, a + * {@link FluidBottleItem} otherwise. Only meaningful when {@link #capacity} is non-zero. + */ + public static ItemStack filled(Fluid fluid) { + return experience(fluid) ? new ItemStack(Items.EXPERIENCE_BOTTLE) : FluidBottleItem.filled(fluid); + } + private static void capabilities(RegisterCapabilitiesEvent event) { event.registerItem( Capabilities.FluidHandler.ITEM, @@ -98,7 +130,7 @@ private static void capabilities(RegisterCapabilitiesEvent event) { CONTENT, stack, new net.minecraft.world.item.ItemStack(Items.GLASS_BOTTLE), CAPACITY) { @Override public boolean canFillFluidType(net.neoforged.neoforge.fluids.FluidStack fluid) { - return bottleable(fluid.getFluid()); + return bottleable(fluid.getFluid()) && !experience(fluid.getFluid()); } }, BOTTLE.get()); @@ -115,7 +147,7 @@ public boolean canFillFluidType(net.neoforged.neoforge.fluids.FluidStack fluid) } /** - * A glass bottle used on a bottleable source fills, both sides, before vanilla's bottle handling + * A glass bottle used on a bottleable or experience source fills, both sides, before vanilla's bottle handling * (which only knows water). Anything else goes on to vanilla. */ private static void fillFromWorld(PlayerInteractEvent.RightClickItem event) { @@ -127,7 +159,7 @@ private static void fillFromWorld(PlayerInteractEvent.RightClickItem event) { if (hit.getType() != HitResult.Type.BLOCK || !(hit instanceof BlockHitResult block)) return; var pos = block.getBlockPos(); var fluid = level.getFluidState(pos); - if (!fluid.isSource() || !bottleable(fluid.getType()) || !level.mayInteract(player, pos)) return; + if (!fluid.isSource() || capacity(fluid.getType()) == 0 || !level.mayInteract(player, pos)) return; level.playSound( player, player.getX(), @@ -140,7 +172,7 @@ private static void fillFromWorld(PlayerInteractEvent.RightClickItem event) { player.awardStat(Stats.ITEM_USED.get(Items.GLASS_BOTTLE)); player.setItemInHand( event.getHand(), - ItemUtils.createFilledResult(held, player, FluidBottleItem.filled(fluid.getType()))); + ItemUtils.createFilledResult(held, player, filled(fluid.getType()))); event.setCanceled(true); event.setCancellationResult(InteractionResult.sidedSuccess(level.isClientSide())); } diff --git a/src/main/java/zone/moddev/mc/lib/common/fluid/package-info.java b/src/main/java/zone/moddev/mc/lib/common/fluid/package-info.java index 39634ffb..ce9fb89b 100644 --- a/src/main/java/zone/moddev/mc/lib/common/fluid/package-info.java +++ b/src/main/java/zone/moddev/mc/lib/common/fluid/package-info.java @@ -1,5 +1,5 @@ /** - * Glass bottles that hold any fluid in the {@code #mmdlib:bottleable} tag; see {@link + * Glass bottles that hold any fluid in the {@code #mmdlib:bottleable} tag, and experience fluids that bottle into Bottles o' Enchanting; see {@link * zone.moddev.mc.lib.common.fluid.FluidBottles}. */ package zone.moddev.mc.lib.common.fluid; diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/FluidBottleSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/FluidBottleSmoke.java index e4216d98..26b7d93c 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/FluidBottleSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/FluidBottleSmoke.java @@ -21,7 +21,8 @@ import zone.moddev.mc.lib.common.fluid.FluidBottles; /** - * Glass bottles and {@code #mmdlib:bottleable}, which test resources fill with molten copper only. + * Glass bottles and {@code #mmdlib:bottleable}, which test resources fill with molten copper only, + * and {@code #c:experience}, which they fill with molten gold. */ final class FluidBottleSmoke { private FluidBottleSmoke() {} @@ -100,6 +101,42 @@ static void tanks(GameTestHelper helper) { helper.succeed(); } + /** A bottle used on an experience source becomes a Bottle o' Enchanting; the source stays. */ + static void experienceWorld(GameTestHelper helper, BlockPos center) { + var level = helper.getLevel(); + var pos = helper.absolutePos(center); + level.setBlock(pos, fluid("mmdlib:gold_molten").defaultFluidState().createLegacyBlock(), 3); + var player = above(helper, pos); + player.gameMode.useItem(player, level, player.getMainHandItem(), InteractionHand.MAIN_HAND); + require(player.getMainHandItem().is(Items.GLASS_BOTTLE) && player.getMainHandItem().getCount() == 1, + "one bottle used"); + require(player.getInventory().items.stream().anyMatch(s -> s.is(Items.EXPERIENCE_BOTTLE)), + "a Bottle o' Enchanting was given"); + require(player.getInventory().items.stream().noneMatch(s -> s.is(FluidBottles.BOTTLE.get())), "no fluid bottle"); + require(level.getFluidState(pos).isSource(), "the source is left in place"); + helper.succeed(); + } + + /** Tanks of experience fill glass bottles 250 mB at a time; the result can't be poured back. */ + static void experienceTanks(GameTestHelper helper) { + var gold = fluid("mmdlib:gold_molten"); + var tank = new FluidTank(1000); + tank.fill(new FluidStack(gold, 1000), FluidTank.FluidAction.EXECUTE); + var filled = FluidUtil.tryFillContainer(new ItemStack(Items.GLASS_BOTTLE), tank, 1000, null, true); + require(filled.isSuccess() && filled.getResult().is(Items.EXPERIENCE_BOTTLE), "a tank makes a Bottle o' Enchanting"); + require(tank.getFluidAmount() == 1000 - FluidBottles.EXPERIENCE_CAPACITY, "the tank gave 250 mB"); + + var low = new FluidTank(1000); + low.fill(new FluidStack(gold, FluidBottles.EXPERIENCE_CAPACITY - 1), FluidTank.FluidAction.EXECUTE); + require(!FluidUtil.tryFillContainer(new ItemStack(Items.GLASS_BOTTLE), low, 1000, null, true).isSuccess(), + "less than a bottle's worth is refused"); + + var empty = new FluidTank(1000); + require(!FluidUtil.tryEmptyContainer(filled.getResult(), empty, 1000, null, true).isSuccess(), + "a Bottle o' Enchanting does not pour back"); + helper.succeed(); + } + private static void require(boolean condition, String what) { if (!condition) throw new AssertionError(what); } diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java index 0ce57f6e..39269042 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java @@ -188,6 +188,16 @@ public static void fluidBottlesAndTanks(GameTestHelper helper) { FluidBottleSmoke.tanks(helper); } + @GameTest(template = EMPTY) + public static void experienceBottlesFromWorld(GameTestHelper helper) { + FluidBottleSmoke.experienceWorld(helper, CENTER); + } + + @GameTest(template = EMPTY) + public static void experienceBottlesFromTanks(GameTestHelper helper) { + FluidBottleSmoke.experienceTanks(helper); + } + @GameTest(template = EMPTY) public static void capabilities(GameTestHelper helper) { CapabilitySmoke.check(helper.getLevel()); diff --git a/src/runtimeTest/resources/data/c/tags/fluid/experience.json b/src/runtimeTest/resources/data/c/tags/fluid/experience.json new file mode 100644 index 00000000..1d0f17ff --- /dev/null +++ b/src/runtimeTest/resources/data/c/tags/fluid/experience.json @@ -0,0 +1,4 @@ +{ + "replace": false, + "values": ["mmdlib:gold_molten"] +}