diff --git a/docs/fluid-bottles.md b/docs/fluid-bottles.md
new file mode 100644
index 00000000..caf01604
--- /dev/null
+++ b/docs/fluid-bottles.md
@@ -0,0 +1,50 @@
+# Fluid bottles
+
+Vanilla glass bottles only pick up water. MMDLib lets them hold any fluid in the
+`#mmdlib:bottleable` fluid tag, without a mixin. A filled bottle is a single item,
+`mmdlib:fluid_bottle`, that holds **333 mB**, a third of a bucket, of one fluid.
+
+## Making a fluid bottleable
+
+Add its **source** fluid to the tag with a datapack or your mod's data:
+
+```json
+// data/mmdlib/tags/fluid/bottleable.json
+{ "replace": false, "values": ["example:molten_tin"] }
+```
+
+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.
+
+## What works
+
+* **Right-click a source block** with a glass bottle to fill it. The source block
+ stays, as it does for water. Protected blocks (`Level.mayInteract`) are skipped.
+* **Tanks and pipes** fill and empty bottles through the fluid item capability
+ (`Capabilities.FluidHandler.ITEM`), for example with `FluidUtil`. Filling takes
+ exactly 333 mB. Emptying a bottle leaves a glass bottle.
+* **Name**: "Bottle of" plus the fluid's name, such as "Bottle of Molten Copper".
+* **Look**: vanilla's potion bottle, with the fluid's still texture showing
+ through the potion's liquid area. It is tinted with whatever color the fluid
+ type registers on the client (`IClientFluidTypeExtensions#getTintColor`);
+ fluids without a tint show their texture as drawn. Glowing fluids glow.
+
+## Other mods' glass bottle capabilities
+
+If another mod already gives glass bottles a fluid capability, MMDLib doesn't
+register its own: it logs that at startup and leaves filling glass bottles from
+tanks to that mod. Right-clicking bottleable fluids, and filled MMDLib bottles,
+work either way. MMDLib registers at the lowest priority, so mods registering at
+the usual priority are seen first.
+
+## Code
+
+`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.
+
+`FluidBottleSmoke` (game tests) checks filling from the world, water left to
+vanilla, untagged fluids refused, and filling and emptying through tanks.
diff --git a/src/main/java/zone/moddev/mc/lib/MMDLib.java b/src/main/java/zone/moddev/mc/lib/MMDLib.java
index 0f5c9cf7..cc44dc59 100644
--- a/src/main/java/zone/moddev/mc/lib/MMDLib.java
+++ b/src/main/java/zone/moddev/mc/lib/MMDLib.java
@@ -117,6 +117,7 @@ public MMDLib(IEventBus modBus, ModContainer container) {
NeoForge.EVENT_BUS.addListener(
zone.moddev.mc.lib.machine.neoforge.MachinePartsLoader::register);
modBus.addListener(zone.moddev.mc.lib.util.Registries::register);
+ zone.moddev.mc.lib.common.fluid.FluidBottles.register(modBus);
if (ModList.get().isLoaded("crafttweaker")) CraftTweakerInit.register(modBus);
diff --git a/src/main/java/zone/moddev/mc/lib/client/FluidBottleClient.java b/src/main/java/zone/moddev/mc/lib/client/FluidBottleClient.java
new file mode 100644
index 00000000..b6ed69c8
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/client/FluidBottleClient.java
@@ -0,0 +1,24 @@
+package zone.moddev.mc.lib.client;
+
+import net.neoforged.api.distmarker.Dist;
+import net.neoforged.bus.api.SubscribeEvent;
+import net.neoforged.fml.common.EventBusSubscriber;
+import net.neoforged.neoforge.client.event.RegisterColorHandlersEvent;
+import net.neoforged.neoforge.client.model.DynamicFluidContainerModel;
+
+import zone.moddev.mc.lib.common.fluid.FluidBottles;
+
+/**
+ * Colors the fluid in a {@code mmdlib:fluid_bottle} with whatever tint its fluid type registers on
+ * the client ({@code IClientFluidTypeExtensions#getTintColor}); fluids that register none show
+ * their texture as drawn.
+ */
+@EventBusSubscriber(modid = "mmdlib", bus = EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
+public final class FluidBottleClient {
+ private FluidBottleClient() {}
+
+ @SubscribeEvent
+ public static void colors(RegisterColorHandlersEvent.Item event) {
+ event.register(new DynamicFluidContainerModel.Colors(), FluidBottles.BOTTLE.get());
+ }
+}
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
new file mode 100644
index 00000000..f4560ac8
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/common/fluid/EmptyBottleFluidHandler.java
@@ -0,0 +1,65 @@
+package zone.moddev.mc.lib.common.fluid;
+
+import net.minecraft.world.item.ItemStack;
+
+import net.neoforged.neoforge.fluids.FluidStack;
+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
+ * handling of glass bottles is left alone.
+ */
+final class EmptyBottleFluidHandler implements IFluidHandlerItem {
+ private ItemStack container;
+
+ EmptyBottleFluidHandler(ItemStack container) {
+ this.container = container;
+ }
+
+ @Override
+ public ItemStack getContainer() {
+ return container;
+ }
+
+ @Override
+ public int getTanks() {
+ return 1;
+ }
+
+ @Override
+ public FluidStack getFluidInTank(int tank) {
+ return FluidStack.EMPTY;
+ }
+
+ @Override
+ public int getTankCapacity(int tank) {
+ return FluidBottles.CAPACITY;
+ }
+
+ @Override
+ public boolean isFluidValid(int tank, FluidStack stack) {
+ return FluidBottles.bottleable(stack.getFluid());
+ }
+
+ @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;
+ }
+
+ @Override
+ public FluidStack drain(FluidStack resource, FluidAction action) {
+ return FluidStack.EMPTY;
+ }
+
+ @Override
+ public FluidStack drain(int maxDrain, FluidAction action) {
+ return FluidStack.EMPTY;
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/lib/common/fluid/FluidBottleItem.java b/src/main/java/zone/moddev/mc/lib/common/fluid/FluidBottleItem.java
new file mode 100644
index 00000000..d064fd5c
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/common/fluid/FluidBottleItem.java
@@ -0,0 +1,44 @@
+package zone.moddev.mc.lib.common.fluid;
+
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.level.material.Fluid;
+
+import net.neoforged.neoforge.fluids.FluidStack;
+import net.neoforged.neoforge.fluids.SimpleFluidContent;
+
+/**
+ * {@code mmdlib:fluid_bottle}: a glass bottle holding {@link FluidBottles#CAPACITY} of one fluid,
+ * kept in the {@link FluidBottles#CONTENT} component. Named after its fluid ("Bottle of Molten
+ * Copper"); emptying it, through its fluid capability, leaves a glass bottle.
+ */
+public final class FluidBottleItem extends Item {
+ /** One-high stacks, like potions: bottles of different fluids cannot share a stack anyway. */
+ public FluidBottleItem(Properties properties) {
+ super(properties.stacksTo(1).craftRemainder(Items.GLASS_BOTTLE));
+ }
+
+ /** The fluid in {@code stack}; empty for an unfilled bottle. */
+ public static FluidStack contents(ItemStack stack) {
+ return stack.getOrDefault(FluidBottles.CONTENT.get(), SimpleFluidContent.EMPTY).copy();
+ }
+
+ /** A bottle full of {@code fluid}. */
+ public static ItemStack filled(Fluid fluid) {
+ var stack = new ItemStack(FluidBottles.BOTTLE.get());
+ stack.set(
+ FluidBottles.CONTENT.get(),
+ SimpleFluidContent.copyOf(new FluidStack(fluid, FluidBottles.CAPACITY)));
+ return stack;
+ }
+
+ @Override
+ public Component getName(ItemStack stack) {
+ var fluid = contents(stack);
+ return fluid.isEmpty()
+ ? super.getName(stack)
+ : Component.translatable(getDescriptionId(stack) + ".filled", fluid.getHoverName());
+ }
+}
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
new file mode 100644
index 00000000..46ef750b
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/common/fluid/FluidBottles.java
@@ -0,0 +1,147 @@
+package zone.moddev.mc.lib.common.fluid;
+
+import java.util.function.Supplier;
+
+import org.slf4j.Logger;
+
+import com.mojang.logging.LogUtils;
+
+import net.minecraft.core.component.DataComponentType;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.sounds.SoundEvents;
+import net.minecraft.sounds.SoundSource;
+import net.minecraft.stats.Stats;
+import net.minecraft.tags.TagKey;
+import net.minecraft.world.InteractionResult;
+import net.minecraft.world.item.ItemUtils;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.level.material.Fluid;
+import net.minecraft.world.phys.BlockHitResult;
+import net.minecraft.world.phys.HitResult;
+
+import net.neoforged.bus.api.EventPriority;
+import net.neoforged.bus.api.IEventBus;
+import net.neoforged.neoforge.capabilities.Capabilities;
+import net.neoforged.neoforge.capabilities.RegisterCapabilitiesEvent;
+import net.neoforged.neoforge.common.NeoForge;
+import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent;
+import net.neoforged.neoforge.fluids.SimpleFluidContent;
+import net.neoforged.neoforge.fluids.capability.templates.FluidHandlerItemStack;
+import net.neoforged.neoforge.registries.DeferredHolder;
+import net.neoforged.neoforge.registries.DeferredRegister;
+
+import zone.moddev.mc.lib.MMDLib;
+
+/**
+ * Lets vanilla glass bottles hold any fluid in {@link #BOTTLEABLE}, without a mixin:
+ *
+ *
+ * - Right-clicking a source block of such a fluid with a glass bottle gives a {@link
+ * FluidBottleItem} and leaves the source in place, as water bottles do.
+ *
- Glass bottles and filled bottles get a fluid capability, so tanks and pipes fill and empty
+ * them. If another mod already gives glass bottles one, MMDLib logs that and leaves glass
+ * bottles to it.
+ *
+ *
+ * Vanilla's own fluids (water, lava) are always left to vanilla, tagged or not.
+ */
+public final class FluidBottles {
+ private static final Logger LOGGER = LogUtils.getLogger();
+
+ /** Source fluids glass bottles can hold. MMDLib ships it empty: packs and mods fill it. */
+ public static final TagKey BOTTLEABLE =
+ TagKey.create(Registries.FLUID, ResourceLocation.fromNamespaceAndPath(MMDLib.MODID, "bottleable"));
+
+ /** Millibuckets in a bottle: a third of a bucket, the common convention. */
+ public static final int CAPACITY = 333;
+
+ private static final DeferredRegister.DataComponents COMPONENTS =
+ DeferredRegister.createDataComponents(Registries.DATA_COMPONENT_TYPE, MMDLib.MODID);
+
+ /** The fluid a {@link FluidBottleItem} holds. */
+ public static final Supplier> CONTENT =
+ COMPONENTS.registerComponentType(
+ "bottle_fluid",
+ builder ->
+ builder
+ .persistent(SimpleFluidContent.CODEC)
+ .networkSynchronized(SimpleFluidContent.STREAM_CODEC));
+
+ /** {@code mmdlib:fluid_bottle}. */
+ public static final DeferredHolder BOTTLE =
+ MMDLib.ITEMS.register(
+ "fluid_bottle", () -> new FluidBottleItem(new net.minecraft.world.item.Item.Properties()));
+
+ private FluidBottles() {}
+
+ /** Registers the component, the capabilities and the bottle-filling hook. */
+ public static void register(IEventBus modBus) {
+ COMPONENTS.register(modBus);
+ // Lowest priority, so mods registering at the usual priority are seen first.
+ modBus.addListener(EventPriority.LOWEST, FluidBottles::capabilities);
+ NeoForge.EVENT_BUS.addListener(FluidBottles::fillFromWorld);
+ }
+
+ /** Whether glass bottles can hold {@code fluid}: tagged, and not one of vanilla's. */
+ public static boolean bottleable(Fluid fluid) {
+ return !BuiltInRegistries.FLUID.getKey(fluid).getNamespace().equals("minecraft")
+ && fluid.defaultFluidState().is(BOTTLEABLE);
+ }
+
+ private static void capabilities(RegisterCapabilitiesEvent event) {
+ event.registerItem(
+ Capabilities.FluidHandler.ITEM,
+ (stack, context) ->
+ new FluidHandlerItemStack.SwapEmpty(
+ 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());
+ }
+ },
+ BOTTLE.get());
+ if (event.isItemRegistered(Capabilities.FluidHandler.ITEM, Items.GLASS_BOTTLE)) {
+ LOGGER.info(
+ "Another mod already gives glass bottles a fluid capability; MMDLib leaves filling them"
+ + " from tanks to it. Right-clicking bottleable fluids still works.");
+ return;
+ }
+ event.registerItem(
+ Capabilities.FluidHandler.ITEM,
+ (stack, context) -> new EmptyBottleFluidHandler(stack),
+ Items.GLASS_BOTTLE);
+ }
+
+ /**
+ * A glass bottle used on a bottleable 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) {
+ var held = event.getItemStack();
+ if (!held.is(Items.GLASS_BOTTLE)) return;
+ var player = event.getEntity();
+ var level = event.getLevel();
+ var hit = player.pick(player.blockInteractionRange(), 0, true);
+ 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;
+ level.playSound(
+ player,
+ player.getX(),
+ player.getY(),
+ player.getZ(),
+ SoundEvents.BOTTLE_FILL,
+ SoundSource.NEUTRAL,
+ 1.0F,
+ 1.0F);
+ player.awardStat(Stats.ITEM_USED.get(Items.GLASS_BOTTLE));
+ player.setItemInHand(
+ event.getHand(),
+ ItemUtils.createFilledResult(held, player, FluidBottleItem.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
new file mode 100644
index 00000000..39634ffb
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/common/fluid/package-info.java
@@ -0,0 +1,5 @@
+/**
+ * Glass bottles that hold any fluid in the {@code #mmdlib:bottleable} tag; see {@link
+ * zone.moddev.mc.lib.common.fluid.FluidBottles}.
+ */
+package zone.moddev.mc.lib.common.fluid;
diff --git a/src/main/resources/assets/mmdlib/lang/en_us.json b/src/main/resources/assets/mmdlib/lang/en_us.json
index 1d4e8032..a1b8ffc5 100644
--- a/src/main/resources/assets/mmdlib/lang/en_us.json
+++ b/src/main/resources/assets/mmdlib/lang/en_us.json
@@ -377,5 +377,7 @@
"screen.mmdlib.stats.cycles": "Cycles",
"screen.mmdlib.stats.cycle_tip": "Click to cycle tick / second / minute. Right-click to cycle backwards.",
"screen.mmdlib.stats.empty": "No statistics available.",
- "screen.mmdlib.stats.tip": "Measured RF: latest 20 ticks. Cycles: up to 60 game seconds. Max cycles uses the active recipe duration; max use is peak recipe demand. Max transfer rates use one transfer per tick."
+ "screen.mmdlib.stats.tip": "Measured RF: latest 20 ticks. Cycles: up to 60 game seconds. Max cycles uses the active recipe duration; max use is peak recipe demand. Max transfer rates use one transfer per tick.",
+ "item.mmdlib.fluid_bottle": "Fluid Bottle",
+ "item.mmdlib.fluid_bottle.filled": "Bottle of %s"
}
diff --git a/src/main/resources/assets/mmdlib/models/item/fluid_bottle.json b/src/main/resources/assets/mmdlib/models/item/fluid_bottle.json
new file mode 100644
index 00000000..38ee9d85
--- /dev/null
+++ b/src/main/resources/assets/mmdlib/models/item/fluid_bottle.json
@@ -0,0 +1,11 @@
+{
+ "parent": "neoforge:item/default",
+ "loader": "neoforge:fluid_container",
+ "fluid": "minecraft:empty",
+ "cover_is_mask": false,
+ "textures": {
+ "fluid": "minecraft:item/potion_overlay",
+ "cover": "minecraft:item/potion",
+ "particle": "minecraft:item/potion"
+ }
+}
diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/FluidBottleSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/FluidBottleSmoke.java
new file mode 100644
index 00000000..e4216d98
--- /dev/null
+++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/FluidBottleSmoke.java
@@ -0,0 +1,106 @@
+package zone.moddev.mc.lib.smoke;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.level.GameType;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.material.Fluid;
+import net.minecraft.world.level.material.Fluids;
+
+import net.neoforged.neoforge.fluids.FluidStack;
+import net.neoforged.neoforge.fluids.FluidUtil;
+import net.neoforged.neoforge.fluids.capability.templates.FluidTank;
+
+import zone.moddev.mc.lib.common.fluid.FluidBottleItem;
+import zone.moddev.mc.lib.common.fluid.FluidBottles;
+
+/**
+ * Glass bottles and {@code #mmdlib:bottleable}, which test resources fill with molten copper only.
+ */
+final class FluidBottleSmoke {
+ private FluidBottleSmoke() {}
+
+ private static Fluid fluid(String id) {
+ return BuiltInRegistries.FLUID.get(ResourceLocation.parse(id));
+ }
+
+ /** A player standing above {@code source}, looking straight down at it, holding two bottles. */
+ private static ServerPlayer above(GameTestHelper helper, BlockPos source) {
+ var player = helper.makeMockServerPlayerInLevel();
+ player.setGameMode(GameType.SURVIVAL);
+ player.moveTo(source.getX() + .5, source.getY() + 1, source.getZ() + .5, 0, 90);
+ player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.GLASS_BOTTLE, 2));
+ return player;
+ }
+
+ /** A bottle used on molten copper fills; the source stays; untagged fluids are refused. */
+ static void world(GameTestHelper helper, BlockPos center) {
+ var level = helper.getLevel();
+ var pos = helper.absolutePos(center);
+ var copper = fluid("mmdlib:copper_molten");
+ level.setBlock(pos, copper.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");
+ var filled = player.getInventory().items.stream().filter(s -> s.is(FluidBottles.BOTTLE.get())).findFirst();
+ require(filled.isPresent(), "a fluid bottle was given");
+ var contents = FluidBottleItem.contents(filled.get());
+ require(contents.is(copper) && contents.getAmount() == FluidBottles.CAPACITY, "it holds 333 mB of copper");
+ require(level.getFluidState(pos).isSource(), "the source is left in place");
+
+ var iron = fluid("mmdlib:iron_molten");
+ level.setBlock(pos, iron.defaultFluidState().createLegacyBlock(), 3);
+ var other = above(helper, pos);
+ other.gameMode.useItem(other, level, other.getMainHandItem(), InteractionHand.MAIN_HAND);
+ require(other.getMainHandItem().getCount() == 2, "untagged molten iron is not bottled");
+ helper.succeed();
+ }
+
+ /** Water stays vanilla's: a water potion, not a fluid bottle. */
+ static void water(GameTestHelper helper, BlockPos center) {
+ var level = helper.getLevel();
+ var pos = helper.absolutePos(center);
+ level.setBlock(pos, Blocks.WATER.defaultBlockState(), 3);
+ var player = above(helper, pos);
+ player.gameMode.useItem(player, level, player.getMainHandItem(), InteractionHand.MAIN_HAND);
+ require(player.getInventory().items.stream().anyMatch(s -> s.is(Items.POTION)), "vanilla made a water potion");
+ require(player.getInventory().items.stream().noneMatch(s -> s.is(FluidBottles.BOTTLE.get())), "no fluid bottle");
+ helper.succeed();
+ }
+
+ /** Tanks fill glass bottles with 333 mB of a bottleable fluid, and take it back. */
+ static void tanks(GameTestHelper helper) {
+ var copper = fluid("mmdlib:copper_molten");
+ var tank = new FluidTank(1000);
+ tank.fill(new FluidStack(copper, 1000), FluidTank.FluidAction.EXECUTE);
+ var filled = FluidUtil.tryFillContainer(new ItemStack(Items.GLASS_BOTTLE), tank, 1000, null, true);
+ require(filled.isSuccess() && filled.getResult().is(FluidBottles.BOTTLE.get()), "a tank fills a glass bottle");
+ require(tank.getFluidAmount() == 1000 - FluidBottles.CAPACITY, "the tank gave 333 mB");
+
+ var iron = new FluidTank(1000);
+ iron.fill(new FluidStack(fluid("mmdlib:iron_molten"), 1000), FluidTank.FluidAction.EXECUTE);
+ require(!FluidUtil.tryFillContainer(new ItemStack(Items.GLASS_BOTTLE), iron, 1000, null, true).isSuccess(),
+ "untagged fluids are refused");
+ var water = new FluidTank(1000);
+ water.fill(new FluidStack(Fluids.WATER, 1000), FluidTank.FluidAction.EXECUTE);
+ require(!FluidUtil.tryFillContainer(new ItemStack(Items.GLASS_BOTTLE), water, 1000, null, true).isSuccess(),
+ "water stays vanilla's");
+
+ var empty = new FluidTank(1000);
+ var poured = FluidUtil.tryEmptyContainer(filled.getResult(), empty, 1000, null, true);
+ require(poured.isSuccess() && poured.getResult().is(Items.GLASS_BOTTLE), "emptying leaves a glass bottle");
+ require(empty.getFluidAmount() == FluidBottles.CAPACITY && empty.getFluid().is(copper), "333 mB poured");
+ 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 64fe4605..57daab5e 100644
--- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java
+++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java
@@ -168,6 +168,21 @@ public static void redstoneGate(GameTestHelper helper) {
RedstoneSmoke.gate(helper, CENTER);
}
+ @GameTest(template = EMPTY)
+ public static void fluidBottlesFromWorld(GameTestHelper helper) {
+ FluidBottleSmoke.world(helper, CENTER);
+ }
+
+ @GameTest(template = EMPTY)
+ public static void fluidBottlesLeaveWaterToVanilla(GameTestHelper helper) {
+ FluidBottleSmoke.water(helper, CENTER);
+ }
+
+ @GameTest(template = EMPTY)
+ public static void fluidBottlesAndTanks(GameTestHelper helper) {
+ FluidBottleSmoke.tanks(helper);
+ }
+
@GameTest(template = EMPTY)
public static void capabilities(GameTestHelper helper) {
CapabilitySmoke.check(helper.getLevel());
diff --git a/src/runtimeTest/resources/data/mmdlib/tags/fluid/bottleable.json b/src/runtimeTest/resources/data/mmdlib/tags/fluid/bottleable.json
new file mode 100644
index 00000000..51f098df
--- /dev/null
+++ b/src/runtimeTest/resources/data/mmdlib/tags/fluid/bottleable.json
@@ -0,0 +1,4 @@
+{
+ "replace": false,
+ "values": ["mmdlib:copper_molten"]
+}