From 98886b252fce0174b50a4ddf0dd29344dd40cb03 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 17:18:37 +0000 Subject: [PATCH 1/8] Expose crafting job progress and measured recipe durations Crafting jobs now remember the amount they started with, so that the part of a job that was crafted already can be derived. Crafting interfaces measure how long each crafting operation takes, and expose a smoothed duration per recipe. This allows the duration of crafting jobs to be estimated, which is not possible otherwise, as the time that a machine needs for a recipe is unknown upfront. Refs CyclopsMC/IntegratedTerminals#145 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0186zEXrjMNMVXQC7wcoSiMR --- .../api/crafting/CraftingJob.java | 21 +++- .../crafting/CraftingJobDependencyGraph.java | 1 + .../api/crafting/ICraftingInterface.java | 18 ++++ .../api/network/ICraftingNetwork.java | 10 ++ .../core/CraftingHelpers.java | 10 ++ .../core/CraftingJobHandler.java | 101 ++++++++++++++++++ .../core/network/CraftingNetwork.java | 19 +++- .../part/PartTypeInterfaceCraftingBase.java | 11 ++ .../gametest/GameTestsItemsCraft.java | 28 +++++ .../TestCraftingJobDependencyGraph.java | 15 +++ .../core/TestCraftingHelpers.java | 8 ++ .../core/TestCraftingJobHandler.java | 76 +++++++++++++ 12 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java diff --git a/src/main/java/org/cyclops/integratedcrafting/api/crafting/CraftingJob.java b/src/main/java/org/cyclops/integratedcrafting/api/crafting/CraftingJob.java index 9ac543853..2ff64050b 100644 --- a/src/main/java/org/cyclops/integratedcrafting/api/crafting/CraftingJob.java +++ b/src/main/java/org/cyclops/integratedcrafting/api/crafting/CraftingJob.java @@ -35,6 +35,7 @@ public class CraftingJob { private final IntList dependencyCraftingJobs; private final IntList dependentCraftingJobs; private int amount; + private int amountTotal; private IMixedIngredients ingredientsStorage; // Total to extract from storage (simulated and immutable) private IMixedIngredients ingredientsStorageBuffer; // The actual ingredients from storage, which are consumed over time. private Map, MissingIngredients> lastMissingIngredients; @@ -49,6 +50,7 @@ public CraftingJob(int id, int channel, IRecipeDefinition recipe, int amount, IM this.channel = channel; this.recipe = recipe; this.amount = amount; + this.amountTotal = amount; this.ingredientsStorage = ingredientsStorage; this.ingredientsStorageBuffer = new MixedIngredients(Maps.newIdentityHashMap()); this.lastMissingIngredients = Maps.newIdentityHashMap(); @@ -86,6 +88,18 @@ public void setAmount(int amount) { this.amount = amount; } + /** + * @return The amount this job started with, including the amount that was crafted already. + * Contrary to {@link #getAmount()}, this value is not decremented while crafting. + */ + public int getAmountTotal() { + return amountTotal; + } + + public void setAmountTotal(int amountTotal) { + this.amountTotal = amountTotal; + } + public void addDependency(CraftingJob dependency) { dependencyCraftingJobs.add(dependency.getId()); dependency.dependentCraftingJobs.add(this.getId()); @@ -239,6 +253,7 @@ public static CompoundTag serialize(HolderLookup.Provider lookupProvider, Crafti tag.put("dependencies", new IntArrayTag(craftingJob.getDependencyCraftingJobs())); tag.put("dependents", new IntArrayTag(craftingJob.getDependentCraftingJobs())); tag.putInt("amount", craftingJob.amount); + tag.putInt("amountTotal", craftingJob.amountTotal); tag.put("ingredientsStorage", IMixedIngredients.serialize(lookupProvider, craftingJob.ingredientsStorage)); tag.put("ingredientsStorageBuffer", IMixedIngredients.serialize(lookupProvider, craftingJob.ingredientsStorageBuffer)); tag.put("lastMissingIngredients", MissingIngredients.serialize(lookupProvider, craftingJob.lastMissingIngredients)); @@ -298,6 +313,8 @@ public static CraftingJob deserialize(HolderLookup.Provider lookupProvider, Comp Map, MissingIngredients> lastMissingIngredients = MissingIngredients .deserialize(lookupProvider, tag.getCompound("lastMissingIngredients")); craftingJob.setLastMissingIngredients(lastMissingIngredients); + craftingJob.setAmountTotal(tag.contains("amountTotal", Tag.TAG_INT) + ? tag.getInt("amountTotal") : amount); // TODO: rm backwards-compat in next major craftingJob.setStartTick(tag.getLong("startTick")); craftingJob.setInvalidInputs(tag.getBoolean("invalidInputs")); if (tag.contains("initiatorUuid", Tag.TAG_STRING)) { @@ -333,12 +350,14 @@ public CraftingJob clone(CraftingHelpers.IIdentifierGenerator identifierGenerato if (!this.getIngredientsStorageBuffer().isEmpty()) { throw new IllegalStateException("Cloning a job with an ingredient buffer is illegal"); } - return new CraftingJob( + CraftingJob clone = new CraftingJob( identifierGenerator.getNext(), getChannel(), getRecipe(), getAmount(), getIngredientsStorage() ); + clone.setAmountTotal(getAmountTotal()); + return clone; } } diff --git a/src/main/java/org/cyclops/integratedcrafting/api/crafting/CraftingJobDependencyGraph.java b/src/main/java/org/cyclops/integratedcrafting/api/crafting/CraftingJobDependencyGraph.java index d94287320..dda6cfe86 100644 --- a/src/main/java/org/cyclops/integratedcrafting/api/crafting/CraftingJobDependencyGraph.java +++ b/src/main/java/org/cyclops/integratedcrafting/api/crafting/CraftingJobDependencyGraph.java @@ -221,6 +221,7 @@ public void importDependencies(CraftingJobDependencyGraph craftingJobsGraph) { */ public void mergeCraftingJobs(CraftingJob target, CraftingJob mergee, boolean markMergeeAsFinished) { target.setAmount(target.getAmount() + mergee.getAmount()); + target.setAmountTotal(target.getAmountTotal() + mergee.getAmountTotal()); target.setIngredientsStorage(CraftingHelpers.mergeMixedIngredients( target.getIngredientsStorage(), mergee.getIngredientsStorage())); diff --git a/src/main/java/org/cyclops/integratedcrafting/api/crafting/ICraftingInterface.java b/src/main/java/org/cyclops/integratedcrafting/api/crafting/ICraftingInterface.java index a5ae9dcf3..2d6d3c765 100644 --- a/src/main/java/org/cyclops/integratedcrafting/api/crafting/ICraftingInterface.java +++ b/src/main/java/org/cyclops/integratedcrafting/api/crafting/ICraftingInterface.java @@ -77,6 +77,24 @@ public interface ICraftingInterface { */ public void cancelCraftingJob(int channel, int craftingJobId); + /** + * @param craftingJobId A crafting job id. + * @return The tick at which the oldest running crafting operation of the given job was started, + * or -1 if no operation is running, or if this is unknown. + */ + public default long getCraftingJobEntryStartTick(int craftingJobId) { + return -1; + } + + /** + * @param recipe A recipe. + * @return The estimated duration in ticks of a single crafting operation of the given recipe, + * based on the operations that were performed by this interface before, or -1 if unknown. + */ + public default long getEstimatedRecipeDuration(IRecipeDefinition recipe) { + return -1; + } + /** * @return The prioritized position of this interface. */ diff --git a/src/main/java/org/cyclops/integratedcrafting/api/network/ICraftingNetwork.java b/src/main/java/org/cyclops/integratedcrafting/api/network/ICraftingNetwork.java index a6c212345..3dfee7c0d 100644 --- a/src/main/java/org/cyclops/integratedcrafting/api/network/ICraftingNetwork.java +++ b/src/main/java/org/cyclops/integratedcrafting/api/network/ICraftingNetwork.java @@ -163,4 +163,14 @@ public Iterator getCraftingJobs(int channel, IngredientCompo */ public long getRunningTicks(CraftingJob craftingJob); + /** + * @param channel The channel. + * @param recipe A recipe. + * @return The estimated duration in ticks of a single crafting operation of the given recipe, + * based on the operations that the crafting interfaces performed before, or -1 if unknown. + */ + public default long getEstimatedRecipeDuration(int channel, IRecipeDefinition recipe) { + return -1; + } + } diff --git a/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java b/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java index 35cccac3a..1a8ba1407 100644 --- a/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java +++ b/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java @@ -5,6 +5,8 @@ import com.google.common.collect.Maps; import com.google.common.collect.Sets; import net.minecraft.core.Direction; +import net.minecraft.world.level.Level; +import net.neoforged.neoforge.server.ServerLifecycleHooks; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.entity.BlockEntity; import org.apache.commons.lang3.tuple.Pair; @@ -1729,6 +1731,13 @@ public static boolean insertCrafting(Function, PartPos return ok; } + /** + * @return The current game tick of the server. + */ + public static long getCurrentTick() { + return ServerLifecycleHooks.getCurrentServer().getLevel(Level.OVERWORLD).getGameTime(); + } + /** * Split the given crafting job amount into new jobs with a given split factor. * @param craftingJob A crafting job to split. @@ -1758,6 +1767,7 @@ public static List splitCraftingJobs(CraftingJob craftingJob, int s modulus--; } clonedJob.setAmount(newAmount); + clonedJob.setAmountTotal(newAmount); } // Collect dependency links diff --git a/src/main/java/org/cyclops/integratedcrafting/core/CraftingJobHandler.java b/src/main/java/org/cyclops/integratedcrafting/core/CraftingJobHandler.java index f38d56de7..3d053934b 100644 --- a/src/main/java/org/cyclops/integratedcrafting/core/CraftingJobHandler.java +++ b/src/main/java/org/cyclops/integratedcrafting/core/CraftingJobHandler.java @@ -6,6 +6,10 @@ import it.unimi.dsi.fastutil.ints.Int2IntOpenHashMap; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongArrayList; +import it.unimi.dsi.fastutil.longs.LongList; +import it.unimi.dsi.fastutil.objects.Object2DoubleMap; +import it.unimi.dsi.fastutil.objects.Object2DoubleOpenHashMap; import it.unimi.dsi.fastutil.objects.Object2IntMap; import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap; import net.minecraft.core.Direction; @@ -49,6 +53,11 @@ */ public class CraftingJobHandler { + /** + * The weight of the latest crafting operation duration within the running average for a recipe. + */ + protected static final double RECIPE_DURATION_SMOOTHING = 0.25D; + private final int maxProcessingJobs; private boolean blockingJobsMode; private final ICraftingResultsSink resultsSink; @@ -65,6 +74,8 @@ public class CraftingJobHandler { private final Int2ObjectMap finishedCraftingJobs; private final Map, Direction> ingredientComponentTargetOverrides; private final Int2IntMap nonBlockingJobsRunningAmount; + private final Int2ObjectMap processingCraftingJobsStartTicks; + private final Object2DoubleMap recipeDurations; public CraftingJobHandler(int maxProcessingJobs, boolean blockingJobsMode, Collection craftingProcessOverrides, @@ -85,6 +96,8 @@ public CraftingJobHandler(int maxProcessingJobs, boolean blockingJobsMode, this.finishedCraftingJobs = new Int2ObjectOpenHashMap<>(); this.ingredientComponentTargetOverrides = Maps.newIdentityHashMap(); this.nonBlockingJobsRunningAmount = new Int2IntOpenHashMap(); + this.processingCraftingJobsStartTicks = new Int2ObjectOpenHashMap<>(); + this.recipeDurations = new Object2DoubleOpenHashMap<>(); } public void writeToNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) { @@ -120,6 +133,12 @@ public void writeToNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) { pendingEntries.add(pendingIngredientInstances); } entriesTag.put("pendingIngredientInstanceEntries", pendingEntries); + + LongList startTicks = this.processingCraftingJobsStartTicks.get(processingCraftingJob.getId()); + if (startTicks != null) { + entriesTag.putLongArray("pendingIngredientInstanceEntryStartTicks", startTicks.toLongArray()); + } + processingCraftingJobs.add(entriesTag); } tag.put("processingCraftingJobs", processingCraftingJobs); @@ -147,6 +166,15 @@ public void writeToNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) { nonBlockingJobsRunningAmount.putInt(String.valueOf(entry.getIntKey()), entry.getIntValue()); } tag.put("nonBlockingJobsRunningAmount", nonBlockingJobsRunningAmount); + + ListTag recipeDurations = new ListTag(); + for (Object2DoubleMap.Entry entry : this.recipeDurations.object2DoubleEntrySet()) { + CompoundTag recipeDuration = new CompoundTag(); + recipeDuration.put("recipe", IRecipeDefinition.serialize(lookupProvider, entry.getKey())); + recipeDuration.putDouble("duration", entry.getDoubleValue()); + recipeDurations.add(recipeDuration); + } + tag.put("recipeDurations", recipeDurations); } public void readFromNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) { @@ -221,6 +249,12 @@ public void readFromNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) { craftingJob.getId(), pendingIngredientInstanceEntries); + if (entryTag.contains("pendingIngredientInstanceEntryStartTicks", Tag.TAG_LONG_ARRAY)) { + this.processingCraftingJobsStartTicks.put( + craftingJob.getId(), + new LongArrayList(entryTag.getLongArray("pendingIngredientInstanceEntryStartTicks"))); + } + } ListTag pendingCraftingJobs = tag.getList("pendingCraftingJobs", Tag.TAG_COMPOUND); @@ -260,6 +294,14 @@ public void readFromNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) { int amount = nonBlockingJobsRunningAmount.getInt(key); this.nonBlockingJobsRunningAmount.put(craftingJobId, amount); } + + this.recipeDurations.clear(); + for (Tag recipeDuration : tag.getList("recipeDurations", Tag.TAG_COMPOUND)) { + CompoundTag recipeDurationTag = (CompoundTag) recipeDuration; + this.recipeDurations.put( + IRecipeDefinition.deserialize(lookupProvider, recipeDurationTag.getCompound("recipe")), + recipeDurationTag.getDouble("duration")); + } } public boolean setBlockingJobsMode(boolean blockingJobsMode) { @@ -316,9 +358,46 @@ public Collection getPendingCraftingJobs() { return pendingCraftingJobs.values(); } + /** + * @param craftingJobId A crafting job id. + * @return The tick at which the oldest running crafting operation of the given job was started, + * or -1 if no operation is running. + */ + public long getCraftingJobEntryStartTick(int craftingJobId) { + LongList startTicks = this.processingCraftingJobsStartTicks.get(craftingJobId); + return startTicks == null || startTicks.isEmpty() ? -1 : startTicks.getLong(0); + } + + /** + * @param recipe A recipe. + * @return The estimated duration in ticks of a single crafting operation of the given recipe, + * based on the operations that were performed by this handler before, or -1 if unknown. + */ + public long getEstimatedRecipeDuration(IRecipeDefinition recipe) { + return this.recipeDurations.containsKey(recipe) ? Math.round(this.recipeDurations.getDouble(recipe)) : -1; + } + + /** + * Take the duration of a finished crafting operation into account for future estimations. + * @param recipe The recipe that was crafted. + * @param durationTicks The number of ticks the crafting operation took. + */ + protected void reportRecipeDuration(IRecipeDefinition recipe, long durationTicks) { + if (this.recipeDurations.containsKey(recipe)) { + // Smooth out the duration over the previous operations, + // as crafting durations can vary due to for example varying machine speeds. + double previousDuration = this.recipeDurations.getDouble(recipe); + this.recipeDurations.put(recipe, + previousDuration + (durationTicks - previousDuration) * RECIPE_DURATION_SMOOTHING); + } else { + this.recipeDurations.put(recipe, (double) durationTicks); + } + } + public void unmarkCraftingJobProcessing(CraftingJob craftingJob) { if (this.processingCraftingJobs.remove(craftingJob.getId()) != null) { this.processingCraftingJobsPendingIngredients.remove(craftingJob.getId()); + this.processingCraftingJobsStartTicks.remove(craftingJob.getId()); this.pendingCraftingJobs.put(craftingJob.getId(), craftingJob); } } @@ -331,6 +410,7 @@ public void addCraftingJobProcessingPendingIngredientsEntry(CraftingJob crafting this.allCraftingJobs.remove(craftingJob.getId()); this.nonBlockingJobsRunningAmount.remove(craftingJob.getId()); this.processingCraftingJobsPendingIngredients.remove(craftingJob.getId()); + this.processingCraftingJobsStartTicks.remove(craftingJob.getId()); } else { this.processingCraftingJobs.put(craftingJob.getId(), craftingJob); @@ -342,6 +422,14 @@ public void addCraftingJobProcessingPendingIngredientsEntry(CraftingJob crafting this.processingCraftingJobsPendingIngredients.put(craftingJob.getId(), pendingIngredientsEntries); } pendingIngredientsEntries.add(pendingIngredients); + + // Remember when this crafting operation started, so that its duration can be measured once it finishes + LongList startTicks = this.processingCraftingJobsStartTicks.get(craftingJob.getId()); + if (startTicks == null) { + startTicks = new LongArrayList(); + this.processingCraftingJobsStartTicks.put(craftingJob.getId(), startTicks); + } + startTicks.add(CraftingHelpers.getCurrentTick()); } } @@ -378,6 +466,7 @@ protected void unregisterIngredientObserver(IngredientComponent ing public void onCraftingJobFinished(CraftingJob craftingJob) { this.processingCraftingJobs.remove(craftingJob.getId()); + this.processingCraftingJobsStartTicks.remove(craftingJob.getId()); this.pendingCraftingJobs.remove(craftingJob.getId()); this.finishedCraftingJobs.put(craftingJob.getId(), craftingJob); this.allCraftingJobs.put(craftingJob.getId(), craftingJob); @@ -386,6 +475,7 @@ public void onCraftingJobFinished(CraftingJob craftingJob) { // This does the same as above, just based on crafting job id public void markCraftingJobFinished(int craftingJobId) { this.processingCraftingJobsPendingIngredients.remove(craftingJobId); + this.processingCraftingJobsStartTicks.remove(craftingJobId); this.processingCraftingJobs.remove(craftingJobId); this.pendingCraftingJobs.remove(craftingJobId); @@ -399,6 +489,17 @@ public void onCraftingJobEntryFinished(ICraftingNetwork craftingNetwork, int cra CraftingJob craftingJob = this.allCraftingJobs.get(craftingJobId); craftingJob.setAmount(craftingJob.getAmount() - 1); + // Measure how long this crafting operation took, so that future jobs for this recipe can be estimated. + // Operations don't necessarily finish in the order in which they were started, + // but as they all apply to the same recipe, the oldest one can safely be used. + LongList startTicks = this.processingCraftingJobsStartTicks.get(craftingJobId); + if (startTicks != null && !startTicks.isEmpty()) { + reportRecipeDuration(craftingJob.getRecipe(), CraftingHelpers.getCurrentTick() - startTicks.removeLong(0)); + if (startTicks.isEmpty()) { + this.processingCraftingJobsStartTicks.remove(craftingJobId); + } + } + if (this.nonBlockingJobsRunningAmount.containsKey(craftingJobId)) { this.nonBlockingJobsRunningAmount.put(craftingJobId, this.nonBlockingJobsRunningAmount.get(craftingJobId) - 1); } diff --git a/src/main/java/org/cyclops/integratedcrafting/core/network/CraftingNetwork.java b/src/main/java/org/cyclops/integratedcrafting/core/network/CraftingNetwork.java index 2de69b0d7..2572804d9 100644 --- a/src/main/java/org/cyclops/integratedcrafting/core/network/CraftingNetwork.java +++ b/src/main/java/org/cyclops/integratedcrafting/core/network/CraftingNetwork.java @@ -7,8 +7,6 @@ import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; import it.unimi.dsi.fastutil.ints.IntListIterator; -import net.minecraft.world.level.Level; -import net.neoforged.neoforge.server.ServerLifecycleHooks; import org.cyclops.commoncapabilities.api.capability.recipehandler.IRecipeDefinition; import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; import org.cyclops.commoncapabilities.api.ingredient.storage.IIngredientComponentStorage; @@ -272,7 +270,7 @@ public void scheduleCraftingJob(CraftingJob craftingJob, boolean allowDistributi } protected long getCurrentTick() { - return ServerLifecycleHooks.getCurrentServer().getLevel(Level.OVERWORLD).getGameTime(); + return CraftingHelpers.getCurrentTick(); } @Override @@ -447,6 +445,21 @@ public long getRunningTicks(CraftingJob craftingJob) { return getCurrentTick() - craftingJob.getStartTick(); } + @Override + public long getEstimatedRecipeDuration(int channel, IRecipeDefinition recipe) { + // Average the durations of all interfaces that can craft this recipe and that measured it before + long totalDuration = 0; + int durationCount = 0; + for (ICraftingInterface craftingInterface : getRecipeCraftingInterfaces(channel).get(recipe)) { + long duration = craftingInterface.getEstimatedRecipeDuration(recipe); + if (duration >= 0) { + totalDuration += duration; + durationCount++; + } + } + return durationCount == 0 ? -1 : totalDuration / durationCount; + } + protected void cleanupChannelIfEmpty(int channel) { Set craftingInterfaces = this.craftingInterfaces.get(channel); if (craftingInterfaces != null && craftingInterfaces.isEmpty()) { diff --git a/src/main/java/org/cyclops/integratedcrafting/core/part/PartTypeInterfaceCraftingBase.java b/src/main/java/org/cyclops/integratedcrafting/core/part/PartTypeInterfaceCraftingBase.java index cdad4ea3c..d1decd8fe 100644 --- a/src/main/java/org/cyclops/integratedcrafting/core/part/PartTypeInterfaceCraftingBase.java +++ b/src/main/java/org/cyclops/integratedcrafting/core/part/PartTypeInterfaceCraftingBase.java @@ -9,6 +9,7 @@ import net.minecraft.resources.ResourceLocation; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.ItemStack; +import org.cyclops.commoncapabilities.api.capability.recipehandler.IRecipeDefinition; import org.cyclops.commoncapabilities.api.ingredient.IPrototypedIngredient; import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; import org.cyclops.commoncapabilities.api.ingredient.IngredientInstanceWrapper; @@ -373,6 +374,16 @@ public CraftingJobStatus getCraftingJobStatus(ICraftingNetwork network, int chan return craftingJobHandler.getCraftingJobStatus(network, channel, craftingJobId); } + @Override + public long getCraftingJobEntryStartTick(int craftingJobId) { + return craftingJobHandler.getCraftingJobEntryStartTick(craftingJobId); + } + + @Override + public long getEstimatedRecipeDuration(IRecipeDefinition recipe) { + return craftingJobHandler.getEstimatedRecipeDuration(recipe); + } + @Override public void cancelCraftingJob(int channel, int craftingJobId) { craftingJobHandler.markCraftingJobFinished(craftingJobId); diff --git a/src/main/java/org/cyclops/integratedcrafting/gametest/GameTestsItemsCraft.java b/src/main/java/org/cyclops/integratedcrafting/gametest/GameTestsItemsCraft.java index 9c2b0f43e..dc1d7cc90 100644 --- a/src/main/java/org/cyclops/integratedcrafting/gametest/GameTestsItemsCraft.java +++ b/src/main/java/org/cyclops/integratedcrafting/gametest/GameTestsItemsCraft.java @@ -20,6 +20,7 @@ import org.cyclops.commoncapabilities.IngredientComponents; import org.cyclops.commoncapabilities.api.capability.itemhandler.ItemMatch; import org.cyclops.commoncapabilities.api.capability.recipehandler.IPrototypedIngredientAlternatives; +import org.cyclops.commoncapabilities.api.capability.recipehandler.IRecipeDefinition; import org.cyclops.commoncapabilities.api.capability.recipehandler.PrototypedIngredientAlternativesItemStackTag; import org.cyclops.commoncapabilities.api.capability.recipehandler.PrototypedIngredientAlternativesList; import org.cyclops.commoncapabilities.api.capability.recipehandler.RecipeDefinition; @@ -99,6 +100,33 @@ public void testItemsCraftChestOne(GameTestHelper helper) { }); } + @GameTest(template = TEMPLATE_EMPTY, timeoutTicks = TIMEOUT) + public void testItemsCraftChestOneRecipeDuration(GameTestHelper helper) { + GameTestHelpersIntegratedCrafting.INetworkPositions positions = createBasicNetwork(helper, POS); + + // Insert items in interface chest + ChestBlockEntity chestIn = helper.getBlockEntity(POS.east()); + chestIn.setItem(0, new ItemStack(Items.OAK_PLANKS, 64)); + + // Add chest recipe to crafting interface + positions.interfaceRecipeAdders().get(0).accept(Triple.of(0, RecipeType.CRAFTING, ResourceLocation.fromNamespaceAndPath("minecraft", "chest"))); + + // Enable crafting aspect in crafting writer + enableRecipeInWriter(helper, positions.writer(), new ItemStack(Items.CHEST)); + + helper.succeedWhen(() -> { + // Check if items have been crafted + helper.assertValueEqual(chestIn.getItem(1).getItem(), Items.CHEST, "Slot 1 item is incorrect"); + + // Check if the duration of the crafted recipe was measured + PartTypeInterfaceCrafting.State interfaceState = positions.interfaceStates().get(0); + for (IRecipeDefinition recipe : interfaceState.getRecipes()) { + helper.assertTrue(interfaceState.getEstimatedRecipeDuration(recipe) >= 0, + "No crafting duration was measured for the crafted recipe"); + } + }); + } + @GameTest(template = TEMPLATE_EMPTY, timeoutTicks = TIMEOUT) public void testItemsCraftChestAll(GameTestHelper helper) { GameTestHelpersIntegratedCrafting.INetworkPositions positions = createBasicNetwork(helper, POS); diff --git a/src/test/java/org/cyclops/integratedcrafting/api/crafting/TestCraftingJobDependencyGraph.java b/src/test/java/org/cyclops/integratedcrafting/api/crafting/TestCraftingJobDependencyGraph.java index 31082fc60..b56f723b9 100644 --- a/src/test/java/org/cyclops/integratedcrafting/api/crafting/TestCraftingJobDependencyGraph.java +++ b/src/test/java/org/cyclops/integratedcrafting/api/crafting/TestCraftingJobDependencyGraph.java @@ -313,6 +313,21 @@ public void testMergeCraftingJobsSimple() { assertThat(g.getDependencies(J0), equalTo(Lists.newArrayList())); } + @Test + public void testMergeCraftingJobsAmountTotal() { + g.addCraftingJobId(J0); + g.addCraftingJobId(J1); + + // Simulate that one of the two operations of J0 was crafted already + J0.setAmount(2); + J0.setAmountTotal(3); + + g.mergeCraftingJobs(J0, J1, true); + + assertThat(J0.getAmount(), equalTo(3)); + assertThat(J0.getAmountTotal(), equalTo(4)); + } + @Test public void testMergeCraftingJobsDependenciesMatching() { IRecipeDefinition R0 = new RecipeDefinition(Maps.newIdentityHashMap(), new MixedIngredients(Maps.newIdentityHashMap())); diff --git a/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingHelpers.java b/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingHelpers.java index 3e7926f33..7e7db14c2 100644 --- a/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingHelpers.java +++ b/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingHelpers.java @@ -3789,6 +3789,14 @@ public void testSplitCraftingJobsThreeOverOne() { ))); } + @Test + public void testSplitCraftingJobsAmountTotal() { + CraftingJob job = new CraftingJob(-1, 0, recipeA, 3, new MixedIngredients(Maps.newIdentityHashMap())); + List jobs = CraftingHelpers.splitCraftingJobs(job, 2, craftingJobDependencyGraph, identifierGenerator); + assertThat(jobs.get(0).getAmountTotal(), equalTo(2)); + assertThat(jobs.get(1).getAmountTotal(), equalTo(1)); + } + @Test public void testSplitCraftingJobsThreeOverTwo() { CraftingJob job = new CraftingJob(-1, 0, recipeA, 3, new MixedIngredients(Maps.newIdentityHashMap())); diff --git a/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java b/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java new file mode 100644 index 000000000..94f46ca30 --- /dev/null +++ b/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java @@ -0,0 +1,76 @@ +package org.cyclops.integratedcrafting.core; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import org.cyclops.commoncapabilities.api.capability.recipehandler.IRecipeDefinition; +import org.cyclops.commoncapabilities.api.capability.recipehandler.RecipeDefinition; +import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; +import org.cyclops.commoncapabilities.api.ingredient.MixedIngredients; +import org.cyclops.integratedcrafting.api.crafting.ICraftingResultsSink; +import org.cyclops.integratedcrafting.ingredient.IngredientComponentStubs; +import org.junit.Before; +import org.junit.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.Assert.assertThat; + +/** + * @author rubensworks + */ +public class TestCraftingJobHandler { + + private CraftingJobHandler handler; + private IRecipeDefinition recipeA; + private IRecipeDefinition recipeB; + + @Before + public void beforeEach() { + this.handler = new CraftingJobHandler(1, true, Collections.emptyList(), new ICraftingResultsSink() { + @Override + public void addResult(IngredientComponent ingredientComponent, T instance) { + + } + }); + this.recipeA = new RecipeDefinition(Maps.newIdentityHashMap(), new MixedIngredients(Maps.newIdentityHashMap())); + + Map, List> outputB = Maps.newIdentityHashMap(); + outputB.put(IngredientComponentStubs.SIMPLE, Lists.newArrayList(1L)); + this.recipeB = new RecipeDefinition(Maps.newIdentityHashMap(), new MixedIngredients(outputB)); + } + + @Test + public void testRecipeDurationUnknown() { + assertThat(handler.getEstimatedRecipeDuration(recipeA), equalTo(-1L)); + } + + @Test + public void testRecipeDurationSingle() { + handler.reportRecipeDuration(recipeA, 100); + assertThat(handler.getEstimatedRecipeDuration(recipeA), equalTo(100L)); + } + + @Test + public void testRecipeDurationSmoothed() { + handler.reportRecipeDuration(recipeA, 100); + handler.reportRecipeDuration(recipeA, 200); + assertThat(handler.getEstimatedRecipeDuration(recipeA), equalTo(125L)); + handler.reportRecipeDuration(recipeA, 200); + assertThat(handler.getEstimatedRecipeDuration(recipeA), equalTo(144L)); + } + + @Test + public void testRecipeDurationPerRecipe() { + handler.reportRecipeDuration(recipeA, 100); + assertThat(handler.getEstimatedRecipeDuration(recipeB), equalTo(-1L)); + } + + @Test + public void testCraftingJobEntryStartTickUnknown() { + assertThat(handler.getCraftingJobEntryStartTick(0), equalTo(-1L)); + } + +} From 278dd6c9e77fee1ba71f0d4aa1342f56dc779f64 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 17:21:35 +0000 Subject: [PATCH 2/8] Fix Level import collision in CraftingHelpers Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0186zEXrjMNMVXQC7wcoSiMR --- .../org/cyclops/integratedcrafting/core/CraftingHelpers.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java b/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java index 1a8ba1407..36fede8cf 100644 --- a/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java +++ b/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java @@ -5,7 +5,6 @@ import com.google.common.collect.Maps; import com.google.common.collect.Sets; import net.minecraft.core.Direction; -import net.minecraft.world.level.Level; import net.neoforged.neoforge.server.ServerLifecycleHooks; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.entity.BlockEntity; @@ -1735,7 +1734,9 @@ public static boolean insertCrafting(Function, PartPos * @return The current game tick of the server. */ public static long getCurrentTick() { - return ServerLifecycleHooks.getCurrentServer().getLevel(Level.OVERWORLD).getGameTime(); + // Fully qualified, as this class already imports org.apache.logging.log4j.Level + return ServerLifecycleHooks.getCurrentServer() + .getLevel(net.minecraft.world.level.Level.OVERWORLD).getGameTime(); } /** From 3b2ed4af12cdc46a48a92994b2d5a8e98fd1db36 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 17:26:54 +0000 Subject: [PATCH 3/8] Add tests for the total crafting job amount Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0186zEXrjMNMVXQC7wcoSiMR --- .../api/crafting/TestCraftingJob.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/test/java/org/cyclops/integratedcrafting/api/crafting/TestCraftingJob.java diff --git a/src/test/java/org/cyclops/integratedcrafting/api/crafting/TestCraftingJob.java b/src/test/java/org/cyclops/integratedcrafting/api/crafting/TestCraftingJob.java new file mode 100644 index 000000000..a85eb34bd --- /dev/null +++ b/src/test/java/org/cyclops/integratedcrafting/api/crafting/TestCraftingJob.java @@ -0,0 +1,38 @@ +package org.cyclops.integratedcrafting.api.crafting; + +import com.google.common.collect.Maps; +import org.cyclops.commoncapabilities.api.ingredient.MixedIngredients; +import org.junit.Before; +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.Assert.assertThat; + +/** + * @author rubensworks + */ +public class TestCraftingJob { + + private CraftingJob job; + + @Before + public void beforeEach() { + job = new CraftingJob(0, 0, null, 3, new MixedIngredients(Maps.newIdentityHashMap())); + } + + @Test + public void testAmountTotalDefaultsToAmount() { + assertThat(job.getAmount(), equalTo(3)); + assertThat(job.getAmountTotal(), equalTo(3)); + } + + @Test + public void testAmountTotalIsUnaffectedByCrafting() { + // The amount is decremented for every crafted operation, the total is not + job.setAmount(1); + + assertThat(job.getAmount(), equalTo(1)); + assertThat(job.getAmountTotal(), equalTo(3)); + } + +} From c1ebbd975cc45ac8fb010db5d29051758725563b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 17:33:27 +0000 Subject: [PATCH 4/8] Unit-test the crafting operation duration measurement Makes the current tick overridable in the crafting job handler, so that measuring the duration of crafting operations can be tested without a running server. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0186zEXrjMNMVXQC7wcoSiMR --- .../core/CraftingJobHandler.java | 11 +- .../core/TestCraftingJobHandler.java | 100 ++++++++++++++++-- 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/cyclops/integratedcrafting/core/CraftingJobHandler.java b/src/main/java/org/cyclops/integratedcrafting/core/CraftingJobHandler.java index 3d053934b..ed71a138e 100644 --- a/src/main/java/org/cyclops/integratedcrafting/core/CraftingJobHandler.java +++ b/src/main/java/org/cyclops/integratedcrafting/core/CraftingJobHandler.java @@ -377,6 +377,13 @@ public long getEstimatedRecipeDuration(IRecipeDefinition recipe) { return this.recipeDurations.containsKey(recipe) ? Math.round(this.recipeDurations.getDouble(recipe)) : -1; } + /** + * @return The current game tick. + */ + protected long getCurrentTick() { + return CraftingHelpers.getCurrentTick(); + } + /** * Take the duration of a finished crafting operation into account for future estimations. * @param recipe The recipe that was crafted. @@ -429,7 +436,7 @@ public void addCraftingJobProcessingPendingIngredientsEntry(CraftingJob crafting startTicks = new LongArrayList(); this.processingCraftingJobsStartTicks.put(craftingJob.getId(), startTicks); } - startTicks.add(CraftingHelpers.getCurrentTick()); + startTicks.add(getCurrentTick()); } } @@ -494,7 +501,7 @@ public void onCraftingJobEntryFinished(ICraftingNetwork craftingNetwork, int cra // but as they all apply to the same recipe, the oldest one can safely be used. LongList startTicks = this.processingCraftingJobsStartTicks.get(craftingJobId); if (startTicks != null && !startTicks.isEmpty()) { - reportRecipeDuration(craftingJob.getRecipe(), CraftingHelpers.getCurrentTick() - startTicks.removeLong(0)); + reportRecipeDuration(craftingJob.getRecipe(), getCurrentTick() - startTicks.removeLong(0)); if (startTicks.isEmpty()) { this.processingCraftingJobsStartTicks.remove(craftingJobId); } diff --git a/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java b/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java index 94f46ca30..e940eb3b0 100644 --- a/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java +++ b/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java @@ -4,9 +4,14 @@ import com.google.common.collect.Maps; import org.cyclops.commoncapabilities.api.capability.recipehandler.IRecipeDefinition; import org.cyclops.commoncapabilities.api.capability.recipehandler.RecipeDefinition; +import org.cyclops.commoncapabilities.api.ingredient.IPrototypedIngredient; import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; import org.cyclops.commoncapabilities.api.ingredient.MixedIngredients; +import org.cyclops.commoncapabilities.api.ingredient.PrototypedIngredient; +import org.cyclops.integratedcrafting.api.crafting.CraftingJob; import org.cyclops.integratedcrafting.api.crafting.ICraftingResultsSink; +import org.cyclops.integratedcrafting.api.network.ICraftingNetwork; +import org.cyclops.integratedcrafting.core.network.CraftingNetwork; import org.cyclops.integratedcrafting.ingredient.IngredientComponentStubs; import org.junit.Before; import org.junit.Test; @@ -23,18 +28,15 @@ */ public class TestCraftingJobHandler { - private CraftingJobHandler handler; + private TickingCraftingJobHandler handler; + private ICraftingNetwork craftingNetwork; private IRecipeDefinition recipeA; private IRecipeDefinition recipeB; @Before public void beforeEach() { - this.handler = new CraftingJobHandler(1, true, Collections.emptyList(), new ICraftingResultsSink() { - @Override - public void addResult(IngredientComponent ingredientComponent, T instance) { - - } - }); + this.handler = new TickingCraftingJobHandler(); + this.craftingNetwork = new CraftingNetwork(); this.recipeA = new RecipeDefinition(Maps.newIdentityHashMap(), new MixedIngredients(Maps.newIdentityHashMap())); Map, List> outputB = Maps.newIdentityHashMap(); @@ -42,6 +44,17 @@ public void addResult(IngredientComponent ingredientComponent, T in this.recipeB = new RecipeDefinition(Maps.newIdentityHashMap(), new MixedIngredients(outputB)); } + protected static Map, List>> newPendingIngredients() { + Map, List>> pendingIngredients = Maps.newIdentityHashMap(); + pendingIngredients.put(IngredientComponentStubs.SIMPLE, Lists.>newArrayList( + new PrototypedIngredient<>(IngredientComponentStubs.SIMPLE, 1L, true))); + return pendingIngredients; + } + + protected CraftingJob newCraftingJob(int id, int amount) { + return new CraftingJob(id, 0, recipeA, amount, new MixedIngredients(Maps.newIdentityHashMap())); + } + @Test public void testRecipeDurationUnknown() { assertThat(handler.getEstimatedRecipeDuration(recipeA), equalTo(-1L)); @@ -73,4 +86,77 @@ public void testCraftingJobEntryStartTickUnknown() { assertThat(handler.getCraftingJobEntryStartTick(0), equalTo(-1L)); } + @Test + public void testCraftingOperationIsMeasured() { + CraftingJob craftingJob = newCraftingJob(1, 2); + + handler.setCurrentTick(100); + handler.addCraftingJobProcessingPendingIngredientsEntry(craftingJob, newPendingIngredients()); + assertThat(handler.getCraftingJobEntryStartTick(1), equalTo(100L)); + + handler.setCurrentTick(160); + handler.onCraftingJobEntryFinished(craftingNetwork, 1); + + assertThat(craftingJob.getAmount(), equalTo(1)); + assertThat(craftingJob.getAmountTotal(), equalTo(2)); + assertThat(handler.getEstimatedRecipeDuration(recipeA), equalTo(60L)); + assertThat(handler.getCraftingJobEntryStartTick(1), equalTo(-1L)); + } + + @Test + public void testParallelCraftingOperationsAreMeasured() { + CraftingJob craftingJob = newCraftingJob(1, 2); + + // Two operations of the same job are running at the same time in non-blocking mode + handler.setCurrentTick(100); + handler.addCraftingJobProcessingPendingIngredientsEntry(craftingJob, newPendingIngredients()); + handler.setCurrentTick(120); + handler.addCraftingJobProcessingPendingIngredientsEntry(craftingJob, newPendingIngredients()); + assertThat(handler.getCraftingJobEntryStartTick(1), equalTo(100L)); + + handler.setCurrentTick(200); + handler.onCraftingJobEntryFinished(craftingNetwork, 1); + assertThat(handler.getEstimatedRecipeDuration(recipeA), equalTo(100L)); + assertThat(handler.getCraftingJobEntryStartTick(1), equalTo(120L)); + + handler.setCurrentTick(220); + handler.onCraftingJobEntryFinished(craftingNetwork, 1); + assertThat(handler.getEstimatedRecipeDuration(recipeA), equalTo(100L)); + assertThat(handler.getCraftingJobEntryStartTick(1), equalTo(-1L)); + } + + @Test + public void testCraftingOperationsAreForgottenWhenTheJobStopsProcessing() { + CraftingJob craftingJob = newCraftingJob(1, 1); + + handler.setCurrentTick(100); + handler.addCraftingJobProcessingPendingIngredientsEntry(craftingJob, newPendingIngredients()); + handler.unmarkCraftingJobProcessing(craftingJob); + + assertThat(handler.getCraftingJobEntryStartTick(1), equalTo(-1L)); + } + + protected static class TickingCraftingJobHandler extends CraftingJobHandler { + + private long currentTick; + + public TickingCraftingJobHandler() { + super(1, true, Collections.emptyList(), new ICraftingResultsSink() { + @Override + public void addResult(IngredientComponent ingredientComponent, T instance) { + + } + }); + } + + public void setCurrentTick(long currentTick) { + this.currentTick = currentTick; + } + + @Override + protected long getCurrentTick() { + return currentTick; + } + } + } From cadd1f16e5cee40080853c053080871f30d82047 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 17:45:26 +0000 Subject: [PATCH 5/8] Test that measured recipe durations survive serialization Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0186zEXrjMNMVXQC7wcoSiMR --- .../core/TestCraftingJobHandler.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java b/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java index e940eb3b0..2ab515c37 100644 --- a/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java +++ b/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java @@ -2,6 +2,7 @@ import com.google.common.collect.Lists; import com.google.common.collect.Maps; +import net.minecraft.nbt.CompoundTag; import org.cyclops.commoncapabilities.api.capability.recipehandler.IRecipeDefinition; import org.cyclops.commoncapabilities.api.capability.recipehandler.RecipeDefinition; import org.cyclops.commoncapabilities.api.ingredient.IPrototypedIngredient; @@ -136,6 +137,19 @@ public void testCraftingOperationsAreForgottenWhenTheJobStopsProcessing() { assertThat(handler.getCraftingJobEntryStartTick(1), equalTo(-1L)); } + @Test + public void testRecipeDurationsSurviveSerialization() { + handler.reportRecipeDuration(recipeA, 100); + + CompoundTag tag = new CompoundTag(); + handler.writeToNBT(null, tag); + + TickingCraftingJobHandler deserialized = new TickingCraftingJobHandler(); + deserialized.readFromNBT(null, tag); + + assertThat(deserialized.getEstimatedRecipeDuration(recipeA), equalTo(100L)); + } + protected static class TickingCraftingJobHandler extends CraftingJobHandler { private long currentTick; From e81a3ceb734546daec31d7fa4a46658e2e1e44ed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 17:50:00 +0000 Subject: [PATCH 6/8] Test that running crafting operations are forgotten when a job stops Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0186zEXrjMNMVXQC7wcoSiMR --- .../core/TestCraftingJobHandler.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java b/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java index 2ab515c37..3df4cf1b2 100644 --- a/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java +++ b/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java @@ -137,6 +137,39 @@ public void testCraftingOperationsAreForgottenWhenTheJobStopsProcessing() { assertThat(handler.getCraftingJobEntryStartTick(1), equalTo(-1L)); } + @Test + public void testCraftingOperationsAreForgottenWhenTheJobIsCancelled() { + CraftingJob craftingJob = newCraftingJob(1, 1); + + handler.setCurrentTick(100); + handler.addCraftingJobProcessingPendingIngredientsEntry(craftingJob, newPendingIngredients()); + handler.markCraftingJobFinished(1); + + assertThat(handler.getCraftingJobEntryStartTick(1), equalTo(-1L)); + } + + @Test + public void testCraftingOperationsAreForgottenWhenTheJobFinishes() { + CraftingJob craftingJob = newCraftingJob(1, 1); + + handler.setCurrentTick(100); + handler.addCraftingJobProcessingPendingIngredientsEntry(craftingJob, newPendingIngredients()); + handler.onCraftingJobFinished(craftingJob); + + assertThat(handler.getCraftingJobEntryStartTick(1), equalTo(-1L)); + } + + @Test + public void testCraftingOperationsAreForgottenWithoutPendingIngredients() { + CraftingJob craftingJob = newCraftingJob(1, 1); + + handler.setCurrentTick(100); + handler.addCraftingJobProcessingPendingIngredientsEntry(craftingJob, newPendingIngredients()); + handler.addCraftingJobProcessingPendingIngredientsEntry(craftingJob, Maps.newIdentityHashMap()); + + assertThat(handler.getCraftingJobEntryStartTick(1), equalTo(-1L)); + } + @Test public void testRecipeDurationsSurviveSerialization() { handler.reportRecipeDuration(recipeA, 100); From 47de92766320dfedf9af0ee6f8f34453b0be2e04 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 18:26:25 +0000 Subject: [PATCH 7/8] Bound and expire the measured crafting durations The measured durations were stored per recipe and serialized with the crafting interface. As the number of recipes that an interface can craft is unbounded (attuned interfaces expose all recipes of their target, and reconfigured interfaces leave behind entries for old recipes), and part states are also stored in the item when a part is broken, this could grow the crafting interface indefinitely. Recipe-specific durations are now kept in memory only, in a bounded least-recently-used cache, and only the average duration over all recipes is serialized. After loading, estimations start from that average, and become recipe-specific again as soon as recipes are crafted. Measurements are also forgotten once they become too old, so that estimations follow changes to the network, such as machines becoming faster. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0186zEXrjMNMVXQC7wcoSiMR --- .../integratedcrafting/GeneralConfig.java | 6 + .../api/crafting/ICraftingInterface.java | 3 + .../core/CraftingJobHandler.java | 57 +++--- .../core/RecipeDurationStatistics.java | 168 +++++++++++++++++ .../core/TestCraftingJobHandler.java | 48 ++++- .../core/TestRecipeDurationStatistics.java | 171 ++++++++++++++++++ 6 files changed, 414 insertions(+), 39 deletions(-) create mode 100644 src/main/java/org/cyclops/integratedcrafting/core/RecipeDurationStatistics.java create mode 100644 src/test/java/org/cyclops/integratedcrafting/core/TestRecipeDurationStatistics.java diff --git a/src/main/java/org/cyclops/integratedcrafting/GeneralConfig.java b/src/main/java/org/cyclops/integratedcrafting/GeneralConfig.java index e9efc3153..e49969a0f 100644 --- a/src/main/java/org/cyclops/integratedcrafting/GeneralConfig.java +++ b/src/main/java/org/cyclops/integratedcrafting/GeneralConfig.java @@ -35,6 +35,12 @@ public class GeneralConfig extends DummyConfig { @ConfigurableProperty(category = "general", comment = "The base energy usage for the attuned crafting interface per crafting job being processed.", minimalValue = 0, configLocation = ModConfig.Type.SERVER) public static int interfaceCraftingAttunedBaseConsumption = 10; + @ConfigurableProperty(category = "machine", comment = "The maximum number of recipes that a crafting interface remembers crafting durations for, which are used to estimate the duration of crafting jobs. Set to 0 to disable recipe-specific estimations.", minimalValue = 0, isCommandable = true, configLocation = ModConfig.Type.SERVER) + public static int craftingInterfaceRecipeDurationEntries = 32; + + @ConfigurableProperty(category = "machine", comment = "The number of ticks after which a measured crafting duration is forgotten, so that estimations follow changes to the network. Set to 0 to never forget them.", minimalValue = 0, isCommandable = true, configLocation = ModConfig.Type.SERVER) + public static int craftingInterfaceRecipeDurationMaxAge = 24000; + @ConfigurableProperty(category = "machine", comment = "Enabling this option will log all recipe validation failures in crafting interfaces into the server logs", isCommandable = true, configLocation = ModConfig.Type.SERVER) public static boolean logRecipeValidationFailures = true; diff --git a/src/main/java/org/cyclops/integratedcrafting/api/crafting/ICraftingInterface.java b/src/main/java/org/cyclops/integratedcrafting/api/crafting/ICraftingInterface.java index 2d6d3c765..040c2f41e 100644 --- a/src/main/java/org/cyclops/integratedcrafting/api/crafting/ICraftingInterface.java +++ b/src/main/java/org/cyclops/integratedcrafting/api/crafting/ICraftingInterface.java @@ -90,6 +90,9 @@ public default long getCraftingJobEntryStartTick(int craftingJobId) { * @param recipe A recipe. * @return The estimated duration in ticks of a single crafting operation of the given recipe, * based on the operations that were performed by this interface before, or -1 if unknown. + * This may fall back to the average duration over all recipes of this interface, + * as recipe-specific durations are only remembered for a limited number of recipes, + * and are forgotten once they become outdated. */ public default long getEstimatedRecipeDuration(IRecipeDefinition recipe) { return -1; diff --git a/src/main/java/org/cyclops/integratedcrafting/core/CraftingJobHandler.java b/src/main/java/org/cyclops/integratedcrafting/core/CraftingJobHandler.java index ed71a138e..78dc63be6 100644 --- a/src/main/java/org/cyclops/integratedcrafting/core/CraftingJobHandler.java +++ b/src/main/java/org/cyclops/integratedcrafting/core/CraftingJobHandler.java @@ -8,8 +8,6 @@ import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; import it.unimi.dsi.fastutil.longs.LongArrayList; import it.unimi.dsi.fastutil.longs.LongList; -import it.unimi.dsi.fastutil.objects.Object2DoubleMap; -import it.unimi.dsi.fastutil.objects.Object2DoubleOpenHashMap; import it.unimi.dsi.fastutil.objects.Object2IntMap; import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap; import net.minecraft.core.Direction; @@ -53,11 +51,6 @@ */ public class CraftingJobHandler { - /** - * The weight of the latest crafting operation duration within the running average for a recipe. - */ - protected static final double RECIPE_DURATION_SMOOTHING = 0.25D; - private final int maxProcessingJobs; private boolean blockingJobsMode; private final ICraftingResultsSink resultsSink; @@ -75,7 +68,7 @@ public class CraftingJobHandler { private final Map, Direction> ingredientComponentTargetOverrides; private final Int2IntMap nonBlockingJobsRunningAmount; private final Int2ObjectMap processingCraftingJobsStartTicks; - private final Object2DoubleMap recipeDurations; + private RecipeDurationStatistics recipeDurationStatistics; public CraftingJobHandler(int maxProcessingJobs, boolean blockingJobsMode, Collection craftingProcessOverrides, @@ -97,7 +90,6 @@ public CraftingJobHandler(int maxProcessingJobs, boolean blockingJobsMode, this.ingredientComponentTargetOverrides = Maps.newIdentityHashMap(); this.nonBlockingJobsRunningAmount = new Int2IntOpenHashMap(); this.processingCraftingJobsStartTicks = new Int2ObjectOpenHashMap<>(); - this.recipeDurations = new Object2DoubleOpenHashMap<>(); } public void writeToNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) { @@ -167,14 +159,9 @@ public void writeToNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) { } tag.put("nonBlockingJobsRunningAmount", nonBlockingJobsRunningAmount); - ListTag recipeDurations = new ListTag(); - for (Object2DoubleMap.Entry entry : this.recipeDurations.object2DoubleEntrySet()) { - CompoundTag recipeDuration = new CompoundTag(); - recipeDuration.put("recipe", IRecipeDefinition.serialize(lookupProvider, entry.getKey())); - recipeDuration.putDouble("duration", entry.getDoubleValue()); - recipeDurations.add(recipeDuration); - } - tag.put("recipeDurations", recipeDurations); + CompoundTag recipeDurationStatistics = new CompoundTag(); + getRecipeDurationStatistics().writeToNBT(recipeDurationStatistics); + tag.put("recipeDurationStatistics", recipeDurationStatistics); } public void readFromNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) { @@ -295,13 +282,7 @@ public void readFromNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) { this.nonBlockingJobsRunningAmount.put(craftingJobId, amount); } - this.recipeDurations.clear(); - for (Tag recipeDuration : tag.getList("recipeDurations", Tag.TAG_COMPOUND)) { - CompoundTag recipeDurationTag = (CompoundTag) recipeDuration; - this.recipeDurations.put( - IRecipeDefinition.deserialize(lookupProvider, recipeDurationTag.getCompound("recipe")), - recipeDurationTag.getDouble("duration")); - } + getRecipeDurationStatistics().readFromNBT(tag.getCompound("recipeDurationStatistics")); } public boolean setBlockingJobsMode(boolean blockingJobsMode) { @@ -372,9 +353,11 @@ public long getCraftingJobEntryStartTick(int craftingJobId) { * @param recipe A recipe. * @return The estimated duration in ticks of a single crafting operation of the given recipe, * based on the operations that were performed by this handler before, or -1 if unknown. + * This falls back to the average duration over all recipes + * when the given recipe itself was not crafted recently. */ public long getEstimatedRecipeDuration(IRecipeDefinition recipe) { - return this.recipeDurations.containsKey(recipe) ? Math.round(this.recipeDurations.getDouble(recipe)) : -1; + return getRecipeDurationStatistics().getEstimatedDuration(recipe, getCurrentTick()); } /** @@ -390,15 +373,23 @@ protected long getCurrentTick() { * @param durationTicks The number of ticks the crafting operation took. */ protected void reportRecipeDuration(IRecipeDefinition recipe, long durationTicks) { - if (this.recipeDurations.containsKey(recipe)) { - // Smooth out the duration over the previous operations, - // as crafting durations can vary due to for example varying machine speeds. - double previousDuration = this.recipeDurations.getDouble(recipe); - this.recipeDurations.put(recipe, - previousDuration + (durationTicks - previousDuration) * RECIPE_DURATION_SMOOTHING); - } else { - this.recipeDurations.put(recipe, (double) durationTicks); + getRecipeDurationStatistics().reportDuration(recipe, durationTicks, getCurrentTick()); + } + + /** + * @return The duration statistics of this handler, which are created lazily, + * as their configuration is only available once the mod is fully loaded. + */ + public RecipeDurationStatistics getRecipeDurationStatistics() { + if (this.recipeDurationStatistics == null) { + this.recipeDurationStatistics = createRecipeDurationStatistics(); } + return this.recipeDurationStatistics; + } + + protected RecipeDurationStatistics createRecipeDurationStatistics() { + return new RecipeDurationStatistics(GeneralConfig.craftingInterfaceRecipeDurationEntries, + GeneralConfig.craftingInterfaceRecipeDurationMaxAge); } public void unmarkCraftingJobProcessing(CraftingJob craftingJob) { diff --git a/src/main/java/org/cyclops/integratedcrafting/core/RecipeDurationStatistics.java b/src/main/java/org/cyclops/integratedcrafting/core/RecipeDurationStatistics.java new file mode 100644 index 000000000..fc0ccf580 --- /dev/null +++ b/src/main/java/org/cyclops/integratedcrafting/core/RecipeDurationStatistics.java @@ -0,0 +1,168 @@ +package org.cyclops.integratedcrafting.core; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; +import org.cyclops.commoncapabilities.api.capability.recipehandler.IRecipeDefinition; + +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Keeps track of how long crafting operations take, so that the duration of crafting jobs can be estimated. + * + * Durations are tracked per recipe, but only the average duration over all recipes is persisted. + * This is because the number of recipes that a crafting interface can craft is unbounded, + * and serializing them would make the crafting interface's state grow indefinitely. + * As such, after loading, estimations start at the average duration of the crafting interface, + * and become recipe-specific again as soon as recipes are crafted. + * + * Measurements are forgotten once they become too old, + * as the time that a recipe takes can change when the network or its machines are modified. + * + * @author rubensworks + */ +public class RecipeDurationStatistics { + + /** + * The weight of the latest crafting operation duration within the running average. + */ + protected static final double SMOOTHING = 0.25D; + + private final int maxEntries; + private final long maxAge; + private final Map recipeDurations; + @Nullable + private Measurement averageDuration; + + /** + * @param maxEntries The maximum number of recipes to remember durations for. + * 0 disables recipe-specific durations. + * @param maxAge The number of ticks after which a measured duration is forgotten. 0 disables forgetting. + */ + public RecipeDurationStatistics(int maxEntries, long maxAge) { + this.maxEntries = maxEntries; + this.maxAge = maxAge; + this.recipeDurations = new LinkedHashMap<>(16, 0.75F, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + // Forget the least recently used recipe once we remember too many of them + return size() > RecipeDurationStatistics.this.maxEntries; + } + }; + } + + /** + * Take the duration of a finished crafting operation into account for future estimations. + * @param recipe The recipe that was crafted. + * @param durationTicks The number of ticks the crafting operation took. + * @param currentTick The current game tick. + */ + public void reportDuration(IRecipeDefinition recipe, long durationTicks, long currentTick) { + if (this.maxEntries > 0) { + Measurement measurement = this.recipeDurations.get(recipe); + if (measurement == null || isExpired(measurement, currentTick)) { + this.recipeDurations.put(recipe, new Measurement(durationTicks, currentTick)); + } else { + measurement.update(durationTicks, currentTick); + } + } + + if (this.averageDuration == null || isExpired(this.averageDuration, currentTick)) { + this.averageDuration = new Measurement(durationTicks, currentTick); + } else { + this.averageDuration.update(durationTicks, currentTick); + } + } + + /** + * @param recipe A recipe. + * @param currentTick The current game tick. + * @return The estimated duration in ticks of a single crafting operation of the given recipe. + * Falls back to {@link #getAverageDuration(long)} if the recipe itself was not measured (recently), + * and is -1 if nothing was measured at all. + */ + public long getEstimatedDuration(IRecipeDefinition recipe, long currentTick) { + Measurement measurement = this.recipeDurations.get(recipe); + if (measurement != null) { + if (!isExpired(measurement, currentTick)) { + return Math.round(measurement.getDuration()); + } + this.recipeDurations.remove(recipe); + } + return getAverageDuration(currentTick); + } + + /** + * @param currentTick The current game tick. + * @return The estimated duration in ticks of a single crafting operation of any recipe, or -1 if unknown. + */ + public long getAverageDuration(long currentTick) { + if (this.averageDuration != null) { + if (!isExpired(this.averageDuration, currentTick)) { + return Math.round(this.averageDuration.getDuration()); + } + this.averageDuration = null; + } + return -1; + } + + /** + * @return The number of recipes that durations are remembered for. + */ + public int getEntryCount() { + return this.recipeDurations.size(); + } + + protected boolean isExpired(Measurement measurement, long currentTick) { + if (this.maxAge <= 0) { + return false; + } + long age = currentTick - measurement.getLastMeasuredTick(); + // Negative ages can occur when the game time is moved backwards, in which case the measurement is useless + return age < 0 || age > this.maxAge; + } + + public void writeToNBT(CompoundTag tag) { + if (this.averageDuration != null) { + tag.putDouble("averageDuration", this.averageDuration.getDuration()); + tag.putLong("averageDurationTick", this.averageDuration.getLastMeasuredTick()); + } + } + + public void readFromNBT(CompoundTag tag) { + this.recipeDurations.clear(); + this.averageDuration = tag.contains("averageDuration", Tag.TAG_DOUBLE) + ? new Measurement(tag.getDouble("averageDuration"), tag.getLong("averageDurationTick")) + : null; + } + + protected static class Measurement { + + private double duration; + private long lastMeasuredTick; + + public Measurement(double duration, long lastMeasuredTick) { + this.duration = duration; + this.lastMeasuredTick = lastMeasuredTick; + } + + public double getDuration() { + return duration; + } + + public long getLastMeasuredTick() { + return lastMeasuredTick; + } + + /** + * Smooth the given duration into this measurement, + * as crafting durations can vary due to for example varying machine speeds. + */ + public void update(long durationTicks, long currentTick) { + this.duration = this.duration + (durationTicks - this.duration) * SMOOTHING; + this.lastMeasuredTick = currentTick; + } + } + +} diff --git a/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java b/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java index 3df4cf1b2..df2ee0e60 100644 --- a/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java +++ b/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java @@ -29,6 +29,8 @@ */ public class TestCraftingJobHandler { + private static final int MAX_RECIPE_DURATION_ENTRIES = 32; + private TickingCraftingJobHandler handler; private ICraftingNetwork craftingNetwork; private IRecipeDefinition recipeA; @@ -38,11 +40,14 @@ public class TestCraftingJobHandler { public void beforeEach() { this.handler = new TickingCraftingJobHandler(); this.craftingNetwork = new CraftingNetwork(); - this.recipeA = new RecipeDefinition(Maps.newIdentityHashMap(), new MixedIngredients(Maps.newIdentityHashMap())); + this.recipeA = newRecipe(0); + this.recipeB = newRecipe(1); + } - Map, List> outputB = Maps.newIdentityHashMap(); - outputB.put(IngredientComponentStubs.SIMPLE, Lists.newArrayList(1L)); - this.recipeB = new RecipeDefinition(Maps.newIdentityHashMap(), new MixedIngredients(outputB)); + protected static IRecipeDefinition newRecipe(long output) { + Map, List> outputs = Maps.newIdentityHashMap(); + outputs.put(IngredientComponentStubs.SIMPLE, Lists.newArrayList(output)); + return new RecipeDefinition(Maps.newIdentityHashMap(), new MixedIngredients(outputs)); } protected static Map, List>> newPendingIngredients() { @@ -77,9 +82,34 @@ public void testRecipeDurationSmoothed() { } @Test - public void testRecipeDurationPerRecipe() { + public void testRecipeDurationFallsBackToAverage() { handler.reportRecipeDuration(recipeA, 100); - assertThat(handler.getEstimatedRecipeDuration(recipeB), equalTo(-1L)); + assertThat(handler.getEstimatedRecipeDuration(recipeB), equalTo(100L)); + } + + @Test + public void testRecipeDurationsAreBounded() { + for (int i = 0; i < MAX_RECIPE_DURATION_ENTRIES + 10; i++) { + handler.reportRecipeDuration(newRecipe(i), 100); + } + + assertThat(handler.getRecipeDurationStatistics().getEntryCount(), equalTo(MAX_RECIPE_DURATION_ENTRIES)); + } + + @Test + public void testSerializationDoesNotGrowWithRecipes() { + handler.reportRecipeDuration(recipeA, 100); + CompoundTag tagSingle = new CompoundTag(); + handler.writeToNBT(null, tagSingle); + + for (int i = 0; i < 100; i++) { + handler.reportRecipeDuration(newRecipe(i), 100); + } + CompoundTag tagMany = new CompoundTag(); + handler.writeToNBT(null, tagMany); + + // Only the average duration is serialized, so crafting more recipes must not grow the crafting interface + assertThat(tagMany.toString(), equalTo(tagSingle.toString())); } @Test @@ -181,6 +211,7 @@ public void testRecipeDurationsSurviveSerialization() { deserialized.readFromNBT(null, tag); assertThat(deserialized.getEstimatedRecipeDuration(recipeA), equalTo(100L)); + assertThat(deserialized.getRecipeDurationStatistics().getEntryCount(), equalTo(0)); } protected static class TickingCraftingJobHandler extends CraftingJobHandler { @@ -196,6 +227,11 @@ public void addResult(IngredientComponent ingredientComponent, T in }); } + @Override + protected RecipeDurationStatistics createRecipeDurationStatistics() { + return new RecipeDurationStatistics(MAX_RECIPE_DURATION_ENTRIES, 24000); + } + public void setCurrentTick(long currentTick) { this.currentTick = currentTick; } diff --git a/src/test/java/org/cyclops/integratedcrafting/core/TestRecipeDurationStatistics.java b/src/test/java/org/cyclops/integratedcrafting/core/TestRecipeDurationStatistics.java new file mode 100644 index 000000000..bd949763a --- /dev/null +++ b/src/test/java/org/cyclops/integratedcrafting/core/TestRecipeDurationStatistics.java @@ -0,0 +1,171 @@ +package org.cyclops.integratedcrafting.core; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import net.minecraft.nbt.CompoundTag; +import org.cyclops.commoncapabilities.api.capability.recipehandler.IRecipeDefinition; +import org.cyclops.commoncapabilities.api.capability.recipehandler.RecipeDefinition; +import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; +import org.cyclops.commoncapabilities.api.ingredient.MixedIngredients; +import org.cyclops.integratedcrafting.ingredient.IngredientComponentStubs; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.Assert.assertThat; + +/** + * @author rubensworks + */ +public class TestRecipeDurationStatistics { + + private static final int MAX_AGE = 1000; + + private RecipeDurationStatistics statistics; + private IRecipeDefinition recipeA; + private IRecipeDefinition recipeB; + private IRecipeDefinition recipeC; + + @Before + public void beforeEach() { + this.statistics = new RecipeDurationStatistics(2, MAX_AGE); + this.recipeA = newRecipe(0); + this.recipeB = newRecipe(1); + this.recipeC = newRecipe(2); + } + + protected static IRecipeDefinition newRecipe(long output) { + Map, List> outputs = Maps.newIdentityHashMap(); + outputs.put(IngredientComponentStubs.SIMPLE, Lists.newArrayList(output)); + return new RecipeDefinition(Maps.newIdentityHashMap(), new MixedIngredients(outputs)); + } + + @Test + public void testUnknown() { + assertThat(statistics.getEstimatedDuration(recipeA, 0), equalTo(-1L)); + assertThat(statistics.getAverageDuration(0), equalTo(-1L)); + } + + @Test + public void testSingleMeasurement() { + statistics.reportDuration(recipeA, 100, 0); + + assertThat(statistics.getEstimatedDuration(recipeA, 0), equalTo(100L)); + assertThat(statistics.getAverageDuration(0), equalTo(100L)); + } + + @Test + public void testSmoothedMeasurements() { + statistics.reportDuration(recipeA, 100, 0); + statistics.reportDuration(recipeA, 200, 1); + + assertThat(statistics.getEstimatedDuration(recipeA, 1), equalTo(125L)); + } + + @Test + public void testMeasurementsPerRecipe() { + statistics.reportDuration(recipeA, 100, 0); + statistics.reportDuration(recipeB, 300, 1); + + assertThat(statistics.getEstimatedDuration(recipeA, 1), equalTo(100L)); + assertThat(statistics.getEstimatedDuration(recipeB, 1), equalTo(300L)); + assertThat(statistics.getAverageDuration(1), equalTo(150L)); + } + + @Test + public void testUnmeasuredRecipeFallsBackToAverage() { + statistics.reportDuration(recipeA, 100, 0); + + assertThat(statistics.getEstimatedDuration(recipeC, 0), equalTo(100L)); + } + + @Test + public void testLeastRecentlyUsedRecipeIsForgotten() { + statistics.reportDuration(recipeA, 100, 0); + statistics.reportDuration(recipeB, 200, 1); + statistics.reportDuration(recipeC, 300, 2); + + assertThat(statistics.getEntryCount(), equalTo(2)); + assertThat(statistics.getEstimatedDuration(recipeB, 2), equalTo(200L)); + assertThat(statistics.getEstimatedDuration(recipeC, 2), equalTo(300L)); + // The oldest recipe was forgotten, so it falls back to the average + assertThat(statistics.getEstimatedDuration(recipeA, 2), equalTo(169L)); + } + + @Test + public void testOutdatedMeasurementsAreForgotten() { + statistics.reportDuration(recipeA, 100, 0); + + assertThat(statistics.getEstimatedDuration(recipeA, MAX_AGE), equalTo(100L)); + assertThat(statistics.getEstimatedDuration(recipeA, MAX_AGE + 1), equalTo(-1L)); + assertThat(statistics.getEntryCount(), equalTo(0)); + } + + @Test + public void testOutdatedMeasurementsDoNotSlowDownNewOnes() { + // The network may have been optimized since the last measurement, + // so an outdated measurement must be replaced instead of smoothed into + statistics.reportDuration(recipeA, 100, 0); + statistics.reportDuration(recipeA, 20, MAX_AGE + 1); + + assertThat(statistics.getEstimatedDuration(recipeA, MAX_AGE + 1), equalTo(20L)); + assertThat(statistics.getAverageDuration(MAX_AGE + 1), equalTo(20L)); + } + + @Test + public void testMeasurementsFromTheFutureAreForgotten() { + // The game time can be moved backwards, which makes existing measurements meaningless + statistics.reportDuration(recipeA, 100, 1000); + + assertThat(statistics.getEstimatedDuration(recipeA, 0), equalTo(-1L)); + } + + @Test + public void testMeasurementsCanBeKeptForever() { + RecipeDurationStatistics statistics = new RecipeDurationStatistics(2, 0); + statistics.reportDuration(recipeA, 100, 0); + + assertThat(statistics.getEstimatedDuration(recipeA, 1_000_000), equalTo(100L)); + } + + @Test + public void testRecipeSpecificMeasurementsCanBeDisabled() { + RecipeDurationStatistics statistics = new RecipeDurationStatistics(0, MAX_AGE); + statistics.reportDuration(recipeA, 100, 0); + statistics.reportDuration(recipeB, 300, 1); + + assertThat(statistics.getEntryCount(), equalTo(0)); + assertThat(statistics.getEstimatedDuration(recipeA, 1), equalTo(150L)); + } + + @Test + public void testSerializationKeepsTheAverage() { + statistics.reportDuration(recipeA, 100, 50); + + CompoundTag tag = new CompoundTag(); + statistics.writeToNBT(tag); + RecipeDurationStatistics deserialized = new RecipeDurationStatistics(2, MAX_AGE); + deserialized.readFromNBT(tag); + + assertThat(deserialized.getAverageDuration(50), equalTo(100L)); + assertThat(deserialized.getEstimatedDuration(recipeA, 50), equalTo(100L)); + // Recipe-specific measurements are not serialized, as their number is unbounded + assertThat(deserialized.getEntryCount(), equalTo(0)); + // Measurements from before a restart can be outdated as well + assertThat(deserialized.getAverageDuration(50 + MAX_AGE + 1), equalTo(-1L)); + } + + @Test + public void testSerializationWithoutMeasurements() { + CompoundTag tag = new CompoundTag(); + statistics.writeToNBT(tag); + RecipeDurationStatistics deserialized = new RecipeDurationStatistics(2, MAX_AGE); + deserialized.readFromNBT(tag); + + assertThat(deserialized.getAverageDuration(0), equalTo(-1L)); + } + +} From f05086a719b45a998b2078a83d4385f7fa2dc504 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:21:56 +0000 Subject: [PATCH 8/8] Address review: no default API impls, longer duration max age Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0186zEXrjMNMVXQC7wcoSiMR --- .../org/cyclops/integratedcrafting/GeneralConfig.java | 2 +- .../api/crafting/ICraftingInterface.java | 8 ++------ .../integratedcrafting/api/network/ICraftingNetwork.java | 4 +--- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/cyclops/integratedcrafting/GeneralConfig.java b/src/main/java/org/cyclops/integratedcrafting/GeneralConfig.java index e49969a0f..dee3a0713 100644 --- a/src/main/java/org/cyclops/integratedcrafting/GeneralConfig.java +++ b/src/main/java/org/cyclops/integratedcrafting/GeneralConfig.java @@ -39,7 +39,7 @@ public class GeneralConfig extends DummyConfig { public static int craftingInterfaceRecipeDurationEntries = 32; @ConfigurableProperty(category = "machine", comment = "The number of ticks after which a measured crafting duration is forgotten, so that estimations follow changes to the network. Set to 0 to never forget them.", minimalValue = 0, isCommandable = true, configLocation = ModConfig.Type.SERVER) - public static int craftingInterfaceRecipeDurationMaxAge = 24000; + public static int craftingInterfaceRecipeDurationMaxAge = 144000; @ConfigurableProperty(category = "machine", comment = "Enabling this option will log all recipe validation failures in crafting interfaces into the server logs", isCommandable = true, configLocation = ModConfig.Type.SERVER) public static boolean logRecipeValidationFailures = true; diff --git a/src/main/java/org/cyclops/integratedcrafting/api/crafting/ICraftingInterface.java b/src/main/java/org/cyclops/integratedcrafting/api/crafting/ICraftingInterface.java index 040c2f41e..29a43b7d2 100644 --- a/src/main/java/org/cyclops/integratedcrafting/api/crafting/ICraftingInterface.java +++ b/src/main/java/org/cyclops/integratedcrafting/api/crafting/ICraftingInterface.java @@ -82,9 +82,7 @@ public interface ICraftingInterface { * @return The tick at which the oldest running crafting operation of the given job was started, * or -1 if no operation is running, or if this is unknown. */ - public default long getCraftingJobEntryStartTick(int craftingJobId) { - return -1; - } + public long getCraftingJobEntryStartTick(int craftingJobId); /** * @param recipe A recipe. @@ -94,9 +92,7 @@ public default long getCraftingJobEntryStartTick(int craftingJobId) { * as recipe-specific durations are only remembered for a limited number of recipes, * and are forgotten once they become outdated. */ - public default long getEstimatedRecipeDuration(IRecipeDefinition recipe) { - return -1; - } + public long getEstimatedRecipeDuration(IRecipeDefinition recipe); /** * @return The prioritized position of this interface. diff --git a/src/main/java/org/cyclops/integratedcrafting/api/network/ICraftingNetwork.java b/src/main/java/org/cyclops/integratedcrafting/api/network/ICraftingNetwork.java index 3dfee7c0d..8a6fbcf46 100644 --- a/src/main/java/org/cyclops/integratedcrafting/api/network/ICraftingNetwork.java +++ b/src/main/java/org/cyclops/integratedcrafting/api/network/ICraftingNetwork.java @@ -169,8 +169,6 @@ public Iterator getCraftingJobs(int channel, IngredientCompo * @return The estimated duration in ticks of a single crafting operation of the given recipe, * based on the operations that the crafting interfaces performed before, or -1 if unknown. */ - public default long getEstimatedRecipeDuration(int channel, IRecipeDefinition recipe) { - return -1; - } + public long getEstimatedRecipeDuration(int channel, IRecipeDefinition recipe); }