Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion gradle/verification.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
Expand Down
1 change: 1 addition & 0 deletions scripts/gui-smoke.init.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/zone/moddev/mc/lib/client/jei/MMDLibJeiPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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<MachineRecipeType> machineRecipeTypes() {
return BuiltInRegistries.RECIPE_TYPE.stream()
.filter(MachineRecipeType.class::isInstance)
.map(MachineRecipeType.class::cast)
.toList();
}

/**
Expand Down
213 changes: 213 additions & 0 deletions src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeCategory.java
Original file line number Diff line number Diff line change
@@ -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.<namespace>.<path>} 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<RecipeHolder<MachineRecipeEntry>> {
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<RecipeHolder<MachineRecipeEntry>> 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<RecipeHolder<MachineRecipeEntry>> shown(
Collection<RecipeHolder<MachineRecipeEntry>> 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<RecipeHolder<MachineRecipeEntry>> 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<MachineRecipeEntry> 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<MachineRecipeEntry> holder, IFocusGroup focuses) {
var recipe = holder.value().recipe();
builder.addRecipeArrowWidget().setPosition(inputColumns * CELL + GAP, (rows * CELL - 17) / 2);
var lines = new ArrayList<Component>();
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<Holder<Fluid>> sources(HolderSet<Fluid> 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<Component> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -39,6 +43,17 @@ public record FluidAmount(Holder<Fluid> fluid, DataComponentPatch components, in
.forGetter(FluidAmount::amount))
.apply(instance, FluidAmount::new));

/** The network form. */
public static final StreamCodec<RegistryFriendlyByteBuf, FluidAmount> 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
*/
Expand Down
Loading
Loading