Skip to content

RecipeLogic 生命周期 ​

MBD2 21.1.1

RecipeLogic 是机器的服务端调度器,一次运行一条配方;一台机器有一个,也可以有多个。它负责决定何时搜索,验证并修改候选配方,模拟全部 handler,获取燃料,提交配方 IO,推进进度,进入等待/衰减,完成输出,并决定同一配方能否立即执行下一轮。

本页按 21.1.2 之后版本的真实调用顺序描述,并注明 21.1.2 及以前有何不同。onBeforeRecipeWorking 等事件名很容易让人误判时机,请以本文注入点表格为准。

状态与持久化字段 ​

  • status

    当前调度状态:IDLE、WORKING、WAITING 或 SUSPEND。

  • lastRecipe

    经机器 modifier 处理后的有效配方。它可能是动态生成的副本,不一定存在于 RecipeManager。

  • lastOriginRecipe

    修改前原始配方的稳定 ID。MBD2 用它为下一轮重新生成有效配方。

  • progress 与 duration

    已完成的工作 tick,以及有效配方的总时长。

  • consumeInputsAfterWorking

    是否把非 per-tick 输入延迟到配方完成时消耗。

  • fuelTime 与 fuelMaxTime

    剩余燃料 tick,以及提供这些 tick 的燃料配方时长。

  • lastFuelRecipe

    最近一次实际消耗的有效燃料配方。

  • recipeDirty

    强制开始新的配方搜索,而不是立即复用 lastRecipe。

  • lastFailedMatches

    已通过初始搜索、但在 modifier、条件或最终验证中失败并等待重试的候选。

这些执行字段会持久化。状态、等待原因、配方进度与燃料进度也用于 UI/Jade 同步。

一个服务端 tick ​

MBDMachine#serverTick 先发布可取消的 MachineTickEvent。未取消时,internalServerTick 依次运行每个允许运行的配方逻辑的 serverTick——主逻辑、它的线程、再是额外逻辑——然后运行每个附加 Trait 的 serverTick。下图描述的是单个逻辑。

燃料倒计时位于 SUSPEND 判断之外。因此已有燃料在机器 WAITING、SUSPEND,甚至当前没有加工配方时仍会下降。

搜索管线 ​

1. 全局搜索前先尝试复用 ​

recipeDirty == false 时,findAndHandleRecipe 会先尝试 lastRecipe。复用要求以下三项全部通过:

  1. lastRecipe.matchRecipe(machine):模拟非 per-tick 输入以及输出容量。
  2. lastRecipe.matchTickRecipe(machine):模拟 per-tick 输入和输出。
  3. lastRecipe.checkConditions(logic):计算分组/reverse 条件。

复用失败或配方 dirty 时,会先清除缓存的有效配方与 origin ID,再开始新搜索。

2. 空闲逻辑何时搜索 ​

没有缓存配方的空闲逻辑按机器错峰的五 tick 计时器轮询。21.1.2 及以前每次轮询都会搜索;之后的版本只在有理由时才搜索:

  • 该逻辑可以使用的某个 handler 自上次搜索以来发生了变化——物品槽、流体槽、FE 存储和 Mekanism 化学品槽会报告每一次变化;
  • 它的某个 handler 无法报告变化——其他任何 Trait,或没有重写 notifiesAllChanges() 的自定义 handler——那么每次轮询都搜索,与以前一样;
  • 机器或其某个 Trait 的 runtime value 变了——槽位上限、过滤器之类可能决定配方放不放得下;
  • 配方或标签重新加载了;
  • 已经连续 20 次轮询没有搜索,作为兜底。

没有理由的轮询只会重新检查 lastFailedMatches,即已匹配但未能开始的配方:天气、红石之类的条件可能在没有任何 handler 变化的情况下重新满足。

3. 配方类型搜索 ​

搜索经由配方类型的索引进行,索引在每次配方重载后构建一次:

  1. 逻辑没有任何配方 handler 时没有候选。
  2. 索引会直接跳过需要某种物品或流体、而该逻辑的输入 handler 都不持有的配方,不再模拟它们。自定义 ingredient、其他 capability,以及无法列出所持内容的 handler 永远不会排除配方,因此索引只会省去工作,绝不会跳过一条本可运行的配方。
  3. 其余候选按整数 priority 升序尝试(数值越小越先),通过 matchRecipe 模拟普通 IO,通过 matchTickRecipe 模拟 per-tick IO。
  4. 第一个通过验证的候选开始运行并结束本次搜索。已匹配但未能开始的保存在 lastFailedMatches 中。

21.1.2 及以前,MBDRecipeType#searchRecipe 会模拟该类型的每一条配方,把匹配结果按优先级排序后交给验证依次尝试:选出的配方相同,代价是要模拟全部配方。

搜索本身不应用条件和机器 modifier;它们属于候选验证阶段。

异步搜索

21.1.2 及以前,启用 ConfigHolder.ASYNC_RECIPE_SEARCHING 会让最初的 searchRecipe 模拟在 Util.backgroundExecutor 上运行。之后的版本经由索引在服务端线程上搜索,该选项只对类重写了 searchRecipe() 的配方逻辑,或配方类型重写了 MBDRecipeType#searchRecipe 的逻辑生效——针对旧搜索编写的附属照旧工作,它的配方类型拿到的仍然是机器。重写了 searchFuelRecipe 的配方类型同样仍会被用来查找燃料。必须轮询、又有索引读不出的输入(例如 ME 接口)的逻辑也适用该选项:索引排除不了任何配方,所以它像以前一样在后台匹配。自定义 handler 的模拟仍必须只读,并能安全应对这种访问方式。future 完成后,服务端线程会重新检查候选,并执行全部修改与 setup 提交。

4. 候选验证与修改 ​

对于每个搜索结果,checkMatchedRecipeAvailable 按以下顺序处理:

text
原始配方
  -> onBeforeRecipeModify(可修改、可取消)
  -> 配置的 getModifiedRecipe / 并行计算
  -> onAfterRecipeModify(可修改)
  -> 条件
  -> 普通 IO 模拟
  -> per-tick IO 模拟
  -> setupRecipe

返回/携带 null 会拒绝当前候选。在初始搜索中匹配、但在 modifier、条件或后续验证中失败的候选会保存到 lastFailedMatches,由没有理由搜索的空闲轮询重试。

只有 setup 真正得到 WORKING 配方后才设置 lastOriginRecipe。即使 lastRecipe 是修改后的副本,它仍保存原始 RecipeManager ID。

Handler 模拟与路由 ​

MBD2 会按 capability 将无标签内容与各 slotName 分组分开。先尝试准确请求 IO 注册的 handler,再尝试 IO.BOTH handler。distinct handler 优先,并且必须独自满足整个适用内容组。

匹配时每个 handler 都收到 simulate = true。返回 null 表示全部剩余内容已经处理;返回非空列表则继续传给后续 handler。提交阶段以 simulate = false 重复相同路由。

输出匹配只是通过模拟预留容量,不会提前插入输出。自定义 handler 对相同状态的模拟和提交必须返回相同 remainder,否则 setup 可能只提交配方的一部分。

Setup 与普通输入消耗 ​

setupRecipe(effectiveRecipe) 的准确顺序为:

  1. 需要燃料时由 handleFuelRecipe() 获取燃料。
  2. machine.beforeWorking(recipe) 发布 onBeforeRecipeWorking;取消会终止 setup。
  3. 读取机器的 consumeInputsAfterWorking 设置。
  4. 输入不延迟时,提交非 per-tick IO.IN。
  5. 保存 lastRecipe,设置 status = WORKING(此时调用输入与输出 handler 的 preWorking),设置 progress = 0,并复制 duration,至少为 1 tick:21.1.2 及以前,修饰器把较短的时长向下取整到 0 时,配方会一直运行下去。

21.1.2 及以前,handler 的 preWorking 是第 3 步,在输入提交之前,即使随后提交失败也会调用。

燃料早于 before-working 事件

取消 onBeforeRecipeWorking 时,燃料可能已经被消耗。不要把该事件当作普通资格检查;应使用 RecipeCondition,或在配方修改阶段拒绝候选。

启用 consumeInputsAfterWorking 时,setup 不会消耗普通输入。之后每个工作 tick 都重新执行 matchRecipe;需要的普通输入或输出容量消失时,interruptRecipe() 会丢弃进度并回到 IDLE。输入最终在普通输出之前提交。

一个工作或等待 tick ​

handleRecipeWorking 同时处理 WORKING 与 WAITING 配方:

  1. 普通输入延迟时,重新模拟全部普通 IO;失败会中断配方。
  2. 重新检查全部 RecipeCondition。
  3. 确保存在燃料;fuelTime == 0 时获取新燃料配方。
  4. 模拟 per-tick IO。
  5. 成功后先提交 per-tick IO.IN,再提交 per-tick IO.OUT。
  6. 将状态设为 WORKING。
  7. 发布 onRecipeWorking,其中 progress 是增加之前的值。
  8. 未取消时增加 progress 与 totalContinuousRunningTime。
  9. 条件/燃料/tick IO 失败时设为 WAITING,保存原因,并发布 onRecipeWaiting。
  10. 启用等待衰减时,以 recipeDampingValue 减少进度,但不会低于零。
  11. 状态离开 WORKING 时 handler 收到 postWorking,重新进入 WORKING 时收到 preWorking。

onRecipeWorking 位于 tick IO 之后

取消 onRecipeWorking 会中断配方,但本 tick 的 per-tick 输入和输出已经提交。需要阻止 tick 执行时应使用条件或 handler 模拟;该事件适合观察或执行 IO 之后的副作用。

等待中的配方每个服务端 tick 都会重试。恢复后的第一个成功 tick 会提交 tick IO、回到 WORKING(handler 在此收到 preWorking,早于 onRecipeWorking),然后增加进度。

每个 preWorking 都恰好对应一个 postWorking,并且发给当初收到 preWorking 的那些 handler,即使逻辑已不再使用它们:等待、暂停、中断、完成以及机器卸载都会结束它,加载时正处于配方中途的逻辑会在第一个 tick 重新启动它的 handler。21.1.2 及以前两者并不配对:暂停从不调用 postWorking,中断一个等待中的配方会调用两次,重新加载则从不调用 preWorking。

燃料引擎 ​

主 MBDRecipeType 通过 FuelRecipeConfig 启用燃料。配置列表保存作为燃料来源的其他 MBD 配方类型 ID。

需要燃料且 fuelTime == 0 时,handleFuelRecipe:

  1. 搜索配置的全部燃料配方类型。
  2. 模拟其普通/per-tick IO,并按 priority 升序排列。
  3. 发布 onFuelRecipeModify;取消或设为 null 会跳过该燃料候选。
  4. 检查修改后燃料配方的条件。
  5. 提交它的非 per-tick 输入。
  6. 设置 fuelMaxTime = fuelRecipe.duration、fuelTime = fuelMaxTime,并保存 lastFuelRecipe。

当前引擎不会提交燃料配方输出;虽然 tick 内容会参与燃料匹配,handleFuelRecipe 只提交普通输入。燃料配方应只依赖 duration 与普通输入,不要依赖燃料输出或 per-tick 燃料内容。

每个有效服务端 tick 结束时,正数 fuelTime 都会减一。变成零时 MBDMachine#onFuelBurningFinish(lastFuelRecipe) 会发布 MachineFuelBurningFinishEvent,Java NeoForge.EVENT_BUS 监听器、KubeJS 的 onFuelBurningFinish handler 与蓝图的 Fuel Burning Finish 入口节点都会收到。21.1.2 及以前这次调用没有走 postCustomEvent(),只有 Java 监听器能收到。直到主配方 setup 或工作 tick 再次调用 handleFuelRecipe,才会搜索下一份燃料。

完成与下一次执行 ​

progress >= duration 时,只要产出放得下,就会在同一个服务端 tick 完成。产出空间在配方开始时检查过,但并没有为它保留,所以完成时会先模拟普通产出;放不下时,逻辑以满进度等待并给出原因,每个 tick 重试。21.1.2 及以前放不下的部分会被销毁。输入延迟消耗时,它会先检查这些输入是否都还在,不在就中断配方。产出放不下时,它会在等待之前先取走这些输入:它们本就该消耗,取走它们可能正好腾出空间,等待期间也不会再被拿走。此时 onConsumeInputsAfterWorking 会先于等待空间的 onAfterRecipeWorking 触发。如果它们仍然无法全部取走,配方就不产出而直接变为 IDLE。产出放得下之后:

  1. machine.afterWorking() 发布 onAfterRecipeWorking。
  2. 输入仍处于延迟时,提交普通 IO.IN、清除标志,再发布 onConsumeInputsAfterWorking。
  3. 对输入/输出 handler 调用 postWorking。
  4. 提交非 per-tick IO.OUT。
  5. 发布 onRecipeFinish。
  6. 决定下一轮能否复用当前配方。

第 1 步发生在产出存在之前,第 5 步在之后——这就是两者的全部区别:在 onAfterRecipeWorking 里加的额外产物会落进配方自己的产出马上要用的槽位。

下一轮选择由两个机器设置控制:

设置完成后的效果
alwaysSearchRecipe将缓存配方标为 dirty,强制重新搜索配方类型
alwaysModifyRecipe从 RecipeManager 重新读取 lastOriginRecipe,再次应用机器 modifier

配方不 dirty 时,MBD2 会重新检查普通 IO、tick IO 与条件。成功就立即调用 setupRecipe(lastRecipe);第一步进度在下一个服务端 tick 才发生。否则状态变为 IDLE,进度/时长清零;下个 tick 会重试缓存配方或开始新搜索。如果这次立即 setup 最终失败(输入取不出来),逻辑同样变为 IDLE;21.1.2 及以前它会停在满进度,每个 tick 都再完成一次,白白产出。

每轮结束后可能出现更高优先级配方时启用 alwaysSearchRecipe。超频、并行、等级或机器数据可能在两轮之间改变有效配方时启用 alwaysModifyRecipe。同一有效配方应持续重复时,两者都关闭开销最低。

Machine Event 注入点 ​

Hook/事件准确时机取消/修改推荐用途
onTick配方逻辑与全部 Trait server tick 之前取消会跳过两者粗粒度机器暂停/控制;必须轻量
onBeforeRecipeModify每个候选的配置 modifier/并行之前可修改配方;取消即拒绝少量动态候选拒绝或预处理
onAfterRecipeModifymodifier/并行之后,条件与最终模拟之前可修改配方观察/收尾有效配方;普通超频优先使用配置 modifier
onFuelRecipeModify燃料匹配后、条件与燃料输入提交前可修改/可取消调整燃料时长/输入,或拒绝燃料候选
onBeforeRecipeWorking已获取燃料后,handler preWorking 与普通输入提交前取消 setupsetup 通知;不适合资格检查或无损取消燃料
onRecipeStatusChanged某个逻辑的 setStatus 切换其状态时读取 oldStatus/newStatus 与 recipeLogic动画、声音、统计;不要从尚未赋值的新 logic 字段推断
onRecipeWorking条件/燃料成功且 per-tick IO 提交后、progress 增加前取消会中断观察已完成的 tick IO,或触发 post-IO 效果
onRecipeWaiting状态与原因设为 waiting 后只观察UI、诊断、限频反馈
onAfterRecipeWorking完成时位于延迟输入/输出之前;中断时也会调用只观察与 setup 配对清理;涉及输出时必须区分完成/中断
onConsumeInputsAfterWorking完成时刚刚提交了延迟的输入只观察只有机器延迟输入时才触发
onRecipeFinish产出已经生成之后只观察「一次合成完成」——额外产物、计数、音效
onFuelBurningFinish服务端 tick 末尾燃料计数变零时只观察燃料统计;21.1.2 及以前只有 Java NeoForge.EVENT_BUS 监听器能收到

上表每一行都同时到达 KubeJS 和蓝图,并带有所关于的 recipeLogic——onFuelBurningFinish 要在 21.1.2 之后的版本。在 21.1.2 及以前,需要可靠的「燃料耗尽」信号请轮询 machine.recipeLogic.fuelTime,或响应 WAITING 状态。

多个配方逻辑 ​

有多个配方逻辑的机器会为每个逻辑分别执行本页的全部内容:每个逻辑都有自己的状态、配方、进度、燃料、搜索和失败候选,并且只看得到它可以使用的 handler。internalServerTick 以固定顺序依次运行它们,一个逻辑在自己的回合内完成配方 IO 的模拟与提交,所以两个逻辑不会同时占用同一份输入,也无需加锁。机器的状态汇总自所有逻辑,而每个事件都指明它所关于的逻辑。

行为应该放在哪里 ​

需求首选扩展点
世界/机器资格RecipeCondition
消耗或产出资源IRecipeHandlerTrait 模拟/提交
超频、并行、时长/内容转换Recipe modifier / getModifiedRecipe
预留/释放外部事务状态Handler preWorking / postWorking
视觉状态与统计状态、等待、燃料结束事件
一次性的 tick 后行为onRecipeWorking,并牢记 tick IO 已提交
一次合成完成后要做的事onRecipeFinish
按机器的配置Runtime value

不要把资源核算放进通用事件。条件可能重复计算,搜索可能异步执行,工作事件也可能发生在 IO 之后;只有 handler 协议专门为“先模拟、后权威提交”设计。

Released under the MIT License.