Skip to content

Commit 47de927

Browse files
committed
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186zEXrjMNMVXQC7wcoSiMR
1 parent e81a3ce commit 47de927

6 files changed

Lines changed: 414 additions & 39 deletions

File tree

src/main/java/org/cyclops/integratedcrafting/GeneralConfig.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ public class GeneralConfig extends DummyConfig {
3535
@ConfigurableProperty(category = "general", comment = "The base energy usage for the attuned crafting interface per crafting job being processed.", minimalValue = 0, configLocation = ModConfig.Type.SERVER)
3636
public static int interfaceCraftingAttunedBaseConsumption = 10;
3737

38+
@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)
39+
public static int craftingInterfaceRecipeDurationEntries = 32;
40+
41+
@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)
42+
public static int craftingInterfaceRecipeDurationMaxAge = 24000;
43+
3844
@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)
3945
public static boolean logRecipeValidationFailures = true;
4046

src/main/java/org/cyclops/integratedcrafting/api/crafting/ICraftingInterface.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ public default long getCraftingJobEntryStartTick(int craftingJobId) {
9090
* @param recipe A recipe.
9191
* @return The estimated duration in ticks of a single crafting operation of the given recipe,
9292
* based on the operations that were performed by this interface before, or -1 if unknown.
93+
* This may fall back to the average duration over all recipes of this interface,
94+
* as recipe-specific durations are only remembered for a limited number of recipes,
95+
* and are forgotten once they become outdated.
9396
*/
9497
public default long getEstimatedRecipeDuration(IRecipeDefinition recipe) {
9598
return -1;

src/main/java/org/cyclops/integratedcrafting/core/CraftingJobHandler.java

Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
99
import it.unimi.dsi.fastutil.longs.LongArrayList;
1010
import it.unimi.dsi.fastutil.longs.LongList;
11-
import it.unimi.dsi.fastutil.objects.Object2DoubleMap;
12-
import it.unimi.dsi.fastutil.objects.Object2DoubleOpenHashMap;
1311
import it.unimi.dsi.fastutil.objects.Object2IntMap;
1412
import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
1513
import net.minecraft.core.Direction;
@@ -53,11 +51,6 @@
5351
*/
5452
public class CraftingJobHandler {
5553

56-
/**
57-
* The weight of the latest crafting operation duration within the running average for a recipe.
58-
*/
59-
protected static final double RECIPE_DURATION_SMOOTHING = 0.25D;
60-
6154
private final int maxProcessingJobs;
6255
private boolean blockingJobsMode;
6356
private final ICraftingResultsSink resultsSink;
@@ -75,7 +68,7 @@ public class CraftingJobHandler {
7568
private final Map<IngredientComponent<?, ?>, Direction> ingredientComponentTargetOverrides;
7669
private final Int2IntMap nonBlockingJobsRunningAmount;
7770
private final Int2ObjectMap<LongList> processingCraftingJobsStartTicks;
78-
private final Object2DoubleMap<IRecipeDefinition> recipeDurations;
71+
private RecipeDurationStatistics recipeDurationStatistics;
7972

8073
public CraftingJobHandler(int maxProcessingJobs, boolean blockingJobsMode,
8174
Collection<ICraftingProcessOverride> craftingProcessOverrides,
@@ -97,7 +90,6 @@ public CraftingJobHandler(int maxProcessingJobs, boolean blockingJobsMode,
9790
this.ingredientComponentTargetOverrides = Maps.newIdentityHashMap();
9891
this.nonBlockingJobsRunningAmount = new Int2IntOpenHashMap();
9992
this.processingCraftingJobsStartTicks = new Int2ObjectOpenHashMap<>();
100-
this.recipeDurations = new Object2DoubleOpenHashMap<>();
10193
}
10294

10395
public void writeToNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) {
@@ -167,14 +159,9 @@ public void writeToNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) {
167159
}
168160
tag.put("nonBlockingJobsRunningAmount", nonBlockingJobsRunningAmount);
169161

170-
ListTag recipeDurations = new ListTag();
171-
for (Object2DoubleMap.Entry<IRecipeDefinition> entry : this.recipeDurations.object2DoubleEntrySet()) {
172-
CompoundTag recipeDuration = new CompoundTag();
173-
recipeDuration.put("recipe", IRecipeDefinition.serialize(lookupProvider, entry.getKey()));
174-
recipeDuration.putDouble("duration", entry.getDoubleValue());
175-
recipeDurations.add(recipeDuration);
176-
}
177-
tag.put("recipeDurations", recipeDurations);
162+
CompoundTag recipeDurationStatistics = new CompoundTag();
163+
getRecipeDurationStatistics().writeToNBT(recipeDurationStatistics);
164+
tag.put("recipeDurationStatistics", recipeDurationStatistics);
178165
}
179166

180167
public void readFromNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) {
@@ -295,13 +282,7 @@ public void readFromNBT(HolderLookup.Provider lookupProvider, CompoundTag tag) {
295282
this.nonBlockingJobsRunningAmount.put(craftingJobId, amount);
296283
}
297284

298-
this.recipeDurations.clear();
299-
for (Tag recipeDuration : tag.getList("recipeDurations", Tag.TAG_COMPOUND)) {
300-
CompoundTag recipeDurationTag = (CompoundTag) recipeDuration;
301-
this.recipeDurations.put(
302-
IRecipeDefinition.deserialize(lookupProvider, recipeDurationTag.getCompound("recipe")),
303-
recipeDurationTag.getDouble("duration"));
304-
}
285+
getRecipeDurationStatistics().readFromNBT(tag.getCompound("recipeDurationStatistics"));
305286
}
306287

307288
public boolean setBlockingJobsMode(boolean blockingJobsMode) {
@@ -372,9 +353,11 @@ public long getCraftingJobEntryStartTick(int craftingJobId) {
372353
* @param recipe A recipe.
373354
* @return The estimated duration in ticks of a single crafting operation of the given recipe,
374355
* based on the operations that were performed by this handler before, or -1 if unknown.
356+
* This falls back to the average duration over all recipes
357+
* when the given recipe itself was not crafted recently.
375358
*/
376359
public long getEstimatedRecipeDuration(IRecipeDefinition recipe) {
377-
return this.recipeDurations.containsKey(recipe) ? Math.round(this.recipeDurations.getDouble(recipe)) : -1;
360+
return getRecipeDurationStatistics().getEstimatedDuration(recipe, getCurrentTick());
378361
}
379362

380363
/**
@@ -390,15 +373,23 @@ protected long getCurrentTick() {
390373
* @param durationTicks The number of ticks the crafting operation took.
391374
*/
392375
protected void reportRecipeDuration(IRecipeDefinition recipe, long durationTicks) {
393-
if (this.recipeDurations.containsKey(recipe)) {
394-
// Smooth out the duration over the previous operations,
395-
// as crafting durations can vary due to for example varying machine speeds.
396-
double previousDuration = this.recipeDurations.getDouble(recipe);
397-
this.recipeDurations.put(recipe,
398-
previousDuration + (durationTicks - previousDuration) * RECIPE_DURATION_SMOOTHING);
399-
} else {
400-
this.recipeDurations.put(recipe, (double) durationTicks);
376+
getRecipeDurationStatistics().reportDuration(recipe, durationTicks, getCurrentTick());
377+
}
378+
379+
/**
380+
* @return The duration statistics of this handler, which are created lazily,
381+
* as their configuration is only available once the mod is fully loaded.
382+
*/
383+
public RecipeDurationStatistics getRecipeDurationStatistics() {
384+
if (this.recipeDurationStatistics == null) {
385+
this.recipeDurationStatistics = createRecipeDurationStatistics();
401386
}
387+
return this.recipeDurationStatistics;
388+
}
389+
390+
protected RecipeDurationStatistics createRecipeDurationStatistics() {
391+
return new RecipeDurationStatistics(GeneralConfig.craftingInterfaceRecipeDurationEntries,
392+
GeneralConfig.craftingInterfaceRecipeDurationMaxAge);
402393
}
403394

404395
public void unmarkCraftingJobProcessing(CraftingJob craftingJob) {
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package org.cyclops.integratedcrafting.core;
2+
3+
import net.minecraft.nbt.CompoundTag;
4+
import net.minecraft.nbt.Tag;
5+
import org.cyclops.commoncapabilities.api.capability.recipehandler.IRecipeDefinition;
6+
7+
import javax.annotation.Nullable;
8+
import java.util.LinkedHashMap;
9+
import java.util.Map;
10+
11+
/**
12+
* Keeps track of how long crafting operations take, so that the duration of crafting jobs can be estimated.
13+
*
14+
* Durations are tracked per recipe, but only the average duration over all recipes is persisted.
15+
* This is because the number of recipes that a crafting interface can craft is unbounded,
16+
* and serializing them would make the crafting interface's state grow indefinitely.
17+
* As such, after loading, estimations start at the average duration of the crafting interface,
18+
* and become recipe-specific again as soon as recipes are crafted.
19+
*
20+
* Measurements are forgotten once they become too old,
21+
* as the time that a recipe takes can change when the network or its machines are modified.
22+
*
23+
* @author rubensworks
24+
*/
25+
public class RecipeDurationStatistics {
26+
27+
/**
28+
* The weight of the latest crafting operation duration within the running average.
29+
*/
30+
protected static final double SMOOTHING = 0.25D;
31+
32+
private final int maxEntries;
33+
private final long maxAge;
34+
private final Map<IRecipeDefinition, Measurement> recipeDurations;
35+
@Nullable
36+
private Measurement averageDuration;
37+
38+
/**
39+
* @param maxEntries The maximum number of recipes to remember durations for.
40+
* 0 disables recipe-specific durations.
41+
* @param maxAge The number of ticks after which a measured duration is forgotten. 0 disables forgetting.
42+
*/
43+
public RecipeDurationStatistics(int maxEntries, long maxAge) {
44+
this.maxEntries = maxEntries;
45+
this.maxAge = maxAge;
46+
this.recipeDurations = new LinkedHashMap<>(16, 0.75F, true) {
47+
@Override
48+
protected boolean removeEldestEntry(Map.Entry<IRecipeDefinition, Measurement> eldest) {
49+
// Forget the least recently used recipe once we remember too many of them
50+
return size() > RecipeDurationStatistics.this.maxEntries;
51+
}
52+
};
53+
}
54+
55+
/**
56+
* Take the duration of a finished crafting operation into account for future estimations.
57+
* @param recipe The recipe that was crafted.
58+
* @param durationTicks The number of ticks the crafting operation took.
59+
* @param currentTick The current game tick.
60+
*/
61+
public void reportDuration(IRecipeDefinition recipe, long durationTicks, long currentTick) {
62+
if (this.maxEntries > 0) {
63+
Measurement measurement = this.recipeDurations.get(recipe);
64+
if (measurement == null || isExpired(measurement, currentTick)) {
65+
this.recipeDurations.put(recipe, new Measurement(durationTicks, currentTick));
66+
} else {
67+
measurement.update(durationTicks, currentTick);
68+
}
69+
}
70+
71+
if (this.averageDuration == null || isExpired(this.averageDuration, currentTick)) {
72+
this.averageDuration = new Measurement(durationTicks, currentTick);
73+
} else {
74+
this.averageDuration.update(durationTicks, currentTick);
75+
}
76+
}
77+
78+
/**
79+
* @param recipe A recipe.
80+
* @param currentTick The current game tick.
81+
* @return The estimated duration in ticks of a single crafting operation of the given recipe.
82+
* Falls back to {@link #getAverageDuration(long)} if the recipe itself was not measured (recently),
83+
* and is -1 if nothing was measured at all.
84+
*/
85+
public long getEstimatedDuration(IRecipeDefinition recipe, long currentTick) {
86+
Measurement measurement = this.recipeDurations.get(recipe);
87+
if (measurement != null) {
88+
if (!isExpired(measurement, currentTick)) {
89+
return Math.round(measurement.getDuration());
90+
}
91+
this.recipeDurations.remove(recipe);
92+
}
93+
return getAverageDuration(currentTick);
94+
}
95+
96+
/**
97+
* @param currentTick The current game tick.
98+
* @return The estimated duration in ticks of a single crafting operation of any recipe, or -1 if unknown.
99+
*/
100+
public long getAverageDuration(long currentTick) {
101+
if (this.averageDuration != null) {
102+
if (!isExpired(this.averageDuration, currentTick)) {
103+
return Math.round(this.averageDuration.getDuration());
104+
}
105+
this.averageDuration = null;
106+
}
107+
return -1;
108+
}
109+
110+
/**
111+
* @return The number of recipes that durations are remembered for.
112+
*/
113+
public int getEntryCount() {
114+
return this.recipeDurations.size();
115+
}
116+
117+
protected boolean isExpired(Measurement measurement, long currentTick) {
118+
if (this.maxAge <= 0) {
119+
return false;
120+
}
121+
long age = currentTick - measurement.getLastMeasuredTick();
122+
// Negative ages can occur when the game time is moved backwards, in which case the measurement is useless
123+
return age < 0 || age > this.maxAge;
124+
}
125+
126+
public void writeToNBT(CompoundTag tag) {
127+
if (this.averageDuration != null) {
128+
tag.putDouble("averageDuration", this.averageDuration.getDuration());
129+
tag.putLong("averageDurationTick", this.averageDuration.getLastMeasuredTick());
130+
}
131+
}
132+
133+
public void readFromNBT(CompoundTag tag) {
134+
this.recipeDurations.clear();
135+
this.averageDuration = tag.contains("averageDuration", Tag.TAG_DOUBLE)
136+
? new Measurement(tag.getDouble("averageDuration"), tag.getLong("averageDurationTick"))
137+
: null;
138+
}
139+
140+
protected static class Measurement {
141+
142+
private double duration;
143+
private long lastMeasuredTick;
144+
145+
public Measurement(double duration, long lastMeasuredTick) {
146+
this.duration = duration;
147+
this.lastMeasuredTick = lastMeasuredTick;
148+
}
149+
150+
public double getDuration() {
151+
return duration;
152+
}
153+
154+
public long getLastMeasuredTick() {
155+
return lastMeasuredTick;
156+
}
157+
158+
/**
159+
* Smooth the given duration into this measurement,
160+
* as crafting durations can vary due to for example varying machine speeds.
161+
*/
162+
public void update(long durationTicks, long currentTick) {
163+
this.duration = this.duration + (durationTicks - this.duration) * SMOOTHING;
164+
this.lastMeasuredTick = currentTick;
165+
}
166+
}
167+
168+
}

src/test/java/org/cyclops/integratedcrafting/core/TestCraftingJobHandler.java

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
*/
3030
public class TestCraftingJobHandler {
3131

32+
private static final int MAX_RECIPE_DURATION_ENTRIES = 32;
33+
3234
private TickingCraftingJobHandler handler;
3335
private ICraftingNetwork craftingNetwork;
3436
private IRecipeDefinition recipeA;
@@ -38,11 +40,14 @@ public class TestCraftingJobHandler {
3840
public void beforeEach() {
3941
this.handler = new TickingCraftingJobHandler();
4042
this.craftingNetwork = new CraftingNetwork();
41-
this.recipeA = new RecipeDefinition(Maps.newIdentityHashMap(), new MixedIngredients(Maps.newIdentityHashMap()));
43+
this.recipeA = newRecipe(0);
44+
this.recipeB = newRecipe(1);
45+
}
4246

43-
Map<IngredientComponent<?, ?>, List<?>> outputB = Maps.newIdentityHashMap();
44-
outputB.put(IngredientComponentStubs.SIMPLE, Lists.newArrayList(1L));
45-
this.recipeB = new RecipeDefinition(Maps.newIdentityHashMap(), new MixedIngredients(outputB));
47+
protected static IRecipeDefinition newRecipe(long output) {
48+
Map<IngredientComponent<?, ?>, List<?>> outputs = Maps.newIdentityHashMap();
49+
outputs.put(IngredientComponentStubs.SIMPLE, Lists.newArrayList(output));
50+
return new RecipeDefinition(Maps.newIdentityHashMap(), new MixedIngredients(outputs));
4651
}
4752

4853
protected static Map<IngredientComponent<?, ?>, List<IPrototypedIngredient<?, ?>>> newPendingIngredients() {
@@ -77,9 +82,34 @@ public void testRecipeDurationSmoothed() {
7782
}
7883

7984
@Test
80-
public void testRecipeDurationPerRecipe() {
85+
public void testRecipeDurationFallsBackToAverage() {
8186
handler.reportRecipeDuration(recipeA, 100);
82-
assertThat(handler.getEstimatedRecipeDuration(recipeB), equalTo(-1L));
87+
assertThat(handler.getEstimatedRecipeDuration(recipeB), equalTo(100L));
88+
}
89+
90+
@Test
91+
public void testRecipeDurationsAreBounded() {
92+
for (int i = 0; i < MAX_RECIPE_DURATION_ENTRIES + 10; i++) {
93+
handler.reportRecipeDuration(newRecipe(i), 100);
94+
}
95+
96+
assertThat(handler.getRecipeDurationStatistics().getEntryCount(), equalTo(MAX_RECIPE_DURATION_ENTRIES));
97+
}
98+
99+
@Test
100+
public void testSerializationDoesNotGrowWithRecipes() {
101+
handler.reportRecipeDuration(recipeA, 100);
102+
CompoundTag tagSingle = new CompoundTag();
103+
handler.writeToNBT(null, tagSingle);
104+
105+
for (int i = 0; i < 100; i++) {
106+
handler.reportRecipeDuration(newRecipe(i), 100);
107+
}
108+
CompoundTag tagMany = new CompoundTag();
109+
handler.writeToNBT(null, tagMany);
110+
111+
// Only the average duration is serialized, so crafting more recipes must not grow the crafting interface
112+
assertThat(tagMany.toString(), equalTo(tagSingle.toString()));
83113
}
84114

85115
@Test
@@ -181,6 +211,7 @@ public void testRecipeDurationsSurviveSerialization() {
181211
deserialized.readFromNBT(null, tag);
182212

183213
assertThat(deserialized.getEstimatedRecipeDuration(recipeA), equalTo(100L));
214+
assertThat(deserialized.getRecipeDurationStatistics().getEntryCount(), equalTo(0));
184215
}
185216

186217
protected static class TickingCraftingJobHandler extends CraftingJobHandler {
@@ -196,6 +227,11 @@ public <T, M> void addResult(IngredientComponent<T, M> ingredientComponent, T in
196227
});
197228
}
198229

230+
@Override
231+
protected RecipeDurationStatistics createRecipeDurationStatistics() {
232+
return new RecipeDurationStatistics(MAX_RECIPE_DURATION_ENTRIES, 24000);
233+
}
234+
199235
public void setCurrentTick(long currentTick) {
200236
this.currentTick = currentTick;
201237
}

0 commit comments

Comments
 (0)