From 446244a5517f8ad346c495548ab8896d2ee974f0 Mon Sep 17 00:00:00 2001 From: Fff Date: Sat, 7 Nov 2026 20:45:28 -0800 Subject: [PATCH] =?UTF-8?q?feat(recipe):=20=E6=B7=BB=E5=8A=A0=E5=8F=97?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=E5=AE=B9=E9=87=8F=E7=BA=A6=E6=9D=9F=E7=9A=84?= =?UTF-8?q?=E6=89=B9=E6=AC=A1=E8=A7=84=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 基于库存快照选择能容纳全部产物的最长连续批次,支持满槽压缩 - 保留未加工原料及可复用催化剂,阻止本轮产物再次参与输入 - 预计算随机与组件函数结果,在库存提交成功后执行选中批次副作用 - 补充鱼缸接入、槽位限制和外部资源校验的接口文档 --- module.recipe/README.md | 26 ++ .../recipe/outcome/IPlannedItemOutcome.java | 34 ++ .../lib/v2/recipe/outcome/PlannedItem.java | 28 ++ .../lib/v2/recipe/outcome/SpawnItem.java | 25 +- .../lib/v2/recipe/util/ItemBatchPlan.java | 344 ++++++++++++++++++ 5 files changed, 446 insertions(+), 11 deletions(-) create mode 100644 module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/IPlannedItemOutcome.java create mode 100644 module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/PlannedItem.java create mode 100644 module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/util/ItemBatchPlan.java diff --git a/module.recipe/README.md b/module.recipe/README.md index 6a220772c..7fc22a3a6 100644 --- a/module.recipe/README.md +++ b/module.recipe/README.md @@ -47,3 +47,29 @@ InWorldRecipeBuilder.incompatible(trigger) 消耗直接移除实体,不触发伤害、死亡或击杀成就。开启物品掉落时,生物使用原生掉落生成流程(包含装备及 LivingDropsEvent),非生物保留其原有移除掉落,例如容器内容物;不会伪造玩家击杀条件。经验为生物经验奖励在无攻击者条件下乘倍率并向下取整,非生物为零,物品和经验可分别开启。奖励遵守 mob/entity drops 游戏规则,默认也会抑制容器移除时的自动掉落。 实体生成支持乘客 NBT,类型和根实体落点由配方决定,UUID 总是重新生成。未指定 NBT 的生物经过原生生成初始化。提交前失效的实体会使提交失败;沿用现有提交失败语义,不尝试回滚已经完成的其他世界修改。 + +## 容器配方批次规划 + +`ItemBatchPlan` 在库存快照上选取容量合法的最长连续批次。使用 `separate` 表示独立输入和输出,使用 `shared` 表示原料与产物共用槽位。规划会检查直到原料不足或达到调用方指定的批次上限,所以满槽压缩能够通过后续批次的累计消耗腾出产物槽位。 + +```java +ItemBatchPlan plan = ItemBatchPlan.shared(snapshot, maxBatches, (slot, stack) -> slotLimit, batch -> { + if (batch.take(stack -> stack.is(Items.IRON_INGOT), 9).isEmpty()) { + return false; + } + batch.produce(new ItemStack(Items.IRON_BLOCK)); + batch.afterCommit(onRecipeCompleted); + return true; +}); +plan.commit(selected -> commitInventory(selected.outputs())); +``` + +`take` 按槽位顺序预约原料并返回保留实际组件的消耗快照;不足时返回空列表且不改变预约。多种有重叠匹配条件的原料应由调用方先确定分配顺序。`catalyst` 在当前批次独占物品,批次结束后归还给下一批使用。产物在全部原料规划结束后才参与输出布局,禁止成为本次触发的后续原料。 + +输出容量回调返回该槽允许存放给定物品的最大数量,零表示拒绝,负数会报错。回调必须是无副作用的槽位规则,不能根据尚未提交的真实槽位内容拒绝即将被消耗的原料所占据的槽位。规划器会合并组件相同的物品,并在空槽有不同物品限制时尝试有效布局;产物无法装入时选择更短的合法前缀,不生成掉落物。 + +`IPlannedItemOutcome.planWithChance(context)` 只计算一次候选批次的概率、数量、组件函数和目标偏移,并返回 `PlannedItem`。`SpawnItem` 已实现该接口;自定义额外物品奖励也必须通过该接口完整声明。将已确定的 `stack()` 交给 `batch.produce`,提交时复用计划,禁止再次调用结果函数或抽取随机数。对于不同目标位置的产物,调用方须分别规划相应容器,禁止把其他位置的产物算入当前容器。 + +规划回调只能修改批次对象和私有规划数据。燃料、流体等其他资源也须在调用方的私有快照中验证足够执行所选批次;世界修改、资源消耗及完成事件通过 `afterCommit` 延迟。`afterCommit` 回调必须捕获每批独立的参数快照,禁止在执行时读取后续规划已经覆盖的共享上下文。`commit` 的库存回调负责在服务端同步校验原库存及燃料、流体等外部资源快照、提交输入和输出,成功返回后才执行所选批次的副作用。库存回调失败会向上传递异常且不执行副作用;计划只允许提交一次。共用槽位的 `inputs()` 和 `outputs()` 表示同一最终布局,只写入一次。 + +此入口由容器配方调用方显式使用,不会自动替换 `InWorldRecipeManager.trigger`。鱼缸接入时应在输入加工阶段选择独立布局,在输出再次加工阶段选择共用布局,并把现有物品条件、额外物品奖励及其他资源约束接入规划回调;不要在候选批次上提前调用有世界副作用的 `assemble`。库发版后由 AnvilCraft 更新依赖并接入。 diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/IPlannedItemOutcome.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/IPlannedItemOutcome.java new file mode 100644 index 000000000..18bc9b7c8 --- /dev/null +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/IPlannedItemOutcome.java @@ -0,0 +1,34 @@ +package dev.anvilcraft.lib.v2.recipe.outcome; + +import dev.anvilcraft.lib.v2.recipe.util.InWorldRecipeContext; + +import java.util.List; + +/** + * 将物品结果计算与输出分开,使容量规划使用与提交相同的数量、物品、组件和位置。 + * 实现只能计算结果及读取规划上下文;禁止修改真实库存、方块、实体或执行其他世界副作用。 + * 自定义的额外物品奖励也应实现此接口,并将全部产物包含在返回值中。 + * + * @param 配方结果类型 + */ +public interface IPlannedItemOutcome> extends IRecipeOutcome { + List plan(InWorldRecipeContext context); + + /** 每个候选批次调用一次并保存返回值;提交时不得再次调用以免重新抽取随机结果。 */ + default List planWithChance(InWorldRecipeContext context) { + if (context.getLevel().getRandom().nextDouble() > context.getFloat(this.chance())) { + return List.of(); + } + return List.copyOf(this.plan(context)); + } + + @Override + default void accept(InWorldRecipeContext context) { + this.plan(context).forEach(item -> item.accept(context)); + } + + @Override + default void acceptWithChance(InWorldRecipeContext context) { + this.planWithChance(context).forEach(item -> item.accept(context)); + } +} diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/PlannedItem.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/PlannedItem.java new file mode 100644 index 000000000..d84d6a9c8 --- /dev/null +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/PlannedItem.java @@ -0,0 +1,28 @@ +package dev.anvilcraft.lib.v2.recipe.outcome; + +import dev.anvilcraft.lib.v2.recipe.cache.ItemCache; +import dev.anvilcraft.lib.v2.recipe.util.InWorldRecipeContext; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; + +/** 保存已经完成随机数量和组件函数计算的产物及其目标位置,供容量规划与最终提交复用。 */ +public record PlannedItem(ItemStack stack, Vec3 pos) { + public PlannedItem { + stack = stack.copy(); + } + + @Override + public ItemStack stack() { + return this.stack.copy(); + } + + /** 使用世界内配方的常规输出规则提交已确定的产物;容器批次应将其交给槽位规划器。 */ + public void accept(InWorldRecipeContext context) { + if (this.stack.isEmpty()) { + return; + } + ItemCache cache = context.computeIfAbsent(ItemCache.ITEM_CACHE); + cache.getOutput(this.stack.copy(), this.pos).grow(this.stack.copy(), true); + context.putAcceptor(ItemCache.ITEM_CACHE.location(), ItemCache.DEFAULT_ACCEPTOR); + } +} diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/SpawnItem.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/SpawnItem.java index a4e05858b..f0929a483 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/SpawnItem.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/SpawnItem.java @@ -5,8 +5,6 @@ import dev.anvilcraft.lib.v2.codec.CodecUtil; import dev.anvilcraft.lib.v2.codec.StreamCodecUtil; import dev.anvilcraft.lib.v2.recipe.cache.BlockCache; -import dev.anvilcraft.lib.v2.recipe.cache.ItemCache; -import dev.anvilcraft.lib.v2.recipe.cache.item.ICacheOutput; import dev.anvilcraft.lib.v2.recipe.init.recipe.LibRecipeOutcomeTypes; import dev.anvilcraft.lib.v2.recipe.outcome.function.ApplyTagToComponent; import dev.anvilcraft.lib.v2.recipe.outcome.function.IOutcomeFunction; @@ -46,7 +44,7 @@ * @param functions 函数列表 */ public record SpawnItem(ItemStackTemplate item, Vec3 offset, ContextIntProvider count, List> functions) - implements IRecipeOutcome { + implements IPlannedItemOutcome { /** * 构造一个新的生成物品配方结果 * @@ -65,7 +63,13 @@ public record SpawnItem(ItemStackTemplate item, Vec3 offset, ContextIntProvider * @param offset 偏移量 * @param count 数量 */ - public SpawnItem(Holder item, DataComponentPatch patch, Vec3 offset, ContextIntProvider count, List> functions) { + public SpawnItem( + Holder item, + DataComponentPatch patch, + Vec3 offset, + ContextIntProvider count, + List> functions + ) { this(new ItemStackTemplate(item, 1, patch), offset, count, functions); } @@ -99,16 +103,17 @@ public Type getType() { } /** - * 接受配方上下文并处理生成物品的结果 + * 计算最终产物,保留随机数量、结果函数和方块偏移计算后的值 * * @param context 配方上下文 */ @Override @SuppressWarnings("unchecked") - public void accept(InWorldRecipeContext context) { - ItemCache cache = context.computeIfAbsent(ItemCache.ITEM_CACHE); + public List plan(InWorldRecipeContext context) { int count = context.getInt(this.count, 0, 99); - if (count == 0) return; + if (count == 0) { + return List.of(); + } ItemStack stack = this.item.withCount(1).create().copyWithCount(count); BlockCache blockCache = context.computeIfAbsent(BlockCache.BLOCK_CACHE); Vec3 offset = context.getPos().add(this.offset); @@ -122,9 +127,7 @@ public void accept(InWorldRecipeContext context) { IOutcomeFunction function1 = (IOutcomeFunction) function; stack = function1.apply(context, stack); } - ICacheOutput output = cache.getOutput(stack, offset); - output.grow(stack, true); - context.putAcceptor(ItemCache.ITEM_CACHE.location(), ItemCache.DEFAULT_ACCEPTOR); + return stack.isEmpty() ? List.of() : List.of(new PlannedItem(stack, offset)); } /** diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/util/ItemBatchPlan.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/util/ItemBatchPlan.java new file mode 100644 index 000000000..8094c56fb --- /dev/null +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/util/ItemBatchPlan.java @@ -0,0 +1,344 @@ +package dev.anvilcraft.lib.v2.recipe.util; + +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Predicate; + +/** + * 在库存快照上寻找产物能够完整装入容器的最长连续批次,允许后续批次消耗原料腾出槽位。 + * 规划回调只能修改批次对象;世界修改须通过 {@link Batch#afterCommit(Runnable)} 延迟执行。 + * 新产物不参与本次规划的原料匹配,调用方在提交时负责检查库存仍与初始快照一致。 + */ +public final class ItemBatchPlan { + private final List inputs; + private final List outputs; + private final int batches; + private final List effects; + private boolean committed; + + private ItemBatchPlan(List inputs, List outputs, int batches, List effects) { + this.inputs = copy(inputs); + this.outputs = copy(outputs); + this.batches = batches; + this.effects = List.copyOf(effects); + } + + /** 输入、输出分开存放时,只将产物装入输出快照。容量回调必须是无副作用的槽位规则。 */ + public static ItemBatchPlan separate( + List inputs, List outputs, int limit, SlotCapacity capacity, Predicate recipe + ) { + return plan(inputs, outputs, false, limit, capacity, recipe); + } + + /** 输入、输出共用槽位时,先扣除所选批次的原料,再检查全部产物的最终布局。 */ + public static ItemBatchPlan shared( + List inventory, int limit, SlotCapacity capacity, Predicate recipe + ) { + return plan(inventory, inventory, true, limit, capacity, recipe); + } + + private static ItemBatchPlan plan( + List inputs, List outputs, boolean shared, + int limit, SlotCapacity capacity, Predicate recipe + ) { + if (limit < 0) { + throw new IllegalArgumentException("Batch limit must not be negative"); + } + Objects.requireNonNull(capacity); + Objects.requireNonNull(recipe); + List remaining = copy(inputs); + List initialOutput = copy(outputs); + List products = new ArrayList<>(); + List effects = new ArrayList<>(); + List acceptedInputs = remaining; + List acceptedOutputs = initialOutput; + int acceptedBatches = 0; + int acceptedEffects = 0; + for (int index = 0; index < limit; index++) { + Batch batch = new Batch(remaining); + boolean matched; + try { + matched = recipe.test(batch); + } finally { + batch.open = false; + } + if (!matched) { + break; + } + remaining = batch.remaining(); + for (ItemStack stack : batch.products) { + addProduct(products, stack); + } + effects.addAll(batch.effects); + List layout = fit(shared ? remaining : initialOutput, products, capacity); + if (layout != null) { + acceptedInputs = shared ? layout : remaining; + acceptedOutputs = layout; + acceptedBatches = index + 1; + acceptedEffects = effects.size(); + } + } + return new ItemBatchPlan(acceptedInputs, acceptedOutputs, acceptedBatches, effects.subList(0, acceptedEffects)); + } + + /** 返回已包含原料消耗的输入快照;共用槽位时同时包含最终产物。 */ + public List inputs() { + return copy(this.inputs); + } + + /** 返回全部产物装入后的输出快照。 */ + public List outputs() { + return copy(this.outputs); + } + + public int batches() { + return this.batches; + } + + /** + * 调用方在回调中校验并提交两份库存,成功返回后才执行所选批次的副作用。 + * 回调抛出异常时不执行副作用;计划只允许提交一次,失败后必须重新读取库存并规划。 + */ + public void commit(Consumer inventoryCommit) { + Objects.requireNonNull(inventoryCommit); + if (this.committed) { + throw new IllegalStateException("The item batch plan was already committed"); + } + this.committed = true; + if (this.batches == 0) { + return; + } + inventoryCommit.accept(this); + this.effects.forEach(Runnable::run); + } + + private static void addProduct(List products, ItemStack stack) { + if (stack.isEmpty()) { + return; + } + for (int index = 0; index < products.size(); index++) { + Product product = products.get(index); + if (ItemStack.isSameItemSameComponents(product.stack(), stack)) { + products.set(index, new Product(product.stack(), Math.addExact(product.count(), stack.getCount()))); + return; + } + } + products.add(new Product(stack.copyWithCount(1), stack.getCount())); + } + + private static @Nullable List fit(List initial, List products, SlotCapacity capacity) { + List layout = copy(initial); + long[] remaining = products.stream().mapToLong(Product::count).toArray(); + List empty = new ArrayList<>(); + for (int slot = 0; slot < layout.size(); slot++) { + ItemStack stored = layout.get(slot); + if (stored.isEmpty()) { + empty.add(slot); + continue; + } + for (int productIndex = 0; productIndex < products.size(); productIndex++) { + Product product = products.get(productIndex); + if (!ItemStack.isSameItemSameComponents(stored, product.stack())) { + continue; + } + int space = Math.max(0, checkedCapacity(capacity, slot, product.stack()) - stored.getCount()); + int inserted = (int) Math.min(remaining[productIndex], space); + stored.grow(inserted); + remaining[productIndex] -= inserted; + } + } + return fillEmpty(layout, empty, 0, products, remaining, capacity, new HashSet<>()) ? layout : null; + } + + private static boolean fillEmpty( + List layout, List empty, int index, + List products, long[] remaining, SlotCapacity capacity, Set failed + ) { + int kinds = 0; + for (long count : remaining) { + if (count > 0) { + kinds++; + } + } + if (kinds == 0) { + return true; + } + if (kinds > empty.size() - index) { + return false; + } + LayoutState state = new LayoutState(index, Arrays.stream(remaining).boxed().toList()); + if (failed.contains(state)) { + return false; + } + long requiredSlots = 0; + for (int productIndex = 0; productIndex < products.size(); productIndex++) { + long space = 0; + int largest = 0; + for (int emptyIndex = index; emptyIndex < empty.size(); emptyIndex++) { + int limit = checkedCapacity(capacity, empty.get(emptyIndex), products.get(productIndex).stack()); + space += limit; + largest = Math.max(largest, limit); + } + if (remaining[productIndex] > space) { + return false; + } + if (remaining[productIndex] > 0) { + requiredSlots += 1 + (remaining[productIndex] - 1) / largest; + } + } + if (requiredSlots > empty.size() - index) { + return false; + } + int slot = empty.get(index); + for (int productIndex = 0; productIndex < products.size(); productIndex++) { + if (remaining[productIndex] == 0) { + continue; + } + ItemStack stack = products.get(productIndex).stack(); + int inserted = (int) Math.min(remaining[productIndex], checkedCapacity(capacity, slot, stack)); + if (inserted == 0) { + continue; + } + layout.set(slot, stack.copyWithCount(inserted)); + remaining[productIndex] -= inserted; + if (fillEmpty(layout, empty, index + 1, products, remaining, capacity, failed)) { + return true; + } + remaining[productIndex] += inserted; + layout.set(slot, ItemStack.EMPTY); + } + if (fillEmpty(layout, empty, index + 1, products, remaining, capacity, failed)) { + return true; + } + failed.add(state); + return false; + } + + private static int checkedCapacity(SlotCapacity capacity, int slot, ItemStack stack) { + int limit = capacity.capacity(slot, stack.copy()); + if (limit < 0) { + throw new IllegalArgumentException("Slot capacity must not be negative: " + slot); + } + return limit; + } + + private static List copy(List stacks) { + return new ArrayList<>(stacks.stream().map(ItemStack::copy).toList()); + } + + private record LayoutState(int index, List remaining) { + } + + private record Product(ItemStack stack, long count) { + } + + /** 返回槽位允许存放该物品的最大数量,零表示拒绝;不得依赖真实槽位尚未提交的内容。 */ + @FunctionalInterface + public interface SlotCapacity { + int capacity(int slot, ItemStack stack); + } + + /** 一次候选配方的原料预约、确定产物及延迟副作用,实例仅在规划回调执行期间可修改。 */ + public static final class Batch { + private final List available; + private final List catalysts; + private final List products = new ArrayList<>(); + private final List effects = new ArrayList<>(); + private boolean open = true; + + private Batch(List inputs) { + this.available = copy(inputs); + this.catalysts = new ArrayList<>(); + for (int slot = 0; slot < inputs.size(); slot++) { + this.catalysts.add(ItemStack.EMPTY); + } + } + + /** 按槽位顺序分配原料,返回包含实际组件的消耗快照;不足时返回空列表且不改变预约。 */ + public List take(Predicate ingredient, int count) { + return this.reserve(ingredient, count, true); + } + + /** 在本批中独占催化剂,下批可重复使用;返回值保留用于产物函数的实际物品组件。 */ + public List catalyst(Predicate ingredient, int count) { + return this.reserve(ingredient, count, false); + } + + private List reserve(Predicate ingredient, int count, boolean consume) { + this.ensureOpen(); + if (count <= 0) { + throw new IllegalArgumentException("Ingredient count must be positive"); + } + List slots = new ArrayList<>(); + long total = 0; + for (int slot = 0; slot < this.available.size(); slot++) { + ItemStack stack = this.available.get(slot); + if (!stack.isEmpty() && ingredient.test(stack.copy())) { + slots.add(slot); + total += stack.getCount(); + } + } + if (total < count) { + return List.of(); + } + List receipt = new ArrayList<>(); + for (int slot : slots) { + ItemStack stack = this.available.get(slot); + int taken = Math.min(count, stack.getCount()); + receipt.add(stack.copyWithCount(taken)); + if (!consume) { + ItemStack catalyst = this.catalysts.get(slot); + this.catalysts.set(slot, stack.copyWithCount(catalyst.getCount() + taken)); + } + stack.shrink(taken); + count -= taken; + if (count == 0) { + break; + } + } + return receipt; + } + + /** 记录已确定数量、物品及组件的产物,不再次执行随机数或结果函数。 */ + public void produce(ItemStack stack) { + this.ensureOpen(); + if (!stack.isEmpty()) { + this.products.add(stack.copy()); + } + } + + /** + * 仅在本批被选中且库存提交成功后执行,适用于燃料消耗、事件及其他世界修改。 + * 回调须捕获本批参数快照,禁止读取后续批次已经覆盖的共享规划上下文。 + */ + public void afterCommit(Runnable effect) { + this.ensureOpen(); + this.effects.add(Objects.requireNonNull(effect)); + } + + private List remaining() { + List result = copy(this.available); + for (int slot = 0; slot < result.size(); slot++) { + ItemStack catalyst = this.catalysts.get(slot); + if (!catalyst.isEmpty()) { + result.set(slot, catalyst.copyWithCount(result.get(slot).getCount() + catalyst.getCount())); + } + } + return result; + } + + private void ensureOpen() { + if (!this.open) { + throw new IllegalStateException("The recipe batch is no longer being planned"); + } + } + } +}