代价列表算法
本 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) — 合并
这是算法的基本合并动作:
- 把
that的所有标签 逐个 copy 后追加到self的列表尾部; - 双重循环
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)标记为已消化;
- 最后
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。
相关条目
- 合成显示模式 -
Mode(4) 如何调用这四个接口 - 合成步骤优化 -
getSteps()的贪心与回退 - 标签类型与合并 -
ILabel.MERGER如何判定两个标签可合并 - 本 mod 没有的东西 - 本 mod 不提供的数值维度