diff --git a/src/main/java/org/cyclops/integratedcrafting/GeneralConfig.java b/src/main/java/org/cyclops/integratedcrafting/GeneralConfig.java index e9efc3153..dee3a0713 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 = 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/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..29a43b7d2 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,23 @@ 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 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 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 a6c212345..8a6fbcf46 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,12 @@ 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 long getEstimatedRecipeDuration(int channel, IRecipeDefinition recipe); + } diff --git a/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java b/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java index 35cccac3a..36fede8cf 100644 --- a/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java +++ b/src/main/java/org/cyclops/integratedcrafting/core/CraftingHelpers.java @@ -5,6 +5,7 @@ import com.google.common.collect.Maps; import com.google.common.collect.Sets; import net.minecraft.core.Direction; +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 +1730,15 @@ public static boolean insertCrafting(Function, PartPos return ok; } + /** + * @return The current game tick of the server. + */ + public static long getCurrentTick() { + // Fully qualified, as this class already imports org.apache.logging.log4j.Level + return ServerLifecycleHooks.getCurrentServer() + .getLevel(net.minecraft.world.level.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 +1768,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..78dc63be6 100644 --- a/src/main/java/org/cyclops/integratedcrafting/core/CraftingJobHandler.java +++ b/src/main/java/org/cyclops/integratedcrafting/core/CraftingJobHandler.java @@ -6,6 +6,8 @@ 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.Object2IntMap; import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap; import net.minecraft.core.Direction; @@ -65,6 +67,8 @@ public class CraftingJobHandler { private final Int2ObjectMap finishedCraftingJobs; private final Map, Direction> ingredientComponentTargetOverrides; private final Int2IntMap nonBlockingJobsRunningAmount; + private final Int2ObjectMap processingCraftingJobsStartTicks; + private RecipeDurationStatistics recipeDurationStatistics; public CraftingJobHandler(int maxProcessingJobs, boolean blockingJobsMode, Collection craftingProcessOverrides, @@ -85,6 +89,7 @@ public CraftingJobHandler(int maxProcessingJobs, boolean blockingJobsMode, this.finishedCraftingJobs = new Int2ObjectOpenHashMap<>(); this.ingredientComponentTargetOverrides = Maps.newIdentityHashMap(); this.nonBlockingJobsRunningAmount = new Int2IntOpenHashMap(); + this.processingCraftingJobsStartTicks = new Int2ObjectOpenHashMap<>(); } public void writeToNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) { @@ -120,6 +125,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 +158,10 @@ public void writeToNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) { nonBlockingJobsRunningAmount.putInt(String.valueOf(entry.getIntKey()), entry.getIntValue()); } tag.put("nonBlockingJobsRunningAmount", nonBlockingJobsRunningAmount); + + CompoundTag recipeDurationStatistics = new CompoundTag(); + getRecipeDurationStatistics().writeToNBT(recipeDurationStatistics); + tag.put("recipeDurationStatistics", recipeDurationStatistics); } public void readFromNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) { @@ -221,6 +236,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 +281,8 @@ public void readFromNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) { int amount = nonBlockingJobsRunningAmount.getInt(key); this.nonBlockingJobsRunningAmount.put(craftingJobId, amount); } + + getRecipeDurationStatistics().readFromNBT(tag.getCompound("recipeDurationStatistics")); } public boolean setBlockingJobsMode(boolean blockingJobsMode) { @@ -316,9 +339,63 @@ 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. + * 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 getRecipeDurationStatistics().getEstimatedDuration(recipe, getCurrentTick()); + } + + /** + * @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. + * @param durationTicks The number of ticks the crafting operation took. + */ + protected void reportRecipeDuration(IRecipeDefinition recipe, long 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) { 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 +408,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 +420,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(getCurrentTick()); } } @@ -378,6 +464,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 +473,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 +487,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(), 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/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/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/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)); + } + +} 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..df2ee0e60 --- /dev/null +++ b/src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java @@ -0,0 +1,245 @@ +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.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; + +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 static final int MAX_RECIPE_DURATION_ENTRIES = 32; + + private TickingCraftingJobHandler handler; + private ICraftingNetwork craftingNetwork; + private IRecipeDefinition recipeA; + private IRecipeDefinition recipeB; + + @Before + public void beforeEach() { + this.handler = new TickingCraftingJobHandler(); + this.craftingNetwork = new CraftingNetwork(); + this.recipeA = newRecipe(0); + this.recipeB = newRecipe(1); + } + + 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() { + 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)); + } + + @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 testRecipeDurationFallsBackToAverage() { + handler.reportRecipeDuration(recipeA, 100); + 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 + 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)); + } + + @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); + + CompoundTag tag = new CompoundTag(); + handler.writeToNBT(null, tag); + + TickingCraftingJobHandler deserialized = new TickingCraftingJobHandler(); + deserialized.readFromNBT(null, tag); + + assertThat(deserialized.getEstimatedRecipeDuration(recipeA), equalTo(100L)); + assertThat(deserialized.getRecipeDurationStatistics().getEntryCount(), equalTo(0)); + } + + 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) { + + } + }); + } + + @Override + protected RecipeDurationStatistics createRecipeDurationStatistics() { + return new RecipeDurationStatistics(MAX_RECIPE_DURATION_ENTRIES, 24000); + } + + public void setCurrentTick(long currentTick) { + this.currentTick = currentTick; + } + + @Override + protected long getCurrentTick() { + return 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)); + } + +}