代价列表算法

本 mod 的核心:一套泛型的「配方展开」算法,把一个需求标签列表递归拆解成 原始材料清单。全部实现在 AbstractCostListService,由 MainCostListService 提供与具体类型(ILabel / Recipe / CostList)的绑定。

基本信息

属性 值
抽象类 me.towdium.jecalculation.data.structure.AbstractCostListService<LabelT, RecipeT, CostListT>
具体绑定 MainCostListService extends AbstractCostListService<ILabel, Recipe, CostList>
单例 MainCostListService.INSTANCE(单例私有构造)
接口定义 me.towdium.jecalculation.data.structure.Calculation<LabelT>
容器类 CostList(持有一个 List<ILabel>)
算法迭代上限 1000 步

文件顶部有一行决定符号约定的注释: // positive => generate; negative => require 正数 = 产出/富余,负数 = 需求/缺口。 下面所有运算都建立在这个约定上。

四个基本算子

1. newNegatedCostList(List<LabelT>) — 取负

把一组标签的数量整体取负(转为「需求」):

对每个标签 i:
  若 isNotEmptyLabel(i) 为假 → 丢弃
  否则 → copyLabel(i) 再 multiplyLabel(·, -1)

MainCostListService 的实现是 label.multiply(-1.0f)。

2. newPosNegCostList(positive, negative) — 正负混合

ret = newNegatedCostList(positive)   // 取负
multiply(ret, -1)                     // 再取负 → 回到正(富余量)
mergeInplace(ret, newNegatedCostList(negative), false)   // 合入负数(缺口)

净效果:positive 贡献正数、negative 贡献负数,二者合并进同一张表。

3. mergeInplace(self, that, strict) — 合并

这是算法的基本合并动作:

  1. 把 that 的所有标签 逐个 copy 后追加到 self 的列表尾部;
  2. 双重循环 i < j 扫描全表,尝试把 j 合并进 i:
    • strict == true:用 d.labelMatches(a, b) 判同,labelMatches 即 ILabel.matches; 命中则 thisLabels[i] = setLabelAmount(a, Math.addExact(amount(a), amount(b)))。 Math.addExact 溢出即抛 ArithmeticException。
    • strict == false:用 d.mergeLabels(a, b)(即 ILabel.MERGER.merge), 成功则写入合并结果。
    • 合并成功的位置 j 被置为 getEmptyLabel()(ILabel.EMPTY)标记为已消化;
  3. 最后 filter(isNotEmptyLabel),把所有 EMPTY 槽位从列表中物理移除, 并写回 setLabels。

双重循环是 O(n²) 的,这是整个算法复杂度的主要来源。注意 j 被置 EMPTY 后 仍留在数组里参与后续 i 扫描,靠 mergeLabels/labelMatches 对 EMPTY 的处理保证不会被重复累加。

4. multiply(self, long i) — 整体缩放

对表内每个标签调用 d.multiplyLabel(j, i),即 ILabel.multiply(float)。

⚠️ 类型细节:multiply 的形参是 long,而 Dependencies.multiplyLabel 的形参是 float。传 -next.two(long)时会隐式转成 float, 超过 2^24(约 1677 万)的数量会丢失精度。这在大批量聚合时是可观测的。

calculate(CostListT) — 主过程

返回一个匿名 Calculation<LabelT>,构造器即执行整个展开过程(不是懒执行):

set = { 初始 costList }
reset()                                   // index = 0, iterator = 新的配方迭代器
next = find()
count = 0
while (next != null):
    step.recipe = next.one
    step.multiplier = next.two

    outL = 配方产物标签(过滤掉 EMPTY)
    outC = newNegatedCostList(outL);  multiply(outC, -next.two)
    step.multipliedRecipeOutputs = outC

    inL  = 配方原料标签(过滤掉 EMPTY)
    inC  = newNegatedCostList(inL);   multiply(inC, +next.two)

    result = mergeCostLists(原始 remaining, outC, false)   // 先扣产物
    mergeInplace(result, inC, false)                        // 再加原料

    step.stillNeeded = result
    if (result 不在 set 里):        // ← 循环检测,靠 CostList 的 equals/hashCode
        set.add(result)
        procedure.add(step)
        addCatalyst(配方催化剂)
        reset()                  // ← 每成功一步就重置配方迭代器,从头重新找
    next = find()
    if (count++ > 1000):
        addMaxLoopChatMessage()   // 发 MAX_LOOP 聊天提示
        break                    // ← 硬性中止

符号推导(以需求 10 个 X、配方「1 锭 → 5 锭 X」为例):

  • outC = negate(outputs) × (-multiplier) → (-5) × (-m) = +5m(富余)
  • inC = negate(inputs) × (+multiplier) → (-1) × (+m) = -m(缺口)
  • result = remaining + outC + inC:净减少 5m - m = 4m 个 X 的缺口。

硬上限:1000 步

count++ > 1000 时通过 Dependencies.addMaxLoopChatMessage() 发出 Utilities.ChatMessage.MAX_LOOP 聊天提示并 break。 MainCostListService 的实现是 Utilities.addChatMessage(Utilities.ChatMessage.MAX_LOOP)。 这是一个「静默截断」:玩家得到一个不完整的结果,但界面不会报错。

find() — 寻找下一个配方

for (; index < labels.size(); index++):
    label = labels.get(index)
    if (getLabelAmount(label) >= 0) continue     // ← 只处理负数(缺口)
    while (iterator.hasNext()):
        r = iterator.next()
        if (recipeOutputMatches(r, label).isPresent()):    // 配方能产出它吗
            return new Pair<>(r, multiplier(r, label))     // ← 并算出一致倍数
    iterator = d.recipeIterator()             // 该配方集扫完 → 重置,继续下一个标签
return null
  • 只展开负数量标签,富余的正数量标签直接跳过(这正是 getOutputs() 能报出「多余产出」的原因)。
  • 某标签的配方迭代器耗尽后重置,继续处理下一个标签。
  • recipeOutputMatches 在 MainCostListService 中转发到 Recipe.matches(label)。

倍数公式 Recipe.multiplier(ILabel label)

这是决定「要做几次这个配方」的唯一算式,源码逐字如下:

long amountA = label.getAmount();                      // 需求量(负数)
if (!label.isPercent()) amountA = Math.multiplyExact(amountA, 100L);
long amountB = i.getAmount();                          // 配方每次的产出量
if (!i.isPercent()) amountB = Math.multiplyExact(amountB, 100L);
return (amountB + Math.abs(amountA) - 1) / amountB;

整理成公式:

amountA = |需求量| ,若该标签是绝对数量则先 ×100
amountB = 配方产出量,若该标签是绝对数量则先 ×100

multiplier = ⌈amountA / amountB⌉   (向上取整除法)
           = (amountB + amountA - 1) / amountB

要点:

细节 说明
向上取整 (b + a - 1) / b 是 Java 整数向上取整的标准写法,倍数不会低于实际需求
Math.abs 只取缺口绝对值,正负号在这一步被丢掉
×100 对称缩放 需求与产出要么都缩放要么都不缩放,所以比值的整数部分不变
Math.multiplyExact 缩放溢出(需求量 > 约 9.2×10¹⁶)抛 ArithmeticException
百分比标签 isPercent() 为真时不缩放,因此一个「100%」的产出可以只算 1 次
matches 用的是同一套判据 Recipe.matches 也是靠 ILabel.MERGER.merge(label, i).isPresent() 过滤产物

matches 与 multiplier 的筛选条件完全一致,都遍历 output 并用 ILabel.MERGER.merge 试合并、findAny() 取第一个命中项。所以一种物品在同一条配方里 出现多次时,只按第一个匹配的产物算倍数。

四个输出接口

接口 数据源 变换
getInputs() getCurrent() 的全部标签 过滤 amount < 0,再 multiplyLabel(·, -1) 变正 → 原始材料清单
getOutputs(ignore) 同上全部标签 先 multiplyLabel(·, -1),再对 ignore 里每个标签尝试 mergeLabels 并取第一个成功者替换,最后再 filter(amount < 0) 并 multiplyLabel(·, -1) 变正 → 扣除背包后的剩余需求
getCatalysts() 累积的 catalysts 数组 在 addCatalyst() 里按 labelMatches 去重,重复时取 Math.max(newAmount, oldAmount)
getSteps(givenInventory) procedure 见 合成步骤优化

getInputs() / getOutputs() 读的都是 getCurrent(): procedure.isEmpty() ? costList : procedure.get(procedure.size() - 1).stillNeeded ——即最后一步之后仍未被消化的部分。

getOutputs(ignore) 的过滤顺序有个易错点:它先过滤 isNotEmptyLabel 与 amount < 0 再取负,所以合并没有成功替换的标签会被保留。

库存槽位估算 estimatedNumSlotsTakenBy

在 合成步骤优化 里用来给候选步骤打分:

total = Σ  ceil( getLabelAmount(label) / 64.0 )

源码注释写明了这是粗略估计:Assuming 64 is not valid for snowballs and unstackables, but good enough for an estimate。

costListEquals — 结构相等

判断两张代价表是否互为相反数:

m = multiply(copyCostList(c), -1)
return getLabels(mergeCostLists(self, m, true)).isEmpty()

即「self 与 -c 严格合并后是否清空」。这是 set.contains(result) 循环检测能工作的基础, 依赖 CostList 的 equals / hashCode。

相关条目