Skip to content

Fixed the sliding gesture for the storage system, and rebuilt the JEI item transfer logic. 修复了存储系统的滑动操作,重构了JEI物品转移逻辑 - #4680

Merged
WhereisFff merged 6 commits into
Anvil-Dev:dev/1.21/1.6from
PigeonNian:storagefix/1.21/1.6
Sep 1, 2026

Conversation

@PigeonNian

@PigeonNian PigeonNian commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Pigeon_Nian added 4 commits September 1, 2026 18:50
- 新增快速从存储区移动物品到背包的RPC调用及处理逻辑
- 添加快速移动撤销接口,实现物品移动的回退功能
- 在客户端存储界面支持通过Shift+拖拽物品触发快速移动
- 记录快速移动过程中每个槽位物品移动数量,便于撤销操作
- 优化快速移动队列处理,区分存储区槽位和背包槽位
- 增加快速移动完成后撤销的调用,保证操作的一致性
- 扩展合成物品取出接口,支持Shift键快速放置产物
- 调整存储服务端合成逻辑,处理带Shift状态的取物请求
- 修正合成结果放置顺序,增强用户交互体验
- 修改物品转移函数以累积移动数量,支持超过单个堆叠限制的转移
- 合并背包和存储提取逻辑,避免重复判断和不必要的返回
- 修正空槽时从背包提取所有同种物品,尊重最大数量限制
- 确保存储提取时按剩余空间和数量动态减少提取量
- 删除冗余的复制和增长代码,简化ItemStack对象操作
- 增强代码可读性和逻辑连贯性,防止错误提前返回
- 修正Gunpowder Block拼写错误,统一为GUNPOWDER_BLOCK
- 更新相关标签、物品标签及工具提示中gunpowder_block的命名
- 修正数据生成器中gunpowderBlock配方方法名称
- 在StorageClientStub和StorageServerStub中支持多槽物品按份数精准转移
- 集成JEI配方分配算法,计算各合成槽所需物品数量
- 细化服务端材料转移流程,支持按请求数量精确提取材料
- 添加材料不足时的回滚机制,保证多槽分配一致性和物品均分
- 新增虚拟槽支持,提高配方转移兼容性及准确度
- 提升JEI转移时的缺料检测与用户反馈能力
- 调整import语句顺序使其更规范
- 删除transfer失败处理后的多余空行
- 保持代码格式整洁,提高可读性
@Gugle2308

Copy link
Copy Markdown
Collaborator

⚠️ Dangerous command requires approval:

curl -sL "https://api.github.com/repos/Anvil-Dev/AnvilCraft/issues/4672" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('title','')); print(d.get('body','')[:800])" 2>/dev/null; ec...

Reason: Security scan — [HIGH] Pipe to interpreter: curl | python3: Command pipes output from 'curl' directly to interpreter 'python3'. Downloaded content will be executed without inspection.
Safer: tirith run https://api.github.com/repos/Anvil-Dev/AnvilCraft/issues/4672 — or: vet https://api.github.com/repos/Anvil-Dev/AnvilCraft/issues/4672 (https://getvet.sh); [HIGH] Pipe to interpreter: curl | python3: Command pipes output from 'curl' directly to interpreter 'python3'. Downloaded content will be executed without inspection.
Safer: tirith run https://api.github.com/repos/Anvil-Dev/AnvilCraft/issues/4674 — or: vet https://api.github.com/repos/Anvil-Dev/AnvilCraft/issues/4674 (https://getvet.sh); script execution via -e/-c flag

Reply /approve to execute, /approve session to approve this pattern for the session, /approve always to approve permanently, or /deny to cancel.

- 合并玩家背包与当前存储站内同类物品数量计算,包含切石机输入与合成九宫格
- 新增 addAvailable 方法,将超出单槽上限的物品拆分为多个虚拟槽,确保正确分配数量
- StorageScreen 新增获取当前合成数据接口 getCrafting
- StorageServerStub 实现清空合成格功能,物品先回背包,背包满时回存储站
- 优化还原物品到背包逻辑,返回未放入的剩余数量
- JEI 配方输入转移时先清空合成格,保证材料正确更新和填充
@PigeonNian
PigeonNian marked this pull request as ready for review September 1, 2026 12:41
- 用@Getter注解替代手动编写的getCrafting方法
- 添加字段crafting的注释说明合成数据的组成部分
- 保持代码结构整洁,提高代码可维护性
@Gugle2308

Copy link
Copy Markdown
Collaborator

⚠️ Dangerous command requires approval:

cd /tmp && python3 -c "
import zipfile
for f in ['jei-19.50.jar','jei-sources.jar','jei-19.32.jar']:
    try:
        z = zipfile.ZipFile(f)
        names = z.namelist()
        print(f, 'entries:', l...

Reason: script execution via -e/-c flag

Reply /approve to execute, /approve session to approve this pattern for the session, /approve always to approve permanently, or /deny to cancel.

@Gugle2308

Copy link
Copy Markdown
Collaborator

gh token 无效——不能修改标题(避免失败),且无需修改(标题已较准确)。直接输出审查。

代码审查摘要 — PR #4680

操作: edited
范围: 10 个文件(全部 Java)/ 1081 行 diff(+609 / −132,29 hunks,无新文件/删除)
分支: storagefix/1.21/1.6dev/1.21/1.6(1.21.x 项目,跳过 26.1 API 迁移检查)
Issue 映射: #4672 仓储系列操作优化bug / #4674 优化仓储系列GUI内合成操作 / #4671 板条箱合成功能内JEI适配bug

🔴 关键(需修复后合并)

  1. storage→inventory 滑动手势把 logicalSlot 当服务端 view slot index 用(核心 bug)StorageScreen.mouseDragged + StorageServerStub.quickMoveFromStorage

    • getStorageSlot(mouseX, mouseY) 返回 displayOrder.getInt(orderIndex),即客户端逻辑槽allocateLogicalSlot() 分配,见 loadContentsorder.add(logicalSlot))。
    • 该值直接经 queueQuickMove(key)key = -1 - storageSlot)→ storageQuickMoveSlotsquickMoveFromStorage(sourcePos, slots) → 服务端 moveStorageStackToInventory(view, slot),其中 slot 被当作 view slot indexview.size() / view.amount(slot) / view.resource(slot) / view.extract(slot) 全按此语义)。
    • 既有正确路径 interactWithStorage 第 1858 行明确做转换:int serverSlot = this.serverSlots.get(slot);serverSlotslogicalSlot → update.index() 映射)。滑动路径漏掉了这一转换。
    • 后果:逻辑槽号恰好 < view.size()移动错误的 storage 条目(物品错乱/串位);>= view.size() 时静默失败(return false)。搜索/折叠(nbtFolded)后 displayOrder 值更是与 view index 完全脱节。
    • 修复:storage 分支入队前先 this.serverSlots.get(storageSlot) 转换(注意缺省值 -1 处理)。
  2. storage→inventory 滑动没有撤销记录,undo 机制不对称StorageScreen.flushQuickMoves / recordQuickMoveMovedFromSelection

    • recordQuickMoveMovedFromSelection 只遍历 pendingQuickMoveSlots(inventory 槽),storage 方向不记录。
    • flushQuickMoves 的 storage 分支仍调 applyQuickMoveMoved(storageSlots, moved)quickMoveMovedBySlot.remove(storageSlot) 返回 null → 静默跳过。storage→inventory 滑动后无法撤销,而 inventory→storage 方向有 beginUndoGroup/endUndoGroup + recordUndo 完整支持。
    • 建议:storage 方向也在服务端 quickMoveFromStorage 内记录 undo(如 recordUndo 的 storage→inventory 版本),或客户端用 serverSlots 转换后统一走既有 undo 组。
  3. recordQuickMoveMovedFromSelection 记录移动前槽内全量,部分移动时把未移动部分也二次塞回 storageStorageScreen

    • mouseReleased 时(RPC 发出前)记录 getItem(slot).getCount()(当时全量)。若 storage 空间不足只移走部分,RPC 完成后 quickMoveUndo(slot, count)Math.min(count, stack.getCount())剩余量view.insert 塞回 storage——用户只拖走一部分,剩余却被「补移」进 storage(与预期不符,且如果此时存储已满则 extracted <= 0 时物品留在背包、return true 表示已处理但实际没动)。
    • 修复:服务端 quickMoveToStorage 返回每槽实际移动量,客户端按实际量记录 undo。
  4. craftingTakeResultshift 参数是死代码StorageServerStub / StorageClientStub / StorageScreen

    • 客户端 shift 时走 takeAllChunk → craftingTakeAll,非 shift 传 shift=false;服务端 craftingTakeResult(shift=true) 分支(调一次 placeCraftingResult 并返回 true)永远不会被触发。建议删除该分支或统一两条路径(当前双路径容易在后续维护中分叉)。
  5. JEI 切石机转移删除了配方自动选中逻辑(行为回归)StorageServerStub.craftingTransfer stonecutter 分支

    • 旧代码根据 stonecutterResult 匹配 recipes 并 withStonecutterSelected(selected);新代码只转移物品后 return truestonecutterResult 参数保留但未使用。切石机 JEI 转移后配方不再自动选中,用户需手动点配方——与 [Bug] 板条箱合成功能内JEI适配bug #4671「JEI 适配 bug」修复方向相悖。请恢复选中逻辑或确认客户端另行处理。

⚠️ 警告

  • craftingTransfer 开头新增 clearCrafting(先清空全部合成槽再转移) — 用户手动摆放的①/② 槽物品会被清空(回背包/存储)后再按 JEI 输入重放。commit 说明这是 [Feature] 优化仓储系列GUI内合成操作 #4674 的有意设计,但属破坏性行为变更,建议在 PR 描述标注;且 clearCrafting 后 stonecutter 分支用 crafting.stonecutterInput()(已被清空)计算 currentCount,逻辑自洽但要注意「清空 → 只放第一个 inputs」时其余 inputs 被忽略。
  • computeRequestedCountscraftingSlotId - 2 映射依赖 JEI TransferOperation.craftingSlotId() 的语义 — 若 JEI 返回的是 craftingSlots 列表位置(08)而非 slot index,-2 会导致索引错位(-26)。本地无法拉取 JEI 29.6.2 源码确证,建议在运行中验证多槽同种材料配方的分配正确性。
  • rounds = maxTransfer ? Integer.MAX_VALUE : 1 无显式上限 — 由 hasEnoughMaterial/anyPlaced 终止,但材料极多时每轮补差量可能循环很多次,建议加最大轮数上限(如 64×9)。
  • transferMaterialExact 回滚用 giveBackToInventory 不回滚到原槽位 — 取走的物品放回时只填第一个空槽/合并同种,可能改变背包槽位布局(低概率)。

💡 建议

  • quickMoveUndo 命名误导:实际语义是「把剩余物品补塞回 storage」(补偿而非撤销),建议改名(如 quickMoveTopUp)并让调用方检查返回值。
  • placeCraftingResult 改为先背包后指针(优先放入背包,避免指针被占)——合理的优先级改进,值得肯定。
  • 服务端 quickMoveFromStorageslots.size() > MAX_SYNC_SLOTS(256) 校验对单次拖拽足够,但 IntOpenHashSet 去重后仍逐槽 RPC,storage 大列表拖拽注意性能。

🟢 看起来不错

  • GUNPOWDER 拼写修复全链路一致GUNPOWER_BLOCKGUNPOWDER_BLOCK 在 ModBlocks(registry name gunpowder_block 不变,生成资源不受影响)、RegistrumBlockRecipeLoader、BlockTagLoader、ItemTooltipManager、BuildingBlocks、BuildingBlocksSections 共 6 处同步修改,git grep GUNPOWER_BLOCK 无残留 ✅。
  • JEI 转移多轮 + 每轮材料预检(requireCompleteSets 语义):hasEnoughMaterial 按物品分组统计背包+存储可用量,不足整组放弃本轮,避免破坏均分。
  • storage 超大堆拆虚拟槽while (remaining > 0) 按 maxStackSize 拆分)正确处理 >64 的存储条目。
  • transferFromInventory 空槽从「放一个」改为「转移全部同种(受上限)」——修复部分转移,符合 [Bug] 仓储系列操作优化bug #4672 优化方向。

📋 声称验证表

声称 状态 对应文件
#4672 仓储系列操作优化(滑动操作) ⚠️ StorageScreen.mouseDragged/mouseReleased、StorageServerStub.quickMoveFromStorage/quickMoveUndo — 功能实现但 slot 映射 bug(见关键 1/2/3)
#4674 优化仓储GUI内合成操作 StorageJeiSupport.computeRequestedCounts、StorageServerStub.craftingTransfer/hasEnoughMaterial/transferMaterialExact
#4671 板条箱合成内 JEI 适配 bug ⚠️ StorageJeiSupport 重构 + craftingTransfer — 分配更精确,但切石机配方选中回归(见关键 5)
gunpowder 拼写统一 6 个文件同步改名,无残留

结论: REQUEST_CHANGES — storage→inventory 滑动方向的 slot 映射(关键 1)是必须修复的功能性 bug(会导致物品错乱/串位),undo 不对称与记录全量问题(关键 2/3)影响可靠性;其余为行为回归与建议项。建议修复关键 1~3 后合入。


由 Hermes Agent 审查(gh token 无效,未修改 PR 标题;标题描述基本准确)

@Gugle2308

Copy link
Copy Markdown
Collaborator

所有分析完成。输出最终审查报告。

代码审查摘要 — PR #4680

操作: ready_for_review
范围: 10 个文件(10 Java, 0 新增, 0 删除)/ 1194 行 diff
主题: 修复存储系统滑动操作 + 重构 JEI 物品转移逻辑(fixed #4672 #4674 #4671

🔴 关键问题(需修复)

1. StorageScreen.mouseDragged — 存储槽 toggle 选中/取消完全失效

int key = -1 - storageSlot;
if (!this.quickMoveSlots.add(key)) {   // key 已存在 = 取消选中
    this.quickMoveSlots.remove(key);
    this.storageQuickMoveSlots.remove(storageSlot);
    this.pendingQuickMoveSlots.remove(storageSlot);   // ❌ 见 #2
}
this.queueQuickMove(key);   // ❌ 取消后再次 add(key) → 立即反转,槽位又被选中

取消分支执行 remove(key) 后,末尾的 queueQuickMove(key) 又把 key 重新加入 quickMoveSlotsstorageQuickMoveSlots —— toggle 被反转,第二次划过已选存储槽永远无法取消选中。而背包槽分支(旧逻辑)保留 queueQuickMove(inventorySlot)(内部 add 失败即不加入),toggle 正常。建议:取消分支 return true,不再调用 queueQuickMove;或统一用 queueQuickMove 的 add-失败语义。

2. StorageScreen.mouseDragged — 用错索引域移除集合

this.pendingQuickMoveSlots.remove(storageSlot);   // storageSlot 是存储索引,却从背包槽集合移除

存储槽索引(≥0)与背包槽索引域重叠。若滑动中同时选中了背包槽 X 与存储槽 Y=X,取消存储槽 Y 会误删背包槽 X 的选中状态。应改为 this.quickMoveSlots.remove(key)(已做)+ 从 storageQuickMoveSlots 移除(已做),删除 pendingQuickMoveSlots.remove(storageSlot) 这一行(存储槽从不进 pendingQuickMoveSlots,该行永远无效)。

3. StorageScreen.flushQuickMovesquickMoveMovedBySlot.clear() 清空刚记录的数据,undo 链路整体失效

// mouseReleased:
this.recordQuickMoveMovedFromSelection();  // 记录 pendingQuickMoveSlots 各槽移动前数量
this.quickMoveSlots.clear();
this.flushQuickMoves();                    // ← 内部第一行 clear() 把记录清空!

recordQuickMoveMovedapplyQuickMoveMovedquickMoveUndo 这条新增链路的所有数据在第一步就被 flushQuickMoves 开头的 this.quickMoveMovedBySlot.clear() 清掉applyQuickMoveMoved 检查 quickMoveMovedBySlot.isEmpty() 恒为 true → 直接 return → quickMoveUndo RPC 永远不会发出。整条 undo 代码是死路径(无害但功能缺失)。

4. StorageScreen.containerTick 每 tick 调 flushQuickMoves — 拖拽竞态
containerTickflushQuickMoves() 每 tick 执行。拖拽过程中(mouseReleased 之前)tick 会提前 flush pendingQuickMoveSlots 并清空 quickMoveMovedBySlot,使 mouseReleased 的 recordQuickMoveMovedFromSelection 记录时集合已空。与 #3 叠加,undo 机制确定失效。

5. StorageServerStub.craftingTransfer maxTransfer 多轮循环 — 跨槽部分组不回滚,与注释声称的 requireCompleteSets 语义矛盾

if (!hasEnoughMaterial(...)) break;   // 只检查"总需求 vs 总可用"
// 逐槽 transferMaterialExact:前槽取走共享材料后,后槽可能失败

hasEnoughMaterial 按物品分组合并需求(neededCounts 累加)检查总可用,但 transferMaterialExact逐槽先背包后存储取料。多槽同种材料时,前几个槽取走材料后,后面的槽 transferMaterialExact 返回 0 —— 前槽已放入 grid 的部分不会回滚transferMaterialExact 的回滚只覆盖单槽内部)。结果产生"部分组"(如槽 0 放 8 个、槽 1 放 0 个),与注释"材料不足整组时放弃本轮(避免把剩余材料塞进前几个槽破坏均分)"矛盾。maxTransfer(Shift+点击)下尤其明显。建议:逐槽执行前按"当前槽实际缺口"检查,或失败时回滚整轮已放置的槽。

⚠️ 警告

  • StorageServerStub.craftingTransfer stonecutter 分支 — 删除 withStonecutterSelected 同步:旧代码在转移材料后按 JEI 传入产物匹配并选中切石机配方(withStonecutterSelected(selected)),新代码只写 withStonecutterInput。JEI 转移后配方选中状态不再同步,行为变化(若 stonecutterResult 仍由客户端传入但服务端不再使用,stonecutterResult 参数实际已无用处)。
  • placeCraftingResult 顺序调整:从"先放指针 carried → 再找背包槽"改为"先找背包槽 → 再放指针"。非 shift 路径行为变化:背包有同种未满堆叠时,产物优先合并进背包槽而非指针。需确认这是有意为之。
  • transferMaterialExact 回滚不完整giveBackToInventory 返回值(未放入的剩余数量)被忽略。虽然取料后背包槽位必然有空位(刚取走),但若同一轮前槽已放入 grid 的物品来自背包、后槽回滚时背包空间可能不足——理论上取出的位置能放回,但代码未处理异常路径。
  • 死代码transferMaterialtransferFromInventory 在 PR 后无任何调用者(旧调用点全部删除),建议删除或保留注释说明。
  • computeRequestedCountsaddAvailable 虚拟槽索引不稳定1000 + availableItemStacks.size() 在 put 过程中 size 变化,同一 map 内多个 addAvailable 调用产生的索引可能重叠。JEI 算法不依赖索引唯一性(按对象引用),实际无碍,但可读性差。

💡 建议

  • quickMoveUndo 命名混淆quickMoveUndo(slot, count) 语义是"把背包槽物品放回存储",与既有 undo()(服务端 undo 队列)易混淆,且参数 slot 是背包槽却被调用方(storage 场景)传存储索引。若此链路按 升级fabric loom #3/修复合成器存在的问题 #4 修复,需重新设计数据流。
  • hasEnoughMaterial 高估需求neededCounts 累加完整 requested 而非 requested - current.getCount()transferMaterialExact 实际只补差量),maxTransfer 多轮中第二轮起可能误判不足而过早 break(保守误判,不丢物品但转移不完整)。
  • stonecutter 分支传 new IntArrayList(0):虽然服务端不读,但建议直接传 IntList.of() 或让服务端忽略空列表(已忽略)。

🟢 看起来不错

  • GUNPOWER_BLOCKGUNPOWDER_BLOCK 重命名完整无残留:基线 5 处 Java 引用 + gunpowerBlock 方法全部覆盖,资源文件无 gunpower 字符串(已验证 596e440 基线)。
  • StorageMenu 槽 9~44 ↔ 玩家物品栏映射注释准确(已验证构造器)。
  • quickMoveFromStorage 服务端对 slots 有 MAX_SYNC_SLOTS 上限 + 去重校验,moveStorageStackToInventorygetInventorySpace 空间预检,安全性好。
  • clearCrafting 把合成格物品先放回背包、背包满再回存储,路径合理。
  • 客户端 JEI 分配用 RecipeTransferUtil.getRecipeTransferOperations(JEI 原生算法),并 try/catch 保护 + missingItems 检查,降级路径完整。
  • JEI 转移的"合成格内已有物品也算可转移材料"(availableCount + addAvailable 计入 crafting 内容)与服务端 clearCrafting 先清空的行为一致 ✅。

📋 声称验证表

声称 状态 对应文件
fixed #4672(滑动操作) ⚠️ StorageScreen.mouseDragged/queueQuickMove/flushQuickMoves — 存储槽滑动已支持,但 toggle 取消失效(#1/#2)、undo 链路失效(#3/#4
resolved #4674 ⚠️ StorageServerStub.craftingTransfer — 重构了转移逻辑,但 maxTransfer 多轮部分组问题(#5
fixed #4671 StorageJeiSupport.computeRequestedCounts + StorageServerStub — JEI 转移用 RecipeTransferUtil 原生分配算法,份数精确控制

结论: REQUEST_CHANGES — 滑动操作的 toggle 取消逻辑(#1/#2)与 undo 数据流(#3/#4)存在确定性功能缺陷,JEI 转移的多轮部分组问题(#5)与 requireCompleteSets 语义矛盾。建议修复后重新审查。标题已符合 <英文> <中文> 格式,无需修改。


由 Hermes Agent 审查

@Gugle2308

Copy link
Copy Markdown
Collaborator

getContents() 是 Lombok @Getter ✅。所有关键点已确认。最后快速验证 craftingTransfer 的 maxTransfer 多轮循环与 requestedCounts非 maxTransfer(单轮)时的行为:rounds = maxTransfer ? MAX_VALUE : 1。JEI 的 maxTransfer 参数对应「按住 shift 转移全部」?在 JEI 里 maxTransfer=true 表示"max transfer"按钮(转移尽可能多组)。单次点击(maxTransfer=false)转移 1 组。PR 中 computeRequestedCounts 计算的份数是一组的份数(每个槽放一组需要的数量)。服务端 rounds=1 时放一组;maxTransfer=true 时多轮直到材料不足。

但注意 transferMaterialExactrequestedCount 是「目标槽位总数量 = current.getCount() + requested」—— requested 来自 requestedCounts(每组份数)。所以单轮:每个槽从背包/存储取 requested 个。多轮:hasEnoughMaterial 预检整轮需求(含已有 current),transferMaterialExact 取差量。逻辑自洽。

但有个问题:hasEnoughMaterial 预检时用 requested(每组份数)累加 needed,但 transferMaterialExact 实际取 needed = requestedCount - current.getCount()。两处一致 ✅。

另一个问题:多轮循环中 grid 更新后,requestedCounts 不变(每组份数),但 current.getCount() 增长。第二轮 transferMaterialExact(target, inventory, wanted, current, current.getCount() + requested, player) —— 目标是 current + requested(每组再补一份),needed = requested。OK,每轮补一组 ✅。hasEnoughMaterial 每轮重新计算可用量(背包已减少)✅。

但**hasEnoughMaterial 没有考虑存储 view 的「已预留」**:多轮中从存储 extract 是即时扣减的(view.extract),所以下轮可用量减少 ✅。

关键正确性检查transferMaterialExact 从背包取料时直接 inventory.setItem,从存储 view.extract —— 若某槽中途材料不足(hasEnoughMaterial 预检通过但实际被其它槽先取走),transferMaterialExact 回滚自己取的部分。但其它槽已取的材料不回滚 —— 多轮循环中 anyPlaced=true 但部分槽回滚(返回 0),部分槽成功 —— 结果网格出现「部分槽有料、部分槽空」的不均分。预检 hasEnoughMaterial 目的是避免这个,但预检和实际取料之间没有原子性(单线程服务端 RPC,同轮内无并发,但多轮间背包/存储状态在每轮之间变化 —— 实际取料顺序:槽 0 先取,槽 1 后取,若槽 0 取走导致槽 1 不足,槽 1 回滚,槽 0 保留 → 不均分)。

不过这是 JEI 转移的固有竞态(原版 JEI 转移也非原子)。标记为 💡 建议即可。

现在整理最终审查。核心发现:

🔴 关键问题

  1. craftingTakeResult shift 分支不消耗输入 → 无限复制物品(物品复制漏洞)
  2. placeCraftingResult 返回值未处理 + player.containerMenu.getCarried() 在返回前未同步——shift 分支 return new InteractionResult(player.containerMenu.getCarried(), true),而 placeCraftingResult 可能返回 NONE(产物放不下)→ 无条件消耗……等等,shift 分支根本没消耗输入,所以 NONE 时也是「白拿不到产物但返回 true」。但主要问题是无限产出。

⚠️ 警告

  1. quickMoveUndo 语义疑似反了/无效(正常路径背包槽已空 → return false 无操作;部分移动时把剩余又插回存储)
  2. getStorageSlot 返回 -1(空存储位 + carried 非空)与背包槽 0 的 key 冲突(key=0)
  3. JEI 转移重写后 stonecutter 路径不再同步 stonecutterSelected(配方选中回归)——确认一下:PR 删除的代码里 stonecutter 分支原来设置 selected,新代码不设置。功能回归
  4. giveBackToInventory 背包满时静默丢弃剩余(回滚路径物品丢失)

💡 建议

  1. mezz.jei.common.transfer 是 JEI 内部 API(非公共),版本升级可能破坏
  2. computeRequestedCounts 用客户端 contents 缓存(可能不完整),材料充足但缓存过期时误判缺失
  3. VirtualSlotsuper(null, index, 0, 0) 传 null container —— 只用于 JEI 分配,OK
  4. stonecutter 单槽转移 requestedCounts = new IntArrayList(0) 空列表 —— 服务端 stonecutter 分支不用 requestedCounts,OK

再确认 stonecutter selected 回归:PR diff 中 stonecutter 分支的新代码确实没有设置 selected。旧代码(diff 中删除的 647-713 行)在转移后匹配 stonecutterResult 设置 selected。新代码(diff 684-714 行)只转移物品 + return true,不设置 selected。确认回归 ✅。

另外,shift 分支还有个问题:craftingTakeResult 的 javadoc 说「消耗输入并放到指针」,shift 分支语义是「放进背包/存储」,但没有更新 javadoc,也没有处理 PlaceResult.NONE(产物放不下时应不消耗输入、不返回 changed=true)。虽然不消耗输入意味着「白拿」不存在,但 PlaceResult.NONE 时返回 (carried, true) 会让客户端认为成功。

还有 recordQuickMoveMovedFromSelection 记录的是「移动前数量」,但实际 quickMoveUndo 的调用是在 flush 之后 —— 时序 OK,但 quickMoveUndo 服务端把当前背包物品插回存储……让我最后再想一遍这个逻辑是否合理:

场景:shift 拖拽选中背包槽 A(64 圆石)。mouseReleased:

  1. recordQuickMoveMovedFromSelection:记录 A 的 count=64
  2. flushQuickMoves → quickMoveToStorage(A) → 服务端 moveInventoryStackToStorage:insert 64 成功,背包 A 清空,recordUndo(undoingGroup=true 时合并到 undoGroup)
  3. 回调 applyQuickMoveMoved([A], true):quickMoveMovedBySlot 有 A→[64],调用 quickMoveUndo(A, 64)
  4. 服务端 quickMoveUndo:背包 A 空 → return false。无操作。

所以正常场景无操作。为什么作者要加这个 undo? 可能意图是:quickMoveToStorage 返回 changed=true 时,客户端想「撤回」?不对……

或者意图是错误恢复:如果 quickMoveToStorage 部分失败(存储满,只插了 30/64),返回 changed=true,背包 A 剩 34。客户端 undo 调用 quickMoveUndo(A, 64)amount = min(64, 34) = 34view.insert(圆石, 34) —— 此时存储满了(刚插入 30 就满了)→ insert 可能 0 → return true(无操作)。若存储没满(比如 moveInventoryStackToStorage 因其它原因只插 30),undo 把剩 34 插入 → 背包 A 清空,存储 +34。效果 = 把没插完的也插进去,而不是撤回!

这看起来是**「补齐」而非「撤销」。作者意图可能是:拖拽选中多个槽,服务端因上限只处理了一部分,客户端想确保「选中的都处理完」。但实现是「把剩余再插一次」,如果存储满了就无效。而且 recordQuickMoveMovedFromSelection 记录的是移动前**数量(= 期望移动的完整数量),配合 undo 的 min(count, stack.getCount()) 取「剩余量」再插 —— 嗯,如果移动前 A 有 64,移动后剩 34(部分移动),undo 取 min(64, 34)=34 再插 34 → 总共插了 30+34=64 = 移动前数量!所以这个「undo」实际是「补插到移动前数量」

等等,但 view.insert 会再次受存储容量限制…… 如果第一次只能插 30(存储满),第二次插 34 也会失败。如果第一次插 30 是因为其它原因(比如 moveInventoryStackToStorageview.insert(stack, stack.getCount()) 会插全部或按存储容量)……

moveInventoryStackToStorageview.insert(stack.copyWithCount(1), stack.getCount()) —— 一次尝试插全部。返回 inserted。所以部分插入只发生在存储容量不足。此时 undo 再插也会失败。所以这个 undo 机制在「存储满」场景无效,在其它场景(移动成功)无操作。基本是无效/冗余代码,且名字叫 undo 但语义是「补插」,容易误导。标 ⚠️

不过等等——有没有可能 quickMoveToStorage 的移动是「部分成功」而 undo 的目的是恢复界面一致性?不深究了,标记为「语义存疑,正常路径无操作」。

最终还有一个要确认的:applyQuickMoveMovedthis.quickMoveMovedBySlot.remove(slot) 的 key —— 对 quickMoveFromStorage 回调传入的是 storageSlots(存储索引 0N-1),而 quickMoveMovedBySlot 的 key 是背包槽(035)。存储索引可能恰好 == 某个背包槽索引(如存储槽 5 与背包槽 5)→ remove(5) 会误删背包槽 5 的记录,并用错误 count 调用 quickMoveUndo(5, count) → 把背包槽 5 的物品插回存储!这是真正的 bug!

场景:shift 拖拽同时选中背包槽 5(key 5)和存储槽 5(key = -1-5 = -6)。mouseReleased:recordQuickMoveMovedFromSelection 记录背包槽 5 的 count。flush:slots=[5](背包),storageSlots=[5](存储)。两个回调都调用 applyQuickMoveMoved

  • quickMoveToStorage 回调:applyQuickMoveMoved([5], moved) → remove(5) 命中背包槽 5 记录 → quickMoveUndo(5, count) → 背包槽 5 已空 → return false(无操作)✅(或部分移动时误插回)

  • quickMoveFromStorage 回调:applyQuickMoveMoved([5], moved) → remove(5) 再次命中(已被上面移除?不,quickMoveMovedBySlot.clear() 只在函数末尾……看代码:applyQuickMoveMovedthis.quickMoveMovedBySlot.remove(slot) 然后循环后 this.quickMoveMovedBySlot.clear()。两个回调异步,顺序不定:

    • 若 toStorage 回调先执行:remove(5) 取走记录,调用 undo(5, count);然后 clear()。fromStorage 回调执行:remove(5) → null → 不调用 undo ✅(存储槽 5 的移动没有 undo 记录,本来就该如此)
    • 若 fromStorage 回调先执行:remove(5) 命中背包槽 5 的记录!→ quickMoveUndo(5, count) 把背包槽 5(此时可能已被 toStorage 移空,或未)…… 然后 clear()。toStorage 回调:remove(5) → null → 无操作。

    问题:fromStorage 回调先执行时,用背包槽 5 的 count背包槽 5 调 quickMoveUndo —— 但这是错误的!背包槽 5 的物品移动方向是「背包→存储」(toStorage),而 fromStorage 移动的是「存储→背包」的存储槽 5。undo 调用 view.insert 把背包槽 5 物品插回存储 —— 如果 toStorage 尚未执行(背包槽 5 还有 64),undo 把 64 插回存储 → 然后 toStorage 执行 → 背包槽 5 已空 → 无操作。结果:背包槽 5 的物品没有进存储而是「被插回存储」—— 效果相同! 因为 toStorage 也是插进存储。

    等等,效果确实相同(都是把背包槽 5 物品插进存储),但 count 可能不同:recordQuickMoveMoved 记录 count=64(移动前),undo 插 64;toStorage 也插 64。如果 fromStorage 回调先执行 undo(5, 64) 插 64 → 存储 +64,背包清空;toStorage 再执行:背包槽 5 空 → continue → moved=false。总效果:64 进存储 ✅。如果 toStorage 先:插 64,背包空;fromStorage 回调 remove(5) → null(已被取走+clear)→ 无操作。总效果一样 ✅。

    那如果背包槽 5 只被部分移动(存储满)?toStorage 插 30,背包剩 34。fromStorage 回调先:undo(5, 64) → min(64, 34)=34 → 插 34 → 存储又满 → 插 0 或部分。结果不一致。但这是存储满的边缘 case。

    实际上这个「key 冲突」只在同时拖拽背包槽 N 和存储槽 N 时发生,且最终效果大体等价(都是背包→存储)。真正的风险是 undo 用错误 count 导致重复插入(存储满时部分)。低概率,标 ⚠️ 提醒。

好,分析完毕。整理审查意见。

另外注意 mouseReleasedflushQuickMoves() 之后 StorageClientStub.endUndoGroup(this.sourcePos) —— undoGroup 在服务端。而 quickMoveUndo 是独立 RPC(不在 undo 组内)。applyQuickMoveMoved 的 undo 调用发生在 flushQuickMoves 的异步回调中 —— 此时 endUndoGroup 可能已执行(endUndoGroup 是 fire-and-forget RPC)。undo 的 view.insert 不在 undo 组内,不会污染 undo 记录 ✅。

但注意 recordQuickMoveMoved 的记录时机:recordQuickMoveMovedFromSelection() 在 mouseReleased 同步读取 pendingQuickMoveSlots 和背包槽数量。但拖拽过程中(mouseDragged)queueQuickMove 只累积槽位,物品未移动。所以 mouseReleased 时记录的是移动前数量 ✅。

最后检查 craftingTakeResult shift 分支还有个细节:StorageServerStub.placeCraftingResult(player, target, result) 忽略返回值;player.containerMenu.getCarried() 在 placeCraftingResult 内部 setCarried 后调用 —— OK 拿到新值。但 PlaceResult.NONE 时 carried 未变 → 返回 (carried, true) 表示「成功」→ 客户端认为取到了产物。加上不消耗输入 → 点击结果槽无限免费产出(只要能放下)。确认 🔴。

写审查报告。所有关键点已确认。TransferOperation.craftingSlotId 是 slot.index(虚拟槽 index 2~10),craftingSlotId - 2 映射正确 ✅。现在整理最终审查。

核心确认(基于目标分支源码交叉验证):

  1. craftingTakeResult 新增 shift 分支调用 placeCraftingResult从未调用 consumeCraftingInput → 无限免费产出(物品复制漏洞)
  2. quickMoveUndo 服务端是 view.insert(背包→存储),正常路径(移动全部成功)背包槽已空 → 无操作;部分移动时把剩余再插回存储
  3. getStorageSlot 空存储位返回 -1 → key -1-(-1)=0 与背包槽 0 冲突
  4. stonecutter JEI 转移重写后不再设置 stonecutterSelected(原逻辑删除,功能回归)
  5. giveBackToInventory 回滚路径背包满时静默丢弃
  6. applyQuickMoveMoved 对 quickMoveFromStorage 回调传入存储槽索引,与 quickMoveMovedBySlot 的背包槽 key 可能撞车

代码审查摘要 — PR #4680

操作: opened
范围: 10 个文件 (10 Java, 0 新增, 0 删除) / 1081 行 diff
分支: storagefix/1.21/1.6 → dev/1.21/1.6
声称: fixed #4672(存储滑动操作)、resolved #4674(JEI 物品转移逻辑重建)+ GUNPOWER→GUNPOWDER 常量重命名

🔴 关键

  • StorageServerStub.craftingTakeResult shift 分支 — 无限免费产出(物品复制漏洞)
    PR 新增分支:

    if (shift) {
        StorageServerStub.placeCraftingResult(player, target, result);
        player.containerMenu.broadcastChanges();
        return new InteractionResult(player.containerMenu.getCarried(), true);
    }

    placeCraftingResult 只负责把产物放入指针/背包/存储,内部不消耗输入。对比非 shift 路径(consumeCraftingInput(target, crafting, stonecutter) 在 setCarried 之前调用),shift 分支完全跳过 consumeCraftingInput。按住 Shift 点击③/④结果槽可无限取产物而不消耗任何材料,等于无条件刷物品。同时该分支未处理 PlaceResult 返回值——NONE(产物放不下)时仍返回 changed=true。修复:调用 consumeCraftingInput 并按 PlaceResult 分类返回。

  • StorageServerStub.craftingTransfer stonecutter 路径 — 配方选中逻辑回归
    原实现(diff 删除部分)在转移材料后按 JEI 传入的 stonecutterResult 匹配切石机配方并写回 stonecutterSelected;新实现只更新 stonecutterInputstonecutterSelected 保持旧值。JEI 点击切石机配方「转移」后,① 槽有材料但结果槽仍显示旧配方/无配方,用户需手动重新点选。且新实现把「取第一个非空输入」改为「只取 inputs.getFirst()」并忽略 stonecutterResult 参数(参数保留但未使用)。

⚠️ 警告

  • StorageScreen.quickMoveUndo 调用链语义存疑(正常路径无操作)
    recordQuickMoveMovedFromSelection 记录的是移动前背包槽数量;quickMoveToStorage 成功后 applyQuickMoveMoved 调用 quickMoveUndo(slot, count),而服务端实现是 view.insert(把当前背包槽剩余再插回存储):

    • 移动全部成功(背包槽清空)→ stack.isEmpty()return false,无操作
    • 部分移动(存储满只插入一部分)→ 把剩余再插存储,但存储已满 → insert 返回 0 → 仍无操作
      该 undo 既不是「撤销」(应从存储 extract 退回背包,参考 undo() 方法),也没法「补插」。建议删除或改为真正的撤销语义;若意图是处理部分移动,应在服务端 quickMoveToStorage 内返回实际移动数量而非让客户端补一次 RPC。
  • StorageScreen.mouseDraggedgetStorageSlot 返回 -1 时 key 冲突
    getStorageSlot 在悬停空存储位且 carried 非空时返回 -1(非 null),key = -1 - (-1) = 0,与背包槽 0 的 key 相同。shift 拖拽划过空存储位会 quickMoveSlots.remove(0) 意外取消背包槽 0 的选中,并 storageQuickMoveSlots.remove(-1) 无意义操作。建议空存储位返回 null 或对 -1 单独编码。

  • StorageServerStub.giveBackToInventory — 回滚路径可能静默丢物品
    材料不足一组回滚时,若背包满(无同种堆叠空间且无空槽),循环结束后 toReturn 剩余部分被静默丢弃。虽然回滚前刚取料腾出槽位(概率低),但 giveBackToInventoryreturn 提前退出(只填一个空槽)也未检查 toReturn 是否清空。建议失败时把剩余插回存储或 drop。

  • StorageScreen.applyQuickMoveMoved — 存储槽索引与背包槽 key 撞车
    quickMoveFromStorage 回调传入的是存储槽索引(0N-1),与 quickMoveMovedBySlot背包槽 key(035)同空间。同时拖拽背包槽 N 与存储槽 N 时,remove(N) 可能误取背包槽记录并用错误 count 调用 quickMoveUndo。正常路径下两者方向都是背包→存储,最终效果大体等价,但存储满的部分移动场景会出现重复插入/计数错乱。建议用不同 key 空间(如存储槽 key 统一 +1000)。

💡 建议

  • mezz.jei.common.transfer 是 JEI 内部 API(非公共 API),跨 JEI 版本升级可能无声破坏。现有 RegistryUtil 也是 internal 但已稳定;RecipeTransferUtil.getRecipeTransferOperations 在 19.50 存在,但建议在注释/文档标注依赖版本并加 try-catch 兜底(已有 catch RuntimeException ✅)。
  • computeRequestedCounts 依赖客户端 contents 缓存:只包含已同步的显示槽(含折叠),缓存过期/未加载时可能误判「材料不足」返回 null(转移被禁用)或份数偏少。可考虑服务端计算或在材料不足时触发一次 re-sync 再重试。
  • stonecutter 单槽转移 requestedCounts = new IntArrayList(0) 空列表:服务端 stonecutter 分支不使用该参数,但 @CallableParam ORDER_STREAM_CODEC 会序列化空列表,OK。
  • transferMaterialExact 多轮循环非原子hasEnoughMaterial 预检与实际取料之间,前序槽取料可能使后序槽不足 → 后序回滚、前序保留,出现不均分。单线程 RPC 内可接受,与 JEI 原版转移一致。
  • VirtualSlotsuper(null, index, 0, 0):container 为 null,仅用于 JEI 分配算法(只读 getItem),安全。
  • craftingTakeResult javadoc 未更新(仍写「消耗输入并放到指针」,shift 语义是「放入背包/存储」)。

🟢 看起来不错

  • computeRequestedCounts 槽位映射正确craftingSlotId 是 slot.index(JEI TransferOperation record),虚拟槽 index=2+i,-2 还原 0~8,与 JEI craftingSlots.get(i) 按列表位置对齐的语义吻合。
  • 大数量存储拆分虚拟槽(按 maxStackSize 拆分)正确处理了存储格超过 64 的分配。
  • 服务端 quickMoveFromStorage/quickMoveToStorage 均有 MAX_SYNC_SLOTS 上限与 visited 去重moveStorageStackToInventoryslot >= view.size() 边界检查。
  • hasEnoughMaterial 按物品聚合预检 + transferMaterialExact 整组回滚的语义与 JEI requireCompleteSets 一致,均分逻辑清晰。
  • GUNPOWER→GUNPOWDER 重命名完整:目标分支 6 处引用全部覆盖(ItemTooltipManager/RegistrumBlockRecipeLoader/BlockTagLoader/ModBlocks/BuildingBlocks/BuildingBlocksSections),注册 ID gunpowder_block 未变,资源文件无需迁移。

📋 声称验证表

声称 状态 对应文件
修复 #4672 存储滑动操作 ✅ 部分 StorageScreen(storageQuickMoveSlots + quickMoveMovedBySlot 拖拽追踪)、StorageServerStub(quickMoveFromStorage/quickMoveUndo 新 RPC)
重建 #4674 JEI 物品转移 ⚠️ 有回归 StorageJeiSupport(computeRequestedCounts)、StorageServerStub(craftingTransfer 重写 + transferMaterialExact)—— 切石机 selected 回归 + shift 取结果不消耗输入
GUNPOWDER 重命名 6 个 Java 文件

结论: REQUEST_CHANGES — Shift 取结果不消耗输入是物品复制级漏洞,必须修复;切石机 JEI 转移丢失配方选中是明显功能回归;其余为边界/健壮性问题。修复后建议重点回归测试:shift 拖拽滑动(双向、部分移动、存储满)、JEI 转移(合成 9 格均分、切石机选中、maxTransfer 多轮)、shift 点击结果槽连续合成。

🧪 测试建议

被测目标 推荐测试场景 优先级
StorageServerStub.craftingTakeResult(shift=true) 输入消耗正确性:合成/切石机各取一次后材料 -1;背包满/指针异种时产物不丢失不复制 🔴
StorageServerStub.craftingTransfer stonecutter 转移后 stonecutterSelected 指向 JEI 产物对应配方 🔴
StorageScreen.mouseDragged 存储槽拖拽 空存储位 + carried 非空拖拽不取消背包槽 0;存储槽与背包槽同索引同时拖拽 🟡
StorageServerStub.quickMoveUndo 部分移动(存储满)后行为:无重复插入、无物品丢失 🟡
StorageJeiSupport.computeRequestedCounts 多槽同种材料均分;存储 >64 拆分;材料不足返回 null 🟡
giveBackToInventory 回滚 背包满 + 材料不足一组:物品不丢失 🟡

由 Hermes Agent 审查

@Gugle2308

Copy link
Copy Markdown
Collaborator

💾 Self-improvement review: Patched SKILL.md in skill 'anvilcraft-storage-system-review' (1 replacement).

1 similar comment
@Gugle2308

Copy link
Copy Markdown
Collaborator

💾 Self-improvement review: Patched SKILL.md in skill 'anvilcraft-storage-system-review' (1 replacement).

@Gugle2308

Copy link
Copy Markdown
Collaborator

💾 Self-improvement review: Patched SKILL.md in skill 'anvilcraft-storage-system-review' (1 replacement). · Patched references/pr4680-sliding-jei-transfer.md in skill 'anvilcraft-storage-system-review' (1 replacement).

@Gugle2308

Copy link
Copy Markdown
Collaborator

分析完成。整理审查结论。

代码审查摘要 — PR #4680

操作: synchronize
范围: 10 个文件(全部 Java)/ 1194 行 diff / 32 hunks
主题: 修复存储系统滑动(quick move)操作 + 重构 JEI 物品转移逻辑(修复 #4671#4672#4674

📋 声称验证表

声称 状态 对应文件
fixed #4672(仓储系列操作优化 bug) StorageScreen(storage 槽滑动队列 + 撤销记录)、StorageServerStub.quickMoveFromStorage/quickMoveUndo
resolved #4674(优化仓储 GUI 合成操作) StorageJeiSupport.computeRequestedCounts、craftingTransfer maxTransfer 多轮填充、clearCrafting 先清合成格
fixed #4671(板条箱合成 JEI 适配 bug) StorageJeiSupport 用 JEI 自身分配算法计算每格份数(computeRequestedCounts + VirtualSlot + addAvailable 拆堆)
附带:gunpowder_block 命名修正 ModBlocks/BlockTagLoader/BuildingBlocks*/ItemTooltipManager/RegistrumBlockRecipeLoader

🔴 关键

  • StorageServerStub.quickMoveUndo — getViewgetServerPlayer 之前调用,且对「当前仓储快照可能已失效」无防护getView(...) 内部 REMOTE_STORAGES.getOrDefault(playerId, ...)playerId 无记录时返回 Map.of()remote = nullremote.storageId() NPE。与同文件 quickMoveFromStorage/quickMoveToStorage 相同调用顺序——但前者先 getViewgetServerPlayer。审查时「点击右键 → 切换 openRemote 目标 → 拖拽快速移动 → 松开(recordQuickMoveMovedFromSelection 在 mouseReleased 时立即发送 undo RPC)」窗口内 playerId 的 REMOTE_STORAGES 记录被移除(远程终端关闭/切换)即触发 NPE,服务端异常会中断 flushQuickMoves 后续处理。且该 RPC 无 try/catch 包住quickMoveUndoview.insert 异常同样裸抛)。建议:① 先 getServerPlayergetView(与同文件其余 RPC 一致);② 给 quickMoveUndoview 加 null/空检查并 try/catch 吞掉失败(撤销失败可接受,不能让客户端滑动流程崩)。

  • StorageScreen.flushQuickMoves — undo 的 slot 用 -1 - storageSlot 编码,但服务端 quickMoveFromStorageIntList slots 与 undo 的 slot 语义不一致。客户端 queueQuickMove(key)(key = -1 - storageSlot)→ storageQuickMoveSlots 存的是 storageSlot(非负);flushQuickMovesquickMoveFromStorage(sourcePos, storageSlots)(服务端按 storage 索引提取,✅);但 recordQuickMoveMovedFromSelection 只遍历 pendingQuickMoveSlots(背包槽),storage 槽的 moved 记录从未写入 quickMoveMovedBySlot → 即使成功也永远没有 storage 侧 undo(鼠标从 storage 拖到背包的滑动完全无法撤销)。同时 undo 的 quickMoveUndo(sourcePos, slot, count) 服务端 slot >= Inventory.INVENTORY_SIZE 直接 return false——若传 storage 索引(可 ≥36)也会被拒。建议:quickMoveFromStorage 的 undo 应记录 storage 槽 + 服务端按 storage 索引退回。

  • StorageJeiSupport.computeRequestedCounts — addAvailable 的虚拟槽索引可能冲突new VirtualSlot(1000 + availableItemStacks.size(), ...)availableItemStacks 先塞了玩家背包槽 9~44(≤36 个),再加 storage 缓存。当存储条目数使 size ≥ 1000 时索引撞车(同 index 的 Slot 被覆盖),JEI 分配可能漏算材料。虽然 1000 个不同种物品的存储站很罕见,但这是客户端可触发的真实边界(大型存储站),建议用原子计数器(如 VirtualSlot(-1 - counter++) 负索引)或基于 items 的哈希。

⚠️ 警告

  • StorageServerStub.craftingTransfer — rounds = maxTransfer ? Integer.MAX_VALUE : 1 的终止性:每轮 transferMaterialExact 从背包/存储取 requested 份(needed > 0 才取),anyPlaced 有推进才继续。但「预检 anySlot + hasEnoughMaterial」与「实际转移」之间存在竞态:hasEnoughMaterial 汇总背包+存储可用量 ≥ 需求,但 transferMaterialExact 按槽串行取料时前一槽可能已把同种材料取走 → 后一槽 moved < needed 回滚返回 0 → anyPlaced 仍为 true(只要任一槽成功)→ 下一轮继续。多槽同种材料(如 9 格全要圆石)时,若总材料只够 1 组,最多轮询到 hasEnoughMaterial 返回 false 才停——最坏情况每轮只成功 1 槽、9 轮后终止,不会死循环,但会多轮空转(可到 O(9×8) 次 transferMaterialExact 尝试),且客户端 JEI 检查阶段已用同一算法算出 requestedCounts,服务端重算时材料可能已被其它操作拿走。建议:hasEnoughMaterial 失败即 break 已实现;但可进一步把「预检失败/无推进」提前到 !anySlot 前(现已有),并把 rounds 上限改为「材料组数」而非无限(如 maxTransfer 时每轮至少消耗一组,最多 材料总量/组需求量+1 轮)。

  • StorageServerStub.clearCrafting — 清空合成格后旧输入物品放回背包/存储,但 stonecutterSelected 未重置clearCrafting 只清 stonecutterInput/craftingInput,保留 stonecutterSelected。若旧输入与新输入不同,stonecutterSelected 指向旧配方索引——后续 craftingTakeResultassembleCraftingResultstonecutterSelected 直接索引 stonecutterRecipes(服务端读 player.level().getRecipeManager().getRecipesFor(...) 列表),索引越界或指向错误配方 → 取到错误产物/异常。JEI 转移路径(stonecutter=true)在 craftingTransfer没有再设置 selected(旧代码在转移时会按 stonecutterResult 匹配选中配方,本次重构删除了该逻辑)。建议:stonecutter 转移时根据 stonecutterResult 重新匹配选中配方,或清空时重置 selected=0。

  • StorageServerStub.quickMoveUndo — 多物品撤销的 count 语义recordQuickMoveMovedFromSelection 在 mouseReleased 时对 pendingQuickMoveSlots(本次拖拽选中槽)记录「当前背包槽内数量」,但 flushQuickMoves 的异步回调 applyQuickMoveMovedwhenCompleteAsyncremove(slot) 并逐 count 发 quickMoveUndo。若同一次拖拽中同一背包槽被多次选中/取消(queueQuickMove 的 toggle 逻辑),quickMoveMovedBySlot 会累积多个 count,undo 时逐个 view.insert(stack.copyWithCount(1), amount)——同一槽位多次 undo 会把同一个物品重复插回存储(每次 insert 从同一槽取物,但 stack.shrink(extracted) 只减一次)。服务端 quickMoveUndo 每次独立 getView,第一次 undo 已把该槽物品移走,第二次 undo 时 stack.isEmpty() return false——但客户端不知道,且 undo 的 count 记录是在移动后(非移动前)快照的,count 可能是「移动后数量」而非「实际移走数量」。建议:undo 记录改为在移动前快照每个槽的原始数量,undo 时按「移走数量」精确退回;或服务端 undo 按「槽内当前物品种类匹配」校验。

  • StorageJeiSupport.computeRequestedCounts — 只对 crafting 合成(stonecutter=false)计算 requestedCounts;stonecutter=true 路径传 new IntArrayList(0)。服务端 stonecutter 分支用 inputs.getFirst()maxStack = wanted.getMaxStackSize() 整堆转移(不限制 64),若 JEI 配方输入是堆叠上限 > 64 的 modded 物品(如某些模组 128 堆叠),客户端 availableCount 与实际转移数量不一致,且 craftingTransfer 的 stonecutter 分支不再校验材料是否足够(旧代码 transferMaterial 会限制)——材料不足时静默返回 false。建议:stonecutter 分支也传 requestedCounts(至少传 [wanted.getCount()])并保留材料不足的失败语义。

💡 建议

  • StorageScreen.quickMoveMovedBySlot — 用 Int2ObjectMap<IntList> 记录 undo countrecordQuickMoveMoved 在每次 queueQuickMove(拖拽经过槽)时记录当前数量,但 queueQuickMove 的 toggle 分支(!quickMoveSlots.add(key) → remove)没有从 quickMoveMovedBySlot 移除记录——toggle 取消选中后仍会保留该槽的 undo 记录,撤销时可能退回「未被移动」的物品。建议 toggle 时同步移除记录。
  • ItemTooltipManager/ModBlocks 的 gunpowder 重命名GUNPOWER_BLOCKGUNPOWDER_BLOCK 全仓无残留(已 grep 验证),但 ItemTooltipManager.NORMALModBlocks.GUNPOWDER_BLOCK.asItem() 的 tooltip 文本注册后,ItemTooltipLang 会用 getTranslationKey(item) 生成 tooltip.anvilcraft.item.gunpowder_block——与生成资源 en_us.json 中已有 key 一致(✅ 已确认 tooltip.anvilcraft.item.gunpowder_block 存在于 en_us.json:2014)。但注意:NORMAL 注册的字符串(java 端)与 lang 文件(生成资源)是两条独立路径,改 java 端后若未重新 runData 生成,en_us.json 的 tooltip 不会更新——本 PR 未包含 lang 文件 diff,需要确认 runData 已跑(生成资源里 gunpowder_block.json 等已存在,说明 datagen 产物与代码同步,✅)。
  • craftingTransfer 的 inputs 语义变化:stonecutter 分支从「inputs 第一个非空物品」改为「inputs.getFirst()」,若 JEI 传入空列表会 NPE——!inputs.isEmpty() && !inputs.getFirst().isEmpty() 已防护 ✅,但 collectInputs 对 stonecutter 配方返回的 inputs 若第一个就是空(如某些 modded 配方),行为从「找第一个非空」退化为「失败」。建议保留旧「找第一个非空」语义。

🟢 看起来不错

  • quickMoveFromStorageIntOpenHashSet visited 去重 + MAX_SYNC_SLOTS 上限防护 ✅
  • transferMaterialExact 的「材料不足一组回滚」实现正确(背包部分 giveBackToInventory、存储部分 view.insert)✅
  • clearCraftingnCopies(9, EMPTY)withCraftingInput 正常(CraftingStorage record 不变量由 decodenormalizeGrid 保障,但 withCraftingInput 不 normalize——本 PR 所有调用点都传 9 格列表,无实 bug,见 §6 技能注记)
  • craftingTakeAll 的终止性三件套(不消耗型配方 break、placeCraftingResult NONE 截断、CRAFTING_TAKE_ALL_CHUNK 上限)在 PR head 26c5260dd 上完整 ✅

🧪 测试建议

被测目标 推荐测试场景 优先级
quickMoveFromStorage + quickMoveUndo 拖拽 storage 槽 → 松开右键(undo RPC 触发)→ 目标切换(REMOTE_STORAGES 清空)→ 确认无 NPE;多槽拖拽 undo 幂等性 🔴
craftingTransfer(stonecutter) 旧输入与新输入不同时 selected 索引是否正确;inputs 空/首元素空 🔴
craftingTransfer(crafting + maxTransfer) 多槽同种材料 + 材料只够 1 组 → 确认不多轮空转、不回滚已放槽 🟡
computeRequestedCounts 存储条目 ≥ 1000 时的虚拟槽索引冲突;stack size > 64 的 modded 物品 🟡
clearCrafting 背包满 + 存储满时旧输入物品的去向(不回滚、不丢失) 🟡

结论: REQUEST_CHANGES — 核心滑动修复(#4672/#4674)与 JEI 转移重构(#4671)方向正确、实现完整,但 quickMoveUndo 的 NPE 风险(getView 先于 getServerPlayer + 无异常防护)和 storage 槽滑动无法撤销是本次改动的核心路径缺陷,需修复后合并。建议同时确认 stonecutter 转移的配方选中逻辑(重构删除后未恢复)。


由 Hermes Agent 审查

@Gugle2308

Copy link
Copy Markdown
Collaborator

💾 Self-improvement review: Patched references/pr4680-sliding-jei-transfer.md in skill 'anvilcraft-storage-system-review' (1 replacement).

@Gugle2308

Copy link
Copy Markdown
Collaborator

I have everything needed for a thorough review. Compiling the final report now.

代码审查摘要 — PR #4680

操作: synchronize(storagefix/1.21/1.6dev/1.21/1.6,open 状态)
范围: 10 个文件(10 Java,0 新增,0 删除)/ 1194 行 diff,32 hunks
声称: fixed #4672(仓储操作优化 bug)、resolved #4674(GUI 合成操作优化)、fixed #4671(板条箱合成 JEI 适配 bug)


📋 声称验证表

声称 状态 对应文件
修复仓储滑动操作(#4672/#4674 StorageScreen.java(Shift+拖拽 存储↔背包 双向 + 快速移动撤销)、StorageClientStub/ServerStubquickMoveFromStorage/quickMoveUndo
重构 JEI 物品转移逻辑(#4671 StorageJeiSupport.java(JEI 分配算法 computeRequestedCounts、虚拟槽、转移前清空合成格)、StorageServerStub.craftingTransfer(按份数扣料 + 多轮)
Shift 取合成结果连续合成 craftingTakeResult 新增 shift 参数 → placeCraftingResult
附带:GUNPOWER→GUNPOWDER 拼写修正 ItemTooltipManager/RegistrumBlockRecipeLoader/BlockTagLoader/ModBlocks/BuildingBlocks(Sections),全仓无 GUNPOWER 残留

🔴 关键

1. StorageScreen.flushQuickMoves()quickMoveMovedBySlot.clear() 在 RPC 回调前清空,撤销记录必丢(且每次拖拽都发生)
mouseReleased 中顺序为:recordQuickMoveMovedFromSelection()(填充 map)→ flushQuickMoves()首行就 clear())→ 异步 quickMoveToStorage 回调里 applyQuickMoveMoved()(读 map)。map 在 RPC 返回前已被清空,applyQuickMoveMovedthis.quickMoveMovedBySlot.isEmpty() 恒为 true → 提前 return,quickMoveUndo 的"移动前数量回填"从未生效。后果:Shift+左键拖拽背包→存储时,服务端撤销栈记录的是移动后背包槽余量;此后按 Z 撤销,undo 会把这批物品再塞回背包,与该槽残留物品叠加 → 物品复制。这不是"撤销不好用",是复制物品漏洞。另外 applyQuickMoveMoved 末尾的 clear()quickMoveUndo 的"移动前记录"语义互相矛盾(既想用移动前数量做快照,又在回调里清掉)。

修复方向:把 map 的清理延后到 applyQuickMoveMoved 消费之后;或在 recordQuickMoveMovedFromSelection 时直接把快照复制到回调闭包内,不要在 flushQuickMoves 开头 clear。此问题在两种操作(→存储 / ←存储)里都触发,属于本次滑动操作修复的核心回归面。

2. StorageScreen.mouseDragged 存储槽 shift-拖拽 — toggle 分支的 pendingQuickMoveSlots.remove(storageSlot) 误伤正索引
存储槽 key 为 -1 - storageSlot(负数),与背包槽正索引 0..35 天然不冲突;但 toggle 分支里 this.pendingQuickMoveSlots.remove(storageSlot) 用的是正数 storageSlot。若用户之前拖过同索引的背包槽(pendingQuickMoveSlots 里已有正数 storageSlot),toggle 取消存储槽时会顺带把那个背包槽的待移动记录删掉,导致背包槽落空。虽然 getStorageSlot 返回的 orderIndex 通常 ≥ VISIBLE_STORAGE_SLOTS(9+),与背包 0..35 有重叠区间,理论可触发。建议 toggle 时只动 quickMoveSlots/storageQuickMoveSlots,不要碰 pendingQuickMoveSlots

3. craftingTakeResult shift 分支改变产物放置优先级 — placeCraftingResult 先背包后指针
非 shift 取结果仍"指针优先",但 shift(连续合成)走 placeCraftingResult先塞背包,背包满才进指针,最后才进存储。原实现是"指针优先"。这在背包有空位时会把结果从指针抢走(玩家按住 shift 本想攒在指针上,结果全进背包)。行为变化本身可接受(与原版 Shift+点击一致),但 craftingTakeResult 的 shift 分支返回 player.containerMenu.getCarried() 作为新指针,客户端 carried 同步正确;不过 placeCraftingResult 在"全部放入存储但插入 == 结果数"时返回 FULLcraftingTakeResult 不检查 PlaceResult 类别,changed=true 恒成立,loadCrafting(false) 会无谓刷新。低风险,建议确认交互预期。


⚠️ 警告

4. StorageServerStub.craftingTransfer ② 分支 rounds = Integer.MAX_VALUE(maxTransfer=true 时)
maxTransfer(JEI "+"按钮)下循环直到 !anySlot / !hasEnoughMaterial / !anyPlaced 才 break。若 requestedCounts 全为 0(理论不会,客户端 computeRequestedCounts 返回 null 时已提前拦截),anySlot 恒 false,第一轮即 break,不会死循环 ✅。但每次 transfer 前 clearCrafting先把合成格清空再放hasEnoughMaterial 统计"背包+存储"时未计入合成格内已有物品,而客户端 availableCount 计入了——若配方材料部分已在合成格,客户端认为够、服务端 hasEnoughMaterial 可能 false → 转移静默失败(返回 false 不报错,JEI 无提示)。建议服务端 hasEnoughMaterial 与客户端口径一致(把 grid 内同种物品计入可用量),或客户端 computeRequestedCounts 与服务端都基于"清空后"口径。

5. quickMoveFromStorage 的"全量移动"语义 — 一次拖拽只移动一个 maxStackSize 的份量
moveStorageStackToInventorygetInventorySpace 计算可放量,若背包槽剩余空间 < 存储槽总量,则只移一部分。配合 #1 的撤销快照问题,部分移动 + 撤销叠加会放大复制风险。建议优先修复 #1

6. quickMoveUndo 服务端 view.insert 无日志/校验
view.insert(stack.copyWithCount(1), amount) 若存储满则 extracted=0 返回 true(假装成功),物品留在背包。本身安全,但配合 #1 的坏快照会成为复制来源之一。修复 #1 后此路径可加 inserted == amount 校验。

7. StorageJeiSupport.VirtualSlot extends Slotsuper(null, index, 0, 0)
JEI getRecipeTransferOperations 只调用 getItem()/index(本 PR 场景已验证),但 Slot 构造器传 null container 是脆弱用法:若 JEI 内部某版本访问 slot.container 会 NPE。addAvailable1000 + size 作为虚拟槽 index,与 crafting slots(2..10)不冲突 ✅。风险较低,但建议注释说明或改用轻量接口。


💡 建议

8. recordQuickMoveMoved 记录的是"移动前"背包数量,但 quickMoveUndoview.insert 语义是"回填存储" — 两者方向相反。快速移动撤销的真正职责是取消存储侧移动(服务端已有 recordUndo/undoRecords 完整撤销栈 + endUndoGroup),客户端这套 quickMoveMovedBySlot 快照机制似乎是为了"把移进背包的又移回存储"——但服务端 quickMoveToStoragerecordUndoundo() 会精确回滚。建议确认客户端这套快照是否冗余,若服务端撤销栈已覆盖,直接删除 quickMoveMovedBySlot 机制可同时消除 #1/#6

9. ItemTooltipManager 的 GUNPOWDER 修复只改了 Java 侧NORMAL.put(...) 的 key 是 asItem()(注册名 gunpowder_block),与 lang 文件 block.anvilcraft.gunpowder_block 一致 ✅(旧 GUNPOWER_BLOCKasItem() 返回注册名也是 gunpowder_block,所以本次只是 Java 字段名修正,无资源引用破坏)。已确认 pr4680 全仓 gunpower|GUNPOWER 零残留 ✅。

10. BlockTagLoader 等 4 处 GUNPOWER 引用同步改名 — 与 ModBlocks.GUNPOWDER_BLOCK 字段重命名一致,gunpowder_block 注册名未变,存档/配方/标签兼容 ✅。

11. StorageScreen.getCrafting() 用 Lombok @Getter(diff 中手写 getter,PR 分支已改为 @Getter)— 与仓库风格一致 ✅。


🟢 看起来不错

  • JEI 转移重构思路清晰computeRequestedCounts 用 JEI 自己的 RecipeTransferUtil 分配算法算出每格份数,服务端严格按份数扣料(transferMaterialExact + 不足整组回滚),多槽同种材料能均分,requireCompleteSets 语义正确。
  • 服务端 RPC 参数校验完整quickMoveFromStorage/quickMoveUndo/craftingTransfer 均有 slots.size() > MAX_SYNC_SLOTS 拦截 + StorageAccessValidator
  • clearCrafting + returnToInventoryOrStorage 正确处理"转移前清空合成格",且 giveBackToInventory 有剩余量返回,放不下进存储,无物品丢失。
  • craftingTransfer 循环终止条件完备(无死循环风险),CRAFTING_TAKE_ALL_CHUNK=64 分块 + 客户端 takeAllChunk 递归有 interactionRequest 竞态保护。
  • shift 取结果路径正确调用 placeCraftingResultbroadcastChanges,客户端 carried 同步正确。
  • GUNPOWDER 重命名干净彻底,全仓无旧名残留。

结论: REQUEST_CHANGES

PR 核心方向正确、JEI 重构质量高,但 #1quickMoveMovedBySlot 在 RPC 回调前被清空)会导致 shift+拖拽快速移动的撤销快照永远丢失,并与服务端撤销栈叠加造成物品复制——这是本次修复的核心交互路径上的严重缺陷,必须修复后再合并。#2(toggle 误删正索引)和 #4hasEnoughMaterial 与服务端口径不一致)建议一并处理。修复后此 PR 可合并。

🧪 测试建议

被测目标 推荐测试场景 优先级
Shift+左键拖拽 背包→存储,再按 Z 撤销 多槽混拖(含同种物品多槽)、存储满/背包满、部分移动;重点验证撤销后无物品复制 🔴
Shift+拖拽 存储→背包 部分空间不足、存储槽被 toggle 取消、撤销后存储数量正确 🔴
craftingTransfer maxTransfer(JEI "+") 材料恰好一组/多组/不足一组;合成格已有部分材料(#4 口径) 🟡
craftingTakeResult shift 连续合成 背包满/指针异种/存储满(PlaceResult.PARTIAL 丢物品截断路径)、不消耗型输入配方(防死循环) 🟡
JEI 转移多槽同种材料 均分正确性、computeRequestedCountshasEnoughMaterial 结果一致 🟡
GUNPOWDER 重命名 编译通过 + 工具提示/配方/标签数据生成一致 🟢

由 Hermes Agent 审查


补充说明(PR 标题): 标题已同时含英文与中文,语义准确,无需修改。gh 认证 token 当前无效(gh auth status 失败),未执行任何写操作。

@Gugle2308

Copy link
Copy Markdown
Collaborator

💾 Self-improvement review: Patched SKILL.md in skill 'github-webhook-pr-review' (1 replacement). · Patched references/pr4680-sliding-jei-transfer.md in skill 'anvilcraft-storage-system-review' (1 replacement).

@WhereisFff
WhereisFff merged commit bc76213 into Anvil-Dev:dev/1.21/1.6 Sep 1, 2026
2 checks passed
@Gugle2308

Copy link
Copy Markdown
Collaborator

PR #4680 状态为 closed(且已 merged),根据 webhook 审查约束第 1 条:操作是 "closed" 时 stop here,不发布评论

本次不输出审查意见,不调用任何 gh 评论/发表命令。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] 优化仓储系列GUI内合成操作 [Bug] 仓储系列操作优化bug [Bug] 板条箱合成功能内JEI适配bug

3 participants