From c240ff7a29484d2e4bd1328e32320ac52f3d1a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=AC=8F=E5=A4=A9?= <1404928002@qq.com> Date: Sat, 4 Jul 2026 23:24:57 +0800 Subject: [PATCH 1/2] feat: support fluid placement in schematicannon --- .../resources/assets/create/lang/en_ud.json | 5 + .../resources/assets/create/lang/en_us.json | 5 + .../equipment/clipboard/ClipboardEntry.java | 40 +- .../equipment/clipboard/ClipboardScreen.java | 13 +- .../stockTicker/StockKeeperRequestScreen.java | 42 +- .../content/schematics/SchematicPrinter.java | 107 ++++- .../cannon/ConfigureSchematicannonPacket.java | 6 +- .../schematics/cannon/LaunchedItem.java | 94 +++- .../schematics/cannon/MaterialChecklist.java | 117 +++++ .../cannon/SchematicannonBlockEntity.java | 340 +++++++++++++-- .../cannon/SchematicannonRenderer.java | 6 + .../cannon/SchematicannonScreen.java | 35 +- .../schematics/client/FluidSbbBuilder.java | 96 +++++ .../schematics/client/SchematicRenderer.java | 39 ++ .../requirement/ItemRequirement.java | 56 ++- .../gametest/CreateGameTests.java | 129 ++++++ .../gametest/tests/TestMisc.java | 4 +- .../TestSchematicannonFluidIntegration.java | 272 ++++++++++++ .../tests/TestSchematicannonFluids.java | 402 ++++++++++++++++++ .../assets/create/lang/default/interface.json | 5 + .../resources/assets/create/lang/zh_cn.json | 7 +- 21 files changed, 1741 insertions(+), 79 deletions(-) create mode 100644 src/main/java/com/simibubi/create/content/schematics/client/FluidSbbBuilder.java create mode 100644 src/main/java/com/simibubi/create/infrastructure/gametest/tests/TestSchematicannonFluidIntegration.java create mode 100644 src/main/java/com/simibubi/create/infrastructure/gametest/tests/TestSchematicannonFluids.java diff --git a/src/generated/resources/assets/create/lang/en_ud.json b/src/generated/resources/assets/create/lang/en_ud.json index 2596b21522c..d880b507037 100644 --- a/src/generated/resources/assets/create/lang/en_ud.json +++ b/src/generated/resources/assets/create/lang/en_ud.json @@ -1312,6 +1312,8 @@ "create.gui.schematicannon.option.skipBlockEntities.description": "˙sʇsǝɥƆ sɐ ɥɔns sʞɔoןq buıpןoɥ ɐʇɐp buıɔɐןdǝɹ pıoʌɐ ןןıʍ uouuɐɔ ǝɥ⟘", "create.gui.schematicannon.option.skipMissing": "sʞɔoןᗺ buıssıɯ dıʞS", "create.gui.schematicannon.option.skipMissing.description": "˙uoıʇɐɔoꞀ ʇxǝu ǝɥʇ ʇɐ ǝnuıʇuoɔ ןןıʍ ʇı 'ʇuǝɯǝɔɐןd ɹoɟ ʞɔoןᗺ pǝɹınbǝɹ ɐ puıɟ ʇouuɐɔ uouuɐɔ ǝɥʇ ɟI", + "create.gui.schematicannon.option.skipMissingFluid": "spınןℲ buıssıɯ dıʞS", + "create.gui.schematicannon.option.skipMissingFluid.description": "˙ʎɹp pǝɔɐןd ǝɹɐ sʞɔoןq buıuıɐʇuoɔ-pınןɟ puɐ pǝddıʞs ǝɹɐ sʞɔoןq pınןɟ ǝǝɹɟ 'ǝןqɐןıɐʌɐun sı pınןɟ ɟI", "create.gui.schematicannon.optionDisabled": "pǝןqɐsıᗡ ʎןʇuǝɹɹnƆ", "create.gui.schematicannon.optionEnabled": "pǝןqɐuƎ ʎןʇuǝɹɹnƆ", "create.gui.schematicannon.shotsRemaining": "%1$s :ʇɟǝן sʇoɥS", @@ -2866,6 +2868,7 @@ "create.schematicannon.status.finished": "pǝɥsıuıℲ", "create.schematicannon.status.idle": "ǝןpI", "create.schematicannon.status.missingBlock": ":ɯǝʇI buıssıW", + "create.schematicannon.status.missingFluid": ":pınןℲ buıssıW", "create.schematicannon.status.noGunpowder": "ɹǝpʍodun⅁ ɟo ʇnO", "create.schematicannon.status.paused": "pǝsnɐԀ", "create.schematicannon.status.placing": "buıɔɐןԀ", @@ -2878,9 +2881,11 @@ "create.schematicannon.status.schematicNotPlaced": "pǝuoıʇısoԀ ʇou ɔıʇɐɯǝɥɔS", "create.schematicannon.status.searching": "buıɥɔɹɐǝS", "create.schematicannon.status.skipping": "buıddıʞS", + "create.schematicannon.status.skippingFluid": "pınןℲ buıddıʞS", "create.schematicannon.status.stopped": "pǝddoʇS", "create.schematicannon.status.targetNotLoaded": "pǝpɐoן ʇou sı ʇǝbɹɐ⟘", "create.schematicannon.status.targetOutsideRange": "ʎɐʍɐ ɹɐɟ ooʇ ʇǝbɹɐ⟘", + "create.schematicannon.status.waitingForStructure": "ǝɹnʇɔnɹʇS ɹoɟ buıʇıɐM", "create.schematics.maxAllowedSize": ":sı ǝzıs ǝןıɟ ɔıʇɐɯǝɥɔs pǝʍoןןɐ ɯnɯıxɐɯ ǝɥ⟘", "create.schematics.synchronizing": "˙˙˙buıɔuʎS", "create.schematics.uploadTooLarge": "˙ɹǝʌɹǝs ǝɥʇ ʎq pǝıɟıɔǝds suoıʇɐʇıɯıן spǝǝɔxǝ ɔıʇɐɯǝɥɔs ɹnoʎ", diff --git a/src/generated/resources/assets/create/lang/en_us.json b/src/generated/resources/assets/create/lang/en_us.json index 930893da30f..f2e0f3793bc 100644 --- a/src/generated/resources/assets/create/lang/en_us.json +++ b/src/generated/resources/assets/create/lang/en_us.json @@ -1312,6 +1312,8 @@ "create.gui.schematicannon.option.skipBlockEntities.description": "The cannon will avoid replacing data holding blocks such as Chests.", "create.gui.schematicannon.option.skipMissing": "Skip missing Blocks", "create.gui.schematicannon.option.skipMissing.description": "If the cannon cannot find a required Block for placement, it will continue at the next Location.", + "create.gui.schematicannon.option.skipMissingFluid": "Skip missing Fluids", + "create.gui.schematicannon.option.skipMissingFluid.description": "If fluid is unavailable, free fluid blocks are skipped and fluid-containing blocks are placed dry.", "create.gui.schematicannon.optionDisabled": "Currently Disabled", "create.gui.schematicannon.optionEnabled": "Currently Enabled", "create.gui.schematicannon.shotsRemaining": "Shots left: %1$s", @@ -2866,6 +2868,7 @@ "create.schematicannon.status.finished": "Finished", "create.schematicannon.status.idle": "Idle", "create.schematicannon.status.missingBlock": "Missing Item:", + "create.schematicannon.status.missingFluid": "Missing Fluid:", "create.schematicannon.status.noGunpowder": "Out of Gunpowder", "create.schematicannon.status.paused": "Paused", "create.schematicannon.status.placing": "Placing", @@ -2878,9 +2881,11 @@ "create.schematicannon.status.schematicNotPlaced": "Schematic not Positioned", "create.schematicannon.status.searching": "Searching", "create.schematicannon.status.skipping": "Skipping", + "create.schematicannon.status.skippingFluid": "Skipping Fluid", "create.schematicannon.status.stopped": "Stopped", "create.schematicannon.status.targetNotLoaded": "Target is not loaded", "create.schematicannon.status.targetOutsideRange": "Target too far away", + "create.schematicannon.status.waitingForStructure": "Waiting for Structure", "create.schematics.maxAllowedSize": "The maximum allowed schematic file size is:", "create.schematics.synchronizing": "Syncing...", "create.schematics.uploadTooLarge": "Your schematic exceeds limitations specified by the server.", diff --git a/src/main/java/com/simibubi/create/content/equipment/clipboard/ClipboardEntry.java b/src/main/java/com/simibubi/create/content/equipment/clipboard/ClipboardEntry.java index f443064da85..8caff2e41d9 100644 --- a/src/main/java/com/simibubi/create/content/equipment/clipboard/ClipboardEntry.java +++ b/src/main/java/com/simibubi/create/content/equipment/clipboard/ClipboardEntry.java @@ -5,6 +5,7 @@ import org.jetbrains.annotations.Nullable; +import com.mojang.datafixers.util.Either; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; import com.simibubi.create.AllDataComponents; @@ -17,16 +18,18 @@ import net.minecraft.network.codec.StreamCodec; import net.minecraft.world.item.ItemStack; +import net.neoforged.neoforge.fluids.FluidStack; + public class ClipboardEntry { public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( Codec.BOOL.fieldOf("checked").forGetter(c -> c.checked), ComponentSerialization.CODEC.fieldOf("text").forGetter(c -> c.text), - ItemStack.OPTIONAL_CODEC.fieldOf("icon").forGetter(c -> c.icon), + Codec.either(ItemStack.OPTIONAL_CODEC, FluidStack.OPTIONAL_CODEC).fieldOf("icon").forGetter(c -> c.icon), Codec.INT.fieldOf("item_amount").forGetter(c -> c.itemAmount) ).apply(i, (checked, text, icon, itemAmount) -> { ClipboardEntry entry = new ClipboardEntry(checked, text.copy()); - if (!icon.isEmpty()) - entry.displayItem(icon, itemAmount); + entry.icon = icon; + entry.itemAmount = itemAmount; return entry; })); @@ -34,12 +37,12 @@ public class ClipboardEntry { public static final StreamCodec STREAM_CODEC = StreamCodec.composite( ByteBufCodecs.BOOL, c -> c.checked, ComponentSerialization.STREAM_CODEC, c -> c.text, - ItemStack.OPTIONAL_STREAM_CODEC, c -> c.icon, + ByteBufCodecs.either(ItemStack.OPTIONAL_STREAM_CODEC, FluidStack.OPTIONAL_STREAM_CODEC), c -> c.icon, ByteBufCodecs.INT, c -> c.itemAmount, (checked, text, icon, itemAmount) -> { ClipboardEntry entry = new ClipboardEntry(checked, text.copy()); - if (!icon.isEmpty()) - entry.displayItem(icon, itemAmount); + entry.icon = icon; + entry.itemAmount = itemAmount; return entry; } @@ -47,21 +50,31 @@ public class ClipboardEntry { public boolean checked; public MutableComponent text; - public ItemStack icon; + public Either icon; public int itemAmount; public ClipboardEntry(boolean checked, MutableComponent text) { this.checked = checked; this.text = text; - this.icon = ItemStack.EMPTY; + this.icon = Either.left(ItemStack.EMPTY); } public ClipboardEntry displayItem(ItemStack icon, int amount) { - this.icon = icon; + this.icon = Either.left(icon); this.itemAmount = amount; return this; } + public ClipboardEntry displayItem(FluidStack icon, int amount) { + this.icon = Either.right(icon); + this.itemAmount = amount; + return this; + } + + public boolean isIconEmpty() { + return icon.map(ItemStack::isEmpty, FluidStack::isEmpty); + } + public static List> readAll(ItemStack clipboardItem) { return readAll(clipboardItem.getComponents()); } @@ -99,14 +112,19 @@ public final boolean equals(Object o) { if (this == o) return true; if (!(o instanceof ClipboardEntry that)) return false; - return checked == that.checked && text.equals(that.text) && ItemStack.isSameItemSameComponents(icon, that.icon); + if (checked != that.checked || itemAmount != that.itemAmount || !text.equals(that.text)) + return false; + return icon.map( + stack -> that.icon.map(other -> ItemStack.isSameItemSameComponents(stack, other), fluid -> false), + fluid -> that.icon.map(stack -> false, other -> FluidStack.isSameFluidSameComponents(fluid, other))); } @Override public int hashCode() { int result = Boolean.hashCode(checked); result = 31 * result + text.hashCode(); - result = 31 * result + ItemStack.hashItemAndComponents(icon); + result = 31 * result + icon.map(ItemStack::hashItemAndComponents, FluidStack::hashFluidAndComponents); + result = 31 * result + itemAmount; return result; } } diff --git a/src/main/java/com/simibubi/create/content/equipment/clipboard/ClipboardScreen.java b/src/main/java/com/simibubi/create/content/equipment/clipboard/ClipboardScreen.java index baf84c7e1b4..99317b8500e 100644 --- a/src/main/java/com/simibubi/create/content/equipment/clipboard/ClipboardScreen.java +++ b/src/main/java/com/simibubi/create/content/equipment/clipboard/ClipboardScreen.java @@ -30,6 +30,7 @@ import it.unimi.dsi.fastutil.ints.IntArrayList; import it.unimi.dsi.fastutil.ints.IntList; import net.createmod.catnip.gui.AbstractSimiScreen; +import net.createmod.catnip.gui.element.GuiGameElement; import net.createmod.catnip.platform.CatnipServices; import net.minecraft.Util; import net.minecraft.client.Minecraft; @@ -187,7 +188,7 @@ public void tick() { ClipboardEntry clipboardEntry = currentEntries.get(i); String text = clipboardEntry.text.getString(); totalHeight += - Math.max(12, font.split(Component.literal(text), clipboardEntry.icon.isEmpty() ? 150 : 130) + Math.max(12, font.split(Component.literal(text), clipboardEntry.isIconEmpty() ? 150 : 130) .size() * 9 + 3); if (totalHeight > my) { @@ -287,7 +288,7 @@ protected void renderWindow(GuiGraphics graphics, int mouseX, int mouseY, float for (int i = 0; i < currentEntries.size(); i++) { ClipboardEntry clipboardEntry = currentEntries.get(i); boolean checked = clipboardEntry.checked; - int iconOffset = clipboardEntry.icon.isEmpty() ? 0 : 16; + int iconOffset = clipboardEntry.isIconEmpty() ? 0 : 16; MutableComponent text = clipboardEntry.text; String string = text.getString(); @@ -312,8 +313,12 @@ protected void renderWindow(GuiGraphics graphics, int mouseX, int mouseY, float continue; } - if (!clipboardEntry.icon.isEmpty()) - graphics.renderItem(clipboardEntry.icon, x + 54, y + 50); + int iconY = y; + clipboardEntry.icon.ifLeft(stack -> graphics.renderItem(stack, x + 54, iconY + 50)) + .ifRight(fluid -> GuiGameElement.of(fluid.getFluid()) + .at(x + 54, iconY + 66, 100) + .scale(16) + .render(graphics)); for (FormattedCharSequence sequence : split) { if (i != editingIndex) diff --git a/src/main/java/com/simibubi/create/content/logistics/stockTicker/StockKeeperRequestScreen.java b/src/main/java/com/simibubi/create/content/logistics/stockTicker/StockKeeperRequestScreen.java index f4e39661c4a..2b25d355d5c 100644 --- a/src/main/java/com/simibubi/create/content/logistics/stockTicker/StockKeeperRequestScreen.java +++ b/src/main/java/com/simibubi/create/content/logistics/stockTicker/StockKeeperRequestScreen.java @@ -85,6 +85,9 @@ import net.minecraft.world.item.crafting.Ingredient; import net.minecraft.world.phys.AABB; +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.FluidUtil; + public class StockKeeperRequestScreen extends AbstractSimiContainerScreen { public static class CategoryEntry { boolean hidden; @@ -176,7 +179,7 @@ public StockKeeperRequestScreen(StockKeeperRequestMenu container, Inventory inv, boolean anyItems = false; for (List list : clipboardItem) for (ClipboardEntry entry : list) - if (!entry.icon.isEmpty()) { + if (!entry.isIconEmpty()) { anyItems = true; break; } @@ -1530,11 +1533,40 @@ public void requestSchematicList() { InventorySummary availableItems = blockEntity.getLastClientsideStockSnapshotAsSummary(); for (List list : clipboardItem) { for (ClipboardEntry entry : list) { - ItemStack stack = entry.icon; - int toOrder = Math.min(entry.itemAmount, availableItems.getCountOf(stack)); - if (toOrder == 0) + Optional itemIcon = entry.icon.left() + .filter(stack -> !stack.isEmpty()); + if (itemIcon.isPresent()) { + ItemStack stack = itemIcon.get(); + int toOrder = Math.min(entry.itemAmount, availableItems.getCountOf(stack)); + if (toOrder > 0) + itemsToOrder.add(new BigItemStack(stack, toOrder)); continue; - itemsToOrder.add(new BigItemStack(stack, toOrder)); + } + entry.icon.right() + .filter(fluid -> !fluid.isEmpty()) + .ifPresent(fluid -> { + long remaining = entry.itemAmount; + for (BigItemStack available : availableItems.getStacksByCount()) { + if (remaining <= 0) + break; + + ItemStack container = available.stack.copyWithCount(1); + FluidStack contained = FluidUtil.getFluidContained(container) + .orElse(FluidStack.EMPTY); + if (contained.isEmpty() || !contained.getFluid() + .isSame(fluid.getFluid())) + continue; + + int amountPerContainer = contained.getAmount(); + long containersNeeded = (remaining + amountPerContainer - 1L) / amountPerContainer; + int toOrder = (int) Math.min(containersNeeded, available.count); + if (toOrder <= 0) + continue; + + itemsToOrder.add(new BigItemStack(container, toOrder)); + remaining -= (long) amountPerContainer * toOrder; + } + }); } } } diff --git a/src/main/java/com/simibubi/create/content/schematics/SchematicPrinter.java b/src/main/java/com/simibubi/create/content/schematics/SchematicPrinter.java index 6e96209337f..f33ead3317d 100644 --- a/src/main/java/com/simibubi/create/content/schematics/SchematicPrinter.java +++ b/src/main/java/com/simibubi/create/content/schematics/SchematicPrinter.java @@ -24,9 +24,11 @@ import net.minecraft.nbt.NbtUtils; import net.minecraft.world.entity.Entity; import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.LiquidBlock; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.properties.BedPart; @@ -40,7 +42,7 @@ public class SchematicPrinter { public enum PrintStage { - BLOCKS, DEFERRED_BLOCKS, ENTITIES + BLOCKS, CONTAINED_FLUID_BLOCKS, DEFERRED_BLOCKS, FREE_FLUIDS, ENTITIES } private boolean schematicLoaded; @@ -51,12 +53,16 @@ public enum PrintStage { private BlockPos currentPos; private int printingEntityIndex; private PrintStage printStage; + private List containedFluidBlocks; private List deferredBlocks; + private List freeFluids; public SchematicPrinter() { printingEntityIndex = -1; printStage = PrintStage.BLOCKS; + containedFluidBlocks = new LinkedList<>(); deferredBlocks = new LinkedList<>(); + freeFluids = new LinkedList<>(); } public void fromTag(CompoundTag compound, boolean clientPacket) { @@ -72,9 +78,9 @@ public void fromTag(CompoundTag compound, boolean clientPacket) { printingEntityIndex = compound.getInt("EntityProgress"); printStage = PrintStage.valueOf(compound.getString("PrintStage")); - compound.getList("DeferredBlocks", 10).stream() - .map(p -> NBTHelper.readBlockPos((CompoundTag) p, "Pos")) - .collect(Collectors.toCollection(() -> deferredBlocks)); + readPositions(compound, "ContainedFluidBlocks", containedFluidBlocks); + readPositions(compound, "DeferredBlocks", deferredBlocks); + readPositions(compound, "FreeFluids", freeFluids); } public void write(CompoundTag compound) { @@ -84,13 +90,27 @@ public void write(CompoundTag compound) { compound.put("Anchor", NbtUtils.writeBlockPos(schematicAnchor)); compound.putInt("EntityProgress", printingEntityIndex); compound.putString("PrintStage", printStage.name()); - ListTag tagDeferredBlocks = new ListTag(); - for (BlockPos p : deferredBlocks) { - CompoundTag tag = new CompoundTag(); - tag.put("Pos", NbtUtils.writeBlockPos(p)); - tagDeferredBlocks.add(tag); + writePositions(compound, "ContainedFluidBlocks", containedFluidBlocks); + writePositions(compound, "DeferredBlocks", deferredBlocks); + writePositions(compound, "FreeFluids", freeFluids); + } + + private static void readPositions(CompoundTag compound, String key, List positions) { + positions.clear(); + compound.getList(key, 10) + .stream() + .map(p -> NBTHelper.readBlockPos((CompoundTag) p, "Pos")) + .collect(Collectors.toCollection(() -> positions)); + } + + private static void writePositions(CompoundTag compound, String key, List positions) { + ListTag listTag = new ListTag(); + for (BlockPos p : positions) { + CompoundTag positionTag = new CompoundTag(); + positionTag.put("Pos", NbtUtils.writeBlockPos(p)); + listTag.add(positionTag); } - compound.put("DeferredBlocks", tagDeferredBlocks); + compound.put(key, listTag); } public void loadSchematic(ItemStack blueprint, Level originalWorld, boolean processNBT) { @@ -125,7 +145,9 @@ public void loadSchematic(ItemStack blueprint, Level originalWorld, boolean proc printingEntityIndex = -1; printStage = PrintStage.BLOCKS; + containedFluidBlocks.clear(); deferredBlocks.clear(); + freeFluids.clear(); BoundingBox bounds = blockReader.getBounds(); currentPos = new BlockPos(bounds.minX() - 1, bounds.minY(), bounds.minZ()); schematicLoaded = true; @@ -139,7 +161,9 @@ public void resetSchematic() { blockReader = null; printingEntityIndex = -1; printStage = PrintStage.BLOCKS; + containedFluidBlocks.clear(); deferredBlocks.clear(); + freeFluids.clear(); } public boolean isLoaded() { @@ -236,6 +260,13 @@ public boolean shouldPlaceBlock(Level world, PlacementPredicate predicate, Block return false; if (toReplace == state && !mergeTEs) return false; + if (printStage == PrintStage.FREE_FLUIDS && toReplace.getBlock() == state.getBlock() && !state.getFluidState() + .isEmpty() && toReplace.getFluidState() + .isSource() && toReplace.getFluidState() + .getType() + .isSame(state.getFluidState() + .getType())) + return false; if (toReplace.getDestroySpeed(world, pos) == -1 || (toReplaceOther != null && toReplaceOther.getDestroySpeed(world, pos) == -1)) return false; @@ -244,7 +275,7 @@ public boolean shouldPlaceBlock(Level world, PlacementPredicate predicate, Block return predicate.shouldPlace(pos, state, blockEntity, toReplace, toReplaceOther, isNormalCube); } - public ItemRequirement getCurrentRequirement() { + public ItemRequirement getCurrentRequirement(Level world) { if (printStage == PrintStage.ENTITIES) return ItemRequirement.of(blockReader.getEntityList().get(printingEntityIndex)); @@ -257,7 +288,18 @@ public ItemRequirement getCurrentRequirement() { if (blockEntity != null && data != null) blockEntity.loadWithComponents(data, blockReader.registryAccess()); } - return ItemRequirement.of(blockState, blockEntity); + ItemRequirement requirement = ItemRequirement.of(blockState, blockEntity); + if (printStage == PrintStage.CONTAINED_FLUID_BLOCKS && isMatchingDryState(world.getBlockState(target), + blockState)) + return requirement.onlyFluids(); + return requirement; + } + + private static boolean isMatchingDryState(BlockState existing, BlockState required) { + if (!required.hasProperty(BlockStateProperties.WATERLOGGED) + || !required.getValue(BlockStateProperties.WATERLOGGED)) + return false; + return existing == required.setValue(BlockStateProperties.WATERLOGGED, false); } public int markAllBlockRequirements(MaterialChecklist checklist, Level world, PlacementPredicate predicate) { @@ -274,6 +316,8 @@ public int markAllBlockRequirements(MaterialChecklist checklist, Level world, Pl if (!shouldPlaceBlock(world, predicate, relPos)) continue; ItemRequirement requirement = ItemRequirement.of(required, requiredBE); + if (isMatchingDryState(world.getBlockState(relPos), required)) + requirement = requirement.onlyFluids(); if (requirement.isEmpty()) continue; if (requirement.isInvalid()) @@ -300,19 +344,33 @@ public boolean advanceCurrentPos() { do { if (printStage == PrintStage.BLOCKS) { - while (tryAdvanceCurrentPos()) { - deferredBlocks.add(currentPos); + while (tryAdvanceCurrentPos()) {} + } + + if (printStage == PrintStage.CONTAINED_FLUID_BLOCKS) { + if (containedFluidBlocks.isEmpty()) { + printStage = PrintStage.DEFERRED_BLOCKS; + } else { + currentPos = containedFluidBlocks.remove(0); } } if (printStage == PrintStage.DEFERRED_BLOCKS) { if (deferredBlocks.isEmpty()) { - printStage = PrintStage.ENTITIES; + printStage = PrintStage.FREE_FLUIDS; } else { currentPos = deferredBlocks.remove(0); } } + if (printStage == PrintStage.FREE_FLUIDS) { + if (freeFluids.isEmpty()) { + printStage = PrintStage.ENTITIES; + } else { + currentPos = freeFluids.remove(0); + } + } + if (printStage == PrintStage.ENTITIES) { if (printingEntityIndex + 1 < entities.size()) { printingEntityIndex++; @@ -340,11 +398,26 @@ public boolean tryAdvanceCurrentPos() { // End of blocks reached if (currentPos.getY() > bounds.getYSpan()) { - printStage = PrintStage.DEFERRED_BLOCKS; + printStage = PrintStage.CONTAINED_FLUID_BLOCKS; return false; } - return shouldDeferBlock(blockReader.getBlockState(getCurrentTarget())); + BlockState state = blockReader.getBlockState(getCurrentTarget()); + if (!state.getFluidState() + .isEmpty() && state.getFluidState() + .isSource()) { + if (state.getBlock() instanceof LiquidBlock || state.getBlock() + .asItem() == Items.AIR) + freeFluids.add(currentPos); + else + containedFluidBlocks.add(currentPos); + return true; + } + if (shouldDeferBlock(state)) { + deferredBlocks.add(currentPos); + return true; + } + return false; } public static boolean shouldDeferBlock(BlockState state) { diff --git a/src/main/java/com/simibubi/create/content/schematics/cannon/ConfigureSchematicannonPacket.java b/src/main/java/com/simibubi/create/content/schematics/cannon/ConfigureSchematicannonPacket.java index 98e0a2a7726..58cce8c4269 100644 --- a/src/main/java/com/simibubi/create/content/schematics/cannon/ConfigureSchematicannonPacket.java +++ b/src/main/java/com/simibubi/create/content/schematics/cannon/ConfigureSchematicannonPacket.java @@ -38,6 +38,9 @@ public void handle(ServerPlayer player) { case SKIP_MISSING: be.skipMissing = this.set; break; + case SKIP_FLUID: + be.skipMissingFluid = this.set; + break; case SKIP_BLOCK_ENTITIES: be.replaceBlockEntities = this.set; break; @@ -62,7 +65,8 @@ public void handle(ServerPlayer player) { } public enum Option { - DONT_REPLACE, REPLACE_SOLID, REPLACE_ANY, REPLACE_EMPTY, SKIP_MISSING, SKIP_BLOCK_ENTITIES, PLAY, PAUSE, STOP; + DONT_REPLACE, REPLACE_SOLID, REPLACE_ANY, REPLACE_EMPTY, SKIP_MISSING, SKIP_FLUID, SKIP_BLOCK_ENTITIES, + PLAY, PAUSE, STOP; public static final StreamCodec STREAM_CODEC = CatnipStreamCodecBuilders.ofEnum(Option.class); } diff --git a/src/main/java/com/simibubi/create/content/schematics/cannon/LaunchedItem.java b/src/main/java/com/simibubi/create/content/schematics/cannon/LaunchedItem.java index 257280665c0..0051acb64d9 100644 --- a/src/main/java/com/simibubi/create/content/schematics/cannon/LaunchedItem.java +++ b/src/main/java/com/simibubi/create/content/schematics/cannon/LaunchedItem.java @@ -22,12 +22,19 @@ import net.minecraft.nbt.NbtUtils; import net.minecraft.nbt.Tag; import net.minecraft.util.Mth; +import net.minecraft.world.InteractionHand; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; + +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.FluidUtil; +import net.neoforged.neoforge.fluids.capability.templates.FluidTank; public abstract class LaunchedItem { @@ -75,8 +82,15 @@ public CompoundTag serializeNBT(HolderLookup.Provider registries) { } public static LaunchedItem fromNBT(CompoundTag c, HolderLookup.Provider registries, HolderGetter holderGetter) { - LaunchedItem launched = c.contains("Length") ? new LaunchedItem.ForBelt() - : c.contains("BlockState") ? new LaunchedItem.ForBlockState() : new LaunchedItem.ForEntity(); + LaunchedItem launched = switch (c.getString("Kind")) { + case "belt" -> new LaunchedItem.ForBelt(); + case "block" -> new LaunchedItem.ForBlockState(); + case "fluid" -> new LaunchedItem.ForFluid(); + case "entity" -> new LaunchedItem.ForEntity(); + default -> c.contains("Length") ? new LaunchedItem.ForBelt() + : c.contains("BlockState") ? new LaunchedItem.ForBlockState() + : c.contains("Fluid") ? new LaunchedItem.ForFluid() : new LaunchedItem.ForEntity(); + }; launched.readNBT(c, registries, holderGetter); return launched; } @@ -93,18 +107,29 @@ void readNBT(CompoundTag c, HolderLookup.Provider registries, HolderGetter holderGetter) { + super.readNBT(tag, registries, holderGetter); + fluid = FluidStack.parseOptional(registries, tag.getCompound("Fluid")); } @Override void place(Level world) { - BlockHelper.placeSchematicBlock(world, state, target, stack, data); + placeFluid(world, target, fluid, true); } + } + + private static boolean placeFluid(Level world, BlockPos target, FluidStack fluid, boolean replaceTarget) { + if (fluid.isEmpty()) + return false; + if (replaceTarget && !world.getBlockState(target) + .isAir()) + world.setBlock(target, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + FluidTank source = new FluidTank(fluid.getAmount()); + source.setFluid(fluid.copy()); + boolean placed = FluidUtil.tryPlaceFluid(null, world, InteractionHand.MAIN_HAND, target, source, fluid); + if (!placed) + world.updateNeighborsAt(target, world.getBlockState(target) + .getBlock()); + return placed; + } + private static BlockState dryStateIfFluidWillBeHandledSeparately(BlockState state) { + if (state.hasProperty(BlockStateProperties.WATERLOGGED) && state.getValue(BlockStateProperties.WATERLOGGED)) + return state.setValue(BlockStateProperties.WATERLOGGED, false); + return state; } public static class ForBelt extends ForBlockState { @@ -141,6 +225,7 @@ public ForBelt() {} @Override public CompoundTag serializeNBT(HolderLookup.Provider registries) { CompoundTag serializeNBT = super.serializeNBT(registries); + serializeNBT.putString("Kind", "belt"); serializeNBT.putInt("Length", length); serializeNBT.putIntArray("Casing", Arrays.stream(casings) .map(CasingType::ordinal) @@ -221,6 +306,7 @@ public boolean update(Level world) { @Override public CompoundTag serializeNBT(HolderLookup.Provider registries) { CompoundTag serializeNBT = super.serializeNBT(registries); + serializeNBT.putString("Kind", "entity"); if (entity != null) serializeNBT.put("Entity", entity.serializeNBT(registries)); return serializeNBT; diff --git a/src/main/java/com/simibubi/create/content/schematics/cannon/MaterialChecklist.java b/src/main/java/com/simibubi/create/content/schematics/cannon/MaterialChecklist.java index 456241239e5..297d42c95be 100644 --- a/src/main/java/com/simibubi/create/content/schematics/cannon/MaterialChecklist.java +++ b/src/main/java/com/simibubi/create/content/schematics/cannon/MaterialChecklist.java @@ -19,15 +19,21 @@ import it.unimi.dsi.fastutil.objects.Object2IntMap; import net.minecraft.ChatFormatting; import net.minecraft.core.component.DataComponents; +import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.HoverEvent; import net.minecraft.network.chat.MutableComponent; import net.minecraft.network.chat.Style; import net.minecraft.server.network.Filterable; +import net.minecraft.resources.ResourceLocation; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; import net.minecraft.world.item.component.WrittenBookContent; +import net.minecraft.world.level.material.Fluid; + +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.FluidType; public class MaterialChecklist { @@ -37,6 +43,8 @@ public class MaterialChecklist { public Object2IntMap gathered = new Object2IntArrayMap<>(); public Object2IntMap required = new Object2IntArrayMap<>(); public Object2IntMap damageRequired = new Object2IntArrayMap<>(); + public Object2IntMap gatheredFluids = new Object2IntArrayMap<>(); + public Object2IntMap requiredFluids = new Object2IntArrayMap<>(); public boolean blocksNotLoaded; public void warnBlockNotLoaded() { @@ -55,6 +63,8 @@ public void require(ItemRequirement requirement) { if (stack.usage == ItemUseType.CONSUME) putOrIncrement(required, stack.stack); } + for (ItemRequirement.FluidRequirement fluid : requirement.getRequiredFluids()) + requiredFluids.put(fluid.fluid(), requiredFluids.getOrDefault(fluid.fluid(), 0) + fluid.amount()); } private void putOrIncrement(Object2IntMap map, ItemStack stack) { @@ -76,6 +86,13 @@ public void collect(ItemStack stack) { gathered.put(item, stack.getCount()); } + public void collect(FluidStack stack) { + Fluid fluid = stack.getFluid(); + if (!requiredFluids.containsKey(fluid)) + return; + gatheredFluids.put(fluid, gatheredFluids.getOrDefault(fluid, 0) + stack.getAmount()); + } + public ItemStack createWrittenBook() { ItemStack book = new ItemStack(Items.WRITTEN_BOOK); @@ -126,6 +143,24 @@ public ItemStack createWrittenBook() { textComponent.append(entry(new ItemStack(item), amount, true, true)); } + List completedFluids = new ArrayList<>(); + for (Fluid fluid : sortedFluids()) { + int amount = requiredFluids.getInt(fluid) - gatheredFluids.getOrDefault(fluid, 0); + if (amount <= 0) { + completedFluids.add(fluid); + continue; + } + if (itemsWritten == MAX_ENTRIES_PER_PAGE) { + itemsWritten = 0; + textComponent.append(Component.literal("\n >>>") + .withStyle(ChatFormatting.BLUE)); + pages.add(Filterable.passThrough(textComponent)); + textComponent = Component.empty(); + } + itemsWritten++; + textComponent.append(fluidEntry(fluid, amount, true, true)); + } + for (Item item : completed) { if (itemsWritten == MAX_ENTRIES_PER_PAGE) { itemsWritten = 0; @@ -139,6 +174,18 @@ public ItemStack createWrittenBook() { textComponent.append(entry(new ItemStack(item), getRequiredAmount(item), false, true)); } + for (Fluid fluid : completedFluids) { + if (itemsWritten == MAX_ENTRIES_PER_PAGE) { + itemsWritten = 0; + textComponent.append(Component.literal("\n >>>") + .withStyle(ChatFormatting.DARK_GREEN)); + pages.add(Filterable.passThrough(textComponent)); + textComponent = Component.empty(); + } + itemsWritten++; + textComponent.append(fluidEntry(fluid, requiredFluids.getInt(fluid), false, true)); + } + pages.add(Filterable.passThrough(textComponent)); WrittenBookContent contents = new WrittenBookContent( @@ -204,6 +251,25 @@ public ItemStack createWrittenClipboard() { .displayItem(new ItemStack(item), amount)); } + List completedFluids = new ArrayList<>(); + for (Fluid fluid : sortedFluids()) { + int amount = requiredFluids.getInt(fluid) - gatheredFluids.getOrDefault(fluid, 0); + if (amount <= 0) { + completedFluids.add(fluid); + continue; + } + if (itemsWritten == MAX_ENTRIES_PER_CLIPBOARD_PAGE) { + itemsWritten = 0; + currentPage.add(new ClipboardEntry(false, Component.literal(">>>") + .withStyle(ChatFormatting.DARK_GRAY))); + pages.add(currentPage); + currentPage = new ArrayList<>(); + } + itemsWritten++; + currentPage.add(new ClipboardEntry(false, fluidEntry(fluid, amount, true, false)) + .displayItem(new FluidStack(fluid, FluidType.BUCKET_VOLUME), amount)); + } + for (Item item : completed) { if (itemsWritten == MAX_ENTRIES_PER_CLIPBOARD_PAGE) { itemsWritten = 0; @@ -218,6 +284,20 @@ public ItemStack createWrittenClipboard() { .displayItem(new ItemStack(item), 0)); } + for (Fluid fluid : completedFluids) { + if (itemsWritten == MAX_ENTRIES_PER_CLIPBOARD_PAGE) { + itemsWritten = 0; + currentPage.add(new ClipboardEntry(true, Component.literal(">>>") + .withStyle(ChatFormatting.DARK_GREEN))); + pages.add(currentPage); + currentPage = new ArrayList<>(); + } + itemsWritten++; + currentPage.add(new ClipboardEntry(true, + fluidEntry(fluid, requiredFluids.getInt(fluid), false, false)) + .displayItem(new FluidStack(fluid, FluidType.BUCKET_VOLUME), 0)); + } + pages.add(currentPage); ItemStack clipboard = AllBlocks.CLIPBOARD.asStack(); @@ -252,4 +332,41 @@ private MutableComponent entry(ItemStack item, int amount, boolean unfinished, b .withStyle(ChatFormatting.GRAY)); } + private List sortedFluids() { + List fluids = new ArrayList<>(requiredFluids.keySet()); + fluids.sort((first, second) -> first.getFluidType() + .getDescription() + .getString() + .compareToIgnoreCase(second.getFluidType() + .getDescription() + .getString())); + return fluids; + } + + private MutableComponent fluidEntry(Fluid fluid, int amount, boolean unfinished, boolean forBook) { + ResourceLocation id = BuiltInRegistries.FLUID.getKey(fluid); + MutableComponent name = fluid.getFluidType() + .getDescription() + .copy() + .withStyle(style -> style.withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, + fluid.getFluidType() + .getDescription() + .copy() + .append("\n") + .append(Component.literal(id.toString()) + .withStyle(ChatFormatting.DARK_GRAY))))); + MutableComponent text = Component.empty() + .append(name); + if (!unfinished && forBook) + text.append(" \u2714"); + if (!unfinished || forBook) + text.withStyle(unfinished ? ChatFormatting.BLUE : ChatFormatting.DARK_GREEN); + int buckets = amount / FluidType.BUCKET_VOLUME; + int remainder = amount % FluidType.BUCKET_VOLUME; + return text.append(Component.literal("\n x" + amount + " mB") + .withStyle(ChatFormatting.BLACK)) + .append(Component.literal(" | " + buckets + " B +" + remainder + " mB" + (forBook ? "\n" : "")) + .withStyle(ChatFormatting.GRAY)); + } + } diff --git a/src/main/java/com/simibubi/create/content/schematics/cannon/SchematicannonBlockEntity.java b/src/main/java/com/simibubi/create/content/schematics/cannon/SchematicannonBlockEntity.java index 8e43f9a54c9..07ac9b5cb34 100644 --- a/src/main/java/com/simibubi/create/content/schematics/cannon/SchematicannonBlockEntity.java +++ b/src/main/java/com/simibubi/create/content/schematics/cannon/SchematicannonBlockEntity.java @@ -1,10 +1,14 @@ package com.simibubi.create.content.schematics.cannon; +import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashSet; +import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Optional; import org.jetbrains.annotations.Nullable; @@ -21,7 +25,9 @@ import com.simibubi.create.content.kinetics.belt.BeltSlope; import com.simibubi.create.content.kinetics.simpleRelays.AbstractSimpleShaftBlock; import com.simibubi.create.content.schematics.SchematicPrinter; +import com.simibubi.create.content.schematics.SchematicPrinter.PrintStage; import com.simibubi.create.content.schematics.requirement.ItemRequirement; +import com.simibubi.create.content.schematics.requirement.ItemRequirement.FluidRequirement; import com.simibubi.create.content.schematics.requirement.ItemRequirement.ItemUseType; import com.simibubi.create.foundation.blockEntity.SmartBlockEntity; import com.simibubi.create.foundation.blockEntity.behaviour.BlockEntityBehaviour; @@ -58,6 +64,7 @@ import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.BlockEntityType; @@ -66,11 +73,17 @@ import net.minecraft.world.level.block.state.properties.BedPart; import net.minecraft.world.level.block.state.properties.BlockStateProperties; import net.minecraft.world.level.block.state.properties.DoubleBlockHalf; +import net.minecraft.world.level.material.Fluid; import net.minecraft.world.phys.AABB; import net.neoforged.api.distmarker.Dist; import net.neoforged.api.distmarker.OnlyIn; import net.neoforged.neoforge.capabilities.Capabilities; +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.FluidUtil; +import net.neoforged.neoforge.fluids.capability.IFluidHandler; +import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction; +import net.neoforged.neoforge.fluids.capability.IFluidHandlerItem; import net.neoforged.neoforge.items.IItemHandler; import net.neoforged.neoforge.items.ItemHandlerHelper; import net.neoforged.neoforge.items.wrapper.EmptyItemHandler; @@ -90,14 +103,17 @@ public class SchematicannonBlockEntity extends SmartBlockEntity implements MenuP // Printer public SchematicPrinter printer; public ItemStack missingItem; + public FluidStack missingFluid; public boolean positionNotLoaded; public boolean hasCreativeCrate; private int printerCooldown; private int skipsLeft; private boolean blockSkipped; + private boolean waitingForStageBarrier; public BlockPos previousTarget; public LinkedHashSet attachedInventories; + public LinkedHashSet attachedFluidInventories; public List flyingBlocks; public MaterialChecklist checklist; @@ -113,6 +129,7 @@ public class SchematicannonBlockEntity extends SmartBlockEntity implements MenuP // Settings public int replaceMode; public boolean skipMissing; + public boolean skipMissingFluid; public boolean replaceBlockEntities; // Render @@ -123,6 +140,7 @@ public SchematicannonBlockEntity(BlockEntityType type, BlockPos pos, BlockSta super(type, pos, state); setLazyTickRate(30); attachedInventories = new LinkedHashSet<>(); + attachedFluidInventories = new LinkedHashSet<>(); flyingBlocks = new LinkedList<>(); inventory = new SchematicannonInventory(this); statusMsg = "idle"; @@ -135,6 +153,7 @@ public SchematicannonBlockEntity(BlockEntityType type, BlockPos pos, BlockSta public void findInventories() { hasCreativeCrate = false; attachedInventories.clear(); + attachedFluidInventories.clear(); for (Direction facing : Iterate.directions) { if (!level.isLoaded(worldPosition.relative(facing))) @@ -151,6 +170,10 @@ public void findInventories() { attachedInventories.add(capability); } } + IFluidHandler fluidCapability = level.getCapability(Capabilities.FluidHandler.BLOCK, + worldPosition.relative(facing), facing.getOpposite()); + if (fluidCapability != null) + attachedFluidInventories.add(fluidCapability); } } @@ -179,12 +202,15 @@ protected void read(CompoundTag compound, HolderLookup.Provider registries, bool if (compound.contains("MissingItem")) { ItemStack.parse(registries, compound.getCompound("MissingItem")).ifPresent(i -> missingItem = i); } + missingFluid = FluidStack.parseOptional(registries, compound.getCompound("MissingFluid")); + waitingForStageBarrier = compound.getBoolean("WaitingForStageBarrier"); // Settings SchematicannonOptions options = CatnipCodecUtils.decode(SchematicannonOptions.CODEC, registries, compound.getCompound("Options")) - .orElse(new SchematicannonOptions(2, false, false)); + .orElse(new SchematicannonOptions(2, false, false, false)); replaceMode = options.replaceMode; skipMissing = options.skipMissing; + skipMissingFluid = options.skipMissingFluid; replaceBlockEntities = options.replaceBlockEntities; // Printer & Flying Blocks @@ -253,9 +279,13 @@ public void write(CompoundTag compound, HolderLookup.Provider registries, boolea if (missingItem != null) compound.put("MissingItem", missingItem.saveOptional(registries)); + if (missingFluid != null && !missingFluid.isEmpty()) + compound.put("MissingFluid", missingFluid.saveOptional(registries)); + compound.putBoolean("WaitingForStageBarrier", waitingForStageBarrier); // Settings - Tag options = CatnipCodecUtils.encode(SchematicannonOptions.CODEC, registries, new SchematicannonOptions(replaceMode, skipMissing, replaceBlockEntities)).orElseThrow(); + Tag options = CatnipCodecUtils.encode(SchematicannonOptions.CODEC, registries, + new SchematicannonOptions(replaceMode, skipMissing, skipMissingFluid, replaceBlockEntities)).orElseThrow(); compound.put("Options", options); // Printer & Flying Blocks @@ -334,7 +364,8 @@ protected void tickPrinter() { return; } - if (state == State.PAUSED && !positionNotLoaded && missingItem == null && remainingFuel > 0) + if (state == State.PAUSED && !positionNotLoaded && missingItem == null + && (missingFluid == null || missingFluid.isEmpty()) && remainingFuel > 0) return; // Initialize Printer @@ -362,18 +393,33 @@ protected void tickPrinter() { if (hasCreativeCrate) { remainingFuel = 0; - if (missingItem != null) { + if (missingItem != null || missingFluid != null && !missingFluid.isEmpty()) { missingItem = null; + missingFluid = FluidStack.EMPTY; state = State.RUNNING; } } // Update Target - if (missingItem == null && !positionNotLoaded) { + if (waitingForStageBarrier) { + if (!flyingBlocks.isEmpty()) { + statusMsg = "waitingForStructure"; + return; + } + waitingForStageBarrier = false; + } else if (missingItem == null && (missingFluid == null || missingFluid.isEmpty()) && !positionNotLoaded) { + PrintStage previousStage = printer.getPrintStage(); if (!printer.advanceCurrentPos()) { finishedPrinting(); return; } + if (previousStage != printer.getPrintStage() && printer.getPrintStage() == PrintStage.FREE_FLUIDS + && !flyingBlocks.isEmpty()) { + waitingForStageBarrier = true; + statusMsg = "waitingForStructure"; + sendUpdate = true; + return; + } sendUpdate = true; } @@ -391,7 +437,7 @@ protected void tickPrinter() { } // Get item requirement - ItemRequirement requirement = printer.getCurrentRequirement(); + ItemRequirement requirement = printer.getCurrentRequirement(level); if (requirement.isInvalid() || !printer.shouldPlaceCurrent(level, this::shouldPlace)) { sendUpdate = !statusMsg.equals("searching"); statusMsg = "searching"; @@ -399,39 +445,108 @@ protected void tickPrinter() { return; } - // Find item + // Find resources List requiredItems = requirement.getRequiredItems(); + List requiredFluids = requirement.getRequiredFluids(); + Map fluidAmounts = new LinkedHashMap<>(); + for (FluidRequirement required : requiredFluids) + fluidAmounts.merge(required.fluid(), required.amount(), Integer::sum); + boolean provideFluid = true; + for (Map.Entry required : fluidAmounts.entrySet()) { + FluidStack stack = new FluidStack(required.getKey(), required.getValue()); + if (required.getKey() + .getFluidType() + .canBePlacedInLevel(level, printer.getCurrentTarget(), stack)) + continue; + if (requirement.isPureFluid()) { + statusMsg = "skippingFluid"; + blockSkipped = true; + return; + } + provideFluid = false; + } if (!requirement.isEmpty()) { + ItemRequirement.StackRequirement missingItemRequirement = null; for (ItemRequirement.StackRequirement required : requiredItems) { if (!grabItemsFromAttachedInventories(required, true)) { - if (skipMissing) { - statusMsg = "skipping"; - blockSkipped = true; - if (missingItem != null) { - missingItem = null; - state = State.RUNNING; - } - return; - } + missingItemRequirement = required; + break; + } + } + + if (missingItemRequirement != null) { + if (skipMissing) { + statusMsg = "skipping"; + blockSkipped = true; + resetMissingState(); + return; + } + missingItem = missingItemRequirement.stack; + missingFluid = FluidStack.EMPTY; + state = State.PAUSED; + statusMsg = "missingBlock"; + return; + } - missingItem = required.stack; + FluidRequirement missingFluidRequirement = null; + if (provideFluid) { + for (Map.Entry required : fluidAmounts.entrySet()) { + if (!grabFluidFromAttachedInventories(required.getKey(), required.getValue(), true)) { + missingFluidRequirement = new FluidRequirement(required.getKey(), required.getValue()); + break; + } + } + } + if (missingFluidRequirement != null) { + if (!skipMissingFluid) { + missingItem = null; + missingFluid = + new FluidStack(missingFluidRequirement.fluid(), missingFluidRequirement.amount()); state = State.PAUSED; - statusMsg = "missingBlock"; + statusMsg = "missingFluid"; return; } + if (requirement.isPureFluid()) { + statusMsg = "skippingFluid"; + blockSkipped = true; + resetMissingState(); + return; + } + provideFluid = false; } for (ItemRequirement.StackRequirement required : requiredItems) grabItemsFromAttachedInventories(required, false); + if (provideFluid) { + for (Map.Entry required : fluidAmounts.entrySet()) { + if (grabFluidFromAttachedInventories(required.getKey(), required.getValue(), false)) + continue; + missingFluid = new FluidStack(required.getKey(), required.getValue()); + state = State.PAUSED; + statusMsg = "missingFluid"; + return; + } + } } // Success state = State.RUNNING; ItemStack icon = requirement.isEmpty() || requiredItems.isEmpty() ? ItemStack.EMPTY : requiredItems.get(0).stack; + FluidStack fluidPayload = FluidStack.EMPTY; + if (provideFluid && !fluidAmounts.isEmpty()) { + Map.Entry firstFluid = fluidAmounts.entrySet() + .iterator() + .next(); + fluidPayload = new FluidStack(firstFluid.getKey(), firstFluid.getValue()); + } + FluidStack launchedFluid = fluidPayload; printer.handleCurrentTarget((target, blockState, blockEntity) -> { // Launch block statusMsg = blockState.getBlock() != Blocks.AIR ? "placing" : "clearing"; - launchBlockOrBelt(target, icon, blockState, blockEntity); + if (printer.getPrintStage() == PrintStage.FREE_FLUIDS) + launchFluid(target, launchedFluid); + else + launchBlockOrBelt(target, icon, blockState, blockEntity, launchedFluid); }, (target, entity) -> { // Launch entity statusMsg = "placing"; @@ -442,6 +557,15 @@ protected void tickPrinter() { remainingFuel -= 1; sendUpdate = true; missingItem = null; + missingFluid = FluidStack.EMPTY; + } + + private void resetMissingState() { + if (missingItem != null || missingFluid != null && !missingFluid.isEmpty()) { + missingItem = null; + missingFluid = FluidStack.EMPTY; + state = State.RUNNING; + } } public int getShotsPerGunpowder() { @@ -582,6 +706,142 @@ protected boolean grabItemsFromAttachedInventories(ItemRequirement.StackRequirem return success; } + protected boolean grabFluidFromAttachedInventories(Fluid fluid, int amount, boolean simulate) { + if (hasCreativeCrate) + return true; + FluidDrainPlan plan = planFluidDrain(fluid, amount); + if (plan == null) + return false; + if (simulate) + return true; + return executeFluidDrainPlan(plan); + } + + @Nullable + private FluidDrainPlan planFluidDrain(Fluid fluid, int amount) { + attachedFluidInventories.removeIf(Objects::isNull); + attachedInventories.removeIf(Objects::isNull); + int remaining = amount; + List tankDrains = new ArrayList<>(); + List itemDrains = new ArrayList<>(); + + for (IFluidHandler handler : attachedFluidInventories) { + for (int tank = 0; tank < handler.getTanks(); tank++) { + FluidStack stored = handler.getFluidInTank(tank); + if (stored.isEmpty() || !stored.getFluid() + .isSame(fluid)) + continue; + FluidStack request = stored.copyWithAmount(Math.min(remaining, stored.getAmount())); + FluidStack drained = handler.drain(request, FluidAction.SIMULATE); + if (drained.isEmpty() || !FluidStack.isSameFluidSameComponents(request, drained)) + continue; + FluidStack plannedDrain = drained.copyWithAmount(Math.min(remaining, drained.getAmount())); + tankDrains.add(new TankDrain(handler, plannedDrain)); + remaining -= plannedDrain.getAmount(); + if (remaining == 0) + return new FluidDrainPlan(tankDrains, itemDrains); + } + } + + for (IItemHandler inventory : attachedInventories) { + for (int slot = 0; slot < inventory.getSlots(); slot++) { + ItemStack inSlot = inventory.getStackInSlot(slot); + ItemStack extracted = inventory.extractItem(slot, 1, true); + if (extracted.isEmpty()) + continue; + Optional fluidHandler = FluidUtil.getFluidHandler(extracted.copy()); + if (fluidHandler.isEmpty()) + continue; + FluidStack available = fluidHandler.get() + .drain(Integer.MAX_VALUE, FluidAction.SIMULATE); + if (available.isEmpty() || !available.getFluid() + .isSame(fluid)) + continue; + FluidStack request = available.copyWithAmount(Math.min(remaining, available.getAmount())); + FluidStack drained = fluidHandler.get() + .drain(request, FluidAction.SIMULATE); + if (drained.isEmpty() || !drained.getFluid() + .isSame(fluid)) + continue; + int drainAmount = Math.min(remaining, drained.getAmount()); + fluidHandler.get() + .drain(drained.copyWithAmount(drainAmount), FluidAction.EXECUTE); + ItemStack resultContainer = fluidHandler.get() + .getContainer(); + if (!canReturnContainer(inventory, slot, inSlot.getCount(), resultContainer)) + continue; + itemDrains.add(new ItemDrain(inventory, slot, drained.copyWithAmount(drainAmount))); + remaining -= drainAmount; + if (remaining == 0) + return new FluidDrainPlan(tankDrains, itemDrains); + } + } + + return null; + } + + private boolean canReturnContainer(IItemHandler source, int sourceSlot, int sourceCount, ItemStack container) { + if (container.isEmpty()) + return true; + if (sourceCount == 1 && source.isItemValid(sourceSlot, container) + && container.getCount() <= source.getSlotLimit(sourceSlot)) + return true; + for (IItemHandler inventory : attachedInventories) + if (ItemHandlerHelper.insertItem(inventory, container.copy(), true) + .isEmpty()) + return true; + return false; + } + + private boolean executeFluidDrainPlan(FluidDrainPlan plan) { + for (TankDrain planned : plan.tanks()) { + FluidStack drained = planned.handler() + .drain(planned.fluid(), FluidAction.EXECUTE); + if (drained.getAmount() != planned.fluid() + .getAmount() || !FluidStack.isSameFluidSameComponents(drained, planned.fluid())) + return false; + } + for (ItemDrain planned : plan.items()) { + ItemStack extracted = planned.inventory() + .extractItem(planned.slot(), 1, false); + Optional fluidHandler = FluidUtil.getFluidHandler(extracted); + if (fluidHandler.isEmpty()) + return false; + FluidStack drained = fluidHandler.get() + .drain(planned.fluid(), FluidAction.EXECUTE); + ItemStack resultContainer = fluidHandler.get() + .getContainer(); + if (drained.getAmount() != planned.fluid() + .getAmount() || !FluidStack.isSameFluidSameComponents(drained, planned.fluid())) { + insertOrEjectContainer(planned.inventory(), extracted); + return false; + } + insertOrEjectContainer(planned.inventory(), resultContainer); + } + return true; + } + + private void insertOrEjectContainer(IItemHandler preferredInventory, ItemStack container) { + if (container.isEmpty()) + return; + ItemStack remainder = ItemHandlerHelper.insertItem(preferredInventory, container, false); + for (IItemHandler inventory : attachedInventories) { + if (remainder.isEmpty()) + return; + if (inventory == preferredInventory) + continue; + remainder = ItemHandlerHelper.insertItem(inventory, remainder, false); + } + if (!remainder.isEmpty()) + Block.popResource(level, worldPosition.above(), remainder); + } + + private record FluidDrainPlan(List tanks, List items) {} + + private record TankDrain(IFluidHandler handler, FluidStack fluid) {} + + private record ItemDrain(IItemHandler inventory, int slot, FluidStack fluid) {} + public void finishedPrinting() { if (replaceMode == ConfigureSchematicannonPacket.Option.REPLACE_EMPTY.ordinal()) printer.sendBlockUpdates(level); @@ -598,6 +858,8 @@ public void finishedPrinting() { protected void resetPrinter() { printer.resetSchematic(); missingItem = null; + missingFluid = FluidStack.EMPTY; + waitingForStageBarrier = false; sendUpdate = true; schematicProgress = 0; blocksPlaced = 0; @@ -787,7 +1049,8 @@ public static BlockState stripBeltIfNotLast(BlockState blockState) { .getAxis()); } - protected void launchBlockOrBelt(BlockPos target, ItemStack icon, BlockState blockState, BlockEntity blockEntity) { + protected void launchBlockOrBelt(BlockPos target, ItemStack icon, BlockState blockState, BlockEntity blockEntity, + FluidStack containedFluid) { if (AllBlocks.BELT.has(blockState)) { blockState = stripBeltIfNotLast(blockState); if (blockEntity instanceof BeltBlockEntity bbe && AllBlocks.BELT.has(blockState)) { @@ -808,12 +1071,12 @@ protected void launchBlockOrBelt(BlockPos target, ItemStack icon, BlockState blo } launchBelt(target, blockState, bbe.beltLength, casings); } else if (blockState != Blocks.AIR.defaultBlockState()) - launchBlock(target, icon, blockState, null); + launchBlock(target, icon, blockState, null, containedFluid); return; } CompoundTag data = BlockHelper.prepareBlockEntityData(level, blockState, blockEntity); - launchBlock(target, icon, blockState, data); + launchBlock(target, icon, blockState, data, containedFluid); } protected void launchBelt(BlockPos target, BlockState state, int length, CasingType[] casings) { @@ -824,9 +1087,21 @@ protected void launchBelt(BlockPos target, BlockState state, int length, CasingT } protected void launchBlock(BlockPos target, ItemStack stack, BlockState state, @Nullable CompoundTag data) { + launchBlock(target, stack, state, data, FluidStack.EMPTY); + } + + protected void launchBlock(BlockPos target, ItemStack stack, BlockState state, @Nullable CompoundTag data, + FluidStack containedFluid) { if (!state.isAir()) blocksPlaced++; - flyingBlocks.add(new LaunchedItem.ForBlockState(this.getBlockPos(), target, stack, state, data)); + flyingBlocks.add( + new LaunchedItem.ForBlockState(this.getBlockPos(), target, stack, state, data, containedFluid)); + playFiringSound(); + } + + protected void launchFluid(BlockPos target, FluidStack fluid) { + blocksPlaced++; + flyingBlocks.add(new LaunchedItem.ForFluid(this.getBlockPos(), target, fluid)); playFiringSound(); } @@ -853,6 +1128,7 @@ public Component getDisplayName() { public void updateChecklist() { checklist.required.clear(); checklist.damageRequired.clear(); + checklist.requiredFluids.clear(); checklist.blocksNotLoaded = false; if (printer.isLoaded() && !printer.isErrored()) { @@ -862,6 +1138,7 @@ public void updateChecklist() { } checklist.gathered.clear(); + checklist.gatheredFluids.clear(); findInventories(); for (IItemHandler cap : attachedInventories) { if (cap == null) @@ -872,8 +1149,14 @@ public void updateChecklist() { .isEmpty()) continue; checklist.collect(stackInSlot); + FluidUtil.getFluidContained(stackInSlot) + .ifPresent(fluid -> checklist.collect(fluid.copyWithAmount(fluid.getAmount() * stackInSlot.getCount()))); } } + for (IFluidHandler handler : attachedFluidInventories) { + for (int tank = 0; tank < handler.getTanks(); tank++) + checklist.collect(handler.getFluidInTank(tank)); + } sendUpdate = true; } @@ -895,32 +1178,37 @@ public AABB getRenderBoundingBox() { @Override protected void applyImplicitComponents(DataComponentInput componentInput) { SchematicannonOptions options = componentInput.getOrDefault(AllDataComponents.SCHEMATICANNON_OPTIONS, - new SchematicannonOptions(2, true, false)); + new SchematicannonOptions(2, true, false, false)); replaceMode = options.replaceMode; skipMissing = options.skipMissing; + skipMissingFluid = options.skipMissingFluid; replaceBlockEntities = options.replaceBlockEntities; } @Override protected void collectImplicitComponents(Builder components) { components.set(AllDataComponents.SCHEMATICANNON_OPTIONS, - new SchematicannonOptions(replaceMode, skipMissing, replaceBlockEntities)); + new SchematicannonOptions(replaceMode, skipMissing, skipMissingFluid, replaceBlockEntities)); } public enum State { STOPPED, PAUSED, RUNNING; } - public record SchematicannonOptions(int replaceMode, boolean skipMissing, boolean replaceBlockEntities) { + public record SchematicannonOptions(int replaceMode, boolean skipMissing, boolean skipMissingFluid, + boolean replaceBlockEntities) { public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( Codec.INT.fieldOf("replace_mode").forGetter(SchematicannonOptions::replaceMode), Codec.BOOL.fieldOf("skip_missing").forGetter(SchematicannonOptions::skipMissing), + Codec.BOOL.optionalFieldOf("skip_missing_fluid", false) + .forGetter(SchematicannonOptions::skipMissingFluid), Codec.BOOL.fieldOf("replace_block_entities").forGetter(SchematicannonOptions::replaceBlockEntities) ).apply(i, SchematicannonOptions::new)); public static final StreamCodec STREAM_CODEC = StreamCodec.composite( ByteBufCodecs.INT, SchematicannonOptions::replaceMode, ByteBufCodecs.BOOL, SchematicannonOptions::skipMissing, + ByteBufCodecs.BOOL, SchematicannonOptions::skipMissingFluid, ByteBufCodecs.BOOL, SchematicannonOptions::replaceBlockEntities, SchematicannonOptions::new ); diff --git a/src/main/java/com/simibubi/create/content/schematics/cannon/SchematicannonRenderer.java b/src/main/java/com/simibubi/create/content/schematics/cannon/SchematicannonRenderer.java index ab69eb14546..e2088eff4f7 100644 --- a/src/main/java/com/simibubi/create/content/schematics/cannon/SchematicannonRenderer.java +++ b/src/main/java/com/simibubi/create/content/schematics/cannon/SchematicannonRenderer.java @@ -8,6 +8,8 @@ import com.simibubi.create.content.schematics.cannon.LaunchedItem.ForBelt; import com.simibubi.create.content.schematics.cannon.LaunchedItem.ForBlockState; import com.simibubi.create.content.schematics.cannon.LaunchedItem.ForEntity; +import com.simibubi.create.content.schematics.cannon.LaunchedItem.ForFluid; +import com.simibubi.create.foundation.fluid.FluidRenderer; import com.simibubi.create.foundation.blockEntity.renderer.SafeBlockEntityRenderer; import dev.engine_room.flywheel.api.visualization.VisualizationManager; @@ -181,6 +183,10 @@ private static void renderLaunchedBlocks(SchematicannonBlockEntity blockEntity, .getBlockRenderer() .renderSingleBlock(state, ms, buffer, light, overlay, VirtualRenderHelper.VIRTUAL_DATA, null); + } else if (launched instanceof ForFluid fluid) { + float scale = .3f; + ms.scale(scale, scale, scale); + FluidRenderer.renderFluidStream(fluid.fluid, Direction.DOWN, .5f, 1, false, buffer, ms, light); } else if (launched instanceof ForEntity) { // Render the item float scale = 1.2f; diff --git a/src/main/java/com/simibubi/create/content/schematics/cannon/SchematicannonScreen.java b/src/main/java/com/simibubi/create/content/schematics/cannon/SchematicannonScreen.java index f971455fe81..f06b99a08f2 100644 --- a/src/main/java/com/simibubi/create/content/schematics/cannon/SchematicannonScreen.java +++ b/src/main/java/com/simibubi/create/content/schematics/cannon/SchematicannonScreen.java @@ -55,6 +55,8 @@ public class SchematicannonScreen extends AbstractSimiContainerScreen sendOptionUpdate(Option.SKIP_FLUID, !menu.contentHolder.skipMissingFluid)); + skipMissingFluidButton.setToolTip(CreateLang.translateDirect("gui.schematicannon.option.skipMissingFluid")); + skipMissingFluidIndicator = new Indicator(x + 129, y + 111, CommonComponents.EMPTY); + Collections.addAll(placementSettingWidgets, skipMissingFluidButton); + + skipBlockEntitiesButton = new IconButton(x + 147, y + 111, AllIcons.I_SKIP_BLOCK_ENTITIES); skipBlockEntitiesButton.withCallback(() -> { sendOptionUpdate(Option.SKIP_BLOCK_ENTITIES, !menu.contentHolder.replaceBlockEntities); }); skipBlockEntitiesButton.setToolTip(CreateLang.translateDirect("gui.schematicannon.option.skipBlockEntities")); - skipBlockEntitiesIndicator = new Indicator(x + 129, y + 111, CommonComponents.EMPTY); + skipBlockEntitiesIndicator = new Indicator(x + 147, y + 111, CommonComponents.EMPTY); Collections.addAll(placementSettingWidgets, skipBlockEntitiesButton); addRenderableWidgets(placementSettingWidgets); @@ -199,6 +208,7 @@ protected void containerTick() { replaceLevelIndicators.get(replaceMode).state = replaceMode == be.replaceMode ? State.ON : State.OFF; } skipMissingButton.green = be.skipMissing; + skipMissingFluidButton.green = be.skipMissingFluid; skipBlockEntitiesButton.green = !be.replaceBlockEntities; } @@ -249,6 +259,7 @@ protected void handleTooltips() { if (hasShiftDown()) { fillToolTip(skipMissingButton, skipMissingIndicator, "skipMissing"); + fillToolTip(skipMissingFluidButton, skipMissingFluidIndicator, "skipMissingFluid"); fillToolTip(skipBlockEntitiesButton, skipBlockEntitiesIndicator, "skipBlockEntities"); fillToolTip(replaceLevelButtons.get(0), replaceLevelIndicators.get(0), "dontReplaceSolid"); fillToolTip(replaceLevelButtons.get(1), replaceLevelIndicators.get(1), "replaceWithSolid"); @@ -301,12 +312,18 @@ protected void renderBg(GuiGraphics graphics, float partialTicks, int mouseX, in Component msg = CreateLang.translateDirect("schematicannon.status." + be.statusMsg); int stringWidth = font.width(msg); - if (be.missingItem != null) { + if (be.missingItem != null && !be.missingItem.isEmpty()) { stringWidth += 16; GuiGameElement.of(be.missingItem).at(x + 128, y + 49, 100) .scale(1) .render(graphics); + } else if (be.missingFluid != null && !be.missingFluid.isEmpty()) { + stringWidth += 16; + GuiGameElement.of(be.missingFluid.getFluid()) + .at(x + 128, y + 49, 100) + .scale(16) + .render(graphics); } graphics.drawString(font, msg, x + 103 - stringWidth / 2, y + 53, 0xDDEEFF); @@ -370,12 +387,22 @@ protected void renderForeground(GuiGraphics graphics, int mouseX, int mouseY, fl mouseY); } - if (be.missingItem != null) { + if (be.missingItem != null && !be.missingItem.isEmpty()) { int missingBlockX = x + 128, missingBlockY = y + 49; if (mouseX >= missingBlockX && mouseY >= missingBlockY && mouseX <= missingBlockX + 16 && mouseY <= missingBlockY + 16) { graphics.renderTooltip(font, be.missingItem, mouseX, mouseY); } + } else if (be.missingFluid != null && !be.missingFluid.isEmpty()) { + int missingFluidX = x + 128; + int missingFluidY = y + 49; + if (mouseX >= missingFluidX && mouseY >= missingFluidY && mouseX <= missingFluidX + 16 + && mouseY <= missingFluidY + 16) + graphics.renderComponentTooltip(font, + List.of(be.missingFluid.getHoverName(), + Component.literal(be.missingFluid.getAmount() + " mB") + .withStyle(ChatFormatting.GRAY)), + mouseX, mouseY); } int paperX = x + 112, paperY = y + 19; diff --git a/src/main/java/com/simibubi/create/content/schematics/client/FluidSbbBuilder.java b/src/main/java/com/simibubi/create/content/schematics/client/FluidSbbBuilder.java new file mode 100644 index 00000000000..da54b8ab487 --- /dev/null +++ b/src/main/java/com/simibubi/create/content/schematics/client/FluidSbbBuilder.java @@ -0,0 +1,96 @@ +package com.simibubi.create.content.schematics.client; + +import org.joml.Matrix3f; +import org.joml.Matrix4f; + +import com.mojang.blaze3d.vertex.BufferBuilder; +import com.mojang.blaze3d.vertex.ByteBufferBuilder; +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.MeshData; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.vertex.VertexFormat; + +import dev.engine_room.flywheel.lib.math.MatrixMath; +import net.createmod.catnip.render.MutableTemplateMesh; +import net.createmod.catnip.render.ShadeSeparatingSuperByteBuffer; +import net.createmod.catnip.render.SuperByteBuffer; +import net.createmod.catnip.render.TemplateMesh; +import net.minecraft.client.renderer.block.model.BakedQuad; + +public class FluidSbbBuilder implements VertexConsumer { + private static final ByteBufferBuilder BYTE_BUFFER_BUILDER = new ByteBufferBuilder(512); + + private final PoseStack poseStack; + private BufferBuilder bufferBuilder; + + private FluidSbbBuilder(PoseStack poseStack) { + this.poseStack = poseStack; + } + + public static FluidSbbBuilder create(PoseStack poseStack) { + return new FluidSbbBuilder(poseStack); + } + + public void begin() { + bufferBuilder = new BufferBuilder(BYTE_BUFFER_BUILDER, VertexFormat.Mode.QUADS, DefaultVertexFormat.BLOCK); + } + + public SuperByteBuffer end() { + MeshData data = bufferBuilder.build(); + if (data == null) + return new ShadeSeparatingSuperByteBuffer(new TemplateMesh(0)); + TemplateMesh mesh = new MutableTemplateMesh(data).toImmutable(); + data.close(); + return new ShadeSeparatingSuperByteBuffer(mesh); + } + + @Override + public void putBulkData(PoseStack.Pose pose, BakedQuad quad, float red, float green, float blue, float alpha, + int packedLight, int packedOverlay) { + bufferBuilder.putBulkData(pose, quad, red, green, blue, alpha, packedLight, packedOverlay); + } + + @Override + public void putBulkData(PoseStack.Pose pose, BakedQuad quad, float[] brightnesses, float red, float green, + float blue, float alpha, int[] lights, int overlay, boolean readExistingColor) { + bufferBuilder.putBulkData(pose, quad, brightnesses, red, green, blue, alpha, lights, overlay, + readExistingColor); + } + + @Override + public VertexConsumer addVertex(float x, float y, float z) { + Matrix4f matrix = poseStack.last() + .pose(); + return bufferBuilder.addVertex(MatrixMath.transformPositionX(matrix, x, y, z), + MatrixMath.transformPositionY(matrix, x, y, z), MatrixMath.transformPositionZ(matrix, x, y, z)); + } + + @Override + public VertexConsumer setColor(int red, int green, int blue, int alpha) { + return bufferBuilder.setColor(red, green, blue, alpha); + } + + @Override + public VertexConsumer setUv(float u, float v) { + return bufferBuilder.setUv(u, v); + } + + @Override + public VertexConsumer setUv1(int u, int v) { + return bufferBuilder.setUv1(u, v); + } + + @Override + public VertexConsumer setUv2(int u, int v) { + return bufferBuilder.setUv2(u, v); + } + + @Override + public VertexConsumer setNormal(float x, float y, float z) { + Matrix3f matrix = poseStack.last() + .normal(); + return bufferBuilder.setNormal(MatrixMath.transformNormalX(matrix, x, y, z), + MatrixMath.transformNormalY(matrix, x, y, z), MatrixMath.transformNormalZ(matrix, x, y, z)); + } +} diff --git a/src/main/java/com/simibubi/create/content/schematics/client/SchematicRenderer.java b/src/main/java/com/simibubi/create/content/schematics/client/SchematicRenderer.java index b2f43194831..f967a4ff09e 100644 --- a/src/main/java/com/simibubi/create/content/schematics/client/SchematicRenderer.java +++ b/src/main/java/com/simibubi/create/content/schematics/client/SchematicRenderer.java @@ -15,17 +15,20 @@ import net.createmod.catnip.render.SuperByteBuffer; import net.createmod.catnip.render.SuperRenderTypeBuffer; import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.ItemBlockRenderTypes; import net.minecraft.client.renderer.RenderType; import net.minecraft.client.renderer.block.BlockRenderDispatcher; import net.minecraft.client.renderer.block.ModelBlockRenderer; import net.minecraft.client.renderer.texture.OverlayTexture; import net.minecraft.client.resources.model.BakedModel; import net.minecraft.core.BlockPos; +import net.minecraft.core.SectionPos; import net.minecraft.util.RandomSource; import net.minecraft.world.level.block.RenderShape; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.material.FluidState; import net.neoforged.neoforge.client.model.data.ModelData; @@ -34,6 +37,7 @@ public class SchematicRenderer { private static final ThreadLocal THREAD_LOCAL_OBJECTS = ThreadLocal.withInitial(ThreadLocalObjects::new); private final Map bufferCache = new LinkedHashMap<>(getLayerCount()); + private final Map fluidBufferCache = new LinkedHashMap<>(getLayerCount()); private boolean changed; protected final SchematicLevel schematic; private final BlockPos anchor; @@ -67,6 +71,7 @@ public void render(PoseStack ms, SuperRenderTypeBuffer buffers) { bufferCache.forEach((layer, buffer) -> { buffer.renderInto(ms, buffers.getBuffer(layer)); }); + fluidBufferCache.forEach((layer, buffer) -> buffer.renderInto(ms, buffers.getBuffer(layer))); scratchErroredBlockEntities.clear(); BlockEntityRenderHelper.renderBlockEntities(renderedBlockEntities, shouldRenderBlockEntities, scratchErroredBlockEntities, null, schematic, ms, null, buffers, AnimationTickHolder.getPartialTicks()); @@ -76,11 +81,15 @@ public void render(PoseStack ms, SuperRenderTypeBuffer buffers) { protected void redraw() { bufferCache.clear(); + fluidBufferCache.clear(); for (RenderType layer : RenderType.chunkBufferLayers()) { SuperByteBuffer buffer = drawLayer(layer); if (!buffer.isEmpty()) bufferCache.put(layer, buffer); + SuperByteBuffer fluidBuffer = drawFluidLayer(layer); + if (!fluidBuffer.isEmpty()) + fluidBufferCache.put(layer, fluidBuffer); } } @@ -128,6 +137,35 @@ protected SuperByteBuffer drawLayer(RenderType layer) { return sbbBuilder.end(); } + protected SuperByteBuffer drawFluidLayer(RenderType layer) { + BlockRenderDispatcher dispatcher = Minecraft.getInstance() + .getBlockRenderer(); + ThreadLocalObjects objects = THREAD_LOCAL_OBJECTS.get(); + PoseStack poseStack = objects.poseStack; + BlockPos.MutableBlockPos mutableBlockPos = objects.mutableBlockPos; + BoundingBox bounds = schematic.getBounds(); + FluidSbbBuilder builder = objects.fluidSbbBuilder; + builder.begin(); + + schematic.renderMode = true; + for (BlockPos localPos : BlockPos.betweenClosed(bounds.minX(), bounds.minY(), bounds.minZ(), bounds.maxX(), + bounds.maxY(), bounds.maxZ())) { + BlockPos worldPos = mutableBlockPos.setWithOffset(localPos, anchor); + BlockState state = schematic.getBlockState(worldPos); + FluidState fluid = state.getFluidState(); + if (fluid.isEmpty() || ItemBlockRenderTypes.getRenderLayer(fluid) != layer) + continue; + poseStack.pushPose(); + poseStack.translate(localPos.getX() - SectionPos.sectionRelative(worldPos.getX()), + localPos.getY() - SectionPos.sectionRelative(worldPos.getY()), + localPos.getZ() - SectionPos.sectionRelative(worldPos.getZ())); + dispatcher.renderLiquid(worldPos, schematic, builder, state, fluid); + poseStack.popPose(); + } + schematic.renderMode = false; + return builder.end(); + } + private static int getLayerCount() { return RenderType.chunkBufferLayers() .size(); @@ -138,6 +176,7 @@ private static class ThreadLocalObjects { public final RandomSource random = RandomSource.createNewThreadLocalInstance(); public final BlockPos.MutableBlockPos mutableBlockPos = new BlockPos.MutableBlockPos(); public final ShadedBlockSbbBuilder sbbBuilder = ShadedBlockSbbBuilder.create(); + public final FluidSbbBuilder fluidSbbBuilder = FluidSbbBuilder.create(poseStack); } } diff --git a/src/main/java/com/simibubi/create/content/schematics/requirement/ItemRequirement.java b/src/main/java/com/simibubi/create/content/schematics/requirement/ItemRequirement.java index 3df40b4188e..d90935b53b0 100644 --- a/src/main/java/com/simibubi/create/content/schematics/requirement/ItemRequirement.java +++ b/src/main/java/com/simibubi/create/content/schematics/requirement/ItemRequirement.java @@ -29,6 +29,7 @@ import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.DirtPathBlock; import net.minecraft.world.level.block.FarmBlock; +import net.minecraft.world.level.block.LiquidBlock; import net.minecraft.world.level.block.SeaPickleBlock; import net.minecraft.world.level.block.SnowLayerBlock; import net.minecraft.world.level.block.TurtleEggBlock; @@ -37,15 +38,25 @@ import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.properties.BlockStateProperties; import net.minecraft.world.level.block.state.properties.SlabType; +import net.minecraft.world.level.material.Fluid; +import net.minecraft.world.level.material.FluidState; + +import net.neoforged.neoforge.fluids.FluidType; public class ItemRequirement { - public static final ItemRequirement NONE = new ItemRequirement(Collections.emptyList()); - public static final ItemRequirement INVALID = new ItemRequirement(Collections.emptyList()); + public static final ItemRequirement NONE = new ItemRequirement(Collections.emptyList(), Collections.emptyList()); + public static final ItemRequirement INVALID = new ItemRequirement(Collections.emptyList(), Collections.emptyList()); protected List requiredItems; + protected List requiredFluids; public ItemRequirement(List requiredItems) { + this(requiredItems, Collections.emptyList()); + } + + public ItemRequirement(List requiredItems, List requiredFluids) { this.requiredItems = requiredItems; + this.requiredFluids = requiredFluids; } public ItemRequirement(StackRequirement stackRequirement) { @@ -90,6 +101,15 @@ public static ItemRequirement of(BlockState state, @Nullable BlockEntity be) { } } + FluidState fluidState = state.getFluidState(); + if (!fluidState.isEmpty() && fluidState.isSource()) { + if (requirement.isInvalid() && (block instanceof LiquidBlock || block.asItem() == Items.AIR)) + requirement = NONE; + if (!requirement.isInvalid()) + requirement = requirement.union(new ItemRequirement(Collections.emptyList(), + List.of(new FluidRequirement(fluidState.getType(), FluidType.BUCKET_VOLUME)))); + } + return requirement; } @@ -97,6 +117,8 @@ private static ItemRequirement defaultOf(BlockState state, BlockEntity be) { Block block = state.getBlock(); if (block == Blocks.AIR) return NONE; + if (block instanceof LiquidBlock) + return INVALID; Item item = block.asItem(); if (item == Items.AIR) @@ -173,6 +195,24 @@ public List getRequiredItems() { return requiredItems; } + public List getRequiredFluids() { + return requiredFluids; + } + + public boolean hasFluidRequirements() { + return !requiredFluids.isEmpty(); + } + + public boolean isPureFluid() { + return requiredItems.isEmpty() && !requiredFluids.isEmpty(); + } + + public ItemRequirement onlyFluids() { + if (isInvalid() || requiredFluids.isEmpty()) + return isInvalid() ? INVALID : NONE; + return new ItemRequirement(Collections.emptyList(), requiredFluids); + } + public ItemRequirement union(ItemRequirement other) { if (this.isInvalid() || other.isInvalid()) return INVALID; @@ -181,8 +221,16 @@ public ItemRequirement union(ItemRequirement other) { if (other.isEmpty()) return this; - return new ItemRequirement(Stream.concat(requiredItems.stream(), other.requiredItems.stream()) - .collect(Collectors.toList())); + return new ItemRequirement( + Stream.concat(requiredItems.stream(), other.requiredItems.stream()).collect(Collectors.toList()), + Stream.concat(requiredFluids.stream(), other.requiredFluids.stream()).collect(Collectors.toList())); + } + + public record FluidRequirement(Fluid fluid, int amount) { + public FluidRequirement { + if (amount <= 0) + throw new IllegalArgumentException("Fluid requirement amount must be positive"); + } } public enum ItemUseType { diff --git a/src/main/java/com/simibubi/create/infrastructure/gametest/CreateGameTests.java b/src/main/java/com/simibubi/create/infrastructure/gametest/CreateGameTests.java index 2c97ae18896..bea8f7fd005 100644 --- a/src/main/java/com/simibubi/create/infrastructure/gametest/CreateGameTests.java +++ b/src/main/java/com/simibubi/create/infrastructure/gametest/CreateGameTests.java @@ -2,27 +2,46 @@ import java.util.Collection; +import com.simibubi.create.infrastructure.gametest.tests.TestSchematicannonFluidIntegration; import com.simibubi.create.infrastructure.gametest.tests.TestContraptions; import com.simibubi.create.infrastructure.gametest.tests.TestFluids; import com.simibubi.create.infrastructure.gametest.tests.TestItems; import com.simibubi.create.infrastructure.gametest.tests.TestMisc; import com.simibubi.create.infrastructure.gametest.tests.TestProcessing; import com.simibubi.create.infrastructure.gametest.tests.TestRegressions; +import com.simibubi.create.infrastructure.gametest.tests.TestSchematicannonFluids; +import net.minecraft.core.component.DataComponents; +import net.minecraft.nbt.CompoundTag; import net.minecraft.gametest.framework.GameTestGenerator; import net.minecraft.gametest.framework.TestFunction; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.component.CustomData; +import net.minecraft.world.level.material.Fluids; import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.capabilities.Capabilities; +import net.neoforged.neoforge.capabilities.RegisterCapabilitiesEvent; import net.neoforged.neoforge.event.RegisterGameTestsEvent; +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.capability.IFluidHandler; +import net.neoforged.neoforge.fluids.capability.IFluidHandlerItem; @EventBusSubscriber public class CreateGameTests { + public static final String FLUID_CONTAINER_MARKER = "CreateGameTestFluidContainer"; + public static final String FLUID_CONTAINER_AMOUNT = "CreateGameTestFluidAmount"; + private static final int TEST_FLUID_CONTAINER_CAPACITY = 4000; + private static final Class[] testHolders = { TestContraptions.class, TestFluids.class, TestItems.class, TestMisc.class, + TestSchematicannonFluids.class, + TestSchematicannonFluidIntegration.class, TestProcessing.class, TestRegressions.class }; @@ -32,8 +51,118 @@ public static void registerTests(RegisterGameTestsEvent event) { event.register(CreateGameTests.class); } + @SubscribeEvent + public static void registerCapabilities(RegisterCapabilitiesEvent event) { + event.registerItem(Capabilities.FluidHandler.ITEM, (stack, context) -> { + CustomData data = stack.get(DataComponents.CUSTOM_DATA); + if (data == null || !data.contains(FLUID_CONTAINER_MARKER)) + return null; + return new GameTestFluidContainer(stack); + }, Items.DEBUG_STICK); + } + @GameTestGenerator public static Collection generateTests() { return CreateTestFunction.getTestsFrom(testHolders); } + + private static class GameTestFluidContainer implements IFluidHandlerItem { + private final ItemStack container; + + private GameTestFluidContainer(ItemStack container) { + this.container = container; + } + + @Override + public ItemStack getContainer() { + return container; + } + + @Override + public int getTanks() { + return 1; + } + + @Override + public FluidStack getFluidInTank(int tank) { + return getFluid(); + } + + @Override + public int getTankCapacity(int tank) { + return TEST_FLUID_CONTAINER_CAPACITY; + } + + @Override + public boolean isFluidValid(int tank, FluidStack stack) { + return stack.is(Fluids.WATER); + } + + @Override + public int fill(FluidStack resource, IFluidHandler.FluidAction action) { + if (!resource.is(Fluids.WATER)) + return 0; + int amount = getAmount(); + int fillAmount = Math.min(TEST_FLUID_CONTAINER_CAPACITY - amount, resource.getAmount()); + if (fillAmount <= 0) + return 0; + if (action.execute()) + setAmount(amount + fillAmount); + return fillAmount; + } + + @Override + public FluidStack drain(FluidStack resource, FluidAction action) { + if (!resource.is(Fluids.WATER)) + return FluidStack.EMPTY; + return drain(resource.getAmount(), action); + } + + @Override + public FluidStack drain(int maxDrain, FluidAction action) { + int amount = getAmount(); + int drainAmount = Math.min(amount, maxDrain); + if (drainAmount <= 0) + return FluidStack.EMPTY; + if (action.execute()) + setAmount(amount - drainAmount); + return new FluidStack(Fluids.WATER, drainAmount); + } + + private FluidStack getFluid() { + int amount = getAmount(); + return amount <= 0 ? FluidStack.EMPTY : new FluidStack(Fluids.WATER, amount); + } + + private int getAmount() { + CustomData data = container.getOrDefault(DataComponents.CUSTOM_DATA, CustomData.EMPTY); + return Math.max(0, data.copyTag() + .getInt(FLUID_CONTAINER_AMOUNT)); + } + + private void setAmount(int amount) { + CustomData.update(DataComponents.CUSTOM_DATA, container, tag -> { + tag.putBoolean(FLUID_CONTAINER_MARKER, true); + if (amount > 0) + tag.putInt(FLUID_CONTAINER_AMOUNT, amount); + else + tag.remove(FLUID_CONTAINER_AMOUNT); + }); + } + } + + public static ItemStack testFluidContainer(int amount) { + ItemStack stack = new ItemStack(Items.DEBUG_STICK); + CompoundTag data = new CompoundTag(); + data.putBoolean(FLUID_CONTAINER_MARKER, true); + data.putInt(FLUID_CONTAINER_AMOUNT, amount); + stack.set(DataComponents.CUSTOM_DATA, CustomData.of(data)); + return stack; + } + + public static int testFluidContainerAmount(ItemStack stack) { + CustomData data = stack.getOrDefault(DataComponents.CUSTOM_DATA, CustomData.EMPTY); + return data.copyTag() + .getInt(FLUID_CONTAINER_AMOUNT); + } } diff --git a/src/main/java/com/simibubi/create/infrastructure/gametest/tests/TestMisc.java b/src/main/java/com/simibubi/create/infrastructure/gametest/tests/TestMisc.java index 7c531165be9..687f0204f80 100644 --- a/src/main/java/com/simibubi/create/infrastructure/gametest/tests/TestMisc.java +++ b/src/main/java/com/simibubi/create/infrastructure/gametest/tests/TestMisc.java @@ -1,6 +1,6 @@ package com.simibubi.create.infrastructure.gametest.tests; -import static com.simibubi.create.infrastructure.gametest.CreateGameTestHelper.FIFTEEN_SECONDS; +import static com.simibubi.create.infrastructure.gametest.CreateGameTestHelper.THIRTY_SECONDS; import com.simibubi.create.AllBlockEntityTypes; import com.simibubi.create.AllDataComponents; @@ -29,7 +29,7 @@ @GameTestGroup(path = "misc") public class TestMisc { - @GameTest(template = "schematicannon", timeoutTicks = FIFTEEN_SECONDS) + @GameTest(template = "schematicannon", timeoutTicks = THIRTY_SECONDS) public static void schematicannon(CreateGameTestHelper helper) { // load the structure BlockPos whiteEndBottom = helper.absolutePos(new BlockPos(5, 2, 1)); diff --git a/src/main/java/com/simibubi/create/infrastructure/gametest/tests/TestSchematicannonFluidIntegration.java b/src/main/java/com/simibubi/create/infrastructure/gametest/tests/TestSchematicannonFluidIntegration.java new file mode 100644 index 00000000000..696539882ef --- /dev/null +++ b/src/main/java/com/simibubi/create/infrastructure/gametest/tests/TestSchematicannonFluidIntegration.java @@ -0,0 +1,272 @@ +package com.simibubi.create.infrastructure.gametest.tests; + +import java.util.concurrent.atomic.AtomicBoolean; + +import com.simibubi.create.AllBlockEntityTypes; +import com.simibubi.create.AllDataComponents; +import com.simibubi.create.content.schematics.SchematicExport; +import com.simibubi.create.content.schematics.SchematicItem; +import com.simibubi.create.content.schematics.cannon.SchematicannonBlockEntity; +import com.simibubi.create.content.schematics.cannon.SchematicannonBlockEntity.State; +import com.simibubi.create.foundation.utility.CreatePaths; +import com.simibubi.create.infrastructure.gametest.CreateGameTestHelper; +import com.simibubi.create.infrastructure.gametest.GameTestGroup; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.material.Fluids; + +@GameTestGroup(path = "misc") +public class TestSchematicannonFluidIntegration { + @GameTest(template = "schematicannon", timeoutTicks = CreateGameTestHelper.TWENTY_SECONDS) + public static void freeFluidWaitsForStructure(CreateGameTestHelper helper) { + ServerLevel level = helper.getLevel(); + BlockPos sourceMin = helper.absolutePos(new BlockPos(5, 2, 1)); + BlockPos sourceMax = helper.absolutePos(new BlockPos(7, 2, 3)); + BlockPos sourceCenter = helper.absolutePos(new BlockPos(6, 2, 2)); + for (BlockPos pos : BlockPos.betweenClosed(sourceMin, sourceMax)) + level.setBlockAndUpdate(pos, pos.equals(sourceCenter) ? Blocks.WATER.defaultBlockState() + : Blocks.STONE.defaultBlockState()); + + String schematicName = "schematicannon_fluid_gametest"; + SchematicExport.saveSchematic(CreatePaths.UPLOADED_SCHEMATICS_DIR.resolve("FluidGameTest"), schematicName, + true, level, sourceMin, sourceMax); + ItemStack schematic = SchematicItem.create(level, schematicName + ".nbt", "FluidGameTest"); + BlockPos anchor = helper.absolutePos(new BlockPos(30, 2, 1)); + level.getChunkAt(anchor); + schematic.set(AllDataComponents.SCHEMATIC_DEPLOYED, true); + schematic.set(AllDataComponents.SCHEMATIC_ANCHOR, anchor); + + for (BlockPos pos : BlockPos.betweenClosed(anchor, anchor.offset(2, 0, 2))) + level.setBlockAndUpdate(pos, Blocks.AIR.defaultBlockState()); + + SchematicannonBlockEntity cannon = + helper.getBlockEntity(AllBlockEntityTypes.SCHEMATICANNON.get(), new BlockPos(3, 2, 6)); + cannon.inventory.setStackInSlot(0, schematic); + cannon.state = State.RUNNING; + cannon.statusMsg = "running"; + + AtomicBoolean fluidArrivedBeforeStructure = new AtomicBoolean(); + AtomicBoolean persistedStageBarrier = new AtomicBoolean(); + helper.succeedWhen(() -> { + BlockPos targetCenter = anchor.offset(1, 0, 1); + if ("waitingForStructure".equals(cannon.statusMsg)) { + var saved = cannon.saveWithFullMetadata(level.registryAccess()); + if (saved.getBoolean("WaitingForStageBarrier") && "FREE_FLUIDS".equals(saved.getCompound("Printer") + .getString("PrintStage"))) + persistedStageBarrier.set(true); + } + if (level.getFluidState(targetCenter) + .isSource()) { + for (BlockPos pos : BlockPos.betweenClosed(anchor, anchor.offset(2, 0, 2))) { + if (pos.equals(targetCenter)) + continue; + if (!level.getBlockState(pos) + .is(Blocks.STONE)) + fluidArrivedBeforeStructure.set(true); + } + } + if (fluidArrivedBeforeStructure.get()) + helper.fail("Free fluid arrived before all containing blocks"); + if (cannon.state != State.STOPPED || !cannon.flyingBlocks.isEmpty()) + helper.fail("Schematicannon has not finished placing fluid structure"); + if (!persistedStageBarrier.get()) + helper.fail("Fluid stage barrier was not persisted while printing"); + if (!level.getFluidState(anchor.offset(1, 0, 1)) + .isSource()) + helper.fail("Schematicannon did not place the final water source"); + }); + } + + @GameTest(template = "schematicannon", timeoutTicks = CreateGameTestHelper.THIRTY_SECONDS) + public static void missingContainedFluidPlacesDryBlockWithoutExtraConsumption(CreateGameTestHelper helper) { + ServerLevel level = helper.getLevel(); + BlockPos source = helper.absolutePos(new BlockPos(5, 2, 1)); + level.setBlockAndUpdate(source, Blocks.OAK_FENCE.defaultBlockState() + .setValue(BlockStateProperties.WATERLOGGED, true)); + + String schematicName = "schematicannon_missing_contained_fluid_gametest"; + SchematicExport.saveSchematic(CreatePaths.UPLOADED_SCHEMATICS_DIR.resolve("FluidGameTest"), schematicName, + true, level, source, source); + ItemStack schematic = SchematicItem.create(level, schematicName + ".nbt", "FluidGameTest"); + BlockPos anchor = helper.absolutePos(new BlockPos(30, 2, 1)); + level.getChunkAt(anchor); + schematic.set(AllDataComponents.SCHEMATIC_DEPLOYED, true); + schematic.set(AllDataComponents.SCHEMATIC_ANCHOR, anchor); + level.setBlockAndUpdate(anchor, Blocks.AIR.defaultBlockState()); + + SchematicannonBlockEntity cannon = + helper.getBlockEntity(AllBlockEntityTypes.SCHEMATICANNON.get(), new BlockPos(3, 2, 6)); + clearAdjacentInventories(level, cannon); + ChestBlockEntity chest = placeSupplyChest(helper, new ItemStack(Items.OAK_FENCE)); + cannon.inventory.setStackInSlot(0, schematic); + cannon.inventory.setStackInSlot(4, new ItemStack(Items.GUNPOWDER)); + cannon.findInventories(); + cannon.skipMissingFluid = true; + cannon.remainingFuel = cannon.getShotsPerGunpowder(); + cannon.state = State.RUNNING; + cannon.statusMsg = "running"; + helper.runAfterDelay(5, () -> { + cannon.state = State.RUNNING; + cannon.statusMsg = "running"; + }); + + helper.succeedWhen(() -> { + if (cannon.state != State.STOPPED || !cannon.flyingBlocks.isEmpty()) + helper.fail("Schematicannon has not finished placing dry fallback; state=%s status=%s flying=%s fuel=%s" + .formatted(cannon.state, cannon.statusMsg, cannon.flyingBlocks.size(), cannon.remainingFuel)); + if (!level.getBlockState(anchor) + .is(Blocks.OAK_FENCE)) + helper.fail("Missing contained fluid should still place the containing block"); + if (level.getBlockState(anchor) + .getValue(BlockStateProperties.WATERLOGGED)) + helper.fail("Missing contained fluid should not create a waterlogged state"); + if (!level.getFluidState(anchor) + .isEmpty()) + helper.fail("Missing contained fluid should not create water"); + if (!chest.getItem(0) + .isEmpty()) + helper.fail("Dry fallback should consume exactly one containing block item"); + }); + } + + @GameTest(template = "schematicannon", timeoutTicks = CreateGameTestHelper.THIRTY_SECONDS) + public static void missingFluidForExistingDryWaterloggableBlockDoesNotConsumeBlock(CreateGameTestHelper helper) { + ServerLevel level = helper.getLevel(); + BlockPos source = helper.absolutePos(new BlockPos(5, 2, 1)); + level.setBlockAndUpdate(source, Blocks.OAK_FENCE.defaultBlockState() + .setValue(BlockStateProperties.WATERLOGGED, true)); + + String schematicName = "schematicannon_existing_dry_waterloggable_gametest"; + SchematicExport.saveSchematic(CreatePaths.UPLOADED_SCHEMATICS_DIR.resolve("FluidGameTest"), schematicName, + true, level, source, source); + ItemStack schematic = SchematicItem.create(level, schematicName + ".nbt", "FluidGameTest"); + BlockPos anchor = helper.absolutePos(new BlockPos(30, 2, 1)); + level.getChunkAt(anchor); + schematic.set(AllDataComponents.SCHEMATIC_DEPLOYED, true); + schematic.set(AllDataComponents.SCHEMATIC_ANCHOR, anchor); + level.setBlockAndUpdate(anchor, Blocks.OAK_FENCE.defaultBlockState()); + + SchematicannonBlockEntity cannon = + helper.getBlockEntity(AllBlockEntityTypes.SCHEMATICANNON.get(), new BlockPos(3, 2, 6)); + clearAdjacentInventories(level, cannon); + ChestBlockEntity chest = placeSupplyChest(helper, new ItemStack(Items.OAK_FENCE)); + cannon.inventory.setStackInSlot(0, schematic); + cannon.inventory.setStackInSlot(4, new ItemStack(Items.GUNPOWDER)); + cannon.findInventories(); + cannon.skipMissingFluid = true; + cannon.remainingFuel = cannon.getShotsPerGunpowder(); + cannon.state = State.RUNNING; + cannon.statusMsg = "running"; + helper.runAfterDelay(5, () -> { + cannon.state = State.RUNNING; + cannon.statusMsg = "running"; + }); + + helper.succeedWhen(() -> { + if (cannon.state != State.STOPPED || !cannon.flyingBlocks.isEmpty()) + helper.fail("Schematicannon has not finished skipping missing contained fluid; state=%s status=%s flying=%s fuel=%s" + .formatted(cannon.state, cannon.statusMsg, cannon.flyingBlocks.size(), cannon.remainingFuel)); + if (!level.getBlockState(anchor) + .is(Blocks.OAK_FENCE)) + helper.fail("Existing dry block should remain in place"); + if (level.getBlockState(anchor) + .getValue(BlockStateProperties.WATERLOGGED)) + helper.fail("Existing dry block should not become waterlogged without fluid"); + if (!level.getFluidState(anchor) + .isEmpty()) + helper.fail("Existing dry block should not receive water without fluid"); + if (chest.getItem(0) + .getCount() != 1) + helper.fail("Skipping missing fluid for an existing dry block must not consume the base block"); + }); + } + + @GameTest(template = "schematicannon", timeoutTicks = CreateGameTestHelper.THIRTY_SECONDS) + public static void mixedFluidSchematicPrintsLavaAndWaterloggedBlock(CreateGameTestHelper helper) { + ServerLevel level = helper.getLevel(); + BlockPos sourceMin = helper.absolutePos(new BlockPos(5, 2, 1)); + BlockPos lavaSource = sourceMin; + BlockPos stoneSource = sourceMin.east(); + BlockPos waterloggedSource = sourceMin.east(2); + level.setBlockAndUpdate(lavaSource, Blocks.LAVA.defaultBlockState()); + level.setBlockAndUpdate(stoneSource, Blocks.STONE.defaultBlockState()); + level.setBlockAndUpdate(waterloggedSource, Blocks.OAK_FENCE.defaultBlockState() + .setValue(BlockStateProperties.WATERLOGGED, true)); + + String schematicName = "schematicannon_mixed_fluid_gametest"; + SchematicExport.saveSchematic(CreatePaths.UPLOADED_SCHEMATICS_DIR.resolve("FluidGameTest"), schematicName, + true, level, sourceMin, waterloggedSource); + ItemStack schematic = SchematicItem.create(level, schematicName + ".nbt", "FluidGameTest"); + BlockPos anchor = helper.absolutePos(new BlockPos(30, 2, 1)); + level.getChunkAt(anchor); + schematic.set(AllDataComponents.SCHEMATIC_DEPLOYED, true); + schematic.set(AllDataComponents.SCHEMATIC_ANCHOR, anchor); + for (BlockPos pos : BlockPos.betweenClosed(anchor, anchor.east(2))) + level.setBlockAndUpdate(pos, Blocks.AIR.defaultBlockState()); + + SchematicannonBlockEntity cannon = + helper.getBlockEntity(AllBlockEntityTypes.SCHEMATICANNON.get(), new BlockPos(3, 2, 6)); + clearAdjacentInventories(level, cannon); + ChestBlockEntity chest = placeSupplyChest(helper, new ItemStack(Items.STONE)); + chest.setItem(1, new ItemStack(Items.OAK_FENCE)); + chest.setItem(2, new ItemStack(Items.LAVA_BUCKET)); + chest.setItem(3, new ItemStack(Items.WATER_BUCKET)); + cannon.inventory.setStackInSlot(0, schematic); + cannon.inventory.setStackInSlot(4, new ItemStack(Items.GUNPOWDER, 4)); + cannon.findInventories(); + cannon.remainingFuel = cannon.getShotsPerGunpowder(); + cannon.state = State.RUNNING; + cannon.statusMsg = "running"; + helper.runAfterDelay(5, () -> { + cannon.state = State.RUNNING; + cannon.statusMsg = "running"; + }); + + helper.succeedWhen(() -> { + if (cannon.state != State.STOPPED || !cannon.flyingBlocks.isEmpty()) + helper.fail("Schematicannon has not finished mixed fluid schematic; state=%s status=%s flying=%s" + .formatted(cannon.state, cannon.statusMsg, cannon.flyingBlocks.size())); + if (!level.getFluidState(anchor) + .isSource() || !level.getFluidState(anchor) + .is(Fluids.LAVA)) + helper.fail("Schematicannon did not place the lava source"); + if (!level.getBlockState(anchor.east()) + .is(Blocks.STONE)) + helper.fail("Schematicannon did not place the structure block between fluids"); + if (!level.getBlockState(anchor.east(2)) + .is(Blocks.OAK_FENCE)) + helper.fail("Schematicannon did not place the waterloggable block"); + if (!level.getBlockState(anchor.east(2)) + .getValue(BlockStateProperties.WATERLOGGED)) + helper.fail("Schematicannon did not fill the waterlogged block"); + if (!level.getFluidState(anchor.east(2)) + .isSource() || !level.getFluidState(anchor.east(2)) + .is(Fluids.WATER)) + helper.fail("Schematicannon did not place water inside the waterlogged block"); + }); + } + + private static void clearAdjacentInventories(ServerLevel level, SchematicannonBlockEntity cannon) { + for (Direction direction : Direction.values()) + level.setBlockAndUpdate(cannon.getBlockPos() + .relative(direction), Blocks.AIR.defaultBlockState()); + cannon.findInventories(); + } + + private static ChestBlockEntity placeSupplyChest(CreateGameTestHelper helper, ItemStack stack) { + BlockPos chestPos = new BlockPos(2, 2, 6); + helper.setBlock(chestPos, Blocks.CHEST.defaultBlockState()); + ChestBlockEntity chest = helper.getBlockEntity(BlockEntityType.CHEST, chestPos); + chest.setItem(0, stack); + return chest; + } +} diff --git a/src/main/java/com/simibubi/create/infrastructure/gametest/tests/TestSchematicannonFluids.java b/src/main/java/com/simibubi/create/infrastructure/gametest/tests/TestSchematicannonFluids.java new file mode 100644 index 00000000000..3a38bbc5607 --- /dev/null +++ b/src/main/java/com/simibubi/create/infrastructure/gametest/tests/TestSchematicannonFluids.java @@ -0,0 +1,402 @@ +package com.simibubi.create.infrastructure.gametest.tests; + +import com.mojang.serialization.DynamicOps; +import com.simibubi.create.AllBlockEntityTypes; +import com.simibubi.create.AllBlocks; +import com.simibubi.create.AllFluids; +import com.simibubi.create.content.schematics.cannon.LaunchedItem; +import com.simibubi.create.content.schematics.cannon.MaterialChecklist; +import com.simibubi.create.content.schematics.cannon.SchematicannonBlockEntity; +import com.simibubi.create.content.schematics.requirement.ItemRequirement; +import com.simibubi.create.infrastructure.gametest.CreateGameTestHelper; +import com.simibubi.create.infrastructure.gametest.CreateGameTests; +import com.simibubi.create.infrastructure.gametest.GameTestGroup; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtOps; +import net.minecraft.nbt.Tag; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestAssertException; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +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.FluidType; +import net.neoforged.neoforge.fluids.capability.templates.FluidTank; +import net.neoforged.neoforge.items.ItemStackHandler; + +@GameTestGroup(path = "fluids") +public class TestSchematicannonFluids { + private static final String TEMPLATE = "small_waterwheel"; + + @GameTest(template = TEMPLATE) + public static void requirementsDistinguishFreeContainedAndFlowingFluid(CreateGameTestHelper helper) { + ItemRequirement source = ItemRequirement.of(Blocks.WATER.defaultBlockState(), null); + assertTrue(source.isPureFluid(), "Water source should have a pure fluid requirement"); + assertEquals(FluidType.BUCKET_VOLUME, source.getRequiredFluids() + .getFirst() + .amount(), "Water source should require one bucket"); + + BlockState waterloggedFence = Blocks.OAK_FENCE.defaultBlockState() + .setValue(BlockStateProperties.WATERLOGGED, true); + ItemRequirement contained = ItemRequirement.of(waterloggedFence, null); + assertEquals(1, contained.getRequiredItems() + .size(), "Waterlogged fence should require its block item"); + assertEquals(1, contained.getRequiredFluids() + .size(), "Waterlogged fence should require water"); + + BlockState flowingWater = Blocks.WATER.defaultBlockState() + .setValue(BlockStateProperties.LEVEL, 1); + assertTrue(ItemRequirement.of(flowingWater, null) + .isInvalid(), "Flowing fluid states should not be printed"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void drainsAcrossMultipleFluidHandlers(CreateGameTestHelper helper) { + TestCannon cannon = new TestCannon(); + FluidTank first = tank(Fluids.WATER, 400); + FluidTank second = tank(Fluids.WATER, 600); + cannon.attachedFluidInventories.add(first); + cannon.attachedFluidInventories.add(second); + + assertTrue(cannon.drain(Fluids.WATER, FluidType.BUCKET_VOLUME, true), + "Combined tanks should satisfy simulation"); + assertTrue(cannon.drain(Fluids.WATER, FluidType.BUCKET_VOLUME, false), + "Combined tanks should satisfy execution"); + assertEquals(0, first.getFluidAmount(), "First tank should be empty"); + assertEquals(0, second.getFluidAmount(), "Second tank should be empty"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void partiallyDrainsLargeFluidHandler(CreateGameTestHelper helper) { + TestCannon cannon = new TestCannon(); + FluidTank tank = tank(Fluids.LAVA, 14000); + cannon.attachedFluidInventories.add(tank); + + assertTrue(cannon.drain(Fluids.LAVA, 10000, false), "Large handler should support partial draining"); + assertEquals(4000, tank.getFluidAmount(), "Large handler should retain unused fluid"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void drainsCustomFluidFromTankWithoutBucket(CreateGameTestHelper helper) { + TestCannon cannon = new TestCannon(); + var honey = AllFluids.HONEY.get() + .getSource(); + FluidTank tank = tank(honey, 2500); + cannon.attachedFluidInventories.add(tank); + + assertTrue(cannon.drain(honey, FluidType.BUCKET_VOLUME, false), + "Custom fluid should be supplied by fluid handlers without item buckets"); + assertEquals(1500, tank.getFluidAmount(), "Custom fluid tank should retain unused fluid"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void fluidItemReturnsItsContainer(CreateGameTestHelper helper) { + TestCannon cannon = new TestCannon(); + ItemStackHandler inventory = new ItemStackHandler(1); + inventory.setStackInSlot(0, new ItemStack(Items.WATER_BUCKET)); + cannon.attachedInventories.add(inventory); + + assertTrue(cannon.drain(Fluids.WATER, FluidType.BUCKET_VOLUME, false), + "Fluid item should satisfy the requirement"); + assertTrue(inventory.getStackInSlot(0) + .is(Items.BUCKET), "Empty container should be returned"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void partiallyDrainsComplexFluidItemContainer(CreateGameTestHelper helper) { + TestCannon cannon = new TestCannon(); + ItemStackHandler inventory = new ItemStackHandler(1); + inventory.setStackInSlot(0, CreateGameTests.testFluidContainer(2500)); + cannon.attachedInventories.add(inventory); + + assertTrue(cannon.drain(Fluids.WATER, FluidType.BUCKET_VOLUME, false), + "Complex fluid item should satisfy the requirement"); + ItemStack remaining = inventory.getStackInSlot(0); + assertTrue(remaining.is(Items.DEBUG_STICK), "Complex container item should be returned"); + assertEquals(1500, CreateGameTests.testFluidContainerAmount(remaining), + "Complex container should retain unused fluid"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void refusesFluidItemWhenContainerCannotBeReturned(CreateGameTestHelper helper) { + TestCannon cannon = new TestCannon(); + ItemStackHandler extractionOnly = new ItemStackHandler(1) { + @Override + public boolean isItemValid(int slot, ItemStack stack) { + return false; + } + }; + extractionOnly.setStackInSlot(0, new ItemStack(Items.WATER_BUCKET)); + cannon.attachedInventories.add(extractionOnly); + + assertTrue(!cannon.drain(Fluids.WATER, FluidType.BUCKET_VOLUME, false), + "Drain should fail when the empty container has nowhere to go"); + assertTrue(extractionOnly.getStackInSlot(0) + .is(Items.WATER_BUCKET), "Failed drain must leave the original item untouched"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void fluidProjectilePlacesAndUpdatesWaterloggedBlock(CreateGameTestHelper helper) { + BlockPos target = helper.absolutePos(new BlockPos(1, 3, 1)); + LaunchedItem.ForBlockState launched = new LaunchedItem.ForBlockState(target, target, Items.OAK_FENCE + .getDefaultInstance(), Blocks.OAK_FENCE.defaultBlockState() + .setValue(BlockStateProperties.WATERLOGGED, true), null, + new FluidStack(Fluids.WATER, FluidType.BUCKET_VOLUME)); + launched.ticksRemaining = 0; + launched.update(helper.getLevel()); + + BlockState placed = helper.getLevel() + .getBlockState(target); + assertTrue(placed.is(Blocks.OAK_FENCE), "Projectile should place the containing block"); + assertTrue(placed.getValue(BlockStateProperties.WATERLOGGED), "Projectile should fill the placed block"); + assertTrue(helper.getLevel() + .getFluidState(target) + .isSource(), "Placed contained fluid should be a source"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void missingContainedFluidPlacesDryBlock(CreateGameTestHelper helper) { + BlockPos target = helper.absolutePos(new BlockPos(1, 3, 1)); + LaunchedItem.ForBlockState launched = new LaunchedItem.ForBlockState(target, target, Items.OAK_FENCE + .getDefaultInstance(), Blocks.OAK_FENCE.defaultBlockState() + .setValue(BlockStateProperties.WATERLOGGED, true), null, FluidStack.EMPTY); + launched.ticksRemaining = 0; + launched.update(helper.getLevel()); + + BlockState placed = helper.getLevel() + .getBlockState(target); + assertTrue(placed.is(Blocks.OAK_FENCE), "Dry fallback should still place the containing block"); + assertTrue(!placed.getValue(BlockStateProperties.WATERLOGGED), "Dry fallback must not create free water"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void freeFluidProjectilePlacesSource(CreateGameTestHelper helper) { + BlockPos target = helper.absolutePos(new BlockPos(1, 3, 1)); + helper.getLevel() + .setBlockAndUpdate(target, Blocks.AIR.defaultBlockState()); + LaunchedItem.ForFluid launched = + new LaunchedItem.ForFluid(target, target, new FluidStack(Fluids.WATER, FluidType.BUCKET_VOLUME)); + launched.ticksRemaining = 0; + launched.update(helper.getLevel()); + + assertTrue(helper.getLevel() + .getFluidState(target) + .isSource(), "Free fluid projectile should place a source"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void customFluidProjectilePlacesSource(CreateGameTestHelper helper) { + BlockPos target = helper.absolutePos(new BlockPos(1, 3, 1)); + var honey = AllFluids.HONEY.get() + .getSource(); + helper.getLevel() + .setBlockAndUpdate(target, Blocks.AIR.defaultBlockState()); + LaunchedItem.ForFluid launched = + new LaunchedItem.ForFluid(target, target, new FluidStack(honey, FluidType.BUCKET_VOLUME)); + launched.ticksRemaining = 0; + launched.update(helper.getLevel()); + + assertTrue(helper.getLevel() + .getFluidState(target) + .isSource(), "Custom fluid projectile should place a source"); + assertTrue(helper.getLevel() + .getFluidState(target) + .getType() + .isSame(honey), "Custom fluid projectile should retain its fluid type"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void freeFluidProjectileSchedulesFlow(CreateGameTestHelper helper) { + BlockPos target = helper.absolutePos(new BlockPos(1, 5, 1)); + BlockPos below = target.below(); + helper.getLevel() + .setBlockAndUpdate(target, Blocks.AIR.defaultBlockState()); + helper.getLevel() + .setBlockAndUpdate(below, Blocks.AIR.defaultBlockState()); + LaunchedItem.ForFluid launched = + new LaunchedItem.ForFluid(target, target, new FluidStack(Fluids.WATER, FluidType.BUCKET_VOLUME)); + launched.ticksRemaining = 0; + launched.update(helper.getLevel()); + + helper.succeedWhen(() -> assertTrue(!helper.getLevel() + .getFluidState(below) + .isEmpty(), "Placed source should receive a fluid tick and flow")); + } + + @GameTest(template = TEMPLATE) + public static void freeFluidProjectileTriggersLavaWaterInteraction(CreateGameTestHelper helper) { + BlockPos target = helper.absolutePos(new BlockPos(1, 3, 1)); + BlockPos water = target.north(); + helper.getLevel() + .setBlockAndUpdate(target, Blocks.AIR.defaultBlockState()); + helper.getLevel() + .setBlockAndUpdate(water, Blocks.WATER.defaultBlockState()); + LaunchedItem.ForFluid launched = + new LaunchedItem.ForFluid(target, target, new FluidStack(Fluids.LAVA, FluidType.BUCKET_VOLUME)); + launched.ticksRemaining = 0; + launched.update(helper.getLevel()); + + assertTrue(helper.getLevel() + .getBlockState(target) + .is(Blocks.OBSIDIAN), "Placed lava should receive neighbor updates and interact with adjacent water"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void freeFluidProjectileReplacesApprovedTarget(CreateGameTestHelper helper) { + BlockPos target = helper.absolutePos(new BlockPos(1, 3, 1)); + helper.getLevel() + .setBlockAndUpdate(target, Blocks.STONE.defaultBlockState()); + LaunchedItem.ForFluid launched = + new LaunchedItem.ForFluid(target, target, new FluidStack(Fluids.WATER, FluidType.BUCKET_VOLUME)); + launched.ticksRemaining = 0; + launched.update(helper.getLevel()); + + assertTrue(helper.getLevel() + .getFluidState(target) + .isSource(), "Approved replacement target should become a fluid source"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void waterVaporizesInTheNether(CreateGameTestHelper helper) { + ServerLevel nether = helper.getLevel() + .getServer() + .getLevel(Level.NETHER); + if (nether == null) + throw new GameTestAssertException("Nether level is unavailable"); + + BlockPos target = new BlockPos(0, 100, 0); + nether.getChunkAt(target); + nether.setBlockAndUpdate(target, Blocks.AIR.defaultBlockState()); + LaunchedItem.ForFluid launched = + new LaunchedItem.ForFluid(target, target, new FluidStack(Fluids.WATER, FluidType.BUCKET_VOLUME)); + launched.ticksRemaining = 0; + launched.update(nether); + + assertTrue(nether.getFluidState(target) + .isEmpty(), "Water should vaporize instead of leaving a source in the Nether"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void waterloggedBlockStaysDryInTheNether(CreateGameTestHelper helper) { + ServerLevel nether = helper.getLevel() + .getServer() + .getLevel(Level.NETHER); + if (nether == null) + throw new GameTestAssertException("Nether level is unavailable"); + + BlockPos target = new BlockPos(0, 100, 0); + nether.getChunkAt(target); + nether.setBlockAndUpdate(target, Blocks.AIR.defaultBlockState()); + LaunchedItem.ForBlockState launched = new LaunchedItem.ForBlockState(target, target, Items.OAK_FENCE + .getDefaultInstance(), Blocks.OAK_FENCE.defaultBlockState() + .setValue(BlockStateProperties.WATERLOGGED, true), null, + new FluidStack(Fluids.WATER, FluidType.BUCKET_VOLUME)); + launched.ticksRemaining = 0; + launched.update(nether); + + BlockState placed = nether.getBlockState(target); + assertTrue(placed.is(Blocks.OAK_FENCE), "Contained block should still be placed"); + assertTrue(!placed.getValue(BlockStateProperties.WATERLOGGED), + "Waterlogged block should stay dry when water cannot be placed"); + assertTrue(nether.getFluidState(target) + .isEmpty(), "Water should not remain in a Nether waterlogged block"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void fluidProjectileSurvivesSerialization(CreateGameTestHelper helper) { + BlockPos target = helper.absolutePos(new BlockPos(1, 3, 1)); + LaunchedItem original = + new LaunchedItem.ForFluid(target, target, new FluidStack(Fluids.LAVA, FluidType.BUCKET_VOLUME)); + LaunchedItem decoded = LaunchedItem.fromNBT(original.serializeNBT(helper.getLevel() + .registryAccess()), helper.getLevel() + .registryAccess(), helper.getLevel() + .holderLookup(Registries.BLOCK)); + assertTrue(decoded instanceof LaunchedItem.ForFluid, "Serialized fluid projectile should retain its type"); + FluidStack fluid = ((LaunchedItem.ForFluid) decoded).fluid; + assertTrue(fluid.is(Fluids.LAVA), "Serialized fluid projectile should retain its fluid"); + assertEquals(FluidType.BUCKET_VOLUME, fluid.getAmount(), "Serialized fluid projectile should retain its amount"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void checklistTracksFluidSeparately(CreateGameTestHelper helper) { + MaterialChecklist checklist = new MaterialChecklist(); + BlockState waterloggedFence = Blocks.OAK_FENCE.defaultBlockState() + .setValue(BlockStateProperties.WATERLOGGED, true); + checklist.require(ItemRequirement.of(waterloggedFence, null)); + assertEquals(1, checklist.required.getInt(Items.OAK_FENCE), "Checklist should require the containing block"); + assertEquals(FluidType.BUCKET_VOLUME, checklist.requiredFluids.getInt(Fluids.WATER), + "Checklist should track water independently"); + checklist.collect(new FluidStack(Fluids.WATER, 400)); + assertEquals(400, checklist.gatheredFluids.getInt(Fluids.WATER), "Checklist should collect available fluid"); + helper.succeedWhen(() -> {}); + } + + @GameTest(template = TEMPLATE) + public static void oldOptionsDecodeWithFluidSkippingDisabled(CreateGameTestHelper helper) { + CompoundTag oldOptions = new CompoundTag(); + oldOptions.putInt("replace_mode", 2); + oldOptions.putBoolean("skip_missing", true); + oldOptions.putBoolean("replace_block_entities", false); + DynamicOps ops = helper.getLevel() + .registryAccess() + .createSerializationContext(NbtOps.INSTANCE); + SchematicannonBlockEntity.SchematicannonOptions options = + SchematicannonBlockEntity.SchematicannonOptions.CODEC.parse(ops, oldOptions) + .result() + .orElseThrow(() -> new GameTestAssertException("Legacy options failed to decode")); + assertTrue(!options.skipMissingFluid(), "Legacy options should default fluid skipping to false"); + helper.succeedWhen(() -> {}); + } + + private static FluidTank tank(Fluid fluid, int amount) { + FluidTank tank = new FluidTank(amount); + tank.setFluid(new FluidStack(fluid, amount)); + return tank; + } + + private static void assertTrue(boolean condition, String message) { + if (!condition) + throw new GameTestAssertException(message); + } + + private static void assertEquals(int expected, int actual, String message) { + if (expected != actual) + throw new GameTestAssertException(message + ": expected " + expected + ", got " + actual); + } + + private static class TestCannon extends SchematicannonBlockEntity { + TestCannon() { + super(AllBlockEntityTypes.SCHEMATICANNON.get(), BlockPos.ZERO, AllBlocks.SCHEMATICANNON.getDefaultState()); + } + + boolean drain(Fluid fluid, int amount, boolean simulate) { + return grabFluidFromAttachedInventories(fluid, amount, simulate); + } + } +} diff --git a/src/main/resources/assets/create/lang/default/interface.json b/src/main/resources/assets/create/lang/default/interface.json index 88e1b863c18..c87ad3c6f6b 100644 --- a/src/main/resources/assets/create/lang/default/interface.json +++ b/src/main/resources/assets/create/lang/default/interface.json @@ -483,6 +483,7 @@ "create.gui.schematicannon.option.replaceWithAny": "Replace Solid with Any", "create.gui.schematicannon.option.replaceWithEmpty": "Replace Solid with Empty", "create.gui.schematicannon.option.skipMissing": "Skip missing Blocks", + "create.gui.schematicannon.option.skipMissingFluid": "Skip missing Fluids", "create.gui.schematicannon.option.skipBlockEntities": "Protect Block Entities", "create.gui.schematicannon.slot.gunpowder": "Add gunpowder to fuel the cannon", @@ -490,6 +491,7 @@ "create.gui.schematicannon.slot.schematic": "Add your Schematic here. Make sure it is deployed at a specific location.", "create.gui.schematicannon.option.skipMissing.description": "If the cannon cannot find a required Block for placement, it will continue at the next Location.", + "create.gui.schematicannon.option.skipMissingFluid.description": "If fluid is unavailable, free fluid blocks are skipped and fluid-containing blocks are placed dry.", "create.gui.schematicannon.option.skipBlockEntities.description": "The cannon will avoid replacing data holding blocks such as Chests.", "create.gui.schematicannon.option.dontReplaceSolid.description": "The cannon will never replace any Solid blocks in its working area, only non-Solid and Air.", "create.gui.schematicannon.option.replaceWithSolid.description": "The cannon will only replace Solid blocks in its working area if the Schematic contains a solid Block at the Location.", @@ -507,7 +509,10 @@ "create.schematicannon.status.targetOutsideRange": "Target too far away", "create.schematicannon.status.searching": "Searching", "create.schematicannon.status.skipping": "Skipping", + "create.schematicannon.status.skippingFluid": "Skipping Fluid", "create.schematicannon.status.missingBlock": "Missing Item:", + "create.schematicannon.status.missingFluid": "Missing Fluid:", + "create.schematicannon.status.waitingForStructure": "Waiting for Structure", "create.schematicannon.status.placing": "Placing", "create.schematicannon.status.clearing": "Clearing Blocks", "create.schematicannon.status.schematicInvalid": "Schematic Invalid", diff --git a/src/main/resources/assets/create/lang/zh_cn.json b/src/main/resources/assets/create/lang/zh_cn.json index bb4955d6cd0..8d7e62bf876 100644 --- a/src/main/resources/assets/create/lang/zh_cn.json +++ b/src/main/resources/assets/create/lang/zh_cn.json @@ -1312,6 +1312,8 @@ "create.gui.schematicannon.option.skipBlockEntities.description": "蓝图加农炮将避免替换例如箱子的存储方块。", "create.gui.schematicannon.option.skipMissing": "跳过缺少的方块", "create.gui.schematicannon.option.skipMissing.description": "如果缺失材料,蓝图加农炮将忽略当前缺失材料并且使用其他已有材料继续工作", + "create.gui.schematicannon.option.skipMissingFluid": "跳过缺少的流体", + "create.gui.schematicannon.option.skipMissingFluid.description": "流体不足时跳过完整流体方块,并以未含流体的状态放置可含流体方块。", "create.gui.schematicannon.optionDisabled": "当前禁用", "create.gui.schematicannon.optionEnabled": "当前启用", "create.gui.schematicannon.shotsRemaining": "燃料余量:%1$s", @@ -2845,6 +2847,7 @@ "create.schematicannon.status.finished": "已完成", "create.schematicannon.status.idle": "闲置", "create.schematicannon.status.missingBlock": "缺少物品:", + "create.schematicannon.status.missingFluid": "缺少流体:", "create.schematicannon.status.noGunpowder": "火药已耗尽", "create.schematicannon.status.paused": "已暂停", "create.schematicannon.status.placing": "正在放置", @@ -2857,6 +2860,8 @@ "create.schematicannon.status.schematicNotPlaced": "蓝图未部署", "create.schematicannon.status.searching": "正在搜索", "create.schematicannon.status.skipping": "正在跳过", + "create.schematicannon.status.skippingFluid": "正在跳过流体", + "create.schematicannon.status.waitingForStructure": "正在等待结构完成", "create.schematicannon.status.stopped": "已停止", "create.schematicannon.status.targetNotLoaded": "目标未被加载", "create.schematicannon.status.targetOutsideRange": "目标太远", @@ -3618,4 +3623,4 @@ "tag.item.create.upright_on_belt": "竖立于传送带上", "tag.item.create.valve_handles": "阀门手轮", "tag.recipe_serializer.create.automation_ignore": "不可自动处理" -} \ No newline at end of file +} From fbfe7abc049d43a32164dc8167d3d318321d1520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=AC=8F=E5=A4=A9?= <1404928002@qq.com> Date: Sun, 5 Jul 2026 00:55:40 +0800 Subject: [PATCH 2/2] fix: move missing fluid icon location. --- .../create/content/schematics/cannon/SchematicannonScreen.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/simibubi/create/content/schematics/cannon/SchematicannonScreen.java b/src/main/java/com/simibubi/create/content/schematics/cannon/SchematicannonScreen.java index f06b99a08f2..af05a812898 100644 --- a/src/main/java/com/simibubi/create/content/schematics/cannon/SchematicannonScreen.java +++ b/src/main/java/com/simibubi/create/content/schematics/cannon/SchematicannonScreen.java @@ -321,7 +321,7 @@ protected void renderBg(GuiGraphics graphics, float partialTicks, int mouseX, in } else if (be.missingFluid != null && !be.missingFluid.isEmpty()) { stringWidth += 16; GuiGameElement.of(be.missingFluid.getFluid()) - .at(x + 128, y + 49, 100) + .at(x + 128, y + 49 + 16, 100) .scale(16) .render(graphics); }