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/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/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..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 @@ -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,14 @@ 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() + : MachineRecipeCategory.shown(type.recipes(level.getRecipeManager())))); } @Override @@ -67,6 +76,19 @@ 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), + MachineRecipeCategory.shown(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..1181619 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeCategory.java @@ -0,0 +1,213 @@ +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.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; +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}. 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> { + 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()); + } + + /** 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) { + 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++); + for (var fluid : sources(fluids.fluids())) + 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(Locale.ROOT).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(); + } + + /** 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() + .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(Locale.ROOT); + 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/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/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/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/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..3872e67 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeBuilder.java @@ -0,0 +1,130 @@ +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. */ + 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), + chance)); + } + + /** How long a run takes, in ticks. */ + public MachineRecipeBuilder time(int ticks) { + time = ticks; + return this; + } + + /** + * The recipe. + * + * @throws IllegalStateException if the time isn't at least 1 or there are no inputs + */ + public MachineRecipe build() { + 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); + } + + /** 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..34eea5e --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/MachineRecipeLookup.java @@ -0,0 +1,93 @@ +package zone.moddev.mc.lib.machine.recipe; + +import java.util.Comparator; +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. {@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; + + 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; + } + + /** + * 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 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) { + 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) { + return switch (reason) { + case OK -> 0; + case NO_ROOM -> 1; + case MISSING_INPUT -> 2; + case WRONG_KIND, NO_GROUP -> 3; + }; + } +} 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/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java index f67b9ad..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 @@ -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,27 +39,50 @@ 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; } + /** + * 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. @@ -97,30 +122,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/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/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/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..63ad000 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,81 @@ 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 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(); + 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"); + 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(), + 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..3349b0d 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(); } @@ -91,4 +93,40 @@ 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 machineRecipeExplain(GameTestHelper helper) { + MachineRecipeTypeSmoke.explainPicksTheClosest(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/MachineRecipeSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeSmoke.java index 8afbe3c..75b0dce 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,8 @@ static void check(ServerLevel level) { .orElse(false), "output with neither key refused, got " + neitherOut); mixedOutputs(ops); + consumedInputsMakeRoom(ops); + holdingSomeInput(ops); require( MachineRecipe.CODEC .codec() @@ -323,6 +325,106 @@ 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 = + 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 new file mode 100644 index 0000000..5d4a37f --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineRecipeTypeSmoke.java @@ -0,0 +1,445 @@ +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; +import zone.moddev.mc.lib.machine.recipe.MatchResult; + +/** + * 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 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() == 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, + "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"); + + // 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(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.unblockOutputs(); + require( + level + .getRecipeManager() + .getRecipeFor(GRINDING, input, level) + .map(RecipeHolder::id) + .orElseThrow() + .equals(IRON), + "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); + + // 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( + 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())) { + 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) { + } + 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. */ + 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(); + } + + 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); + } + } + + /** + * 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/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)); 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/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 +} 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 +}