From 1b89b1082706335a35e20cda0b827b0072044da1 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Sun, 1 Nov 2026 11:05:47 +0200 Subject: [PATCH 1/5] feat(machine): machine recipe types with datapack loading, lookup, datagen and JEI Phase 2 of the machine module. Mods register a MachineRecipeType through their own DeferredRegisters (MachineRecipeTypes.register); its recipe files load, reload and sync like any recipe. - STREAM_CODECs for MachineRecipe, MachineIngredient, MachineProduct and FluidAmount; the type's serializer is built from CODEC and STREAM_CODEC. - MachineRecipeEntry wraps a MachineRecipe as a vanilla Recipe; it matches through RecipeMatcher.check and stays out of the recipe book. - MachineRecipeInput exposes a MachineInventory to vanilla and is never empty, so fluid- or energy-only recipes are still asked. - MachineRecipeLookup finds the runnable recipe, trying the last one by ID so /reload is picked up. - MachineRecipeBuilder writes recipes from datagen. - JEI: a generic MachineRecipeCategory for every registered machine recipe type, sized for its largest recipe. mmdlib:crushing is unchanged; moving it is left for a separate decision. Tests: five game tests over mmdlib_smoke:grinding and its recipe files (registration, loading with a broken file skipped, lookup, network round trip, builder output equal to the file); the GUI smoke test checks the JEI category and saves a screenshot, which CI now uploads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .github/workflows/ci.yml | 4 +- scripts/gui-smoke.init.gradle | 1 + .../mc/lib/client/jei/MMDLibJeiPlugin.java | 18 + .../lib/client/jei/MachineRecipeCategory.java | 183 ++++++++++ .../lib/machine/ingredient/FluidAmount.java | 15 + .../machine/ingredient/MachineIngredient.java | 50 +++ .../machine/neoforge/MachineRecipeTypes.java | 42 +++ .../mc/lib/machine/recipe/MachineProduct.java | 43 +++ .../mc/lib/machine/recipe/MachineRecipe.java | 23 +- .../machine/recipe/MachineRecipeBuilder.java | 124 +++++++ .../machine/recipe/MachineRecipeEntry.java | 81 +++++ .../machine/recipe/MachineRecipeInput.java | 49 +++ .../machine/recipe/MachineRecipeLookup.java | 33 ++ .../lib/machine/recipe/MachineRecipeType.java | 76 ++++ .../resources/assets/mmdlib/lang/en_us.json | 3 + .../moddev/mc/lib/smoke/GuiClientSmoke.java | 21 ++ .../zone/moddev/mc/lib/smoke/GuiJeiSmoke.java | 48 +++ .../moddev/mc/lib/smoke/MMDLibGameTests.java | 30 ++ .../mc/lib/smoke/MachineRecipeTypeSmoke.java | 342 ++++++++++++++++++ .../mmdlib_smoke/recipe/grinding/broken.json | 6 + .../mmdlib_smoke/recipe/grinding/copper.json | 13 + .../mmdlib_smoke/recipe/grinding/iron.json | 13 + 22 files changed, 1216 insertions(+), 2 deletions(-) create mode 100644 src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeCategory.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineRecipeTypes.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeBuilder.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeEntry.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeInput.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeLookup.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeType.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java create mode 100644 src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/broken.json create mode 100644 src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/copper.json create mode 100644 src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/iron.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72917b1..e96273b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,12 +57,14 @@ jobs: timeout-minutes: 20 run: xvfb-run -a ./gradlew runClient -PwithoutJei -I scripts/gui-smoke.init.gradle - - name: Upload the client logs + - name: Upload the client logs and screenshots if: always() uses: actions/upload-artifact@v7 with: name: gui-smoke-logs path: | build/gui-smoke/logs/ + build/gui-smoke/screenshots/ build/gui-smoke-without-jei/logs/ + build/gui-smoke-without-jei/screenshots/ if-no-files-found: ignore diff --git a/scripts/gui-smoke.init.gradle b/scripts/gui-smoke.init.gradle index 61a94cb..b4fc49d 100644 --- a/scripts/gui-smoke.init.gradle +++ b/scripts/gui-smoke.init.gradle @@ -5,6 +5,7 @@ allprojects { afterEvaluate { if (extensions.findByName('runs') != null) { sourceSets.main.java.srcDir(file('src/runtimeTest/java')) + sourceSets.main.resources.srcDir(file('src/runtimeTest/resources')) def smokeDir = file(providers.gradleProperty('withoutJei').isPresent() ? 'build/gui-smoke-without-jei' : 'build/gui-smoke') smokeDir.mkdirs() new File(smokeDir, 'options.txt').text = 'guiScale:2\nmaxFps:30\nonboardAccessibility:false\n' diff --git a/src/main/java/zone/moddev/mc/lib/client/jei/MMDLibJeiPlugin.java b/src/main/java/zone/moddev/mc/lib/client/jei/MMDLibJeiPlugin.java index 1c086d6..3138547 100644 --- a/src/main/java/zone/moddev/mc/lib/client/jei/MMDLibJeiPlugin.java +++ b/src/main/java/zone/moddev/mc/lib/client/jei/MMDLibJeiPlugin.java @@ -17,6 +17,7 @@ import zone.moddev.mc.lib.common.recipes.CrushingRecipe; import zone.moddev.mc.lib.common.runtime.CrackhammerEvents; import zone.moddev.mc.lib.common.runtime.ItemEventBridge; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeType; /** * Optional JEI integration. Only JEI loads this class (via {@link JeiPlugin}); nothing else in @@ -57,6 +58,11 @@ public ResourceLocation getPluginUid() { public void registerCategories(IRecipeCategoryRegistration registration) { registration.addRecipeCategories( new CrushingCategory(registration.getJeiHelpers().getGuiHelper())); + var level = Minecraft.getInstance().level; + for (var type : machineRecipeTypes()) + registration.addRecipeCategories( + MachineRecipeCategory.create( + type, level == null ? List.of() : type.recipes(level.getRecipeManager()))); } @Override @@ -67,6 +73,18 @@ public void registerRecipes(IRecipeRegistration registration) { level == null ? List.of() : level.getRecipeManager().getAllRecipesFor(CrushingRecipe.TYPE.get())); + if (level != null) + for (var type : machineRecipeTypes()) + registration.addRecipes( + MachineRecipeCategory.recipeType(type), type.recipes(level.getRecipeManager())); + } + + /** Every registered machine recipe type, whichever mod registered it. */ + private static List machineRecipeTypes() { + return BuiltInRegistries.RECIPE_TYPE.stream() + .filter(MachineRecipeType.class::isInstance) + .map(MachineRecipeType.class::cast) + .toList(); } /** diff --git a/src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeCategory.java b/src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeCategory.java new file mode 100644 index 0000000..7a070d6 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeCategory.java @@ -0,0 +1,183 @@ +package zone.moddev.mc.lib.client.jei; + +import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Locale; + +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.crafting.RecipeHolder; + +import mezz.jei.api.gui.builder.IRecipeLayoutBuilder; +import mezz.jei.api.gui.builder.IRecipeSlotBuilder; +import mezz.jei.api.gui.widgets.IRecipeExtrasBuilder; +import mezz.jei.api.recipe.IFocusGroup; +import mezz.jei.api.recipe.RecipeIngredientRole; +import mezz.jei.api.recipe.RecipeType; +import mezz.jei.api.recipe.category.AbstractRecipeCategory; + +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; +import zone.moddev.mc.lib.machine.recipe.MachineProduct; +import zone.moddev.mc.lib.machine.recipe.MachineRecipe; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeEntry; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeType; + +/** + * The JEI category MMDLib registers for every {@link MachineRecipeType}: item and fluid inputs, an + * arrow, the products, then the energy and the time. It is sized for the largest recipe loaded. Its + * title is the translation {@code jei..} of the type's ID. A mod adds its machines + * as catalysts from its own JEI plugin, with {@link #recipeType}. + */ +public final class MachineRecipeCategory + extends AbstractRecipeCategory> { + private static final int CELL = 18, COLUMNS = 3, ARROW = 24, GAP = 4, TEXT = 10; + private static final int TEXT_COLOR = 0xFF808080; + + private final int inputColumns, rows; + + private MachineRecipeCategory( + MachineRecipeType type, int inputColumns, int outputColumns, int rows) { + super( + recipeType(type), + Component.translatableWithFallback( + "jei." + type.id().getNamespace() + "." + type.id().getPath(), type.id().toString()), + null, + inputColumns * CELL + GAP + ARROW + GAP + outputColumns * CELL, + rows * CELL + TEXT); + this.inputColumns = inputColumns; + this.rows = rows; + } + + /** The JEI recipe type of {@code type}, for catalysts and recipe lookups. */ + public static RecipeType> recipeType(MachineRecipeType type) { + return RecipeType.createRecipeHolderType(type.id()); + } + + /** A category for {@code type}, wide and tall enough for every recipe in {@code recipes}. */ + static MachineRecipeCategory create( + MachineRecipeType type, Collection> recipes) { + int inputs = 1, outputs = 1; + for (var holder : recipes) { + inputs = Math.max(inputs, inputSlots(holder.value().recipe())); + outputs = Math.max(outputs, outputSlots(holder.value().recipe())); + } + int inputColumns = Math.min(COLUMNS, inputs), outputColumns = Math.min(COLUMNS, outputs); + int rows = Math.max(ceil(inputs, inputColumns), ceil(outputs, outputColumns)); + return new MachineRecipeCategory(type, inputColumns, outputColumns, rows); + } + + @Override + public void setRecipe( + IRecipeLayoutBuilder builder, RecipeHolder holder, IFocusGroup focuses) { + var recipe = holder.value().recipe(); + int index = 0; + for (var parts : recipe.inputs().values()) + for (var part : parts) { + switch (part) { + case MachineIngredient.ItemPart items -> + slot(builder, RecipeIngredientRole.INPUT, 0, index++) + .addItemStacks( + Arrays.stream(items.items().getItems()) + .map(stack -> stack.copyWithCount(items.count())) + .toList()); + case MachineIngredient.FluidPart fluids -> { + var slot = slot(builder, RecipeIngredientRole.INPUT, 0, index++); + fluids + .fluids() + .forEach(fluid -> slot.addFluidStack(fluid.value(), fluids.millibuckets())); + slot.setFluidRenderer(fluids.millibuckets(), false, 16, 16); + } + case MachineIngredient.EnergyPart energy -> {} + } + } + int outputX = inputColumns * CELL + GAP + ARROW + GAP; + index = 0; + for (var products : recipe.outputs().values()) + for (var product : products) { + var slot = slot(builder, RecipeIngredientRole.OUTPUT, outputX, index++); + switch (product) { + case MachineProduct.ItemProduct item -> slot.addItemStack(item.stack()); + case MachineProduct.FluidProduct fluid -> { + slot.addFluidStack( + fluid.fluid().fluid().value(), fluid.fluid().amount(), fluid.fluid().components()); + slot.setFluidRenderer(fluid.fluid().amount(), false, 16, 16); + } + } + if (product.chance() < 1) + slot.addRichTooltipCallback( + (view, tooltip) -> + tooltip.add( + Component.translatable( + "jei.mmdlib.machine.chance", percent(product.chance())))); + } + } + + @Override + public void createRecipeExtras( + IRecipeExtrasBuilder builder, RecipeHolder holder, IFocusGroup focuses) { + var recipe = holder.value().recipe(); + builder.addRecipeArrowWidget().setPosition(inputColumns * CELL + GAP, (rows * CELL - 17) / 2); + var lines = new ArrayList(); + long energy = + recipe.inputs().values().stream() + .flatMap(List::stream) + .filter(MachineIngredient.EnergyPart.class::isInstance) + .mapToLong(MachineIngredient::amount) + .sum(); + if (energy > 0) + lines.add( + Component.translatable( + "jei.mmdlib.machine.energy", NumberFormat.getIntegerInstance().format(energy))); + lines.add(Component.translatable("jei.mmdlib.machine.time", seconds(recipe.time()))); + builder + .addText(joined(lines), getWidth(), TEXT) + .setPosition(0, rows * CELL + 1) + .setColor(TEXT_COLOR); + } + + private IRecipeSlotBuilder slot( + IRecipeLayoutBuilder builder, RecipeIngredientRole role, int originX, int index) { + return builder + .addSlot(role, originX + index % COLUMNS * CELL + 1, index / COLUMNS * CELL + 1) + .setStandardSlotBackground(); + } + + private static int inputSlots(MachineRecipe recipe) { + return (int) + recipe.inputs().values().stream() + .flatMap(List::stream) + .filter(part -> !(part instanceof MachineIngredient.EnergyPart)) + .count(); + } + + private static int outputSlots(MachineRecipe recipe) { + return recipe.outputs().values().stream().mapToInt(List::size).sum(); + } + + private static int ceil(int count, int columns) { + return Math.max(1, (count + columns - 1) / columns); + } + + private static String percent(float chance) { + var format = NumberFormat.getNumberInstance(Locale.ROOT); + format.setMaximumFractionDigits(1); + return format.format(chance * 100); + } + + private static String seconds(int ticks) { + var format = NumberFormat.getNumberInstance(); + format.setMaximumFractionDigits(2); + return format.format(ticks / 20.0); + } + + private static Component joined(List parts) { + var result = Component.empty(); + for (int i = 0; i < parts.size(); i++) { + if (i > 0) result.append(" ยท "); + result.append(parts.get(i)); + } + return result; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/ingredient/FluidAmount.java b/src/main/java/zone/moddev/mc/lib/machine/ingredient/FluidAmount.java index b39f524..1773db0 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/ingredient/FluidAmount.java +++ b/src/main/java/zone/moddev/mc/lib/machine/ingredient/FluidAmount.java @@ -8,6 +8,10 @@ import net.minecraft.core.Holder; import net.minecraft.core.component.DataComponentPatch; import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; import net.minecraft.world.level.material.Fluid; import net.minecraft.world.level.material.Fluids; @@ -39,6 +43,17 @@ public record FluidAmount(Holder fluid, DataComponentPatch components, in .forGetter(FluidAmount::amount)) .apply(instance, FluidAmount::new)); + /** The network form. */ + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.holderRegistry(Registries.FLUID), + FluidAmount::fluid, + DataComponentPatch.STREAM_CODEC, + FluidAmount::components, + ByteBufCodecs.VAR_INT, + FluidAmount::amount, + FluidAmount::new); + /** * @throws IllegalArgumentException if {@code amount} is negative */ diff --git a/src/main/java/zone/moddev/mc/lib/machine/ingredient/MachineIngredient.java b/src/main/java/zone/moddev/mc/lib/machine/ingredient/MachineIngredient.java index f7ffbe0..5c6dd0a 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/ingredient/MachineIngredient.java +++ b/src/main/java/zone/moddev/mc/lib/machine/ingredient/MachineIngredient.java @@ -13,9 +13,14 @@ import net.minecraft.core.HolderSet; import net.minecraft.core.RegistryCodecs; import net.minecraft.core.registries.Registries; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; import net.minecraft.world.item.crafting.Ingredient; import net.minecraft.world.level.material.Fluid; +import io.netty.handler.codec.DecoderException; + /** * Some amount of something a machine consumes: items, fluid or energy. JSON is one of {@code * {"items": , "count": 1}}, {@code {"fluids": "minecraft:water", "amount": 250}} or @@ -31,6 +36,35 @@ public sealed interface MachineIngredient */ Codec CODEC = new Dispatch().codec(); + /** The network form: a kind byte, then the record. Item tags arrive as the items in them. */ + StreamCodec STREAM_CODEC = + StreamCodec.of( + (buffer, ingredient) -> { + switch (ingredient) { + case ItemPart part -> { + buffer.writeByte(0); + ItemPart.STREAM_CODEC.encode(buffer, part); + } + case FluidPart part -> { + buffer.writeByte(1); + FluidPart.STREAM_CODEC.encode(buffer, part); + } + case EnergyPart part -> { + buffer.writeByte(2); + EnergyPart.STREAM_CODEC.encode(buffer, part); + } + } + }, + buffer -> { + int kind = buffer.readByte(); + return switch (kind) { + case 0 -> ItemPart.STREAM_CODEC.decode(buffer); + case 1 -> FluidPart.STREAM_CODEC.decode(buffer); + case 2 -> EnergyPart.STREAM_CODEC.decode(buffer); + default -> throw new DecoderException("Unknown machine ingredient kind " + kind); + }; + }); + /** * Items matching a vanilla {@link Ingredient} (an item, a tag or a custom ingredient). * @@ -47,6 +81,13 @@ record ItemPart(Ingredient items, int count) implements MachineIngredient { .optionalFieldOf("count", 1) .forGetter(ItemPart::count)) .apply(instance, ItemPart::new)); + static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + Ingredient.CONTENTS_STREAM_CODEC, + ItemPart::items, + ByteBufCodecs.VAR_INT, + ItemPart::count, + ItemPart::new); @Override public long amount() { @@ -72,6 +113,13 @@ record FluidPart(HolderSet fluids, int millibuckets) implements MachineIn .fieldOf("amount") .forGetter(FluidPart::millibuckets)) .apply(instance, FluidPart::new)); + static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.holderSet(Registries.FLUID), + FluidPart::fluids, + ByteBufCodecs.VAR_INT, + FluidPart::millibuckets, + FluidPart::new); @Override public long amount() { @@ -94,6 +142,8 @@ record EnergyPart(long amount) implements MachineIngredient { : DataResult.error(() -> "Energy must be at least 1: " + value)) .fieldOf("energy") .xmap(EnergyPart::new, EnergyPart::amount); + static final StreamCodec STREAM_CODEC = + ByteBufCodecs.VAR_LONG.map(EnergyPart::new, EnergyPart::amount).cast(); } /** Picks the record by its key; none, or more than one, is an error naming the three keys. */ diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineRecipeTypes.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineRecipeTypes.java new file mode 100644 index 0000000..1736c36 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineRecipeTypes.java @@ -0,0 +1,42 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.crafting.RecipeSerializer; +import net.minecraft.world.item.crafting.RecipeType; + +import net.neoforged.neoforge.registries.DeferredRegister; + +import zone.moddev.mc.lib.machine.recipe.MachineRecipeType; + +/** Registers machine recipe types through a mod's own NeoForge registers. */ +public final class MachineRecipeTypes { + private MachineRecipeTypes() {} + + /** + * A machine recipe type {@code :}, registered as both a recipe type and its + * serializer. Call it from a static initializer; the registers must be attached to the mod bus. + * + *
{@code
+   * public static final MachineRecipeType CRUSHING =
+   *     MachineRecipeTypes.register(RECIPE_TYPES, RECIPE_SERIALIZERS, "crushing");
+   * }
+ * + * @throws IllegalArgumentException if the two registers have different namespaces + */ + public static MachineRecipeType register( + DeferredRegister> types, + DeferredRegister> serializers, + String name) { + if (!types.getNamespace().equals(serializers.getNamespace())) + throw new IllegalArgumentException( + "Recipe type and serializer registers differ: " + + types.getNamespace() + + ", " + + serializers.getNamespace()); + var type = + new MachineRecipeType(ResourceLocation.fromNamespaceAndPath(types.getNamespace(), name)); + types.register(name, () -> type); + serializers.register(name, type::serializer); + return type; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineProduct.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineProduct.java index 9faccbe..e417daf 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineProduct.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineProduct.java @@ -11,8 +11,13 @@ import com.mojang.serialization.RecordBuilder; import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; import net.minecraft.world.item.ItemStack; +import io.netty.handler.codec.DecoderException; + import zone.moddev.mc.lib.machine.ingredient.FluidAmount; /** @@ -28,6 +33,30 @@ public sealed interface MachineProduct /** The JSON form: exactly one of {@code item} or {@code fluid}. */ Codec CODEC = new Dispatch().codec(); + /** The network form: a kind byte, then the record. */ + StreamCodec STREAM_CODEC = + StreamCodec.of( + (buffer, product) -> { + switch (product) { + case ItemProduct item -> { + buffer.writeByte(0); + ItemProduct.STREAM_CODEC.encode(buffer, item); + } + case FluidProduct fluid -> { + buffer.writeByte(1); + FluidProduct.STREAM_CODEC.encode(buffer, fluid); + } + } + }, + buffer -> { + int kind = buffer.readByte(); + return switch (kind) { + case 0 -> ItemProduct.STREAM_CODEC.decode(buffer); + case 1 -> FluidProduct.STREAM_CODEC.decode(buffer); + default -> throw new DecoderException("Unknown machine product kind " + kind); + }; + }); + /** An item stack. */ record ItemProduct(ItemStack stack, float chance) implements MachineProduct { static final MapCodec CODEC = @@ -38,6 +67,13 @@ record ItemProduct(ItemStack stack, float chance) implements MachineProduct { ItemStack.CODEC.fieldOf("item").forGetter(ItemProduct::stack), chanceField().forGetter(ItemProduct::chance)) .apply(instance, ItemProduct::new)); + static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ItemStack.STREAM_CODEC, + ItemProduct::stack, + ByteBufCodecs.FLOAT, + ItemProduct::chance, + ItemProduct::new); /** Copies the stack. */ public ItemProduct { @@ -61,6 +97,13 @@ record FluidProduct(FluidAmount fluid, float chance) implements MachineProduct { FluidAmount.CODEC.fieldOf("fluid").forGetter(FluidProduct::fluid), chanceField().forGetter(FluidProduct::chance)) .apply(instance, FluidProduct::new)); + static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + FluidAmount.STREAM_CODEC, + FluidProduct::fluid, + ByteBufCodecs.FLOAT, + FluidProduct::chance, + FluidProduct::new); } private static MapCodec chanceField() { diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipe.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipe.java index ef45fbc..874a925 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipe.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipe.java @@ -10,12 +10,16 @@ import com.mojang.serialization.MapCodec; import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; + import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; /** * What a machine consumes and produces, by group key, and how long it takes. JSON: {@code * {"inputs": {"input": [...], "energy": [{"energy": 4000}]}, "outputs": {"output": [...]}, "time": - * 200}}. Registered as a recipe type in a later phase; for now the data shape and matching only. + * 200}}. A {@link MachineRecipeType} makes these datapack recipes; {@link RecipeMatcher} runs them. * * @param time ticks, at least 1 */ @@ -45,12 +49,29 @@ public record MachineRecipe( ? DataResult.error(() -> "A machine recipe needs at least one input") : DataResult.success(recipe)); + /** The network form; group order is kept. */ + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + groups(MachineIngredient.STREAM_CODEC), + MachineRecipe::inputs, + groups(MachineProduct.STREAM_CODEC), + MachineRecipe::outputs, + ByteBufCodecs.VAR_INT, + MachineRecipe::time, + MachineRecipe::new); + /** Copies the maps and lists. */ public MachineRecipe { inputs = copy(inputs); outputs = copy(outputs); } + private static StreamCodec>> groups( + StreamCodec entry) { + return ByteBufCodecs.map( + LinkedHashMap::new, ByteBufCodecs.STRING_UTF8, entry.apply(ByteBufCodecs.list())); + } + private static Map> copy(Map> map) { var copy = new LinkedHashMap>(); map.forEach((key, list) -> copy.put(key, List.copyOf(list))); diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeBuilder.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeBuilder.java new file mode 100644 index 0000000..efbadd5 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeBuilder.java @@ -0,0 +1,124 @@ +package zone.moddev.mc.lib.machine.recipe; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import net.minecraft.core.HolderSet; +import net.minecraft.core.component.DataComponentPatch; +import net.minecraft.data.recipes.RecipeOutput; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.level.material.Fluid; + +import zone.moddev.mc.lib.machine.ingredient.FluidAmount; +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; + +/** + * Builds machine recipes for datagen, from a {@code RecipeProvider}'s {@code buildRecipes}: + * + *
{@code
+ * MachineRecipeBuilder.recipe(ExampleRecipes.CRUSHING)
+ *     .inputItems("input", Ingredient.of(Tags.Items.ORES_COPPER), 1)
+ *     .inputEnergy("energy", 4_000)
+ *     .outputItem("output", new ItemStack(Items.RAW_COPPER, 2))
+ *     .outputItem("byproduct", new ItemStack(Items.GRAVEL), 0.25F)
+ *     .time(200)
+ *     .save(output, ResourceLocation.parse("example:crushing/copper"));
+ * }
+ * + * Groups keep the order they are first named in. Amounts and chances are checked when the recipe is + * encoded, by the codec that reads recipe files. + */ +public final class MachineRecipeBuilder { + private final MachineRecipeType type; + private final Map> inputs = new LinkedHashMap<>(); + private final Map> outputs = new LinkedHashMap<>(); + private int time; + + private MachineRecipeBuilder(MachineRecipeType type) { + this.type = type; + } + + /** A builder for a recipe of {@code type}. */ + public static MachineRecipeBuilder recipe(MachineRecipeType type) { + return new MachineRecipeBuilder(type); + } + + /** Adds {@code ingredient} to the inputs of {@code group}. */ + public MachineRecipeBuilder input(String group, MachineIngredient ingredient) { + inputs.computeIfAbsent(group, key -> new ArrayList<>()).add(ingredient); + return this; + } + + /** {@code count} items matching {@code items}. */ + public MachineRecipeBuilder inputItems(String group, Ingredient items, int count) { + return input(group, new MachineIngredient.ItemPart(items, count)); + } + + /** {@code millibuckets} of any fluid in {@code fluids}, such as a tag from a registry lookup. */ + public MachineRecipeBuilder inputFluids(String group, HolderSet fluids, int millibuckets) { + return input(group, new MachineIngredient.FluidPart(fluids, millibuckets)); + } + + /** {@code millibuckets} of {@code fluid}. */ + @SuppressWarnings("deprecation") + public MachineRecipeBuilder inputFluid(String group, Fluid fluid, int millibuckets) { + return inputFluids(group, HolderSet.direct(fluid.builtInRegistryHolder()), millibuckets); + } + + /** {@code energy} FE over the recipe's time. */ + public MachineRecipeBuilder inputEnergy(String group, long energy) { + return input(group, new MachineIngredient.EnergyPart(energy)); + } + + /** Adds {@code product} to the outputs of {@code group}. */ + public MachineRecipeBuilder output(String group, MachineProduct product) { + outputs.computeIfAbsent(group, key -> new ArrayList<>()).add(product); + return this; + } + + /** {@code stack}, every run. */ + public MachineRecipeBuilder outputItem(String group, ItemStack stack) { + return outputItem(group, stack, 1); + } + + /** {@code stack}, made with {@code chance} in (0, 1]. */ + public MachineRecipeBuilder outputItem(String group, ItemStack stack, float chance) { + return output(group, new MachineProduct.ItemProduct(stack, chance)); + } + + /** {@code millibuckets} of {@code fluid}, every run. */ + @SuppressWarnings("deprecation") + public MachineRecipeBuilder outputFluid(String group, Fluid fluid, int millibuckets) { + return output( + group, + new MachineProduct.FluidProduct( + new FluidAmount(fluid.builtInRegistryHolder(), DataComponentPatch.EMPTY, millibuckets), + 1)); + } + + /** How long a run takes, in ticks. */ + public MachineRecipeBuilder time(int ticks) { + time = ticks; + return this; + } + + /** + * The recipe. + * + * @throws IllegalStateException if no time was set or there are no inputs + */ + public MachineRecipe build() { + if (time == 0) throw new IllegalStateException("A machine recipe needs a time"); + if (inputs.isEmpty()) throw new IllegalStateException("A machine recipe needs an input"); + return new MachineRecipe(inputs, outputs, time); + } + + /** Builds the recipe and hands it to {@code output} under {@code id}, without an advancement. */ + public void save(RecipeOutput output, ResourceLocation id) { + output.accept(id, new MachineRecipeEntry(type, build()), null); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeEntry.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeEntry.java new file mode 100644 index 0000000..8f01a9c --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeEntry.java @@ -0,0 +1,81 @@ +package zone.moddev.mc.lib.machine.recipe; + +import java.util.List; +import java.util.Objects; + +import net.minecraft.core.HolderLookup; +import net.minecraft.core.NonNullList; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.Recipe; +import net.minecraft.world.item.crafting.RecipeSerializer; +import net.minecraft.world.item.crafting.RecipeType; +import net.minecraft.world.level.Level; + +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; + +/** + * A {@link MachineRecipe} as a vanilla recipe of a {@link MachineRecipeType}. It matches when + * {@link RecipeMatcher#check} passes: every input present and room for every product. + */ +public record MachineRecipeEntry(MachineRecipeType type, MachineRecipe recipe) + implements Recipe { + public MachineRecipeEntry { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(recipe, "recipe"); + } + + @Override + public boolean matches(MachineRecipeInput input, Level level) { + return RecipeMatcher.check(recipe, input.inventory()).ok(); + } + + /** The first item product; machines make their products through {@link RecipeMatcher}. */ + @Override + public ItemStack assemble(MachineRecipeInput input, HolderLookup.Provider registries) { + return getResultItem(registries); + } + + @Override + public boolean canCraftInDimensions(int width, int height) { + return true; + } + + /** A copy of the first item product, or empty if the recipe makes only fluids. */ + @Override + public ItemStack getResultItem(HolderLookup.Provider registries) { + return recipe.outputs().values().stream() + .flatMap(List::stream) + .filter(MachineProduct.ItemProduct.class::isInstance) + .map(product -> ((MachineProduct.ItemProduct) product).stack()) + .findFirst() + .orElse(ItemStack.EMPTY); + } + + /** The item inputs, in group order. */ + @Override + public NonNullList getIngredients() { + var result = NonNullList.create(); + recipe.inputs().values().stream() + .flatMap(List::stream) + .filter(MachineIngredient.ItemPart.class::isInstance) + .forEach(part -> result.add(((MachineIngredient.ItemPart) part).items())); + return result; + } + + /** Machine recipes stay out of the recipe book. */ + @Override + public boolean isSpecial() { + return true; + } + + @Override + public RecipeSerializer getSerializer() { + return type.serializer(); + } + + @Override + public RecipeType getType() { + return type; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeInput.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeInput.java new file mode 100644 index 0000000..f0a4ff1 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeInput.java @@ -0,0 +1,49 @@ +package zone.moddev.mc.lib.machine.recipe; + +import java.util.Objects; + +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.RecipeInput; + +import zone.moddev.mc.lib.machine.inventory.ItemGroup; +import zone.moddev.mc.lib.machine.inventory.MachineInventory; + +/** + * A machine's inventory as a vanilla recipe input. Vanilla sees the item groups' slots one after + * another, in group order; matching itself reads the groups by key. + */ +public record MachineRecipeInput(MachineInventory inventory) implements RecipeInput { + public MachineRecipeInput { + Objects.requireNonNull(inventory, "inventory"); + } + + /** A copy of the stack at {@code index} across the item groups; empty past the end. */ + @Override + public ItemStack getItem(int index) { + int offset = index; + for (var key : inventory.keys()) + if (inventory.group(key).orElseThrow() instanceof ItemGroup items) { + if (offset < items.size()) return items.peek(offset); + offset -= items.size(); + } + return ItemStack.EMPTY; + } + + /** The slot count across the item groups. */ + @Override + public int size() { + int size = 0; + for (var key : inventory.keys()) + if (inventory.group(key).orElseThrow() instanceof ItemGroup items) size += items.size(); + return size; + } + + /** + * Never empty: a recipe can run on fluids or energy alone, and vanilla's {@code getRecipeFor} + * returns nothing for an empty input without asking the recipes. + */ + @Override + public boolean isEmpty() { + return false; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeLookup.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeLookup.java new file mode 100644 index 0000000..00d983d --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeLookup.java @@ -0,0 +1,33 @@ +package zone.moddev.mc.lib.machine.recipe; + +import java.util.Optional; + +import javax.annotation.Nullable; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.level.Level; + +import zone.moddev.mc.lib.machine.inventory.MachineInventory; + +/** + * Finds the recipe of one type that a machine can run now. It tries the machine's last recipe + * first, by ID, so a {@code /reload} that changes or removes it is picked up. When several recipes + * match, which one wins is unspecified; keep a type's recipes from overlapping. + */ +public final class MachineRecipeLookup { + private final MachineRecipeType type; + @Nullable private ResourceLocation last; + + MachineRecipeLookup(MachineRecipeType type) { + this.type = type; + } + + /** A recipe whose inputs are present and whose products fit; changes nothing. */ + public Optional> find(Level level, MachineInventory inventory) { + var found = + level.getRecipeManager().getRecipeFor(type, new MachineRecipeInput(inventory), level, last); + found.ifPresent(holder -> last = holder.id()); + return found; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeType.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeType.java new file mode 100644 index 0000000..8bec746 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeType.java @@ -0,0 +1,76 @@ +package zone.moddev.mc.lib.machine.recipe; + +import java.util.List; +import java.util.Objects; + +import com.mojang.serialization.MapCodec; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeManager; +import net.minecraft.world.item.crafting.RecipeSerializer; +import net.minecraft.world.item.crafting.RecipeType; + +/** + * A vanilla recipe type whose recipes are {@link MachineRecipe}s, with its own serializer. Recipe + * files name the type's ID as {@code "type"}, so datapacks, {@code /reload} and recipe viewers work + * as for any recipe. Register one with the loader's {@code MachineRecipeTypes.register}, which + * registers it under its ID as both a recipe type and a recipe serializer. + */ +public final class MachineRecipeType implements RecipeType { + private final ResourceLocation id; + private final RecipeSerializer serializer; + + /** A type with the ID it will be registered under. */ + public MachineRecipeType(ResourceLocation id) { + this.id = Objects.requireNonNull(id, "id"); + MapCodec codec = + MachineRecipe.CODEC.xmap(this::entry, MachineRecipeEntry::recipe); + StreamCodec streamCodec = + MachineRecipe.STREAM_CODEC.map(this::entry, MachineRecipeEntry::recipe); + serializer = + new RecipeSerializer<>() { + @Override + public MapCodec codec() { + return codec; + } + + @Override + public StreamCodec streamCodec() { + return streamCodec; + } + }; + } + + /** The ID the type and its serializer are registered under. */ + public ResourceLocation id() { + return id; + } + + /** The serializer, registered under the same ID. */ + public RecipeSerializer serializer() { + return serializer; + } + + /** Every loaded recipe of this type. */ + public List> recipes(RecipeManager manager) { + return manager.getAllRecipesFor(this); + } + + /** A new lookup for one machine; it remembers that machine's last recipe. */ + public MachineRecipeLookup lookup() { + return new MachineRecipeLookup(this); + } + + private MachineRecipeEntry entry(MachineRecipe recipe) { + return new MachineRecipeEntry(this, recipe); + } + + /** The ID, as vanilla's own recipe types print themselves. */ + @Override + public String toString() { + return id.toString(); + } +} diff --git a/src/main/resources/assets/mmdlib/lang/en_us.json b/src/main/resources/assets/mmdlib/lang/en_us.json index db62dcf..225c659 100644 --- a/src/main/resources/assets/mmdlib/lang/en_us.json +++ b/src/main/resources/assets/mmdlib/lang/en_us.json @@ -219,6 +219,9 @@ "fluid_type.mmdlib.wood_molten": "Molten Wood", "jei.mmdlib.crushing": "Crackhammer", "jei.mmdlib.crushing.mined": "Also crushes drops when mining", + "jei.mmdlib.machine.chance": "%s%% chance", + "jei.mmdlib.machine.energy": "%s FE", + "jei.mmdlib.machine.time": "%s s", "item.mmdlib.copper_arrow": "Copper Arrow", "item.mmdlib.copper_axe": "Copper Axe", "item.mmdlib.copper_blend": "Copper Blend", diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiClientSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiClientSmoke.java index 180b9b2..d78e549 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiClientSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiClientSmoke.java @@ -213,6 +213,27 @@ public static void frame(RenderFrameEvent.Post event) { MMDLib.LOGGER.info( "MMDLIB_GUI_SIDES_PASS: actual context navigation, hidden slots, side-face click and" + " authoritative sync"); + if (!net.neoforged.fml.ModList.get().isLoaded("jei")) { + finished = true; + mc.stop(); + return; + } + try { + GuiJeiSmoke.showMachineRecipes(); + } catch (Throwable error) { + fail(error); + return; + } + frames = 0; + stage = 3; + } + // JEI's recipe screen replaces the machine screen, so wait by frames rather than ticks. + if (stage == 3 && ++frames == 30) { + Screenshot.grab( + mc.gameDirectory, + "gui-jei-machine-recipes.png", + mc.getMainRenderTarget(), + message -> MMDLib.LOGGER.info("JEI machine recipes screenshot: {}", message.getString())); finished = true; mc.stop(); } diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiJeiSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiJeiSmoke.java index 2b773e1..649aa1a 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiJeiSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiJeiSmoke.java @@ -56,11 +56,59 @@ public static void check(MachineScreen screen) throws Exception { net.minecraft.world.item.crafting.RecipeHolder.class) .orElseThrow(); checkTransfer(runtime, screen, type); + checkMachineCategory(runtime); MMDLib.LOGGER.info( "MMDLIB_GUI_JEI_HOOKS_PASS: registered exclusion areas, recipe click area, ghost drag" + " target and recipe transfer request"); } + /** + * The generic category for {@code mmdlib_smoke:grinding} lists both test recipes; the copper one + * lays out an ore and a water input and two products. + */ + private static void checkMachineCategory(IJeiRuntime runtime) { + var manager = runtime.getRecipeManager(); + var type = + zone.moddev.mc.lib.client.jei.MachineRecipeCategory.recipeType( + MachineRecipeTypeSmoke.GRINDING); + var category = manager.getRecipeCategory(type); + var recipes = manager.createRecipeLookup(type).get().toList(); + if (recipes.size() != 2) throw new AssertionError("JEI machine recipes: " + recipes.size()); + var copper = + recipes.stream() + .filter(holder -> holder.id().getPath().equals("grinding/copper")) + .findFirst() + .orElseThrow(); + var slots = + manager + .createRecipeLayoutDrawable( + category, copper, runtime.getJeiHelpers().getFocusFactory().getEmptyFocusGroup()) + .orElseThrow(() -> new AssertionError("JEI machine layout failed")) + .getRecipeSlotsView(); + int inputs = slots.getSlotViews(mezz.jei.api.recipe.RecipeIngredientRole.INPUT).size(); + int outputs = slots.getSlotViews(mezz.jei.api.recipe.RecipeIngredientRole.OUTPUT).size(); + if (inputs != 2 || outputs != 2) + throw new AssertionError("JEI machine slots: " + inputs + " in, " + outputs + " out"); + MMDLib.LOGGER.info( + "MMDLIB_GUI_JEI_MACHINE_PASS: generic machine recipe category with {}x{} layout", + category.getWidth(), + category.getHeight()); + } + + /** Opens JEI's recipe screen on the grinding category, for the screenshot. */ + static void showMachineRecipes() throws Exception { + var field = + Class.forName("zone.moddev.mc.lib.client.jei.MachineJei").getDeclaredField("runtime"); + field.setAccessible(true); + var runtime = (IJeiRuntime) field.get(null); + runtime + .getRecipesGui() + .showTypes( + java.util.List.of( + zone.moddev.mc.lib.client.jei.MachineRecipeCategory.recipeType( + MachineRecipeTypeSmoke.GRINDING))); + } + @SuppressWarnings("removal") private static void checkTransfer( IJeiRuntime runtime, MachineScreen screen, mezz.jei.api.recipe.RecipeType type) { diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java index fb62048..923a849 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java @@ -91,4 +91,34 @@ public static void machineRecipes(GameTestHelper helper) { MachineRecipeSmoke.check(helper.getLevel()); helper.succeed(); } + + @GameTest(template = EMPTY) + public static void machineRecipeTypeRegistered(GameTestHelper helper) { + MachineRecipeTypeSmoke.registered(); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void machineRecipesLoad(GameTestHelper helper) { + MachineRecipeTypeSmoke.recipesLoad(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void machineRecipeLookup(GameTestHelper helper) { + MachineRecipeTypeSmoke.lookupFinds(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void machineRecipeNetworkCodec(GameTestHelper helper) { + MachineRecipeTypeSmoke.streamCodecRoundTrips(helper.getLevel()); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void machineRecipeBuilder(GameTestHelper helper) { + MachineRecipeTypeSmoke.builderMatchesFile(helper.getLevel()); + helper.succeed(); + } } diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java new file mode 100644 index 0000000..07174ea --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java @@ -0,0 +1,342 @@ +package zone.moddev.mc.lib.smoke; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import com.google.gson.JsonElement; + +import com.mojang.serialization.JsonOps; + +import net.minecraft.advancements.Advancement; +import net.minecraft.advancements.AdvancementHolder; +import net.minecraft.core.HolderSet; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.data.recipes.RecipeOutput; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.resources.RegistryOps; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.tags.FluidTags; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.Recipe; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeSerializer; +import net.minecraft.world.item.crafting.RecipeType; +import net.minecraft.world.level.material.Fluids; + +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.ModList; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.fml.event.lifecycle.FMLConstructModEvent; +import net.neoforged.neoforge.common.conditions.ICondition; +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.capability.IFluidHandler; +import net.neoforged.neoforge.registries.DeferredRegister; + +import io.netty.buffer.Unpooled; + +import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; +import zone.moddev.mc.lib.api.capabilities.FluidStorage; +import zone.moddev.mc.lib.api.capabilities.ItemStorage; +import zone.moddev.mc.lib.api.capabilities.Role; +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; +import zone.moddev.mc.lib.machine.inventory.MachineInventory; +import zone.moddev.mc.lib.machine.neoforge.MachineRecipeTypes; +import zone.moddev.mc.lib.machine.neoforge.StorageGroups; +import zone.moddev.mc.lib.machine.recipe.MachineRecipe; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeBuilder; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeEntry; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeInput; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeType; + +/** + * Registers the {@code mmdlib_smoke:grinding} machine recipe type and checks it against the recipe + * files in {@code src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/}: loading, lookup, + * the network codec and the datagen builder. + */ +@EventBusSubscriber(modid = "mmdlib", bus = EventBusSubscriber.Bus.MOD) +public final class MachineRecipeTypeSmoke { + private static final String NAMESPACE = "mmdlib_smoke"; + private static final DeferredRegister> TYPES = + DeferredRegister.create(Registries.RECIPE_TYPE, NAMESPACE); + private static final DeferredRegister> SERIALIZERS = + DeferredRegister.create(Registries.RECIPE_SERIALIZER, NAMESPACE); + + /** The test recipe type; the GUI smoke test checks its JEI category. */ + public static final MachineRecipeType GRINDING = + MachineRecipeTypes.register(TYPES, SERIALIZERS, "grinding"); + + private static final ResourceLocation COPPER = id("grinding/copper"); + private static final ResourceLocation IRON = id("grinding/iron"); + + private MachineRecipeTypeSmoke() {} + + @SubscribeEvent + static void construct(FMLConstructModEvent event) { + var bus = ModList.get().getModContainerById("mmdlib").orElseThrow().getEventBus(); + TYPES.register(bus); + SERIALIZERS.register(bus); + } + + /** The type and serializer are in the registries under the type's ID. */ + static void registered() { + var id = id("grinding"); + require(GRINDING.id().equals(id), "type ID " + GRINDING.id()); + require(BuiltInRegistries.RECIPE_TYPE.get(id) == GRINDING, "type registered"); + require( + BuiltInRegistries.RECIPE_SERIALIZER.get(id) == GRINDING.serializer(), + "serializer registered"); + require(GRINDING.toString().equals("mmdlib_smoke:grinding"), "type name " + GRINDING); + } + + /** The two good files load as grinding recipes; the one without inputs is skipped. */ + static void recipesLoad(ServerLevel level) { + var manager = level.getRecipeManager(); + var all = GRINDING.recipes(manager); + require(all.size() == 2, "two grinding recipes, got " + all.stream().map(h -> h.id()).toList()); + var copper = recipe(level, COPPER); + require( + copper.time() == 200 && copper.inputs().size() == 3 && copper.outputs().size() == 2, + "copper decoded"); + var iron = recipe(level, IRON); + require(iron.outputs().get("water").size() == 1, "iron has a fluid output"); + require(manager.byKey(id("grinding/broken")).isEmpty(), "broken recipe skipped"); + var entry = (MachineRecipeEntry) manager.byKey(COPPER).orElseThrow().value(); + require(entry.getType() == GRINDING && entry.getSerializer() == GRINDING.serializer(), "type"); + require(entry.isSpecial(), "kept out of the recipe book"); + require( + entry.getResultItem(level.registryAccess()).is(Items.RAW_COPPER) + && entry.getResultItem(level.registryAccess()).getCount() == 2, + "result item"); + require( + entry.getIngredients().size() == 1 && entry.getIngredients().getFirst().test(ore(true)), + "item ingredients"); + } + + /** A lookup finds the recipe the inventory can run, switches when the input changes. */ + static void lookupFinds(ServerLevel level) { + var lookup = GRINDING.lookup(); + var grinder = new Grinder(); + require(lookup.find(level, grinder.inventory).isEmpty(), "nothing to grind"); + + grinder.items.insertResult(0, new ItemStack(Items.COPPER_ORE, 2), false); + require(id(lookup.find(level, grinder.inventory)).equals(COPPER), "copper ore"); + require(id(lookup.find(level, grinder.inventory)).equals(COPPER), "copper again"); + + grinder.items.setStackInSlot(0, new ItemStack(Items.IRON_ORE)); + require(id(lookup.find(level, grinder.inventory)).equals(IRON), "switched to iron"); + + // The fluid output can't fit: the iron recipe doesn't match. + grinder.fluids.tank(0).fill(new FluidStack(Fluids.WATER, 4_000), execute()); + require(lookup.find(level, grinder.inventory).isEmpty(), "no room for the water output"); + + var input = new MachineRecipeInput(grinder.inventory); + require(input.size() == 4 && input.getItem(0).is(Items.IRON_ORE), "item view"); + require(!input.isEmpty(), "a machine input is never empty to vanilla"); + grinder.fluids.tank(0).drain(1_000, execute()); + require( + level + .getRecipeManager() + .getRecipeFor(GRINDING, input, level) + .map(RecipeHolder::id) + .orElseThrow() + .equals(IRON), + "vanilla getRecipeFor"); + } + + /** Every loaded recipe survives the serializer's network codec. */ + static void streamCodecRoundTrips(ServerLevel level) { + for (var holder : GRINDING.recipes(level.getRecipeManager())) { + var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), level.registryAccess()); + GRINDING.serializer().streamCodec().encode(buffer, holder.value()); + var decoded = GRINDING.serializer().streamCodec().decode(buffer); + require(buffer.readableBytes() == 0, holder.id() + " read fully"); + require(decoded.getType() == GRINDING, holder.id() + " keeps its type"); + requireSame(holder.value().recipe(), decoded.recipe(), level, holder.id().toString()); + } + } + + /** The builder writes the same recipe as the copper file, with the type's ID as "type". */ + static void builderMatchesFile(ServerLevel level) { + var saved = new ArrayList>(); + var ids = new ArrayList(); + var output = + new RecipeOutput() { + @Override + public Advancement.Builder advancement() { + return Advancement.Builder.recipeAdvancement(); + } + + @Override + public void accept( + ResourceLocation id, + Recipe recipe, + AdvancementHolder advancement, + ICondition... conditions) { + ids.add(id); + saved.add(recipe); + } + }; + var water = HolderSet.direct(Fluids.WATER.builtInRegistryHolder()); + MachineRecipeBuilder.recipe(GRINDING) + .inputItems( + "input", + Ingredient.of( + net.minecraft.tags.ItemTags.create(ResourceLocation.parse("c:ores/copper"))), + 1) + .input("water", new MachineIngredient.FluidPart(water, 250)) + .inputEnergy("energy", 4_000) + .outputItem("output", new ItemStack(Items.RAW_COPPER, 2)) + .outputItem("byproduct", new ItemStack(Items.GRAVEL), 0.25F) + .time(200) + .save(output, COPPER); + require(ids.equals(List.of(COPPER)), "saved under its ID"); + var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()); + var json = Recipe.CODEC.encodeStart(ops, saved.getFirst()).getOrThrow().getAsJsonObject(); + require(json.get("type").getAsString().equals("mmdlib_smoke:grinding"), "type field " + json); + var file = Recipe.CODEC.encodeStart(ops, recipeEntry(level, COPPER)).getOrThrow(); + require(json.equals(file), "builder JSON " + json + " vs file " + file); + + var fluidTag = + level.registryAccess().lookupOrThrow(Registries.FLUID).getOrThrow(FluidTags.WATER); + var iron = + MachineRecipeBuilder.recipe(GRINDING) + .inputItems("input", Ingredient.of(Items.IRON_ORE), 1) + .inputFluids("water", fluidTag, 100) + .inputEnergy("energy", 2_000) + .outputItem("output", new ItemStack(Items.RAW_IRON, 2)) + .outputFluid("water", Fluids.WATER, 50) + .time(100) + .build(); + require( + encode(ops, iron).equals(encode(ops, recipe(level, IRON))), + "iron builder " + encode(ops, iron)); + + try { + MachineRecipeBuilder.recipe(GRINDING).inputEnergy("energy", 1).build(); + throw new AssertionError("A recipe without a time must be refused"); + } catch (IllegalStateException expected) { + } + try { + MachineRecipeBuilder.recipe(GRINDING).time(10).build(); + throw new AssertionError("A recipe without inputs must be refused"); + } catch (IllegalStateException expected) { + } + } + + /** A grinder's storages: ore in slot 0, outputs in 1 and 2, byproduct in 3, a water tank. */ + private static final class Grinder { + final ItemStorage items = new ItemStorage(4).role(Role.INPUT, 0).role(Role.OUTPUT, 1, 2, 3); + final FluidStorage fluids = new FluidStorage(); + final EnergyBuffer energy = new EnergyBuffer(100_000, 1_000); + final MachineInventory inventory; + + Grinder() { + fluids.addTank(4_000, Role.INPUT); + fluids.tank(0).fill(new FluidStack(Fluids.WATER, 1_000), execute()); + energy.setStored(10_000); + inventory = + MachineInventory.builder() + .group("input", StorageGroups.items(items, 0)) + .group("output", StorageGroups.items(items, 1, 2)) + .group("byproduct", StorageGroups.items(items, 3)) + .group("water", StorageGroups.fluids(fluids, 0)) + .group("energy", StorageGroups.energy(energy)) + .build(); + } + } + + /** + * Same groups, counts, products and time. Item ingredients compare by their item lists, because + * the network sends a tag as the items in it. + */ + private static void requireSame( + MachineRecipe expected, MachineRecipe actual, ServerLevel level, String what) { + var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()); + require(expected.time() == actual.time(), what + " time"); + require(encodeOutputs(ops, expected).equals(encodeOutputs(ops, actual)), what + " outputs"); + require(expected.inputs().keySet().equals(actual.inputs().keySet()), what + " input keys"); + expected + .inputs() + .forEach( + (key, parts) -> { + var other = actual.inputs().get(key); + require(parts.size() == other.size(), what + " " + key + " size"); + for (int i = 0; i < parts.size(); i++) { + var a = parts.get(i); + var b = other.get(i); + require(a.amount() == b.amount(), what + " " + key + " amount"); + switch (a) { + case MachineIngredient.ItemPart part -> + require( + b instanceof MachineIngredient.ItemPart same + && itemIds(part.items()).equals(itemIds(same.items())), + what + " " + key + " items"); + case MachineIngredient.FluidPart part -> + require( + b instanceof MachineIngredient.FluidPart same + && part.fluids().stream() + .toList() + .equals(same.fluids().stream().toList()), + what + " " + key + " fluids"); + case MachineIngredient.EnergyPart part -> + require( + b instanceof MachineIngredient.EnergyPart, what + " " + key + " energy"); + } + } + }); + } + + private static List itemIds(Ingredient ingredient) { + return Arrays.stream(ingredient.getItems()) + .map(stack -> BuiltInRegistries.ITEM.getKey(stack.getItem())) + .toList(); + } + + private static JsonElement encodeOutputs(RegistryOps ops, MachineRecipe recipe) { + return encode(ops, recipe).getAsJsonObject().get("outputs"); + } + + private static JsonElement encode(RegistryOps ops, MachineRecipe recipe) { + return MachineRecipe.CODEC.codec().encodeStart(ops, recipe).getOrThrow(); + } + + private static MachineRecipe recipe(ServerLevel level, ResourceLocation id) { + return recipeEntry(level, id).recipe(); + } + + private static MachineRecipeEntry recipeEntry(ServerLevel level, ResourceLocation id) { + var holder = + level + .getRecipeManager() + .byKey(id) + .orElseThrow(() -> new AssertionError("Recipe not loaded: " + id)); + if (!(holder.value() instanceof MachineRecipeEntry entry)) + throw new AssertionError("Not a machine recipe: " + id); + return entry; + } + + private static ItemStack ore(boolean copper) { + return new ItemStack(copper ? Items.COPPER_ORE : Items.IRON_ORE); + } + + private static ResourceLocation id(java.util.Optional> holder) { + return holder.orElseThrow(() -> new AssertionError("No recipe found")).id(); + } + + private static IFluidHandler.FluidAction execute() { + return IFluidHandler.FluidAction.EXECUTE; + } + + private static ResourceLocation id(String path) { + return ResourceLocation.fromNamespaceAndPath(NAMESPACE, path); + } + + private static void require(boolean condition, String what) { + if (!condition) throw new AssertionError("Machine recipe type: " + what); + } +} diff --git a/src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/broken.json b/src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/broken.json new file mode 100644 index 0000000..41c837c --- /dev/null +++ b/src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/broken.json @@ -0,0 +1,6 @@ +{ + "type": "mmdlib_smoke:grinding", + "inputs": {}, + "outputs": { "output": [{ "item": { "id": "minecraft:raw_iron" } }] }, + "time": 100 +} diff --git a/src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/copper.json b/src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/copper.json new file mode 100644 index 0000000..76561be --- /dev/null +++ b/src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/copper.json @@ -0,0 +1,13 @@ +{ + "type": "mmdlib_smoke:grinding", + "inputs": { + "input": [{ "items": { "tag": "c:ores/copper" }, "count": 1 }], + "water": [{ "fluids": "minecraft:water", "amount": 250 }], + "energy": [{ "energy": 4000 }] + }, + "outputs": { + "output": [{ "item": { "id": "minecraft:raw_copper", "count": 2 } }], + "byproduct": [{ "item": { "id": "minecraft:gravel" }, "chance": 0.25 }] + }, + "time": 200 +} diff --git a/src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/iron.json b/src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/iron.json new file mode 100644 index 0000000..cdd195c --- /dev/null +++ b/src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/iron.json @@ -0,0 +1,13 @@ +{ + "type": "mmdlib_smoke:grinding", + "inputs": { + "input": [{ "items": { "item": "minecraft:iron_ore" } }], + "water": [{ "fluids": "#minecraft:water", "amount": 100 }], + "energy": [{ "energy": 2000 }] + }, + "outputs": { + "output": [{ "item": { "id": "minecraft:raw_iron", "count": 2 } }], + "water": [{ "fluid": { "id": "minecraft:water", "amount": 50 } }] + }, + "time": 100 +} From 045844a128de08262060a4caf8bf4272151468b0 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Mon, 2 Nov 2026 18:49:33 +0200 Subject: [PATCH 2/5] test: keep game tests in their structures and narrow the jar guard Review notes from #82. - blocksPlace, bucketsPlaceAndRecover and MachineSmoke.check used absolute world coordinates. They now take a position from helper.absolutePos, so they stay inside their own structure. The first two used a spaced grid, which doesn't fit in the 5x5x5 structure, so they place one block or fluid at a time on the same spot. - verifyReleaseClasses refused everything under data/mmdlib/structure/, which would fail the build if MMDLib ever ships a structure. It now refuses only the test structure, empty.nbt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- gradle/verification.gradle | 2 +- .../moddev/mc/lib/smoke/ContentSmoke.java | 28 ++++++++----------- .../moddev/mc/lib/smoke/MMDLibGameTests.java | 12 ++++---- .../moddev/mc/lib/smoke/MachineSmoke.java | 4 +-- 4 files changed, 21 insertions(+), 25 deletions(-) diff --git a/gradle/verification.gradle b/gradle/verification.gradle index 844d990..6aa9cab 100644 --- a/gradle/verification.gradle +++ b/gradle/verification.gradle @@ -77,7 +77,7 @@ def verifyReleaseClasses = tasks.register('verifyReleaseClasses') { dependsOn tasks.named('jar') doLast { new java.util.zip.ZipFile(tasks.named('jar').get().archiveFile.get().asFile).withCloseable { archive -> - def testOnly = ['zone/moddev/mc/lib/smoke/', 'data/mmdlib_smoke/', 'data/mmdlib/structure/'] + def testOnly = ['zone/moddev/mc/lib/smoke/', 'data/mmdlib_smoke/', 'data/mmdlib/structure/empty.nbt'] def leaked = archive.entries().findAll { entry -> testOnly.any { entry.name.startsWith(it) } } if (!leaked.isEmpty()) throw new GradleException('Test classes and data must not be packaged: ' + leaked.collect { it.name }) } diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContentSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContentSmoke.java index 95de728..fd1a05d 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContentSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContentSmoke.java @@ -44,24 +44,22 @@ static void registered() { throw new AssertionError("Wrong runtime item count: " + registrar().items().size()); } - /** Every block item places its block. */ - static void blocksPlace(ServerLevel level) { + /** Every block item places its block, one at a time on {@code floor}. */ + static void blocksPlace(ServerLevel level, BlockPos floor) { var player = FakePlayerFactory.getMinecraft(level); int placed = 0; for (var entry : registrar().blocks().entrySet()) { var itemHolder = registrar().items().get(entry.getKey()); if (itemHolder == null || !(itemHolder.get() instanceof BlockItem item)) continue; - var pos = new BlockPos((placed % 8) * 4, level.getMaxBuildHeight() - 12, (placed / 8) * 4); - level.getChunkAt(pos); - level.setBlock(pos, Blocks.STONE.defaultBlockState(), 3); - level.setBlock(pos.above(), Blocks.AIR.defaultBlockState(), 3); - level.setBlock(pos.above(2), Blocks.AIR.defaultBlockState(), 3); - player.setPos(pos.getX() + .5, pos.getY() + 1, pos.getZ() + 2.5); + level.setBlock(floor, Blocks.STONE.defaultBlockState(), 3); + level.setBlock(floor.above(), Blocks.AIR.defaultBlockState(), 3); + level.setBlock(floor.above(2), Blocks.AIR.defaultBlockState(), 3); + player.setPos(floor.getX() + .5, floor.getY() + 1, floor.getZ() + 2.5); player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(item)); - var hit = new BlockHitResult(Vec3.atCenterOf(pos).add(0, .5, 0), Direction.UP, pos, false); + var hit = new BlockHitResult(Vec3.atCenterOf(floor).add(0, .5, 0), Direction.UP, floor, false); var result = item.useOn(new UseOnContext(player, InteractionHand.MAIN_HAND, hit)); if (!result.consumesAction() - || level.getBlockState(pos.above()).getBlock() != entry.getValue().get()) + || level.getBlockState(floor.above()).getBlock() != entry.getValue().get()) throw new AssertionError("Placement failed: " + entry.getKey() + " result " + result); placed++; } @@ -92,15 +90,13 @@ static void ammunitionRecovers(ServerLevel level) { if (projectiles != 14) throw new AssertionError("Wrong projectile count: " + projectiles); } - /** Every bucket places its fluid and picks it back up. */ - static void bucketsPlaceAndRecover(ServerLevel level) { + /** Every bucket places its fluid and picks it back up, one at a time on {@code floor}. */ + static void bucketsPlaceAndRecover(ServerLevel level, BlockPos floor) { var player = FakePlayerFactory.getMinecraft(level); int fluids = 0; for (var family : registrar().fluidFamilies()) { - var pos = - new BlockPos((fluids % 8) * 4, level.getMaxBuildHeight() - 12, 48 + (fluids / 8) * 4); - level.getChunkAt(pos); - level.setBlock(pos.below(), Blocks.STONE.defaultBlockState(), 3); + var pos = floor.above(); + level.setBlock(floor, Blocks.STONE.defaultBlockState(), 3); level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); var bucket = family.bucket.get(); var stack = new ItemStack(bucket); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java index 923a849..51a83b9 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java @@ -17,6 +17,9 @@ public final class MMDLibGameTests { private static final String EMPTY = "empty"; + /** Inside the structure, away from its edges. */ + private static final BlockPos CENTER = new BlockPos(2, 1, 2); + private MMDLibGameTests() {} @GameTest(template = EMPTY) @@ -27,7 +30,7 @@ public static void contentRegistered(GameTestHelper helper) { @GameTest(template = EMPTY) public static void blocksPlace(GameTestHelper helper) { - ContentSmoke.blocksPlace(helper.getLevel()); + ContentSmoke.blocksPlace(helper.getLevel(), helper.absolutePos(CENTER)); helper.succeed(); } @@ -39,14 +42,13 @@ public static void ammunitionRecovers(GameTestHelper helper) { @GameTest(template = EMPTY) public static void bucketsPlaceAndRecover(GameTestHelper helper) { - ContentSmoke.bucketsPlaceAndRecover(helper.getLevel()); + ContentSmoke.bucketsPlaceAndRecover(helper.getLevel(), helper.absolutePos(CENTER)); helper.succeed(); } @GameTest(template = EMPTY) public static void crackhammerCrushesDrops(GameTestHelper helper) { - ContentSmoke.crackhammerCrushesDrops( - helper.getLevel(), helper.absolutePos(new BlockPos(2, 1, 2))); + ContentSmoke.crackhammerCrushesDrops(helper.getLevel(), helper.absolutePos(CENTER)); helper.succeed(); } @@ -64,7 +66,7 @@ public static void capabilities(GameTestHelper helper) { @GameTest(template = EMPTY) public static void standaloneMachine(GameTestHelper helper) { - MachineSmoke.check(helper.getLevel()); + MachineSmoke.check(helper.getLevel(), helper.absolutePos(CENTER)); helper.succeed(); } diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineSmoke.java index c580713..65c7214 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineSmoke.java @@ -96,15 +96,13 @@ static void construct(FMLConstructModEvent event) { } /** Places the machine, ticks it through the block's own ticker, and checks the item. */ - static void check(ServerLevel level) { + static void check(ServerLevel level, BlockPos pos) { var block = BuiltInRegistries.BLOCK.get(MACHINE); if (!(block instanceof MaterialEntityBlock)) throw new AssertionError("Standalone machine block missing: " + block); var holder = registrar.blockEntityTypes().get(MACHINE); if (holder == null || BuiltInRegistries.BLOCK_ENTITY_TYPE.get(MACHINE) != holder.get()) throw new AssertionError("Standalone machine block-entity type missing"); - var pos = new BlockPos(0, level.getMaxBuildHeight() - 40, 0); - level.getChunkAt(pos); level.setBlock(pos, block.defaultBlockState(), 3); if (!(level.getBlockEntity(pos) instanceof Counter counter)) throw new AssertionError("Machine has no counter: " + level.getBlockEntity(pos)); From 7f4d3d699da0fd6e92eaccc40906ac6f8ee2d3d8 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Mon, 2 Nov 2026 19:31:16 +0200 Subject: [PATCH 3/5] fix(machine): count what a recipe consumes when checking output room RecipeMatcher.check tested output room against the inventory as it stood, before the recipe's own inputs were gone. A recipe whose input and output share a group couldn't start: 1 iron in a one-slot group returned NO_ROOM for its gold, and a full tank refused a recipe that drains it first. check now keeps what each group's inputs claim and passes it to a new fits(list, taken) on ItemGroup and FluidGroup, which subtracts it from its copies. The claims are built the same way as in consume, so the check matches the slots consume really takes from. Nothing is extracted or rolled back, because that would run the storage's onChange on every recipe check. machineRecipeLookup expected a full tank to block a recipe that drains that tank, so it now blocks the item output instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../mc/lib/machine/inventory/FluidGroup.java | 10 ++- .../mc/lib/machine/inventory/ItemGroup.java | 10 ++- .../lib/machine/neoforge/StorageGroups.java | 14 +++- .../mc/lib/machine/recipe/RecipeMatcher.java | 32 +++++--- .../mc/lib/smoke/MachineRecipeSmoke.java | 80 +++++++++++++++++++ .../mc/lib/smoke/MachineRecipeTypeSmoke.java | 20 ++++- 6 files changed, 148 insertions(+), 18 deletions(-) diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/FluidGroup.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/FluidGroup.java index fc5dbf4..08f5496 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/inventory/FluidGroup.java +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/FluidGroup.java @@ -19,5 +19,13 @@ public non-sealed interface FluidGroup extends SlotGroup { int insert(FluidAmount fluid, MachineTx tx); /** Whether all of {@code fluids} would fit together. Changes nothing. */ - boolean fits(List fluids); + default boolean fits(List fluids) { + return fits(fluids, new long[tanks()]); + } + + /** + * Whether all of {@code fluids} would fit together once {@code taken[tank]} mB are gone from each + * tank. Changes nothing. + */ + boolean fits(List fluids, long[] taken); } diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java index fc7c2e9..ad9048e 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java @@ -22,5 +22,13 @@ public non-sealed interface ItemGroup extends SlotGroup { int insert(ItemStack stack, MachineTx tx); /** Whether all of {@code stacks} would fit together. Works on copies; changes nothing. */ - boolean fits(List stacks); + default boolean fits(List stacks) { + return fits(stacks, new long[size()]); + } + + /** + * Whether all of {@code stacks} would fit together once {@code taken[slot]} items are gone from + * each slot. Works on copies; changes nothing. + */ + boolean fits(List stacks, long[] taken); } diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java index 334b3b4..0062b51 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java @@ -89,9 +89,12 @@ public int insert(ItemStack stack, MachineTx tx) { } @Override - public boolean fits(List stacks) { + public boolean fits(List stacks, long[] taken) { var held = new ArrayList(); - for (int index : slots) held.add(storage.getStackInSlot(index).copy()); + for (int i = 0; i < slots.length; i++) { + var stack = storage.getStackInSlot(slots[i]); + held.add(stack.copyWithCount(stack.getCount() - (int) taken[i])); + } for (var stack : stacks) { int left = stack.getCount(); for (boolean merging : new boolean[] {true, false}) @@ -162,9 +165,12 @@ public int insert(FluidAmount fluid, MachineTx tx) { } @Override - public boolean fits(List fluids) { + public boolean fits(List fluids, long[] taken) { var held = new ArrayList(); - for (int index : indexes) held.add(storage.tank(index).getFluid().copy()); + for (int i = 0; i < indexes.length; i++) { + var fluid = storage.tank(indexes[i]).getFluid(); + held.add(fluid.copyWithAmount(fluid.getAmount() - (int) taken[i])); + } for (var fluid : fluids) { if (fluid.isEmpty()) continue; var stack = stack(fluid); diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java index f67b9ad..f7cd7e7 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java @@ -1,7 +1,9 @@ package zone.moddev.mc.lib.machine.recipe; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import net.minecraft.util.RandomSource; import net.minecraft.world.item.ItemStack; @@ -37,22 +39,24 @@ public final class RecipeMatcher { private RecipeMatcher() {} /** - * Whether {@code recipe} can start: every input present and room for every product, counting - * chance products as certain. Changes nothing. + * Whether {@code recipe} can start: every input present and room for every product once the + * inputs are gone, counting chance products as certain. A product may land in the slot or tank + * its own input empties. Changes nothing, and no storage's change callback runs. */ public static MatchResult check(MachineRecipe recipe, MachineInventory inventory) { + var taken = new HashMap(); for (var entry : recipe.inputs().entrySet()) { var key = entry.getKey(); var group = inventory.group(key).orElse(null); if (group == null) return new MatchResult(MatchResult.Reason.NO_GROUP, key, -1); - var result = checkInputs(key, group, entry.getValue(), recipe.time()); + var result = checkInputs(key, group, entry.getValue(), recipe.time(), taken); if (!result.ok()) return result; } for (var entry : recipe.outputs().entrySet()) { var key = entry.getKey(); var group = inventory.group(key).orElse(null); if (group == null) return new MatchResult(MatchResult.Reason.NO_GROUP, key, -1); - var result = checkOutputs(key, group, entry.getValue()); + var result = checkOutputs(key, group, entry.getValue(), taken.get(key)); if (!result.ok()) return result; } return MatchResult.OK; @@ -97,30 +101,40 @@ public static boolean produce( } } + /** Checks one group's inputs, and records what {@link #take} would take from it. */ private static MatchResult checkInputs( - String key, SlotGroup group, List ingredients, int time) { - var claims = new InputClaims(available(group)); + String key, + SlotGroup group, + List ingredients, + int time, + Map taken) { + long[] held = available(group); + var claims = new InputClaims(held); for (int i = 0; i < ingredients.size(); i++) { long need = claim(claims, group, ingredients.get(i), time); if (need < 0) return new MatchResult(MatchResult.Reason.WRONG_KIND, key, i); if (need > 0) return new MatchResult(MatchResult.Reason.MISSING_INPUT, key, i); } + var claimed = new long[held.length]; + for (int entry = 0; entry < held.length; entry++) claimed[entry] = claims.claimed(entry); + taken.put(key, claimed); return MatchResult.OK; } + /** Checks one group's products; {@code taken} is what its inputs use, null when it has none. */ private static MatchResult checkOutputs( - String key, SlotGroup group, List products) { + String key, SlotGroup group, List products, long[] taken) { var stacks = new ArrayList(); var fluids = new ArrayList(); for (int i = 0; i < products.size(); i++) { boolean fits; if (products.get(i) instanceof ItemProduct item && group instanceof ItemGroup items) { stacks.add(item.stack()); - fits = items.fits(stacks); + fits = taken == null ? items.fits(stacks) : items.fits(stacks, taken); } else if (products.get(i) instanceof FluidProduct fluid && group instanceof FluidGroup tanks) { fluids.add(fluid.fluid()); - fits = tanks.fits(fluids); + fits = taken == null ? tanks.fits(fluids) : tanks.fits(fluids, taken); } else return new MatchResult(MatchResult.Reason.WRONG_KIND, key, i); if (!fits) return new MatchResult(MatchResult.Reason.NO_ROOM, key, i); } diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeSmoke.java index 8afbe3c..465fa6b 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeSmoke.java @@ -232,6 +232,7 @@ static void check(ServerLevel level) { .orElse(false), "output with neither key refused, got " + neitherOut); mixedOutputs(ops); + consumedInputsMakeRoom(ops); require( MachineRecipe.CODEC .codec() @@ -323,6 +324,85 @@ private static void mixedOutputs(RegistryOps ops) { require(full.fluids.getFluidInTank(0).getAmount() == 4_000, "tank untouched"); } + /** A product may land where its inputs leave room, and only there; checking changes nothing. */ + private static void consumedInputsMakeRoom(RegistryOps ops) { + var ironToGold = + recipe( + ops, + """ + {"inputs": {"main": [{"items": {"item": "minecraft:iron_ingot"}}]}, + "outputs": {"main": [{"item": {"id": "minecraft:gold_ingot"}}]}, "time": 10} + """); + var changes = new int[1]; + var slot = new ItemStorage(1).onChange(() -> changes[0]++); + var oneSlot = MachineInventory.builder().group("main", StorageGroups.items(slot, 0)).build(); + slot.setStackInSlot(0, new ItemStack(Items.IRON_INGOT)); + int before = changes[0]; + require(RecipeMatcher.check(ironToGold, oneSlot).ok(), "gold fits where the iron was"); + require(changes[0] == before, "checking room changed nothing"); + require(RecipeMatcher.consume(ironToGold, oneSlot), "iron taken from the shared slot"); + require(RecipeMatcher.produce(ironToGold, oneSlot, fixed(.5F)), "gold made in the shared slot"); + require(slot.getStackInSlot(0).is(Items.GOLD_INGOT), "the slot now holds gold"); + + // One iron stays behind, so there's still nowhere for the gold. + slot.setStackInSlot(0, new ItemStack(Items.IRON_INGOT, 2)); + expect(RecipeMatcher.check(ironToGold, oneSlot), Reason.NO_ROOM, "main", 0); + + var ironToIron = + recipe( + ops, + """ + {"inputs": {"main": [{"items": {"item": "minecraft:iron_ingot"}}]}, + "outputs": {"main": [{"item": {"id": "minecraft:iron_ingot"}}]}, "time": 10} + """); + slot.setStackInSlot(0, new ItemStack(Items.IRON_INGOT, 64)); + require(RecipeMatcher.check(ironToIron, oneSlot).ok(), "a full stack makes room for itself"); + + // Which slot the input comes from is the one consume will really use. + var anyLogToGold = + recipe( + ops, + """ + {"inputs": {"main": [{"items": {"tag": "minecraft:logs"}}]}, + "outputs": {"main": [{"item": {"id": "minecraft:gold_ingot"}}]}, "time": 10} + """); + var logs = new ItemStorage(2); + var twoSlots = MachineInventory.builder().group("main", StorageGroups.items(logs, 0, 1)).build(); + logs.setStackInSlot(0, new ItemStack(Items.OAK_LOG)); + logs.setStackInSlot(1, new ItemStack(Items.BIRCH_LOG, 2)); + require(RecipeMatcher.check(anyLogToGold, twoSlots).ok(), "the first slot empties"); + require(RecipeMatcher.consume(anyLogToGold, twoSlots), "the log is taken"); + require(RecipeMatcher.produce(anyLogToGold, twoSlots, fixed(.5F)), "the gold lands"); + + var waterToLava = + recipe( + ops, + """ + {"inputs": {"tank": [{"fluids": "minecraft:water", "amount": 1000}]}, + "outputs": {"tank": [{"fluid": {"id": "minecraft:lava", "amount": 1000}}]}, "time": 10} + """); + var someWaterToLava = + recipe( + ops, + """ + {"inputs": {"tank": [{"fluids": "minecraft:water", "amount": 500}]}, + "outputs": {"tank": [{"fluid": {"id": "minecraft:lava", "amount": 500}}]}, "time": 10} + """); + var fluids = new FluidStorage(); + fluids.addTank(1_000, Role.INPUT); + fluids + .tank(0) + .fill( + new net.neoforged.neoforge.fluids.FluidStack(Fluids.WATER, 1_000), + net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction.EXECUTE); + var tank = MachineInventory.builder().group("tank", StorageGroups.fluids(fluids, 0)).build(); + expect(RecipeMatcher.check(someWaterToLava, tank), Reason.NO_ROOM, "tank", 0); + require(RecipeMatcher.check(waterToLava, tank).ok(), "lava fits once the water is gone"); + require(RecipeMatcher.consume(waterToLava, tank), "water taken"); + require(RecipeMatcher.produce(waterToLava, tank, fixed(.5F)), "lava made"); + require(fluids.getFluidInTank(0).is(Fluids.LAVA), "the tank now holds lava"); + } + private static MachineRecipe recipe(RegistryOps ops, String json) { return MachineRecipe.CODEC.codec().parse(ops, JsonParser.parseString(json)).getOrThrow(); } diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java index 07174ea..88193d3 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java @@ -130,14 +130,18 @@ static void lookupFinds(ServerLevel level) { grinder.items.setStackInSlot(0, new ItemStack(Items.IRON_ORE)); require(id(lookup.find(level, grinder.inventory)).equals(IRON), "switched to iron"); - // The fluid output can't fit: the iron recipe doesn't match. + // A full tank is no obstacle: the recipe drains 100 mB from it before adding 50. grinder.fluids.tank(0).fill(new FluidStack(Fluids.WATER, 4_000), execute()); - require(lookup.find(level, grinder.inventory).isEmpty(), "no room for the water output"); + require(id(lookup.find(level, grinder.inventory)).equals(IRON), "water makes its own room"); + + // The item output can't fit: the iron recipe doesn't match. + grinder.blockOutputs(); + require(lookup.find(level, grinder.inventory).isEmpty(), "no room for the raw iron"); var input = new MachineRecipeInput(grinder.inventory); require(input.size() == 4 && input.getItem(0).is(Items.IRON_ORE), "item view"); require(!input.isEmpty(), "a machine input is never empty to vanilla"); - grinder.fluids.tank(0).drain(1_000, execute()); + grinder.unblockOutputs(); require( level .getRecipeManager() @@ -248,6 +252,16 @@ private static final class Grinder { .group("energy", StorageGroups.energy(energy)) .build(); } + + void blockOutputs() { + items.setStackInSlot(1, new ItemStack(Items.COBBLESTONE, 64)); + items.setStackInSlot(2, new ItemStack(Items.COBBLESTONE, 64)); + } + + void unblockOutputs() { + items.setStackInSlot(1, ItemStack.EMPTY); + items.setStackInSlot(2, ItemStack.EMPTY); + } } /** From 97037867dc0307ee48ff711ba1bebfae112d7d2e Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Mon, 2 Nov 2026 19:32:16 +0200 Subject: [PATCH 4/5] feat(machine): explain why no recipe ran, and JEI and builder fixes Review notes from #83. - MachineRecipeLookup.explain returns the recipe closest to running and its MatchResult, for screens and debugging: one that can run, then NO_ROOM, then MISSING_INPUT, then a recipe the machine doesn't suit. It checks every recipe, so it's for when the answer will be shown, and doesn't add to the cost of find. - JEI: fluid inputs from a tag show source fluids, so #minecraft:water no longer cycles through flowing_water. Recipes that can never run, such as one whose input is an empty tag from an optional mod, are left out. - JEI: the energy, time and chance text all format with Locale.ROOT. - MachineRecipeBuilder.build refuses a time below 1, not just 0, instead of failing when the recipe is encoded. - MachineRecipeBuilder.outputFluid has a chance overload, like outputItem. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../mc/lib/client/jei/MMDLibJeiPlugin.java | 8 ++- .../lib/client/jei/MachineRecipeCategory.java | 42 ++++++++++-- .../machine/recipe/MachineRecipeBuilder.java | 14 ++-- .../machine/recipe/MachineRecipeLookup.java | 32 ++++++++- .../zone/moddev/mc/lib/smoke/GuiJeiSmoke.java | 26 +++++++- .../moddev/mc/lib/smoke/MMDLibGameTests.java | 6 ++ .../mc/lib/smoke/MachineRecipeTypeSmoke.java | 65 ++++++++++++++++++- .../recipe/grinding/empty_fluids.json | 10 +++ .../recipe/grinding/empty_items.json | 10 +++ 9 files changed, 196 insertions(+), 17 deletions(-) create mode 100644 src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/empty_fluids.json create mode 100644 src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/empty_items.json diff --git a/src/main/java/zone/moddev/mc/lib/client/jei/MMDLibJeiPlugin.java b/src/main/java/zone/moddev/mc/lib/client/jei/MMDLibJeiPlugin.java index 3138547..aa341da 100644 --- a/src/main/java/zone/moddev/mc/lib/client/jei/MMDLibJeiPlugin.java +++ b/src/main/java/zone/moddev/mc/lib/client/jei/MMDLibJeiPlugin.java @@ -62,7 +62,10 @@ public void registerCategories(IRecipeCategoryRegistration registration) { for (var type : machineRecipeTypes()) registration.addRecipeCategories( MachineRecipeCategory.create( - type, level == null ? List.of() : type.recipes(level.getRecipeManager()))); + type, + level == null + ? List.of() + : MachineRecipeCategory.shown(type.recipes(level.getRecipeManager())))); } @Override @@ -76,7 +79,8 @@ public void registerRecipes(IRecipeRegistration registration) { if (level != null) for (var type : machineRecipeTypes()) registration.addRecipes( - MachineRecipeCategory.recipeType(type), type.recipes(level.getRecipeManager())); + MachineRecipeCategory.recipeType(type), + MachineRecipeCategory.shown(type.recipes(level.getRecipeManager()))); } /** Every registered machine recipe type, whichever mod registered it. */ diff --git a/src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeCategory.java b/src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeCategory.java index 7a070d6..1181619 100644 --- a/src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeCategory.java +++ b/src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeCategory.java @@ -7,8 +7,11 @@ import java.util.List; import java.util.Locale; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; import net.minecraft.network.chat.Component; import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.level.material.Fluid; import mezz.jei.api.gui.builder.IRecipeLayoutBuilder; import mezz.jei.api.gui.builder.IRecipeSlotBuilder; @@ -28,7 +31,8 @@ * The JEI category MMDLib registers for every {@link MachineRecipeType}: item and fluid inputs, an * arrow, the products, then the energy and the time. It is sized for the largest recipe loaded. Its * title is the translation {@code jei..} of the type's ID. A mod adds its machines - * as catalysts from its own JEI plugin, with {@link #recipeType}. + * as catalysts from its own JEI plugin, with {@link #recipeType}. Recipes that can never run, such + * as one whose input is an empty tag from an optional mod, are left out. */ public final class MachineRecipeCategory extends AbstractRecipeCategory> { @@ -55,6 +59,24 @@ public static RecipeType> recipeType(MachineRec return RecipeType.createRecipeHolderType(type.id()); } + /** The recipes to show: those whose every input names at least one item or fluid. */ + static List> shown( + Collection> recipes) { + return recipes.stream() + .filter( + holder -> + holder.value().recipe().inputs().values().stream() + .flatMap(List::stream) + .allMatch( + part -> + switch (part) { + case MachineIngredient.ItemPart items -> !items.items().hasNoItems(); + case MachineIngredient.FluidPart fluids -> fluids.fluids().size() > 0; + case MachineIngredient.EnergyPart energy -> true; + })) + .toList(); + } + /** A category for {@code type}, wide and tall enough for every recipe in {@code recipes}. */ static MachineRecipeCategory create( MachineRecipeType type, Collection> recipes) { @@ -84,9 +106,8 @@ public void setRecipe( .toList()); case MachineIngredient.FluidPart fluids -> { var slot = slot(builder, RecipeIngredientRole.INPUT, 0, index++); - fluids - .fluids() - .forEach(fluid -> slot.addFluidStack(fluid.value(), fluids.millibuckets())); + for (var fluid : sources(fluids.fluids())) + slot.addFluidStack(fluid.value(), fluids.millibuckets()); slot.setFluidRenderer(fluids.millibuckets(), false, 16, 16); } case MachineIngredient.EnergyPart energy -> {} @@ -129,7 +150,8 @@ public void createRecipeExtras( if (energy > 0) lines.add( Component.translatable( - "jei.mmdlib.machine.energy", NumberFormat.getIntegerInstance().format(energy))); + "jei.mmdlib.machine.energy", + NumberFormat.getIntegerInstance(Locale.ROOT).format(energy))); lines.add(Component.translatable("jei.mmdlib.machine.time", seconds(recipe.time()))); builder .addText(joined(lines), getWidth(), TEXT) @@ -144,6 +166,14 @@ private IRecipeSlotBuilder slot( .setStandardSlotBackground(); } + /** The source fluids of {@code fluids}, as a tag also lists flowing ones; all if none are. */ + private static List> sources(HolderSet fluids) { + var sources = + fluids.stream().filter(fluid -> fluid.value().isSource(fluid.value().defaultFluidState())); + var list = sources.toList(); + return list.isEmpty() ? fluids.stream().toList() : list; + } + private static int inputSlots(MachineRecipe recipe) { return (int) recipe.inputs().values().stream() @@ -167,7 +197,7 @@ private static String percent(float chance) { } private static String seconds(int ticks) { - var format = NumberFormat.getNumberInstance(); + var format = NumberFormat.getNumberInstance(Locale.ROOT); format.setMaximumFractionDigits(2); return format.format(ticks / 20.0); } diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeBuilder.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeBuilder.java index efbadd5..3872e67 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeBuilder.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeBuilder.java @@ -91,13 +91,19 @@ public MachineRecipeBuilder outputItem(String group, ItemStack stack, float chan } /** {@code millibuckets} of {@code fluid}, every run. */ - @SuppressWarnings("deprecation") public MachineRecipeBuilder outputFluid(String group, Fluid fluid, int millibuckets) { + return outputFluid(group, fluid, millibuckets, 1); + } + + /** {@code millibuckets} of {@code fluid}, made with {@code chance} in (0, 1]. */ + @SuppressWarnings("deprecation") + public MachineRecipeBuilder outputFluid( + String group, Fluid fluid, int millibuckets, float chance) { return output( group, new MachineProduct.FluidProduct( new FluidAmount(fluid.builtInRegistryHolder(), DataComponentPatch.EMPTY, millibuckets), - 1)); + chance)); } /** How long a run takes, in ticks. */ @@ -109,10 +115,10 @@ public MachineRecipeBuilder time(int ticks) { /** * The recipe. * - * @throws IllegalStateException if no time was set or there are no inputs + * @throws IllegalStateException if the time isn't at least 1 or there are no inputs */ public MachineRecipe build() { - if (time == 0) throw new IllegalStateException("A machine recipe needs a time"); + if (time < 1) throw new IllegalStateException("A machine recipe needs a time of at least 1"); if (inputs.isEmpty()) throw new IllegalStateException("A machine recipe needs an input"); return new MachineRecipe(inputs, outputs, time); } diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeLookup.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeLookup.java index 00d983d..e196aa5 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeLookup.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeLookup.java @@ -1,5 +1,6 @@ package zone.moddev.mc.lib.machine.recipe; +import java.util.Comparator; import java.util.Optional; import javax.annotation.Nullable; @@ -13,9 +14,13 @@ /** * Finds the recipe of one type that a machine can run now. It tries the machine's last recipe * first, by ID, so a {@code /reload} that changes or removes it is picked up. When several recipes - * match, which one wins is unspecified; keep a type's recipes from overlapping. + * match, which one wins is unspecified; keep a type's recipes from overlapping. {@link #explain} + * says why nothing ran. */ public final class MachineRecipeLookup { + /** A recipe and how it does in the machine now. */ + public record Attempt(RecipeHolder recipe, MatchResult result) {} + private final MachineRecipeType type; @Nullable private ResourceLocation last; @@ -30,4 +35,29 @@ public Optional> find(Level level, MachineInven found.ifPresent(holder -> last = holder.id()); return found; } + + /** + * The recipe closest to running, for a screen or debugging: one that can run, then one with its + * inputs but no room for its products, then one missing an input, then one the machine doesn't + * suit. Between equals it picks the machine's last recipe, then the lowest ID. Empty only when + * the type has no recipes. It checks every recipe, so call it when the answer will be shown + * rather than every tick. Changes nothing. + */ + public Optional explain(Level level, MachineInventory inventory) { + return type.recipes(level.getRecipeManager()).stream() + .map(holder -> new Attempt(holder, RecipeMatcher.check(holder.value().recipe(), inventory))) + .min( + Comparator.comparingInt((Attempt attempt) -> rank(attempt.result().reason())) + .thenComparing(attempt -> !attempt.recipe().id().equals(last)) + .thenComparing(attempt -> attempt.recipe().id())); + } + + private static int rank(MatchResult.Reason reason) { + return switch (reason) { + case OK -> 0; + case NO_ROOM -> 1; + case MISSING_INPUT -> 2; + case WRONG_KIND, NO_GROUP -> 3; + }; + } } diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiJeiSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiJeiSmoke.java index 649aa1a..63ad000 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiJeiSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiJeiSmoke.java @@ -63,8 +63,9 @@ public static void check(MachineScreen screen) throws Exception { } /** - * The generic category for {@code mmdlib_smoke:grinding} lists both test recipes; the copper one - * lays out an ore and a water input and two products. + * The generic category for {@code mmdlib_smoke:grinding} lists the two test recipes that can run, + * not the two whose inputs name nothing; the copper one lays out an ore and a water input and two + * products; the iron one's water tag shows source water, not flowing water. */ private static void checkMachineCategory(IJeiRuntime runtime) { var manager = runtime.getRecipeManager(); @@ -89,6 +90,27 @@ private static void checkMachineCategory(IJeiRuntime runtime) { int outputs = slots.getSlotViews(mezz.jei.api.recipe.RecipeIngredientRole.OUTPUT).size(); if (inputs != 2 || outputs != 2) throw new AssertionError("JEI machine slots: " + inputs + " in, " + outputs + " out"); + var iron = + recipes.stream() + .filter(holder -> holder.id().getPath().equals("grinding/iron")) + .findFirst() + .orElseThrow(); + var ironSlots = + manager + .createRecipeLayoutDrawable( + category, iron, runtime.getJeiHelpers().getFocusFactory().getEmptyFocusGroup()) + .orElseThrow(() -> new AssertionError("JEI machine layout failed")) + .getRecipeSlotsView(); + var inputFluids = + ironSlots.getSlotViews(mezz.jei.api.recipe.RecipeIngredientRole.INPUT).stream() + .flatMap(view -> view.getAllIngredients()) + .map(mezz.jei.api.ingredients.ITypedIngredient::getIngredient) + .filter(net.neoforged.neoforge.fluids.FluidStack.class::isInstance) + .map(net.neoforged.neoforge.fluids.FluidStack.class::cast) + .toList(); + if (inputFluids.size() != 1 + || inputFluids.getFirst().getFluid() != net.minecraft.world.level.material.Fluids.WATER) + throw new AssertionError("JEI water tag shows: " + inputFluids); MMDLib.LOGGER.info( "MMDLIB_GUI_JEI_MACHINE_PASS: generic machine recipe category with {}x{} layout", category.getWidth(), diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java index 51a83b9..3349b0d 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java @@ -112,6 +112,12 @@ public static void machineRecipeLookup(GameTestHelper helper) { helper.succeed(); } + @GameTest(template = EMPTY) + public static void machineRecipeExplain(GameTestHelper helper) { + MachineRecipeTypeSmoke.explainPicksTheClosest(helper.getLevel()); + helper.succeed(); + } + @GameTest(template = EMPTY) public static void machineRecipeNetworkCodec(GameTestHelper helper) { MachineRecipeTypeSmoke.streamCodecRoundTrips(helper.getLevel()); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java index 88193d3..f04265b 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java @@ -52,6 +52,7 @@ import zone.moddev.mc.lib.machine.recipe.MachineRecipeEntry; import zone.moddev.mc.lib.machine.recipe.MachineRecipeInput; import zone.moddev.mc.lib.machine.recipe.MachineRecipeType; +import zone.moddev.mc.lib.machine.recipe.MatchResult; /** * Registers the {@code mmdlib_smoke:grinding} machine recipe type and checks it against the recipe @@ -93,11 +94,15 @@ static void registered() { require(GRINDING.toString().equals("mmdlib_smoke:grinding"), "type name " + GRINDING); } - /** The two good files load as grinding recipes; the one without inputs is skipped. */ + /** + * The four good files load as grinding recipes, two of them with inputs that name nothing; the one + * without inputs is skipped. + */ static void recipesLoad(ServerLevel level) { var manager = level.getRecipeManager(); var all = GRINDING.recipes(manager); - require(all.size() == 2, "two grinding recipes, got " + all.stream().map(h -> h.id()).toList()); + require( + all.size() == 4, "four grinding recipes, got " + all.stream().map(h -> h.id()).toList()); var copper = recipe(level, COPPER); require( copper.time() == 200 && copper.inputs().size() == 3 && copper.outputs().size() == 2, @@ -152,6 +157,49 @@ static void lookupFinds(ServerLevel level) { "vanilla getRecipeFor"); } + /** What explain picks from the recipes, and that it doesn't disturb the lookup. */ + static void explainPicksTheClosest(ServerLevel level) { + var lookup = GRINDING.lookup(); + var grinder = new Grinder(); + expectAttempt(lookup, level, grinder, COPPER, MatchResult.Reason.MISSING_INPUT); + + grinder.items.insertResult(0, new ItemStack(Items.IRON_ORE), false); + expectAttempt(lookup, level, grinder, IRON, MatchResult.Reason.OK); + require(id(lookup.find(level, grinder.inventory)).equals(IRON), "explain left find working"); + + // The inputs are there but the raw iron has no room: closer than copper's missing ore. + grinder.blockOutputs(); + expectAttempt(lookup, level, grinder, IRON, MatchResult.Reason.NO_ROOM); + + // Nothing to tell the recipes apart: the machine's last recipe wins over the lowest ID. + grinder.items.setStackInSlot(0, ItemStack.EMPTY); + expectAttempt(lookup, level, grinder, IRON, MatchResult.Reason.MISSING_INPUT); + + var noGroups = MachineInventory.builder().build(); + expectAttempt(GRINDING.lookup(), level, noGroups, COPPER, MatchResult.Reason.NO_GROUP); + } + + private static void expectAttempt( + zone.moddev.mc.lib.machine.recipe.MachineRecipeLookup lookup, + ServerLevel level, + Grinder grinder, + ResourceLocation recipe, + MatchResult.Reason reason) { + expectAttempt(lookup, level, grinder.inventory, recipe, reason); + } + + private static void expectAttempt( + zone.moddev.mc.lib.machine.recipe.MachineRecipeLookup lookup, + ServerLevel level, + MachineInventory inventory, + ResourceLocation recipe, + MatchResult.Reason reason) { + var attempt = lookup.explain(level, inventory).orElseThrow(); + require( + attempt.recipe().id().equals(recipe) && attempt.result().reason() == reason, + "expected " + recipe + " " + reason + ", got " + attempt); + } + /** Every loaded recipe survives the serializer's network codec. */ static void streamCodecRoundTrips(ServerLevel level) { for (var holder : GRINDING.recipes(level.getRecipeManager())) { @@ -230,6 +278,19 @@ public void accept( throw new AssertionError("A recipe without inputs must be refused"); } catch (IllegalStateException expected) { } + try { + MachineRecipeBuilder.recipe(GRINDING).inputEnergy("energy", 1).time(-5).build(); + throw new AssertionError("A negative time must be refused"); + } catch (IllegalStateException expected) { + } + + var chance = + MachineRecipeBuilder.recipe(GRINDING) + .inputEnergy("energy", 1) + .outputFluid("water", Fluids.WATER, 50, 0.25F) + .time(1) + .build(); + require(chance.outputs().get("water").getFirst().chance() == 0.25F, "fluid output chance"); } /** A grinder's storages: ore in slot 0, outputs in 1 and 2, byproduct in 3, a water tank. */ diff --git a/src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/empty_fluids.json b/src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/empty_fluids.json new file mode 100644 index 0000000..a4106e3 --- /dev/null +++ b/src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/empty_fluids.json @@ -0,0 +1,10 @@ +{ + "type": "mmdlib_smoke:grinding", + "inputs": { + "water": [{ "fluids": "#mmdlib_smoke:nothing", "amount": 10 }] + }, + "outputs": { + "output": [{ "item": { "id": "minecraft:gravel" } }] + }, + "time": 10 +} diff --git a/src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/empty_items.json b/src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/empty_items.json new file mode 100644 index 0000000..dd8d328 --- /dev/null +++ b/src/runtimeTest/resources/data/mmdlib_smoke/recipe/grinding/empty_items.json @@ -0,0 +1,10 @@ +{ + "type": "mmdlib_smoke:grinding", + "inputs": { + "input": [{ "items": { "tag": "mmdlib_smoke:nothing" } }] + }, + "outputs": { + "output": [{ "item": { "id": "minecraft:gravel" } }] + }, + "time": 10 +} From ed81e1e7faf0cedcd993993f3f2c5ac5f62d8852 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Mon, 2 Nov 2026 20:10:04 +0200 Subject: [PATCH 5/5] fix(machine): don't name a missing input on a machine with nothing in it explain reported the first recipe's missing input whatever the machine held, so an empty machine could say "missing raw aluminum". Reported on #83. A missing input now only counts when the machine holds some other item or fluid input of that recipe. Energy doesn't count, as a powered machine with nothing in it hasn't begun on any recipe. When nothing qualifies, explain is empty, so a screen can say so. A recipe with NO_GROUP or WRONG_KIND is only returned when the machine suits none of them, as a setup problem. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018zpP6ZrFjaGe477FvWAZKo --- .../machine/recipe/MachineRecipeLookup.java | 50 +++++++++++++++---- .../mc/lib/machine/recipe/RecipeMatcher.java | 21 ++++++++ .../mc/lib/smoke/MachineRecipeSmoke.java | 22 ++++++++ .../mc/lib/smoke/MachineRecipeTypeSmoke.java | 28 +++++++++++ 4 files changed, 111 insertions(+), 10 deletions(-) diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeLookup.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeLookup.java index e196aa5..34eea5e 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeLookup.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeLookup.java @@ -38,18 +38,48 @@ public Optional> find(Level level, MachineInven /** * The recipe closest to running, for a screen or debugging: one that can run, then one with its - * inputs but no room for its products, then one missing an input, then one the machine doesn't - * suit. Between equals it picks the machine's last recipe, then the lowest ID. Empty only when - * the type has no recipes. It checks every recipe, so call it when the answer will be shown - * rather than every tick. Changes nothing. + * inputs but no room for its products, then one missing an input while the machine holds another + * of its item or fluid inputs. Between equals it picks the machine's last recipe, then the lowest + * ID. + * + *

Empty when the machine holds nothing any recipe uses, so a screen can say so rather than + * name a missing input of a recipe nobody asked for. If the machine suits none of the recipes, it + * returns one of those, as that's a setup problem worth seeing. Also empty when the type has no + * recipes. It checks every recipe, so call it when the answer will be shown rather than every + * tick. Changes nothing. */ public Optional explain(Level level, MachineInventory inventory) { - return type.recipes(level.getRecipeManager()).stream() - .map(holder -> new Attempt(holder, RecipeMatcher.check(holder.value().recipe(), inventory))) - .min( - Comparator.comparingInt((Attempt attempt) -> rank(attempt.result().reason())) - .thenComparing(attempt -> !attempt.recipe().id().equals(last)) - .thenComparing(attempt -> attempt.recipe().id())); + var attempts = + type.recipes(level.getRecipeManager()).stream() + .map( + holder -> + new Attempt(holder, RecipeMatcher.check(holder.value().recipe(), inventory))) + .toList(); + var closest = + attempts.stream().filter(attempt -> isClose(attempt, inventory)).min(closestFirst()); + if (closest.isPresent() || attempts.stream().anyMatch(attempt -> !isUnsuited(attempt))) + return closest; + return attempts.stream().min(closestFirst()); + } + + private Comparator closestFirst() { + return Comparator.comparingInt((Attempt attempt) -> rank(attempt.result().reason())) + .thenComparing(attempt -> !attempt.recipe().id().equals(last)) + .thenComparing(attempt -> attempt.recipe().id()); + } + + private static boolean isClose(Attempt attempt, MachineInventory inventory) { + return switch (attempt.result().reason()) { + case OK, NO_ROOM -> true; + case MISSING_INPUT -> + RecipeMatcher.holdsSomeInput(attempt.recipe().value().recipe(), inventory); + case WRONG_KIND, NO_GROUP -> false; + }; + } + + private static boolean isUnsuited(Attempt attempt) { + var reason = attempt.result().reason(); + return reason == MatchResult.Reason.WRONG_KIND || reason == MatchResult.Reason.NO_GROUP; } private static int rank(MatchResult.Reason reason) { diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java index f7cd7e7..0a22287 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java @@ -62,6 +62,27 @@ public static MatchResult check(MachineRecipe recipe, MachineInventory inventory return MatchResult.OK; } + /** + * Whether the machine holds at least some of one of {@code recipe}'s item or fluid inputs, which + * is what makes a missing input worth reporting. Stored energy doesn't count, because a powered + * machine with nothing in it hasn't begun on any recipe. A recipe that needs only energy always + * passes. Changes nothing. + */ + public static boolean holdsSomeInput(MachineRecipe recipe, MachineInventory inventory) { + boolean energyOnly = true; + for (var entry : recipe.inputs().entrySet()) { + var group = inventory.group(entry.getKey()).orElse(null); + for (var part : entry.getValue()) { + if (part instanceof EnergyPart) continue; + energyOnly = false; + if (group == null) continue; + long need = claim(new InputClaims(available(group)), group, part, recipe.time()); + if (need >= 0 && need < part.amount()) return true; + } + } + return energyOnly; + } + /** * Takes the item and fluid inputs in one transaction, all or nothing. Energy is not taken; it is * drawn per tick while the recipe runs. diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeSmoke.java index 465fa6b..75b0dce 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeSmoke.java @@ -233,6 +233,7 @@ static void check(ServerLevel level) { "output with neither key refused, got " + neitherOut); mixedOutputs(ops); consumedInputsMakeRoom(ops); + holdingSomeInput(ops); require( MachineRecipe.CODEC .codec() @@ -324,6 +325,27 @@ private static void mixedOutputs(RegistryOps ops) { require(full.fluids.getFluidInTank(0).getAmount() == 4_000, "tank untouched"); } + /** Item and fluid inputs count as a start on a recipe, however little; energy doesn't. */ + private static void holdingSomeInput(RegistryOps ops) { + var crusher = recipe(ops, CRUSHER); + require( + !RecipeMatcher.holdsSomeInput(crusher, new Crusher(0, 0, 1_000, true).inventory), + "energy alone is no start"); + require( + RecipeMatcher.holdsSomeInput(crusher, new Crusher(1, 0, 0, true).inventory), + "one ore of the input is a start"); + require( + RecipeMatcher.holdsSomeInput(crusher, new Crusher(0, 1, 0, true).inventory), + "1 mB of 250 is a start"); + require( + !RecipeMatcher.holdsSomeInput(crusher, new Crusher(0, 1, 0, false).inventory), + "a tank the machine has no group for doesn't count"); + var energyOnly = recipe(ops, "{\"inputs\": {\"energy\": [{\"energy\": 5}]}, \"time\": 1}"); + require( + RecipeMatcher.holdsSomeInput(energyOnly, new Crusher(0, 0, 0, true).inventory), + "energy is all this recipe could be missing"); + } + /** A product may land where its inputs leave room, and only there; checking changes nothing. */ private static void consumedInputsMakeRoom(RegistryOps ops) { var ironToGold = diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java index f04265b..5d4a37f 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java @@ -177,6 +177,34 @@ static void explainPicksTheClosest(ServerLevel level) { var noGroups = MachineInventory.builder().build(); expectAttempt(GRINDING.lookup(), level, noGroups, COPPER, MatchResult.Reason.NO_GROUP); + + // Only energy left, which doesn't count as having begun on a recipe: no recipe to name. + grinder.fluids.tank(0).drain(1_000, execute()); + expectNothing(lookup, level, grinder.inventory, "an empty machine"); + + // Something no recipe wants is the same as nothing, not "missing copper ore". + grinder.items.setStackInSlot(0, new ItemStack(Items.COBBLESTONE)); + expectNothing(lookup, level, grinder.inventory, "a machine holding only cobblestone"); + + // Holding one input of a recipe makes that recipe's missing input worth reporting. + grinder.items.setStackInSlot(0, new ItemStack(Items.IRON_ORE)); + expectAttempt(GRINDING.lookup(), level, grinder, IRON, MatchResult.Reason.MISSING_INPUT); + + // A recipe the machine doesn't suit isn't named over "nothing to work with" for one it does. + var water = new FluidStorage(); + water.addTank(1_000, Role.INPUT); + var waterOnly = + MachineInventory.builder().group("water", StorageGroups.fluids(water, 0)).build(); + expectNothing(GRINDING.lookup(), level, waterOnly, "a machine with only an empty tank"); + } + + private static void expectNothing( + zone.moddev.mc.lib.machine.recipe.MachineRecipeLookup lookup, + ServerLevel level, + MachineInventory inventory, + String what) { + var attempt = lookup.explain(level, inventory); + require(attempt.isEmpty(), "nothing to report for " + what + ", got " + attempt); } private static void expectAttempt(