Skip to content

Estimate a duration for recipes that are crafted instantly - #223

Merged
rubensworks merged 1 commit into
master-1.21-ltsfrom
fix/instant-recipe-duration
Sep 4, 2026
Merged

Estimate a duration for recipes that are crafted instantly#223
rubensworks merged 1 commit into
master-1.21-ltsfrom
fix/instant-recipe-duration

Conversation

@rubensworks

Copy link
Copy Markdown
Member

Follow-up to #220, found while using its measurements in CyclopsMC/IntegratedTerminals#211.

The problem

CraftingJobHandler measures a recipe duration as the time between inserting a crafting operation into the target and its outputs coming back in. A regular crafting recipe produces its outputs within the tick it is started in, so it measures as 0.

That zero then spreads: it is smoothed into the interface's average duration, which is the fallback for every recipe that interface has never crafted. One instant craft is enough to make every estimate on that interface report zero.

Observed on a fresh network, after a single oak stairs craft:

RecipeDurationStatistics.recipeDurations = { <oak stairs recipe>: duration=0.0 }
RecipeDurationStatistics.averageDuration = duration=0.0
ICraftingNetwork#getEstimatedRecipeDuration(0, <any recipe>) = 0

Consumers multiply this per-operation duration by the number of operations in a job, so a plan for a hundred chests came out as taking no time at all, and the remaining time of a running job stayed at zero for its whole life.

The fix

The measurement is a latency, and on its own it cannot answer how long a job takes, because it says nothing about how often the interface can start an operation. In blocking mode CraftingJobHandler#update starts exactly one operation per call (it breaks out of the pending-jobs loop as soon as one is inserted), so an operation occupies the handler for a full update interval however quickly the recipe itself is done. That is a lower bound on the duration of one operation.

CraftingJobHandler#getEstimatedRecipeDuration therefore takes the update interval and bounds the measurement by it. Non-blocking mode pushes as many operations into the target as it accepts within one update, so no such bound applies there and the interval is ignored. An unknown duration stays -1.

PartTypeInterfaceCraftingBase.State passes its own getUpdateInterval(), which defaults to minCraftingInterfaceUpdateFreq but is configurable per part, so an interface that was slowed down estimates accordingly. CraftingNetwork#getEstimatedRecipeDuration needs no change: it averages over the interfaces that expose a recipe, and each of them now contributes its own bound.

With the default interval of 5 ticks, a plan for 100 chests goes from 0 to 500 ticks, which matches the throughput I measured in-game (2 operations per 10 ticks on one interface).

The javadoc of ICraftingInterface#getEstimatedRecipeDuration and ICraftingNetwork#getEstimatedRecipeDuration now states this bound. This is a behaviour change for existing callers rather than a signature change; the only signature that changed is CraftingJobHandler#getEstimatedRecipeDuration, which is not part of the API.

Tests

  • TestCraftingJobHandler: a measured duration of 0 estimates as one update interval, a longer measurement is kept as-is, an unknown duration stays unknown, and non-blocking mode is not bounded. The existing statistics tests pass an interval of 0, as they are about the smoothing and expiry rather than the bound.
  • GameTestsItemsCraft#testItemsCraftChestOneRecipeDuration now asserts >= getUpdateInterval() instead of >= 0. A chest is the exact case this fixes, so the old assertion passed against a zero. I verified this test fails on the unfixed code (testitemscraftchestonerecipeduration failed ... No crafting duration was measured for the crafted recipe) and passes with the fix.

./gradlew build and ./gradlew runGameTestServer both pass locally (18 handler unit tests, all 53 game tests).

🤖 Generated with Claude Code

https://claude.ai/code/session_016gX5ZToD5ApLBXQkKW2QGj


Generated by Claude Code

Only the time between starting a crafting operation and its outputs coming
back in is measured. Regular crafting recipes produce their outputs within
the tick they are started in, so they measure as taking no time at all, and
their average drags every other recipe on the interface down with them.

Consumers multiply this by the number of operations of a job, so a plan for
a hundred chests came out as taking no time.

In blocking mode a crafting interface starts one operation per update, so an
operation occupies it for a full update interval however quickly the recipe
itself is done. Take that as a lower bound. Non-blocking mode starts as many
operations as the target accepts, so no bound applies there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gX5ZToD5ApLBXQkKW2QGj
@rubensworks
rubensworks merged commit adad3ba into master-1.21-lts Sep 4, 2026
7 checks passed
@rubensworks
rubensworks deleted the fix/instant-recipe-duration branch September 4, 2026 15:35
rubensworks pushed a commit to CyclopsMC/IntegratedTerminals that referenced this pull request Sep 4, 2026
Recipes that are crafted within the tick they are started in were measured
as taking no time, which was worked around here by taking the crafting
interface update interval as a lower bound.

CyclopsMC/IntegratedCrafting#223 moved that bound to where the numbers come
from, where it also knows that non-blocking interfaces start as many
operations as their target accepts, and that an interface can be configured
to update less often than the configured minimum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gX5ZToD5ApLBXQkKW2QGj
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants