Repository navigation
Plan recipe batches within container capacity 添加受容器容量约束的配方批次规划 - #245
Conversation
- 基于库存快照选择能容纳全部产物的最长连续批次,支持满槽压缩 - 保留未加工原料及可复用催化剂,阻止本轮产物再次参与输入 - 预计算随机与组件函数结果,在库存提交成功后执行选中批次副作用 - 补充鱼缸接入、槽位限制和外部资源校验的接口文档
🌿 Roseau API Breaking Change Report
|
Gugle2308
left a comment
There was a problem hiding this comment.
结论:此为第 6 条版本线,算法本体与已合并版本五条线逐字节相同;但库自带的 ChooseOneOutcome 仍无法参与容量规划——choose_one 产物的配方仍会复现 #5961 的产物溢出,需在 AnvilCraft 接入前补齐;另,344 行回溯搜索零单元测试。
审查范围:dev/26.2 → head 35de5faf,5 文件 +439/−10(patch-diff 与 API 声明逐位一致)、draft=false、state=open、CI build / build 仍 in_progress、无既有评论或 review。
跨线基线核对:ItemBatchPlan.java(344 行)、IPlannedItemOutcome.java、PlannedItem.java 与已合并的 dev/1.21.11(#242)逐字节相同;SpawnItem.java 因线别 API 不同(ItemStackTemplate vs ItemStack、typeHolder()/components() vs getItemHolder()/getComponentsPatch()),plan() 主体结构一致;README.md 差异仅为既有示例的 26.2 命名(Blocks.CUT_COPPER_STAIRS.waxed()、EntityTypes.COW),新增的「容器配方批次规划」整节逐字相同。因此以下结论对全部 6 条线成立。
🔴 一、ChooseOneOutcome 绕开容量规划:#5961 的核心场景仍会复现
IPlannedItemOutcome 是"全部产物"的唯一声明入口(IPlannedItemOutcome.java:14),但库自带的 ChooseOneOutcome 未实现它:
// ChooseOneOutcome.java:26
public record ChooseOneOutcome(NumberProvider chance, List<Choice> choices)
implements IRecipeOutcome<ChooseOneOutcome> { // ← 不是 IPlannedItemOutcome
// :116 Choice 可持有任意 outcome,包括 SpawnItem
public record Choice(IRecipeOutcome<?> outcome, NumberProvider weight) {}
// :84 执行时直接产出
this.choices.get(i).outcome().acceptWithChance(context);后果两条:
- 规划侧完全不可见 ——
plan/planWithChance只存在于IPlannedItemOutcome,ChooseOneOutcome既无plan,也不暴露内部候选。按 README 收集IPlannedItemOutcome去规划时,这份产物不进products,容量算少;真正执行时InWorldRecipe.java的outcome.acceptWithChance(context)照样产出 → 超出容量的部分再次掉落,与 #5961 现象一致。 - 调用方无法"安全拒绝" ——
choose_one不暴露内部 outcome,只能用instanceof ChooseOneOutcome整类排除配方,等于放弃功能。
下游正在使用(AnvilCraft 默认分支 dev/1.21/1.6 实测 ItemCrushRecipeLoader.java:94):
.chooseOne((builder) -> builder
.choice(SpawnItem.builder().offset(0, -.6875, 0).item(ModItems.RUBY).build(), .25f)
.choice(...).choice(...).choice(...))建议(前者更彻底):让 ChooseOneOutcome implements IPlannedItemOutcome<ChooseOneOutcome>,plan 复用与 accept 同一套权重前缀和选出下标,再委托 choices.get(i).outcome() instanceof IPlannedItemOutcome<?> p ? p.plan(context) : List.of(),并保证 planWithChance→plan 只抽样一次;或至少在 README 明确"含 choose_one 的配方必须由调用方整体排除"。
⚠️ 二、344 行回溯搜索与公开 API 零单元测试
仓库其他模块已有测试源集(module.config / module.math(含 MathTestBootstrap)/ module.registrum / module.rendering / module.util),而 module.recipe/src/test 不存在,本 PR 新增的 344 行回溯 + 备忘录 + 容量回调无任何测试覆盖。
建议的最小用例矩阵(均可用小型假 SlotCapacity 表达):
| 用例 | 期望 |
|---|---|
| 单产物恰好填满全部槽位 | batches() == limit |
| 产物总量超出容器容量 | batches() 收敛到最大可行前缀 |
shared 且输出槽全被原料占满 |
靠后续批次腾槽后成功(README 的"满槽压缩") |
| 空槽对不同物品容量不同 | fillEmpty 回溯找到有效布局(而非贪心失败) |
take 超过匹配总量 |
返回 List.of() 且 remaining 不变 |
catalyst 跨批复用 |
第 2 批仍可取到同一催化剂 |
capacity 返回负数 |
IllegalArgumentException |
commit 二次调用 |
IllegalStateException |
⚠️ 三、fillEmpty 最坏复杂度与"每批重算"缺搜索预算上限
fillEmpty 逐空槽 × 逐产物 + "留空"分支回溯,靠 failed(LayoutState = (index, remaining))去重。去重键本身正确:同一 index 之后待填槽位与容量规则只依赖 remaining,容量回调按槽号取值、不依赖已填布局。但:
- 递归深度 = 空槽数(最多 54),分支数 = 产物种类数 + 1;
- 不同
remaining向量数随产物种类数组合增长; fit在plan的每一轮批次都从头重跑,无增量复用。
最坏为 批次上限 × 组合状态数,仓库无性能门禁,CI 不会暴露。建议加可配置的节点预算(超限保守返回 null = 取更短前缀,语义安全),并在 README 注明。
💡 四、resolved #5961 是跨仓引用,且合入不等于 issue 已修复
PR 在 AnvilLib,issue #5961 在 AnvilCraft。resolved #5961 只会被解析为本仓库的 #5961,不会建立跨仓链接,应写 Anvil-Dev/AnvilCraft#5961。且该 issue 当前已是 closed,本 PR 只提供库侧 API、AnvilCraft 接入尚未发生(正文自述"库发版后由 AnvilCraft 更新依赖并接入"),建议把 resolved 改为 part of / contributes to。
💡 五、commit() 两处边界建议补文档
this.committed = true;
if (this.batches == 0) return; // ① 不调用 inventoryCommit
inventoryCommit.accept(this);
this.effects.forEach(Runnable::run); // ② 某条 effect 抛异常会中断后续 effect- ①
batches == 0时直接返回、不调用库存回调。若下游把"外部资源(燃料/流体)校验"也放在该回调里会静默跳过——README 只写了"库存回调失败会向上传递异常"。 - ② 库存已提交后,
effects中任一条抛异常会跳过其余,留下"部分副作用已执行"的中间态。README 覆盖了"库存回调失败不执行副作用",未覆盖"副作用自身失败"。
💡 六、Batch.take/catalyst 的 count <= 0 抛异常未在 README 说明
reserve 对 count <= 0 抛 IllegalArgumentException。README 详述了"不足时返回空列表且不改变预约",但没提"非正数直接抛"。
✅ 七、已验证正确的部分
SpawnItem 重构是行为等价重构(对照 base 逐行核对):
| 检查点 | 结论 |
|---|---|
plan() 与旧 accept() 求值顺序 |
完全一致:getInt(count,0,99) → item.withCount(1).create().copyWithCount(count) → BlockCache + IRecipeResultOffsetBlock 修正偏移 → 逐个 IOutcomeFunction.apply |
count == 0 早退 |
旧 return → 新 List.of() ✅ |
ItemCache 注册时机 |
旧:accept 内 putAcceptor;新:PlannedItem.accept 内同一位置 ✅ |
| 概率抽取次数 | IRecipeOutcome.acceptWithChance(:47,getFloat(this.chance()))与新增 IPlannedItemOutcome.acceptWithChance 都是"先 roll chance,再 roll count",各一次 ✅ |
| 残留 import | SpawnItem 已移除 ItemCache/ICacheOutput,零残留 ✅ |
| 其它 outcome 未受影响 | 本分支 5 个 implements IRecipeOutcome 的类中只有 SpawnItem 被改 ✅ |
算法完备性:fillEmpty 对每个空槽穷举"放哪个产物(取最大可放量)/ 留空"全部分支,kinds == 0 才返回 true,返回 true 时产物必然全部落位;requiredSlots/space 两处剪枝都是合法下界(remaining > 0 && space >= remaining 已保证 space > 0,故 largest > 0)。plan 允许"第 k+1 批装不下、第 k+2 批装得下"而接受前缀 1..k+2,正是满槽压缩,acceptedEffects = effects.size() 快照语义与之一致 ✅
其它正向确认:take 不足时在任何 shrink 之前返回 List.of();产物从不进入下一批的 available;shared 下 inputs()/outputs() 指向同一最终布局;Math.addExact 防合并溢出;ItemBatchPlan 文件 344 行完整、无 >140 列行、无尾随空白。org.jspecify.annotations.Nullable 在本分支已被 25 个文件使用、根 build.gradle:100 启用 @NullMarked,不构成新问题;ItemStackTemplate 全仓 17 处既有用法,withCount(1).create() 为 26.x 标准 API。
优先修复顺序
- §一
ChooseOneOutcome—— 不修则 #5961 对choose_one配方无效,下游也无法自行绕过; - §三 搜索预算 —— 容器配方会频繁调用,先给上限再接入;
- §二 测试 —— 至少覆盖上表 8 条;
- §四/§五/§六 文案与边界说明。
验证边界
未编译、未运行(依赖 MC 注册表),且本 head 的 CI 仍 in_progress,请以 Actions 最终结果为准。结论基于 head 源码逐行核对 + base 侧对照 + 一处 AnvilCraft 实际调用点取证(ItemCrushRecipeLoader.java:94)。ItemBatchPlan 目前全仓无调用方,故无集成回归可查;下游接入后的实际槽位/容量语义需 AnvilCraft 侧验证。
feat(recipe): 添加受容器容量约束的批次规划