Skip to content

Support repeated projection of blueprints and improve runtime status and entity restoration 支持蓝图重复投影并完善运行状态与实体恢复 - #4971

Merged
Gu-ZT merged 3 commits into
Anvil-Dev:dev/1.21/1.6from
WhereisFff:dev/1.21/fix6
Sep 21, 2026
Merged

Gu-ZT merged 3 commits into
Anvil-Dev:dev/1.21/1.6from
WhereisFff:dev/1.21/fix6

Conversation

@WhereisFff

@WhereisFff WhereisFff commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

- 支持拖拽平铺蓝图投影、松开锁定并再次右键批量粘贴,统一预览与放置范围限制
- 完善选区距离锁定、方向调整和尺寸显示,同步更新操作提示与中英文翻译
- 统一结构扫描快照采集,保存并恢复方块运行进度、计划刻与时间基准,兼容 Litematica 计划刻导入
- 完善建造后的红石、流体与邻居更新,避免恢复结构时误触发侦测器
- 扩展生物、掉落物、投射物、移动方块及模组实体的蓝图建造,恢复运动状态、乘骑关系和实体引用
- 支持树脂封存生物供料,补全实体装备、库存、流体及特殊方块材料需求
- 增加磁铁、铁砧锤与点火工具需求校验,并显示不支持建造的具体对象
- 将实体纳入建造撤销,仅撤销状态未变化且允许修改的建造组
- 蓝图导出遇到同名文件时自动添加序号,并反馈实际导出文件名
Copilot AI lite review requested due to automatic review settings September 21, 2026 01:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Gugle2308

Copy link
Copy Markdown
Collaborator

⚠️ Dangerous command requires approval:

curl -s "https://api.github.com/repos/Anvil-Dev/AnvilCraft/pulls/4971" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print('changed_files',d['changed_files'])
print('additions',d['additions'],...

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/pulls/4971 — or: vet https://api.github.com/repos/Anvil-Dev/AnvilCraft/pulls/4971 (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.

@Gugle2308

Copy link
Copy Markdown
Collaborator

⚠️ Dangerous command requires approval:

cd /tmp; cat > extract.sh <<'EOF'
#!/bin/bash
# usage: extract.sh <file-substring> <diff> 
F="$1"; D="${2:-/tmp/pr4971.diff}"
H="${D}.headers"
grep -n "^diff --git" "$D" > "$H"
START=$(grep -n "^diff ...

Reason: shell execution via heredoc

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

I have everything I need. Writing the review now.

代码审查摘要 — PR #4971

操作: opened(dev/1.21/fix6dev/1.21/1.6,base 8aba6d835f
范围: 41 个文件(39 Java,9 新增,0 删除)/ 3307 行 diff,146 hunks(+1746 / -305
版本线: 1.21.x 常规功能 PR(非 26.1 迁移),跳过 API 迁移健康度检查

✅ 已核对的编译面(无问题)

用官方 1.21.1 client_mappings + 混淆客户端字节码核对:

引用 结论
AABB.of(BoundingBox)(BuildingRodRenderer) ✅ 存在(AABB.a(BoundingBox)
Level.neighborShapeChanged(Direction, BlockState, BlockPos, BlockPos, int, int)(BlueprintLevelMixin 注入目标) ✅ 签名与参数顺序完全对应
PistonMovingBlockEntity 持久化键 blockState/facing/progress/extending/source ✅ 全部存在,BlueprintRuntimeData/transform 的键名正确(字段名是 direction,NBT 键确是 facing
isProcessing()/getPhaseRemainingTicks()(BuildingCommit.activate) ✅ 目标分支已存在
StructureScannerFiles.handle 异常契约 ✅ 已捕获 IllegalArgumentExceptionflatten 抛「Too many blueprint passengers」不会炸服务端
en_us / en_ud 同步 ✅ 两侧 6 个键改动一致

📋 声称验证表

声称 状态 对应文件
拖拽平铺投影 / 松开锁定 / 右键批量粘贴 / 统一范围限制 BlueprintPlacement.tileBuildingRodService.blueprintsBuildingRodPacket.PLACE_BLUEPRINTSBuildingRodClient.blueprintPlacements
选区距离锁定、方向调整、尺寸显示、中英翻译 BuildingRodClient.applyControl/selectionBoundsBuildingRodLang、两个 lang json
统一快照采集 + 运行进度/计划刻/时间基准 + Litematica 计划刻 BlueprintCaptureBlueprintTicksBlueprintRuntimeDataLitematicaImporter
建造后红石/流体/邻居更新,避免误触发侦测器 BuildingCommit.activate/activatePlacedBlueprintLevelMixin.neighborShapeChanged 守卫
生物/掉落物/投射物/移动方块/模组实体,运动状态、乘骑、实体引用 ✅(见 🔴1) DynamicBuildingEntitiesBlueprintEntitiesisTransient 放宽
树脂封存生物供料,装备/库存/流体/特殊方块材料 ✅(提示文案见 💡8) BuildingMaterials.reserveCreatureEntityBuildAdapters.insertContents
磁铁/铁砧锤/点火工具校验 + 显示不支持对象 requiredTool/requiresHammer/ignitionsunsupported_type
实体纳入建造撤销,仅撤销未变化的建造组 BuildingRodUndo.recordEntitySavedEntity.matches
导出同名加序号 + 反馈真实文件名 ✅(见 💡9) StructureBlueprintFiles.writeStructureScannerFilesBlueprintClientFiles

🔴 关键

  • EntityBuildAdapters.isTransient 移除 Projectile 过滤后,任何“无适配器”的实体会中止整次建造**(不是跳过)**
    isTransient(EntityType) 删掉了 Projectile.class.isAssignableFrom(base)ItemEntity(前者有意为“支持投射物”),但 BuildingRodService.planBlueprintBuildingRodService.java:589-598)在找不到适配器时是 unsupported(...); return false;blueprints() 随即 return——整份蓝图(含全部平铺副本)一块都不放,只留一条客户端提示。
    箭/三叉戟靠 NBT 里的 item 键命中匹配器 ✅,但凋灵之首、末影龙火球、风弹、烟花火箭、羊驼唾沫、拴绳结等既不是 Mob/FallingBlockEntity,NBT 也没有 item/Health 键 → 旧版作为 transient 被静默忽略,现在会让整份蓝图无法建造。建造杆蓝图里混进一发风弹/烟花就会全盘失败,体验上很像 bug。
    建议:把 adapter == nullplan.unsupported() 改成 continue(跳过该实体),在结束时用一条消息汇总被跳过的对象;实在要中止,至少区分“方块不可建造”(中止)与“个别实体不可建造”(跳过)。

⚠️ 警告

  • BuildingEntityTransform.sanitize 由白名单改为「整份 NBT 去掉 UUID/Passengers」BuildingEntityTransform.java:120-140
    旧实现只放行 id/Pos/Rotation/CustomName + 各实体类型白名单字段,是一道明确的加固措施;现在蓝图文件里的实体数据可以整体注入世界(TagsInvulnerableNoGravityAttributes、超上限 HealthActiveEffects…)。StructureScannerFiles 的 IMPORT 分支允许客户端上传任意 .nbt/.litematic,所以“文件内容不可信”这条前提是成立的(op-only 实体与 FallingBlockEntity.TileEntityDatarequiresOperator 闸门做得不错,但它们只挡了方块实体载荷,没挡实体自身字段)。
    建议保留白名单思路,把 Motion/Health/HandItems/ArmorItems/ArmorItems/Inventory/Brain(已有 relocateMemories 处理)本次确实需要恢复的键显式加进去,而不是整体放开。另外 sanitize(Entity entity, …)entity 参数现在已无引用,可一并清理。

  • BlueprintTicks.capture 把接口强转成具体实现BlueprintTicks.java:81,86
    LevelChunk.getBlockTicks() 的声明返回类型是 TickContainerAccess<Block>(已用 mappings 核实),这里直接 (LevelChunkTicks<Block>) 向下转型。任何包装/替换 tick 容器的实现(部分性能类模组会这么做)都会在导出蓝图时抛 ClassCastException,而异常会一路冒到 StructureScannerFiles.handle 只留一条 warn。
    建议:if (chunk.getBlockTicks() instanceof LevelChunkTicks<Block> ticks) { … } 加守卫(拿不到就当作“无计划刻”,退化为旧行为),流体侧同理。

  • BlueprintRuntimeData.take 的调用时机使多处按类型清洗变成死代码,并让“设置值”绕过钳制
    take()BlueprintBlockConfiguration.take最前面执行(BlueprintBlockConfiguration.java:57),而 FIELDS 里包含 Cooldown / cd / Rolling / Faces / PreviousFaces / Output / RollStart / phase / progress / currentPlacementIndex / Powered / PoweredBefore …(对所有方块实体生效),随后 result.merge(runtime) 又把这些原值盖回结果。因此:

    • integer(source, result, "Cooldown", 0, 3)(ItemCollector/ExpCollector)、integer(..., "Cooldown") 的 BaseChute、smartPlacer(... "currentPlacementIndex","phase","progress")、红石骰子的 remove(source, "Rolling", …) 全部读不到键 → 钳制/清洗失效;
    • 结果里这些值由 runtime 原样注入(例如超范围的 Cooldown 会被直接还原),而「库存由材料系统供应、设置才走校验」的设计意图被削弱。
      建议:把 take 拆成“先按类型收集 settings,再取 runtime”,或在 merge(runtime) 时对需要钳制的键单独处理。
  • 服务端导出改为读取实时世界(BlueprintCapture),与客户端预览路径分叉
    StructureSaveUtil.buildSnapshotServerLevel 上直接 BlueprintCapture.capture(serverLevel, getScanBounds())scannedBlocks 参数被完全忽略;而客户端 StructureScannerScreen / DiskDisplaySupport 仍走旧的缓存路径(getLevel() 非 ServerLevel)。后果:

    1. 导出结果 = 当前世界状态,不再是“扫描时”的缓存结果;
    2. BlueprintTicks.capture 对范围内每个区块调用 level.getChunkAt(...),会**强制同步加载(必要时生成)**扫描区域内的区块——旧路径只读缓存,不触chunk 加载。扫描器区域一般不大,风险有限,但建议确认 rangeX/Y/Z 上限下的最坏区块数。
    3. 客户端预览与最终文件内容(计划刻、实体、方块实体数据)会不一致。
      顺带确认:新路径对 ScannerDiskNormalizer 固定传 Direction.NORTH, false 是正确的——因为捕获已是世界坐标系(旧路径是 世界→预览帧→normalize(facing) 两次旋转相消),这点我逐分支核对过,不是 bug。

💡 建议

  • DynamicBuildingEntities.spawnsliding.setMoveDirection(sliding.getMoveDirection()) 是空操作DynamicBuildingEntities.java:162-164)。看着像原本想恢复/重算方向或触发同步;如果只是残留,请删掉。
  • BuildingMaterials.missing() 会改状态reserveTool/reserveHammer 会置 retainedToolreserveIgnitions/reserveCreature 会递增 reserved。目前三个调用点都传 new BuildingMaterials(player) 才侥幸安全,一旦有人在“已预留”的实例上调用就会重复计数。建议拆出只读的 missingReport()
  • 无刷怪蛋生物的材料提示会误导missing()SpawnEggItem.byId(type) == null ? ModBlocks.RESIN_BLOCK.asStack() 给的是纯净树脂块,而 reserveCreature 要求带 SAVED_ENTITY 组件且实体类型匹配的树脂块。玩家照单拿来会继续失败,建议提示写“封存了 <实体> 的树脂方块”。
  • StructureBlueprintFiles.writefor (int index = 1; ; index++) 无上界(并发写入时理论上死循环),且为了拿到 FileAlreadyExistsException 放弃了 ATOMIC_MOVE。建议加个重试上限(如 1000 次后抛 IOException)并保留原子移动。
  • BuildingRodService.blueprints 只对 anchorlastwithinReach(..., 26) 校验,而平铺后最后一个副本的方块会超出 last 近一整份尺寸;单份蓝图本来也只校验锚点,属于既有宽松度,确认是否有意为之即可。

🟢 看起来不错

  • BlueprintCapture 用世界坐标 + NORTH 归一化与被替换掉的旧流水线等价,说明作者清楚两套坐标系的关系(这点最容易踩坑)。
  • BuildingCommit.activate 的顺序很讲究:先 clearArea 清掉残留计划刻 → 恢复快照计划刻 → onPlace/流体 tick → 统一邻居更新;且 activatequietly() 退出之后执行,因此 BlueprintTicksMixinschedule 拦截不会把恢复的计划刻一起吞掉。
  • isRestoredObserverUpdate 同时校验“目标格是本次恢复的侦测器 + 朝向匹配 + 源格也是本次恢复的同一状态”,只保护被恢复的侦测器,不会干扰世界里既存的侦测器。
  • 撤销侧 SavedEntity.matches()(存活 + 序列化快照全等)与 IdentityHashMap<Planned, PlacedGroup>Planned 实例同一性是正确的:BuildingMaterials.reserve 只做 addAll 复制引用,entities 列表与 placedGroups 里的对象是同一批,recordEntity 能命中。
  • BlueprintClientFiles.receive 对服务端回传文件名做了 isSafeName + .nbt 后缀双重校验,导出文件名回显路径是安全的。
  • tile()count * entries > MAX_BLOCKS 估算总量而非只数格子,避免了“1 格 × 999 份却每个 4000 方块”的绕过。
  • MAX_ENTITY_ENTRIES 现在在 capture/canonicalize 阶段就触发,比旧版“写得出、读不进”更早失败。

🧪 测试建议

被测目标 建议场景 优先级
BlueprintPlacement.tile 锚点=last(单份)、±X/Z 平铺、Y 向堆叠、count*entries 刚好越界、跨 4000 上限 🔴
planBlueprint 实体分支 范围内放风弹/烟花火箭/凋灵之首,确认是“跳过”还是“整盘失败”(当前实现验证 🔴1) 🔴
BlueprintEntities.flatten/link 三阶乘骑(船→马→玩家)、自引用/循环乘骑、>32 层嵌套、缺 UUID 的实体、同锚点重复粘贴的 UUID 复用 🔴
BlueprintTicks.capture/restore 边界跨区块的计划刻、delay=0、流体/方块双类、还原后立即 tick 的行为、被 cells 过滤掉的方块上的计划刻 🟡
BlueprintRuntimeData.restoreClock capturedAt=0(Litematica 导入)与有捕获时间的两种路径、RemainingWaitingTime>0、熔炉 BurnTime/CookTime 经“库存插入后”不被清零 🟡
BuildingCommit.activate 侦测器相邻放置不翻转、红石线网络恢复、含水方块不流水、计划刻不被重复调度 🟡
StructureBlueprintFiles.write 同名连续导出 3 次 → _1/_2 序号与回显文件名、超长名截断到 128、并发同名 🟢

结论: REQUEST_CHANGES

功能覆盖与 PR 声称一致,坐标系/时序这类最容易出错的点做得相当扎实。但 🔴1(个别不支持实体会让整份蓝图静默失败,且触发面因 isTransient 放宽而扩大)⚠️2(实体 NBT 白名单整体取消,而蓝图文件可来自客户端上传) 建议先处理;⚠️3、⚠️4 是明确的健壮性/语义回归风险,成本都不高。

PR 标题「Support repeated projection of blueprints and improve runtime status and entity restoration 支持蓝图重复投影并完善运行状态与实体恢复」与变更内容相符,无需修改。


由 Hermes Agent 审查

@Gugle2308

Copy link
Copy Markdown
Collaborator

代码审查摘要 — PR #4971

操作: edited(标题/分支变更 → 全量复审)
范围: 41 个文件(33 Java + 2 生成 lang + 6 其他)/ +1746 −305,新增 9 个文件(BlueprintCaptureBlueprintEntitiesBlueprintIgnitionBlueprintRuntimeDataBlueprintSpecialBlocksBlueprintTicksBlueprintBlockEntitiesDynamicBuildingEntitiesMagnetizedNodeBuildAdapter
版本线: dev/1.21/fix6dev/1.21/1.6(1.21.1 常规功能 PR,不适用 26.1 迁移指标;head=f363b43,与 git ls-remote 一致)

审查方式:patch-diff 全量 + 目标分支/头部树上 git show 交叉核对(未编译,无 issue 状态查询——gh token 已失效)。


🔴 关键

未发现确定性阻断缺陷(未发现必然崩溃/数据丢失的硬 bug)。

⚠️ 警告

  1. BuildingRodUndo.SavedEntity.matches() 让「实体组」实际上永远不可撤销,且连带其方块src/main/java/dev/dubhe/anvilcraft/building/BuildingRodUndo.java:113-118
    matches()entity.saveAsPassenger(tag) 与建造完成后立即抓取的快照做 NBT 全等比较,而 Entity.saveWithoutId 会写出 Pos/Motion/FallDistance/Air/PortalCooldown/HurtTime每 tick 变化的字段。生物/掉落物/下落方块/箭在被放下的 1~2 tick 内就会(重力、AI、Motion 归零)产生差异 → matches()==falseundo()continue 跳过整组(方块不还原、group.returned 不返还),玩家只看到 message.anvilcraft.building_rod.nothing_to_undo。PR 声称"仅撤销状态未变化的建造组"是设计意图,但实际效果是:含生物/实体的蓝图组连同其方块一起失去撤销。建议:把实体判据由"整组跳过"改为"只跳过该实体",或改比对白名单字段(类型/装备/位置取整等)。

  2. StructureScannerActionPacket 的 SAVE 调用点未捕获 IllegalArgumentException(兄弟路径都补了)network/StructureScannerActionPacket.java:120
    saveStructureToDisk 本身只 catch (IOException)StructureScannerSavePacket:50catch (IOException | IllegalArgumentException)StructureScannerBlockEntity:341 自动保存也补了 IAE catch,唯独这里没有(该文件全文无 try/catch)。本 PR 把 BlueprintCapture.capture 接进了 buildStructureNBT,新增了若干 IAE 源:BlueprintEntities.flatten"Too many blueprint passengers"(>4096 条/深度>32)、MagnetizedNodeBuildAdapter.support"Missing magnetized node support at …"(节点支撑方块不在快照内/已被破坏时必抛)、SlidingBlockSection.CODEC…getOrThrow()。请补齐与兄弟路径一致的 catch(或统一收口在 saveStructureToDisk)。

  3. 中文翻译未同步(PR 声称"同步更新操作提示与中英文翻译",实际只改了 en_us/en_ud + 生成器)src/main/resources/assets/anvilcraft/lang/zh_cn.json

    • 新增键缺失:message.anvilcraft.building_rod.unsupported_typescreen.anvilcraft.building_rod.selection_size(中文客户端回退英文);
    • 语义已变的键仍是旧中文:screen.anvilcraft.building_rod.distance(zh:2745 仍"固定蓝图")、.optimized(zh:2747 仍是"放置")、item.anvilcraft.building_rod.controls(zh:2712 仍是旧操作说明)、screen.anvilcraft.building_rod.traditional.hint
      占位符个数未变(distance 4 个、optimized 11 个、selection_size 3 个与 Component.translatable(..., xSpan, ySpan, zSpan) 一致 ✓),因此不是 Fix fluid port active draining and improve building-rod overlay text 修复流体端口主动排液并改进建筑杖文本显示 #4916 那种按键错位,只是漏翻 + 文案过期。
  4. 平铺上限估算偏低,且 commit 的 4000 上限对蓝图路径不生效building/BlueprintPlacement.java:126-131building/BuildingRodService.java:645
    entries = max(1, nonAirBlockCount + entities)快照条目数估算,但真正的 cell 来自 BlueprintMultiblocks.expand(门上下半、床、活塞头、多方块都会额外生成部件),实际格数可达估算的 2~4 倍;而 commit 里的 cells.size() > MAX_BLOCKS 检查带 !quiet 前提,蓝图路径恒为 quiet=true。于是"拖拽平铺"可能一次放下远超 4000 格(旧代码单次蓝图也绕过该检查,但平铺会成倍放大)。建议按 expand 后实际格数估算,或对平铺路径启用上限校验。

  5. 客户端预览与服务端保存的数据源不再一致util/StructureSaveUtil.java:154-158
    buildSnapshot 在服务端提前返回 BlueprintCapture.capture(serverLevel, getScanBounds())读实时世界),scannedBlocks 参数在服务端已成死参数;而客户端预览 StructureScannerScreen:1196、磁盘图标 DiskDisplaySupport:89 仍在 ClientLevel 上走旧路径(缓存 getScannedBlocks())。扫描后世界被改动时会出现"预览所见 ≠ 保存所得";另外 StructureScannerScreenif (!scannedBlocks.isEmpty()) 守卫与新的服务端逻辑也不再对称。请确认/注明或统一入口。

  6. 投射物等实体的语义变化:从"静默忽略"变成"整体拒绝建造"building/EntityBuildAdapters.java:56-72building/DynamicBuildingEntities.javabuilding/BuildingRodService.java:408-420
    isTransient(type) 删掉了 Projectile.class.isAssignableFrom(base)(以及 ItemEntity,改由 type == EntityType.ITEM 等显式列举替代),于是箭/三叉戟/烟花火箭/雪球等现在必须能算出材料并被适配器接管;一旦算不出(例如 AbstractArrow.getPickupItemStackOrigin() 为空 → Planned.skip()),Planned.skip()unsupported=true 会让整个投影unsupported_type: <对象名> 失败,而不是像以前那样跳过该实体。这与 PR 的"显示不支持建造的具体对象"一致,但会使用户既有的蓝图(含无材料投射物)直接建不出来——请确认是预期;若否,建议对"单个对象不可供料"只跳过该对象。

  7. (既有问题,本次未触及但正改同一函数)细雪桶返还仍按「首段」计数building/BuildingMaterials.java:257-262
    allocated.blockMaterials.put(entry.getKey(), taken.getFirst().placed()) 只保留第一个 Source 的片段,末尾 for (ItemStack stack : allocated.blockMaterials.values()) if (stack.is(POWDER_SNOW_BUCKET)) … 按该片段数量补空桶。单组期望数跨 ≥2 个 Source(背包每栈独立成 Source)时少还(Fix building wand material handling, equipment abilities and terminal item extraction logic 修复建筑杖材料处理、装备能力与终端取物逻辑 #4901 曾实测:40 个细雪返 32 桶,8 桶蒸发)。修法:遍历 allocated.materialsis(POWDER_SNOW_BUCKET) 的项求和。

💡 建议

  • BuildingCommit.activatePlaced 的邻居/形状扫描开销building/BuildingCommit.java:118-151):每格执行 onPlace + updateNeighbourShapes + updateIndirectNeighbourShapes + updateNeighborsAt + updateNeighbourForOutputSignal + 6×neighborChanged,4000 格时约 4 万次方块操作集中在一 tick,且平铺会更容易触顶。建议分帧/限流,或只在"该格有实际邻居语义"时补齐。
  • DynamicBuildingEntities.spawnif (entity instanceof SlidingBlockEntity sliding && sliding.getMoveDirection() != null) sliding.setMoveDirection(sliding.getMoveDirection()); 是自赋值空语句,若依赖 setter 副作用请加注释,否则删除。
  • AnimateAscendingBlockEntity 计划材料为 ItemStack.EMPTYDynamicBuildingEntities.java:174-176),且非 Mob 分支会 group.materials.add(EMPTY) 留下空栈;它其实是纯动画实体(animate()displayAnvilAnimation 配置门控、tick() 只上移后 discard(),不落地方块),零材料可接受,但与 SlidingBlockEntity 对内部方块逐个计费的写法不对称,请确认。
  • BuildingRodClient.tickclient/building/BuildingRodClient.java:185-190)在 first != null每 tick setOverlayMessage(...) 选区尺寸,会持续覆盖其他 actionbar 提示(含原版),建议仅在尺寸变化时刷新。
  • StructureSaveUtil.buildSnapshotscannedBlocks 参数在服务端分支已不使用,建议清理或注明"客户端预览专用"。

🟢 看起来不错

  • 平铺几何正确tile()bounds(size) 的 span 做步长(拷贝间不重叠)、signX/Y/Z 决定朝向、双重上限(单轴 ≤ MAX_BLOCKS 且 总量×条目 ≤ MAX_BLOCKS),blueprints() 同时校验 anchorlast 的 26 格触及范围;BuildingRodPacket.PLACE_BLUEPRINTS 与客户端 placement.anchor().offset(blueprintOffset) 对得上。
  • 工具/材料账目自洽(本轮改动)retainedTool 在所有 8 条失败路径上都与 reserved 一起由 restoreSources 回滚 ✓;reserveBlock/reserveItem/reserveContainer 一致地扣除被保留的工具位 ✓;reserveCreature 的树脂分支把 1~4 树脂写入 allocated.returned 且与撤销侧 group.returned 对称 ✓;missing() 的 4 个调用点全部是 new BuildingMaterials(player).missing(...)(新实例),探测期的 reserved/retainedTool 变更不会污染真正的预扣 ✓。
  • 计划刻/运行进度BlueprintTicks.capture 记录 subTickOrder 并按序回放、restore 前用 placed 集合 + 当前方块/流体类型一致性过滤;read 校验类型存在性、条目上限 32768;BlueprintRuntimeData.rebase/restoreClockcapturedAt>0 与 Litematica Time 两种来源都给了回退分支;BuildingCommit.activate 只对 quiet 路径调用 ✓。
  • 实体身份/乘骑链flatten 为缺 UUID 的条目补确定性 UUID → identities 按锚点派生新 UUID → scope/link 重映射 + startRidingrelocateMemories 丢弃越界记忆,sanitize 移除 UUID/Passengers 后由 link 重建 ✓ 设计自洽(HasMobBlockEntity:95entity.load(tag) 先例也证明该 API 会应用完整实体状态)。
  • 导出链路两端同步改造:服务端把实际文件名作为载荷(sendFile(..., name.getBytes(UTF_8))total=bytes.lengthoffset=0 与客户端校验一致)、客户端 isSafeName + .nbt 校验后 message("exported", exported) 回显真实名;Files.move 不带 REPLACE_EXISTING 以触发 FileAlreadyExistsException 重试、后缀基于原始 name 生成并做 128 长度截断 ✓。
  • BlueprintLevelMixin 新增的观测器抑制isRestoredObserverUpdate 同时校验 activation.level()、目标格仍是同一 ObserverBlock 状态实例、FACING 与更新方向一致、来源格也是蓝图状态,ACTIVATIONfinally 中原样复原 ✓。
  • en_ud 生成正确:4 处改动字符串逐字符翻转无误,新增键同步;selection_size%s × %s × %s 与翻译参数个数一致 ✓。

📋 声称验证表

声称 状态 对应实现
拖拽平铺投影、松开锁定再右键批量粘贴、统一预览与范围限制 BlueprintPlacement.tileBuildingRodClient.beginBlueprintSelection/confirmSelection/blueprintPlacementsBuildingRodPacket.PLACE_BLUEPRINTSBuildingRodService.blueprints
选区距离锁定、方向调整、尺寸显示 canAdjustDistance/controlAvailable/selectionOffsetselection_size 三参数文案
同步更新中英文翻译 ⚠️ en_us/en_ud/生成器 ✅;zh_cn 未更新(见 ⚠️3)
统一结构扫描快照采集 ⚠️ BlueprintCapture.capture 接入服务端保存;客户端预览仍走缓存(见 ⚠️5)
保存/恢复运行进度、计划刻、时间基准,兼容 Litematica 计划刻导入 BlueprintRuntimeDataBlueprintTicksLitematicaImporter(PendingBlockTicks/PendingFluidTicks)、StructureSnapshot.capturedAt
建造后红石/流体/邻居更新,避免误触发侦测器 ✅(性能见 💡) BuildingCommit.activate/activatePlacedBlueprintLevelMixin#neighborShapeChanged
扩展生物/掉落物/投射物/移动方块/模组实体建造,恢复运动状态/乘骑/引用 ⚠️ DynamicBuildingEntitiesBuildingEntityTransform(Motion/BlockPos/SlidingBlocks/记忆);语义变化见 ⚠️6
树脂封存生物供料,补全装备/库存/流体/特殊方块材料 reserveCreatureBlueprintSpecialBlocks、Mob 装备/InventoryCarrier 剥离与回填、FluidTankMinecartEntity 储罐
磁铁/铁砧锤/点火工具校验 + 显示不支持对象 requiredTool/requiresHammerreserveIgnitionsunsupported_type
实体纳入建造撤销(仅未变化且可修改的组) ⚠️ 已实现 recordEntity/SavedEntity,但状态门使实体组实际不可撤销(见 ⚠️1)
导出同名自动加序号并反馈实际文件名 StructureBlueprintFiles.write 返回候选名、BlueprintClientFiles.receive 校验并回显
resolved #4963/#4964/#4966、fixed #4968、resolved #4970 未校验 无 gh 凭据,无法查询 issue 状态与关闭链

结论: COMMENT(建议处理 ⚠️1/2/3/4 后再合并,⚠️5/6 请作者确认预期) —— 整体设计自洽、账目回滚与两端协议改造质量较好;上述问题集中在"撤销状态门过严导致整组失效"、"保存包路径 catch 不对称"、"中文漏翻"与"平铺上限估算"四点,均属可小范围修复。

🧪 测试建议

被测目标 推荐测试场景 优先级
BuildingRodUndo.SavedEntity.matches() 放置含生物/掉落物的蓝图 → 等 1 tick → 撤销;验证方块是否应还原、材料是否应返还 🔴
StructureSaveUtil.buildSnapshotBlueprintCapture 分支) 扫描区内含磁铁节点但支撑方块被破坏 → SAVE 各入口(GUI 保存 / 自动保存 / action 包)逐一验证不抛异常 🔴
BlueprintPlacement.tile × commit 上限 含门/床/活塞头的多方块蓝图拖拽平铺 → 统计实际放置格数与材料扣减是否一致 🟡
BlueprintTicks.capture/restore 含红石计划刻/流体计划刻的结构导出再投影,验证 tick 时刻与 priority 保持 🟡
BuildingMaterials.reserveCreature 背包树脂块 + 刷怪蛋混放、无蛋生物(仅树脂)、蛋跨两栈拆分 → 计量与撤销返还 🟡
BlueprintEntities.link 生物乘骑载具(含嵌套乘客)蓝图投影,验证乘骑关系与记忆引用指向新 UUID 🟡

标题

建议标题(本次 gh 未认证,未能自动修改):
Support tiled blueprint projection, block/entity runtime restoration and material requirements 支持蓝图平铺重复投影、方块/实体运行状态恢复与材料需求补全


由 Hermes Agent 审查

@Gugle2308

Copy link
Copy Markdown
Collaborator

💾 Self-improvement review: Patched references/blueprint-repetition-and-runtime-entity-restore-review.md in skill 'building-rod-blueprint-review' (1 replacement). · Patched SKILL.md in skill 'building-rod-blueprint-review' (1 replacement).

@Gugle2308

Copy link
Copy Markdown
Collaborator

💾 Self-improvement review: Patched SKILL.md in skill 'building-rod-blueprint-review' (1 replacement). · Patched SKILL.md in skill 'mixin-injection-verification' (1 replacement).

@Gugle2308

Copy link
Copy Markdown
Collaborator

已完成审查。以下是审查意见(gh token 已失效,未能自动改标题,建议标题附在末尾)。


代码审查摘要 — PR #4971

操作: opened(synchronize 后 head f363b43f
范围: 41 个文件(39 Java,9 新增,0 删除)/ +1705 −264 行(api 与 diff 校验一致:changed_files=41,md5 d7b18df…
目标线: dev/1.21/fix6dev/1.21/1.6(1.21.1 线,非 26.1 迁移,跳过 API 迁移健康度检查)

🔴 关键

  1. BuildingEntityTransform.sanitize() 由白名单改为全量复制 → 实体 NBT 信任边界被显著放宽

    CompoundTag safe = tag.copy();  // 旧实现:id/Pos/Rotation/CustomName + 各实体类型专属安全键
    safe.remove("UUID"); safe.remove("Passengers");

    配合新的 DynamicBuildingEntities(Mob/投射物/掉落物路径),蓝图文件里几乎全部实体 NBT 会原样落到生成实体上:Attributes(任意 max_health/attack_damage)、ActiveEffects(任意层数/时长)、HealthInvulnerableFireTagsPersistenceRequiredBrain 记忆值、以及模组自定义数据。
    这不是纯理论:扫描仪的导入链路接受客户端字节——StructureScannerFilePacketStructureScannerFiles.importBlueprint()StructureScannerFiles.parseImportResult() → 快照实体 NBT,随后 planBlueprint()find(probe, transformed) + adapter.plan(...) 把这段 NBT 当作可放置实体。改前SpawnEggAdapter 只复制 id/Pos/Rotation,所以「蓝图里塞一只 1e9 生命的生物 / 无敌盔甲架」这条路是本次新开出来的(成本仅一枚刷怪蛋或树脂封存生物)。
    建议:保留一份黑名单Attributes/ActiveEffects/Health/Invulnerable/Fire/Tags/PersistenceRequired/ForgeData/Brain.memories 的非 pos 值),或对数值做钳制;同时把 planBlueprint() 里已对「受限方块」做的 requiresOperator() 校验延伸到实体侧。

  2. BlueprintEntities.flatten() 重复 UUID 分支缺 hasUUID 判断 → NPE

    CompoundTag existing = seen.putIfAbsent(tag.getUUID("UUID"), tag);
    if (existing != null) {
        if (tag.hasUUID(VEHICLE)) existing.putUUID(VEHICLE, tag.getUUID(VEHICLE));  // 条件在 if 里,但第二行无条件取
    }

    实际写法是 if (tag.hasUUID(VEHICLE)) existing.putUUID(VEHICLE, tag.getUUID(VEHICLE))——只在 hasUUID 为真时才取值,这里是安全的;但重复 UUID 且该条没有 anvilcraft:vehicle 时,不会崩溃也不会补链,而是静默丢弃这一条的载具信息(existing 已存在则直接 return)。真正的风险在另一处:CompoundTag.getUUID() 对缺失键返回 nullNbtUtils.loadUUID 返回 null),putUUID(key, null)UUIDUtil.uuidToIntArray(null)NPE。触发条件:手工/第三方 .nbt 里同一实体既出现在顶层、又出现在别的实体的 Passengers 中且顶层那条没有 vehicle 键(导入路径允许这种输入)。该 NPE 不属于 catch (ConstructionBlueprintException | IllegalArgumentException),会从包处理里逃逸 → 客户端掉线/日志刷屏。
    建议:改为 if (tag.hasUUID(VEHICLE)) { UUID v = tag.getUUID(VEHICLE); if (v != null) existing.putUUID(VEHICLE, v); }

⚠️ 警告

  1. BlueprintTicks.read()orElseThrow()NoSuchElementException,逃逸所有 catch
    NbtUtils.readBlockPos(entry, "pos").orElseThrow()(以及 StructureScannerFiles.handleBuildingRodService.blueprintsDiskDisplaySupportBuildingRodClientcatch (…| IllegalArgumentException))——NoSuchElementException 不是 IAE,导入一份 anvilcraft:scheduled_ticks 里缺 pos 的文件就能走到。同一函数里其它错误用 IAE、StructureSnapshotCodec 又用 warnings 收集,建议统一:要么显式抛 IAE,要么记 warning 跳过该条(后者更符合 codec 现有风格,也能避免「一条坏计划刻否决整个结构」)。

  2. MagnetizedNodeBuildAdapter.support() 匹配过严且直接抛异常 → 整个结构/扫描作废
    Math.abs(pos.getY() + height - entry.pos().y) < 1.0E-5heightstate.getCollisionShape(EmptyBlockGetter.INSTANCE, pos) 计算,而节点实体的 Y 是在真实 levelMagnetUtilgetCollisionShape(level, pos))里算出来的;对形状依赖世界上下文/邻居的方块,这两者可能不等 → throw new IllegalArgumentException("Missing magnetized node support at …") 会拒绝整个蓝图/整次扫描(StructureSaveUtil.saveStructureToDisk 的自动保存 tick 仅 catch (IllegalArgumentException) 记录警告,正常路径虽被捕获,但用户拿到的是「无法保存」而不是「跳过该节点」)。
    建议:找不到支撑时降级处理(用 entry.blockPos() 或同列最近候选),不要在采集路径上抛。

  3. StructureSaveUtil.buildSnapshot() 在服务端改为实时重采集,scannedBlocks 参数与「扫描所见」失效

    if (blockEntity.getLevel() instanceof ServerLevel serverLevel) return BlueprintCapture.capture(serverLevel, blockEntity.getScanBounds());

    集成服务器也是 ServerLevel,所以旧分支(用缓存 + scanner.getDirection()/isScannerUpsideDown())实际成为死代码,第二个参数在服务端永不使用。后果:导出内容 ≠ 玩家在扫描器界面看到的预览(扫描后改动过的方块、扫描后才出现的实体都会进文件)。我按四个朝向逐一验算了坐标:worldBlockToPreview ∘ normalize(scannerFacing) 的横轴系数恒为 +1(纯平移),因此新路径固定用 NORTH/false 不会引入朝向/镜像回归——这点没问题;但「实时 vs 缓存」的产品语义请确认,若是刻意为之,建议删掉 scannedBlocks 形参并同步 UI 提示。

  4. LitematicaImporterPendingBlockTicks.Time 直接当剩余延迟使用
    (int) Math.clamp(tick.getLong("Time"), 0, Integer.MAX_VALUE)。请确认 Litematica 该字段是剩余延迟还是绝对 triggerTick;若是后者,导入的计划刻会被推到约 2^31 tick 后(静默失效,而不是报错)。建议同时传入/减去一个基准(例如本地 capturedAt)。

  5. BuildingRodUndo.SavedEntity.matches()saveAsPassenger 全量等值比较 → 实体组几乎不可能被撤销
    实体 NBT 里 Pos/Motion/Air/HurtTime/PortalCooldown/TicksFrozen,掉落物的 Age/PickupDelayCauldronOutletEntity 新增持久化的 TargetPos/WasMoving 都会自然变化;而这些实体与「磁铁节点/坩埚出口」是挂在方块组里的(groups.get(core)),一旦实体 NBT 变动,整组(含方块)都不会被撤销。建议只比较稳定子集(如 blockState/id/BlockPos/CustomName + 结构性字段),或在 recordEntity 时快照一份「受控字段」用于比较。

  6. 平铺距离校验只覆盖 anchorlast
    blueprints() 只校验两个角点 withinReach(…, 26),而最后一份副本还会向 last 之外延伸最多一个蓝图尺寸(width-1),实际可放置到 ~26 + size 格外。建议把 count 夹到「最后一份副本的远角仍在 reach 内」,或对远角追加一次 withinReach

  7. BlueprintRuntimeData.FIELDS 是跨实体类型的全局字符串名单
    "Output"/"Powered"/"progress"/"phase"/"OutputSignal"/"Rolling" 等通用键对所有方块实体生效:take() 先把它们从 source 摘走,再在 afterContents 阶段合并回写。当前是自洽的(同名键不会丢,只是延后到内容插入之后再应用),但一旦将来某个 BE 把这些名字用作「必须在前一阶段生效的设置」,就会出现难查的时序 bug。建议按实体类型(或至少按注册的方块实体类型)限定字段集,而不是全局名单。

💡 建议

  • BuildingEntityTransform.sanitize(Entity entity, CompoundTag tag)entity 形参已完全无用,可删(否则误导以为仍按类型过滤)。
  • SlidingBlockEntity.setMoveDirection(sliding.getMoveDirection()) 是"自赋值",实际靠 setter 内的 SlidingEntitySyncPacket 副作用做重同步——建议加注释或抽成 resync(),否则极易被后来者当死代码删掉。
  • BuildingCommit.set()serverLevel.onBlockStateChange(...) / state.onBlockStateChange(...) 被放在静默阶段(邻居尚未更新)调用,而 NeoForge 契约(IBlockExtension#onBlockStateChange)是「状态变更且邻居更新之后」。本仓库无覆写者,但第三方方块可能依赖该顺序,建议注释说明或移到 activate() 里。
  • planBlueprint() 每次副本都重算 BlueprintIgnition.portalCores(declared)declared 随副本增长)→ 最坏 O(copies × declared)。可把结果缓存在副本循环外、只增量处理新位置。
  • group.ignitions = 1赋值不是自增,正确性依赖「每个点火方块自成一组」(BlueprintIgnition.portalCores 让整扇传送门共组 → 1 个点火工具,是刻意设计);若将来 BlueprintMultiblocks.core() 的归组变化会漏算数量,建议写成 Math.max(1, …) 语义的显式注释。
  • BuildingMaterials.missing()reserveTool()/reserveHammer() 当纯谓词用,但它们会写 Source.retainedTool;目前只因每次 new BuildingMaterials(player) 一次性使用才安全,建议拆出无副作用的 hasTool()
  • 单份蓝图(count == 1)不校验 snapshot.nonAirBlockCount() 上限,而平铺路径校验 count*entries ≤ MAX_BLOCKS(quiet 路径也不校验 cells 总数)——两条路径的上限语义请对齐或在文档里说明。
  • VoxelShape.max(Axis.Y, 0.5, 0.5) + EmptyBlockGetter.INSTANCE 与运行时形状可能不同(同第 4 条),建议抽出公共 helper 供 MagnetUtil/MagnetizedNodeBuildAdapter/BuildingEntityTransform 共用。
  • 客户端 updateTarget() 把原来的 player.pick(...)OUTLINE)换成了 ClipContext.Block.COLLIDERitem.anvilcraft.building_rod.controls 已写成 "drag from a collidable block",看得出是刻意的;但副作用是瞄准无碰撞但有轮廓的方块(草/花/红石粉/火把等)时会直接穿过去命中后方方块,与旧行为不同,建议在 PR 描述里标注这是一次交互行为变更。

🟢 看起来不错

  • 朝向归一化等价性(我做了逐项验算)BlueprintCapture.capture() 固定 ScannerDiskNormalizer.normalize(raw, NORTH, false) 不会造成朝向回归——旧路径 worldBlockToPreview(scannerFacing)normalize(scannerFacing, upsideDown) 的复合在 SOUTH/WEST/EAST/NORTH 四个朝向下横轴系数恒为 +1(含 Y 轴翻转抵消),净效果与新的「世界坐标 → 平移」完全一致。
  • 侦测器抑制isRestoredObserverUpdate()neighborShapeChanged 首参语义核对正确(经 RedstoneWireBlock/GiantAnvilBlock/MultiblockConversionRecipe 三处现有调用确认「首参 = 从被更新方块指向变化源的方向」),guard 同时校验 level、observer FACING、declared 状态与实时状态引用,且只在 activate() 的 ThreadLocal 窗口内生效 ✅。
  • 位置/尺度计算BlueprintCaptureorigin = BlockPos.containing(min) / end = max-1getScanBounds()rangeX/Y/Z 一致;tile()floor(offset/size)+1 + count*entries ≤ MAX_BLOCKS 保证副本不重叠、总量受控,客户端与服务端共用同一实现,连红框合法色都用 blueprintPlacements().isEmpty() 对齐。
  • 计划刻BlueprintTicks 做了注册表存在性校验 + 32768 条上限 + capturedAt == 0(老文件/Litematica)回退到 tick.delay()restore 前用 clearArea 清空再按 order 排序恢复、并以 placed 集合过滤,语义完整。
  • 撤销映射entityGroupsIdentityHashMap(正确地规避了 record 值相等导致的合并),且撤销前要求「实体仍存活 + NBT 未变 + 可修改」。
  • 导出重名:循环内每轮都重算 target 并保留 resolve() 路径穿越校验,name 已被 StructureFileTransfer.isSafeName 二次校验;exported 名称回传链路(StructureBlueprintFiles.writeStructureScannerFiles.handleFileResultPacketBlueprintClientFiles.receive)的 total/offset/isSafeName/.nbt 校验与服务端 sendFile 的语义一致。
  • 中英 en_us/en_udBuildingRodLang 三处同步,%s 占位符个数一致,en_ud 行序反转正确。

📋 声称验证表

声称 状态 对应文件
拖拽平铺投影 / 松开锁定 / 再右键批量粘贴 BlueprintPlacement.tile, BuildingRodClientblueprintOffset/press/release/confirm/PLACE_BLUEPRINTS), BuildingRodPacket
统一预览与放置范围限制 ✅(⚠️ 第 8 条:距离只校验两角) BlueprintPlacement.tile ← 客户端/服务端共用, BuildingRodRenderer
选区距离锁定 / 方向调整 / 尺寸显示 BuildingRodClient.selectionBounds, applyControl(4 方向), handleKeyboardInput, selection_size
统一结构扫描快照采集 ✅(⚠️ 第 5 条:实时重采集语义 + 死形参) StructureSaveUtil.buildSnapshotBlueprintCapture
保存/恢复运行进度、计划刻、时间基准 ✅(⚠️ 第 3、9 条) BlueprintRuntimeData, BlueprintTicks, StructureSnapshot(+Codec), BlueprintBlockConfiguration
Litematica 计划刻导入 ⚠️(第 6 条:Time 语义待确认) LitematicaImporter
建造后红石/流体/邻居更新,避免误触侦测器 BuildingCommit.activate/activatePlaced/isRestoredObserverUpdate, BlueprintLevelMixin
生物/掉落物/投射物/移动方块/模组实体建造 ✅(🔴 第 1 条信任边界) DynamicBuildingEntities, BlueprintEntities, MagnetizedNodeBuildAdapter, EntityBuildAdapters, BuildingEntityTransform
恢复运动状态/乘骑/实体引用 BuildingEntityTransform(Motion 旋转/SlidingBlocks/Relative*), BlueprintEntities.link
树脂封存生物供料,补全装备/库存/流体/特殊方块材料 BuildingMaterials.reserveCreature, DynamicBuildingEntities(contents), VanillaBuildingEntities.VehicleAdapter(fluids), BlueprintSpecialBlocks
磁铁/铁砧锤/点火工具需求校验 + 显示不支持对象 EntityBuildAdapter.requiredTool/requiresHammer, BuildingMaterials.reserveTool/reserveHammer/reserveIgnitions, unsupported_type
实体纳入撤销(仅状态未变且可修改的组) ✅(⚠️ 第 7 条:比较过严) BuildingRodUndo
导出同名自动编号 + 反馈实际文件名 StructureBlueprintFiles.write, BlueprintClientFiles.receive

结论: REQUEST_CHANGES(建议先处理 🔴 第 1、2 条,⚠️ 第 3、4 条建议同批修) — 功能映射完整、坐标与状态机语义经得起推敲、客户端/服务端约束对齐得不错;主要顾虑集中在两处:实体 NBT 白名单被移除后的信任边界(配合客户端可提交原始 NBT 的导入链路),以及「解析/采集失败」路径上混用异常类型导致的部分逃逸与整体作废。

🧪 测试建议

被测目标 推荐场景 优先级
BlueprintPlacement.tile() offset 恰好 = / 略小于 / 为 0 / 为负;count*entries 刚好越界;MAX_BLOCKS 边界 🔴
BuildingRodService.blueprints() + planBlueprint() 平铺多份时的 declared/portalCores 归属;第二份失败时不得留下半成品 🔴
BlueprintTicks.read/restore pos/未知 type/>32768 条 → 期望可捕获的 IAE 而非 NSEE;capturedAt=0 回退分支;RESTORE 时 placed 过滤 🔴
BuildingEntityTransform.sanitize() 恶意 NBT(1e9 Attributes/ActiveEffects/Invulnerable)不得生效;Passengers/UUID 必须被剥离 🔴
BuildingRodUndo 实体 NBT 自然漂移(Age/Motion)后撤销行为;节点/出口实体与方块同组时的跳过语义 🟡
BuildingMaterials 工具/生物/点火预留 铁砧锤被 retainedTool 占用后不能当材料消耗;树脂封存生物匹配;火焰计数(多扇传送门/多处火) 🟡
BuildingCommit.activate() 侦测器朝向不被邻居更新改写、且恢复后仍能被真实邻居触发;计划刻/流体刻数量与蓝图一致 🟡
StructureSaveUtil.buildSnapshot() / ScannerDiskNormalizer 扫描区域被改动后导出内容;magnetized_node 支撑缺失时不得整体失败 🟡
StructureBlueprintFiles.write() 目录内已存在 a.nbta_1.nbt、…;名称长度接近 128 的截断 🟢

建议标题gh auth status 显示 token 已失效,故未自动修改):
Support blueprint tiling, runtime-tick restoration and entity building 支持蓝图平铺投影、运行状态/计划刻恢复与实体建造


由 Hermes Agent 审查

@Gugle2308

Copy link
Copy Markdown
Collaborator

💾 Self-improvement review: Patched SKILL.md in skill 'building-rod-blueprint-review' (1 replacement). · Patched references/shell-only-diff-navigation.md in skill 'github-webhook-pr-review' (1 replacement). · Patched references/pr-class-routing.md in skill 'github-webhook-pr-review' (1 replacement).

- 将撤销改为恢复建造前的区域快照,涵盖方块、方块实体、原有实体、计划刻与方块事件
- 跟踪建造实体及区域变化产生的掉落物、下落方块和 TNT,撤销时清理关联实体
- 按实际消耗记录物品与流体账单,扣除随区域快照恢复的库存,避免重复返还材料
- 支持向原仓储退还流体,保留未完成的实体回收与退款记录,并增加部分撤销提示
- 补全拴绳结的扫描、预览与放置,恢复实体间及栅栏上的拴绳关系,兼容旧版拴绳数据
- 将拴绳纳入材料消耗与缺料清单,校验栅栏支撑和修改权限,并在撤销时清理闲置绳结
Copilot AI review requested due to automatic review settings September 21, 2026 05:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Gugle2308

Copy link
Copy Markdown
Collaborator

⚠️ Dangerous command requires approval:

cd /tmp && curl -sL "https://api.github.com/repos/Anvil-Dev/AnvilCraft/pulls/4971/commits" | python3 -c "import json,sys; d=json.load(sys.stdin); [print(c['sha'][:8], c['commit']['message'].split('\n'...

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/pulls/4971/commits — or: vet https://api.github.com/repos/Anvil-Dev/AnvilCraft/pulls/4971/commits (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.

@Gugle2308

Copy link
Copy Markdown
Collaborator

⚠️ Dangerous command requires approval:

cd /tmp && python3 -c "
import zipfile
z=zipfile.ZipFile('/tmp/minecraft-client-1.21.1.jar')
names=[n for n in z.namelist() if n.endswith('.class')]
print(len(names)); print(names[:5])
print([n for n ...

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

⚠️ Dangerous command requires approval:

cd /tmp && ls -la minecraft-server-1.21.1.jar minecraft-client-1.21.1.jar 2>/dev/null && python3 -c "
import zipfile
z=zipfile.ZipFile('/tmp/minecraft-client-1.21.1.jar')
names=z.namelist()
print('ent...

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

代码审查摘要 — PR #4971

操作: synchronize(重审当前 head 5914a1a2
范围: 46 个文件(43 Java / 13 新增 / 0 删除)/ 4,119 行 diff(+2,284 / −363)
CI 状态: build ✅ · Pull Request Check ✅ · Check Style in Pull Request
diff 完整性: 下载到 46 个 diff --git 头,与 API changed_files=46 一致;No newline at end of file = 0,无 ghost 文件;无关的生成资源(lang 除外)未混入。


🔴 关键

1. 无适配器的实体会让「整份蓝图(含全部平铺副本)」无法建造 —— 相对 base 的行为回归
EntityBuildAdapters.isTransient() 删掉了原先的 Projectile.class.isAssignableFrom(base),改为只列具体类型;而兜底的 DynamicBuildingEntities.matches(type, nbt) 只认 箭(有 item 键)/投掷物/火球/雪球/蛋/珍珠/药水/末影水晶/TNT/物品/下落方块/生物 + anvilcraft 类型。

实测(1.21.1 客户端字节码,常量池键名):WitherSkull/ShulkerBullet/WindCharge/LlamaSpit 既无 Health 也无 itemAbstractArrowitemItemEntityItem,所以这两类没问题)。于是:

BuildingRodService.java:567   if (type == null || EntityBuildAdapters.isTransient(type)) continue;
BuildingRodService.java:604   EntityBuildAdapter adapter = EntityBuildAdapters.find(probe, transformed).orElse(null);
BuildingRodService.java:605   if (adapter == null) { unsupported(player, type.getDescription()); return false; }

扫描区域里只要残留一个凋灵之首 / 潜影贝导弹 / 风弹 / 烟花火箭 / 羊驼口水,planBlueprint 立刻 return false,玩家看到「No supported building material for blueprint object: …」,蓝图彻底无法粘贴(多格平铺也一起失败);base 里这些实体会被静默跳过。

建议:isTransient(EntityType) 恢复 Projectile.class.isAssignableFrom(base)(对确实无适配器的投射物兜底),或把 adapter == null 改为「跳过该实体 + 用 unsupported_type 汇报」,不要中止整次建造。同理 :570probe == null 也建议只跳过该实体。


⚠️ 警告

2. BlueprintTicks.read() 抛的异常类型不在 blueprints() 的 catch 范围内

BlueprintTicks.java:44   BlockPos pos = NbtUtils.readBlockPos(entry, "pos").orElseThrow();   // NoSuchElementException
BlueprintTicks.java:45   ResourceLocation type = ResourceLocation.parse(entry.getString("type")); // ResourceLocationException

两者都不是 IllegalArgumentException(已核对字节码:ResourceLocationException.super = java.lang.RuntimeException),而三处调用点只 catch IOException | ConstructionBlueprintException | IllegalArgumentException

  • BuildingRodService.blueprints()(try 内 BlueprintNormalizer.load
  • BlueprintClientFiles.receive()(客户端预览)
  • StructureScannerFiles.handle()

结果:手工构造/他人分享的 .nbtanvilcraft:scheduled_tickspos、或 type 是非法 id,就会逃逸到网络包处理层(服务端玩家断线 / 客户端崩溃),而不是给出「invalid_structure」提示。注意同一个方法对「未知 type」「条目 > 32768」是抛 IAE 的,说明意图就是要被 catch —— 这里属遗漏。建议统一改抛 IAE(或调用点 catch RuntimeException)。LitematicaImporter 里同样的 ResourceLocation.parse(tick.getString(...)) 也是这个问题。

3. sanitize() 从白名单改为「整份实体 NBT 复制」,蓝图文件成了任意实体数据的注入面

BuildingEntityTransform.sanitize():  CompoundTag safe = tag.copy(); safe.remove("UUID"); safe.remove("Passengers");

这条改动是「恢复装备/库存/乘骑」的基础,方向没问题,且已做了不少防护(onlyOpCanSetNbt 校验、Brain.memories 越界记忆剔除、ArmorItems/HandItems/Inventory 抽取成材料后清空、命令方块矿车需 op)。但整份复制会一并带入 DeathLootTableMob 会持久化它)、drop_chancesActiveEffectsAttributes、村民的 Offers 等。也就是说:一份手工构造/服务器上他人提供的蓝图,可以让「用刷怪蛋/树脂封存生物建出来的怪」携带自定义掉落表或自定义交易,击杀/交易即可获得未支付的材料 —— 这是资源无中生有的口子。建议对这类「会产生未支付收益」的键做剔除或按材料计价(至少 DeathLootTable、交易表)。

4. undo.consumed() 在放置前调用,实际是空转(意图不明)

BuildingRodService.java:745   undo.consumed();      // ← 在 place.run() 之前

BuildingRodUndo.consumed()region.restoredMaterials()(快照内容 − 当前内容)去抵扣 receipt.materials。但 region放置前捕获的,consumed() 也在放置前调用,此刻「当前 BE 内容 == 快照内容」,差集恒为空(除非两条 BlockEntityContentAdapter.extract 重载对同一方块实体结果不一致,那反而会错扣玩家退款)。若意图是「不退还由旧容器自带的内容」,应移到 place 之后再调用;若已无需求,建议删掉这段以免误导。


💡 建议

  • DynamicBuildingEntities.java:157sliding.setMoveDirection(sliding.getMoveDirection()) 是自赋值空操作(读同一个 getter 再写回),看不出副作用。若本意是「按旋转后的朝向重设滑行方向」,这里少了变换;若是为了触发同步,请注释说明。
  • BuildingRodUndo 的事件回调开销spawnedBy() / blockDrops() 是全局事件,被任何实体/掉落物生成、任何方块破坏触发,内部遍历 HISTORYregion.containsEntity(uuid) 是线性扫描。建议把快照实体换成 Set<UUID>(O(1)),并把 SavedEntity.original 强引用换成 RemovalReason,避免撤销历史在玩家不撤销时长期持有实体对象。
  • BuildingCommit.activatePlacedstate.onPlace(level, pos, Blocks.AIR.defaultBlockState(), false) — 一律以 AIR 作为「旧状态」,对个别依赖旧状态决定行为的方块(如合并/连接类)会与真实情况不一致;撤销路径其实拿得到真实旧状态,可考虑一并传入。
  • 中文翻译未随 PR 更新en_us/en_ud 已生成 3 个新键(undo_partialunsupported_typeselection_size),但 zh_cn/zh_hk/zh_tw/zh_meme 均缺失这 3 个键,且被改写的 screen.anvilcraft.building_rod.distance / traditional.hint / optimized 仍是旧译文(中文玩家会看到英文或旧文案)。仓库里 zh_cn.json 最近一次改动是 Weblate 提交,若工作流是合并后由 Weblate 补齐,忽略此条即可;否则请把中文一并补上。
  • BuildingRodService.java:447undoBoundsplacements.getFirst()/getLast() 的包围盒 + encapsulate 求并集 —— 已核实 1.21.1 的 BoundingBox.encapsulate(BoundingBox)原地修改并 return this(字节码含 6 组 getfieldputIntputfield),所以忽略返回值是正确写法,不是 bug。仅提示:实体位置若略微超出 placement.bounds(size) 会落在撤销范围之外(旧实现用 bounds(planned) 从实际单元格/实体位置求并集)。

🟢 看起来不错

  • 「恢复结构不误触发侦测器」的实现是自洽的BuildingCommit.ACTIVATION ThreadLocal 只覆盖 activate() 窗口(含 previous 保存/还原,可重入),isRestoredObserverUpdate 同时要求「已放置的观察者朝向一致 + 源方块等于蓝图声明状态 + 世界当前状态与声明一致」,所以只压制蓝图内部形状更新(正是 Level.neighborShapeChangedupdateShape 触发观察者的路径),外部变化仍会正常传递。BlueprintLevelMixin 注入的签名 (Direction, BlockState, BlockPos, BlockPos, int, int) 与 1.21.1 Level.neighborShapeChanged 完全一致(已用官方 mappings 核对)。
  • 平铺的资源上界做得很稳BlueprintPlacement.tile() 先按 countX/Y/Zcount*entries > MAX_BLOCKS 双重拦截、返回空列表并由调用方回 too_manygetXSpan/getYSpan/getZSpan 恒 ≥ 1 不会除零;placements.isEmpty() 检查在 getFirst() 之前,无 NPE 风险。
  • 材料系统的「工具保留」语义闭环retainedTool 在不增加 reserved 的前提下扣掉一格占用,reserveBlock/reserveItem/reserveContainer/reserveCreature 都按 + (retainedTool ? 1 : 0) 让位,失败路径统一走 restoreSources(before, toolsBefore)(含 toolsBefore 快照,回滚完整),并在非创造模式用 source.reserved - before[i] 重建 materials/returned/fluidPayments,避免把工具误算成消耗。
  • 撤销从「逐组核对」升级为「区域快照 + 分组回执」是正确的方向canRestore 逐块跑 canModify + CommonHooks.fireBlockBreak、逐实体跑 mayInteractrestoring 标志避免恢复过程自身被 replaced() 记账,BlueprintLeashes.link 创建的栓绳结通过 recordAuxiliary 纳入撤销(不会遗留孤立方块实体)。
  • 导出重名自增序号(_1_2…)并在客户端校验 + 回显实际文件名;Files.move 去掉 REPLACE_EXISTING 后同目录 rename 会按预期抛 FileAlreadyExistsException,临时文件仍在目标父目录,跨文件系统回退风险不存在。

📋 声称验证表

PR 声称 状态 对应实现
拖拽平铺投影、松开锁定、再右键批量粘贴,统一预览与放置范围 BlueprintPlacement.tile / BuildingRodClient.blueprintPlacements·selectionBounds·beginBlueprintSelection / BuildingRodPacket.Action.PLACE_BLUEPRINTS / BuildingRodService.blueprints / BuildingRodRenderer.renderCopy
选区距离锁定、方向调整、尺寸显示 canAdjustDistance/controlAvailable/applyControl(index≥2 → selectionOffset) + screen.anvilcraft.building_rod.selection_size
同步更新操作提示与中英文翻译 ⚠️ 英文(en_us/en_ud,数据生成)✅;zh_cn/zh_hk/zh_tw/zh_meme 缺 3 个新键且旧提示未更新
统一扫描快照采集,保存/恢复运行进度、计划刻与时间基准 BlueprintCapture(服务端统一入口,StructureSaveUtil.buildSnapshot 改走它)、BlueprintTicks.capture/restoreBlueprintRuntimeData.rebase/restoreClockStructureSnapshotCodec 写入 anvilcraft:captured_at
兼容 Litematica 计划刻导入 ⚠️ 待作者确认 LitematicaImporterPendingBlockTicks/PendingFluidTicks + x/y/z/Block·Fluid/Time/Priority/SubTick(离线无法核对 Litematica 实际写出键名,建议贴一份真实 .litematic 验证;另见警告 2 的 ResourceLocation.parse
建造后红石/流体/邻居更新,避免误触发侦测器 BuildingCommit.activate/activatePlacedset()onBlockStateChangeisRestoredObserverUpdate + BlueprintLevelMixin
扩展生物/掉落物/投射物/移动方块/模组实体,恢复运动、乘骑、实体引用 ⚠️ DynamicBuildingEntitiesBlueprintEntities.flatten/identities/scope/link/remapBuildingEntityTransform(Motion/TileX/SlidingBlocks/Anvilcraft 实体)、SlidingBlockEntity/AnimateAscendingBlockEntity/CauldronOutletEntity 补存档 — 但见警告 1
树脂封存生物供料,补全装备/库存/流体/特殊方块材料 BuildingMaterials.reserveCreature(树脂优先,退化为刷怪蛋)、DynamicBuildingEntities Mob 装备+InventoryCarrierVanillaBuildingEntities 坦克流体、BlueprintSpecialBlocks.material/extraBuildingBlockMaterial:31
磁铁/铁砧锤/点火工具需求校验,显示不支持对象 requiredTool/requiresHammer/ignitionsreserveTool/reserveHammer/reserveConsumable + unsupported_type 消息
实体纳入撤销,仅撤销状态未变化且允许修改的组 BuildingRegionSnapshot + Receipt + canRestore/canRemove(详见警告 4)
导出重名自动加序号并反馈实际文件名 StructureBlueprintFiles.write 返回文件名 → StructureScannerFiles.sendFileBlueprintClientFiles 校验/显示

关联 issue(#4963/#4964/#4966/#4968/#4970:5 个均仍为 open(PR 未合并,符合预期),标题主题与本次改动逐条对应:空中取点放方块 ✅、区域范围显示 ✅、蓝图批量放置 ✅、磁化节点导致无法粘贴(MagnetizedNodeBuildAdapter.support 按碰撞高度反查支撑方块)✅、背包有打火石/火焰弹可放火 ✅。


🧪 测试建议

被测目标 场景 优先级
BlueprintPlacement.tile last == anchor(count=1);单轴反向拖拽;count*entries > MAX_BLOCKS 返回空;尺寸 1×1×1(不除零) 🔴
BuildingRodService.planBlueprint 实体循环 区域含 凋灵之首/潜影贝导弹/风弹(无适配器)时应跳过而非中止;含箭/掉落物应正常 🔴
BlueprintTicks.read pos、非法 type、条目 > 32768 —— 断言抛 IllegalArgumentException 并被 blueprints() 捕获 🔴
BuildingMaterials.reserve 工具保留(retainedTool 不消耗、材料让位一格);树脂优先于刷怪蛋;失败后 reserved/retainedTool/returned/fluidPayments 完全回滚 🟡
BlueprintRuntimeData.rebase/restoreClock capturedAt=0 与 >0;RemainingWaitingTime/PhaseDuration 分支;PhaseStartGameTime 仍为 long 🟡
BuildingRodUndo.undo 二次调用(restored=true 后不重复恢复区域);实体被玩家拒绝交互 → undo_partial;栓绳结辅助实体被回收 🟡
StructureBlueprintFiles.write 连续同名 _1/_2;超长名截断至 128;目标已存在为目录时不死循环 🟢

结论: COMMENT(倾向修复后合并) — 主体设计与实现质量高(平铺上界、工具保留语义、激活窗口内的观察者抑制、区域快照式撤销都经得起推敲),但 🔴1「无适配器实体导致整份蓝图无法建造」是相对 base 的功能回归且修复成本极低,⚠️2 的异常类型遗漏会让畸形蓝图逃逸到包处理层,建议一并处理后再合并;⚠️3(任意实体 NBT)与 ⚠️4(空转代码)可按维护者判断。

说明:PR 标题 Support repeated projection of blueprints and improve runtime status and entity restoration 支持蓝图重复投影并完善运行状态与实体恢复 准确清晰,未改标题(本环境的 gh 凭据已失效,gh auth status 报 token invalid,符合流程中的回退约定)。


由 Hermes Agent 审查

@Gugle2308

Copy link
Copy Markdown
Collaborator

审查完成。以下是审查意见(Webhook 会自动发布为 PR 评论;未执行任何 gh 评论命令,gh auth status 显示 token 失效,故未修改标题——标题建议见文末)。


代码审查摘要 — PR #4971

范围: 46 个文件(43 Java,13 新增,0 删除)/ +2284 −363
HEAD: 5914a1a2 · CI build + checkstyle 均 success(故本次审查聚焦运行时语义,不再逐项查 API 是否存在)

先说结论:整体质量很高——材料账单/退款记账、实体快照与引用重建、静默提交后的统一激活,都是相当讲究的实现。下面按严重程度列出问题。

✅ 已交叉验证的运行时前提(高风险点,均通过)

官方 1.21.1 client_mappings 逐条核对,这几处若写错会是启动崩溃级问题,实测无误:

检查项 结论
BlueprintLevelMixinneighborShapeChanged 的 handler 形参顺序 ✅ 签名就是 (Direction, BlockState, BlockPos, BlockPos, int, int),与 (direction, source, pos, sourcePos, flags, recursion) 完全一致(顺序错 = Mixin 启动报错)
BlueprintBlockEventsAccessor@Accessor("blockEvents") ServerLevel.blockEvents 声明类型恰为 it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet,类型可赋值
Leashable / LeashData.delayedLeashInfo / setLeashedTo(Entity,boolean) ✅ 1.21.1 已存在(非 1.21.5+ 专属)
LevelChunkTicks.getAll() ✅ 返回 StreamLevelChunkTicks 强转多余但安全

🔴 关键(需修复或明确答复)

1. BuildingRodUndo 区域撤销可复制物品,且会静默删除他人掉落物 — BuildingRodUndo.java:131-182,191-250 + BuildingRegionSnapshot.java:126

  • 复制路径:建造 → 挖掉区域内方块并拾取掉落 → Ctrl+Z 撤销。此时 region.restore() 无条件把区域恢复成建造前状态(方块回位),receipt.materials 又全额退还材料。玩家净得被挖掉的方块物品 材料全数返还 —— 仅受建造杖 FE 消耗限制。
    • 旧实现刻意堵住了这条路:PlacedGroup.replaced + Saved.matches() 会跳过"被改动过的组"(不退材料)。本 PR 移除了该守卫(注释也写明"区域恢复不依赖当前方块或实体仍与蓝图相同"),属于有意取舍,但复制漏洞是净新增的
    • undo.consumed() 只对"区域内 BE 库存被建造消耗"做了抵扣,覆盖不到"方块被玩家挖走后拾取"这一路径。
    • 建议最小防护:撤销前校验建造组方块是否仍在(或 derived 中的掉落物是否仍存在),不满足则只恢复区域、不退还材料;若确为有意设计,请在 PR 描述里写明取舍。
  • 删除路径:blockDrops/spawnedBy任何玩家在区域内挖掘/击杀产生的掉落都记入 derived,撤销时 entity.discard() 删除。虽与"区域恢复会重建方块/生物"自洽,但会连带删掉其他玩家(或本玩家事后)在区域内获得的、仍留在世界里的物品,且没有任何提示。

2. 撤销快照区域 = 平铺副本的并集包围盒(含空洞),且无体积上限 — BuildingRodService.java:447-449BuildingRegionSnapshot.java:44-96

  • undoBoundsplacements.getFirst()/getLast().bounds().encapsulate() 得到并集盒,其中包含副本之间的空隙(不是建造格子)。BuildingRegionSnapshot 对盒内每个位置做 BuildingRodService.canModify(世界边界/区块加载/mayInteract)+ getBlockState + getBlockEntity,非空 BE 还要 saveWithFullMetadata
  • 建造格子数受 MAX_BLOCKS=4000 限制,但盒体积不受限:16³ 大小的稀疏蓝图(非空方块极少 → entries 小)平铺 2×2×2 时盒可达 48³≈1.1e5 个位置;canRestore()/restoredMaterials() 还要各自再遍历一遍。这是一次同步的、可感知的卡顿 + 可观分配。
  • 附带影响:盒内空隙位置也要求 canModify 通过,导致放置本身合法但撤销因空隙落在他人保护区/未加载区块而失败,且失败信息是 invalid_structure(见 💡6)。
  • 建议:按副本分别取各自 bounds 的并集改为按"建造格子 + 副本 hull 的格子集合",或对 undoBounds 体积设上限并给出明确提示。

⚠️ 警告

3. 结构扫描快照出现"服务端/客户端双路径",预览与实际导出可能不一致 — StructureSaveUtil.java:154-158

if (blockEntity.getLevel() instanceof ServerLevel serverLevel) {
    return BlueprintCapture.capture(serverLevel, blockEntity.getScanBounds());
}
// 以下是原来的 scannedBlocks 缓存路径

buildSnapshot 的调用方分成两侧:服务端(buildStructureNBT → 磁盘保存/导出)永远走实时采集并忽略 scannedBlocks 参数;客户端(StructureScannerScreen:1196DiskDisplaySupport:89)仍走缓存路径。后果:

  • 扫描完成后玩家改动了世界(开箱/拿走方块/生物移动),玩家在扫描仪界面看到的预览、材料需求与实际写出的 .nbt 内容会不一致;
  • scannedBlocks 形参在服务端已成死参数,缓存分支在服务端不可达(建议至少注释澄清哪一侧是权威,或让两侧共用一条采集路径)。

4. 平铺预览按副本重复绘制,无副本数上限 — BuildingRodRenderer.java:142-144,148-

renderCopy 对每个副本都执行一次 mesh.bind()/drawWithShader() 完整网格绘制,并把 ENTITIES/PREVIEW_ENTITIES 的 BE/实体渲染再跑一遍(每副本一次 endBatch())。blueprintPlacements() 只受总方块数限制(最多可达数千个副本,例如单方块蓝图),届时每帧数千次 draw call + BE 派发,客户端会明显掉帧。建议给预览副本数设上限(例如超过 N 个副本只画轮廓盒),或合并为一次批量绘制。

5. 描述与实现不符:"仅撤销状态未变化且允许修改的建造组"

PR 描述(= 第 2 个 commit message)声称"仅撤销状态未变化…的建造组",但 undo() 现在无条件恢复整个区域快照:canRestore() 只做权限/破坏事件校验(BuildingRegionSnapshot.java:77-96),不再检查建造组是否仍与蓝图一致;原 replaced/matches 逻辑已删除,changedPositions 仅用于追踪衍生实体。建议同步修正描述(message...nothing_to_undo 的文案已从 "No unchanged placement" 改成 "No placement",描述也应随之更新)。

💡 建议

  1. DynamicBuildingEntities.java:156-158sliding.setMoveDirection(sliding.getMoveDirection()) 是自赋值,唯一效果是重发一次 SlidingEntitySyncPacket;实体刚 addFreshEntity,客户端马上就会收到生成包,这一句是非必要的冗余(若本意是强制重同步,请加注释说明)。
  2. BlueprintRuntimeData.FIELDS对所有方块实体生效的全局 NBT 键表(含 Output/Faces/cd/phase/progress/Powered 等极通用名字)。虽然 runtime 会 merge 回去所以语义等价,但任何模组 BE 的同名"配置"字段都会被无差别挪进 runtime;建议按 BE 类型分桶,或在类注释里说明该表为何安全。
  3. 权限/区块失败统一报 invalid_structureblueprints()catch),会把"区域内某格不可修改/区块未加载"误导成"蓝图损坏";建议区分消息(blocked 已有独立文案)。
  4. 平铺可达性只校验 anchorlastBuildingRodService.java:427-429withinReach(...,26)),实际最远副本会超出 last 最多 span-1(16 宽蓝图即 +15 格)。建议对最远副本的 bounds 校验,或在 tile() 里按可达距离裁剪。
  5. BlueprintEntities.flattenresult.size() >= MAX_ENTITY_ENTRIES(4096)时抛 "Too many blueprint passengers",该异常也会从扫描/导出路径抛出(StructureScannerFiles.handle 会捕获并回显),文案对"区域里实体太多"的场景不合适。

🟢 看起来不错

  • BuildingMaterials 的工具/生物/点火/拴绳预占逻辑严谨:retainedToolreserved+1 保护使"工具那一格"不会被当材料吃掉,reserveBlock/reserveItem 的负值路径被上界检查封死;按单位增量重建 allocated.materials 并用 restoredMaterials() 抵扣,避免重复退还,思路清晰。
  • 移动活塞支持完整:BlueprintNormalizer 去掉 MovingPistonBlock 抛错后,planBlueprintblockState/progress/facing/extending/source 做了有限性校验与旋转(BlueprintBlockConfiguration.transform),BlueprintBlockEntities.create 专门构造 PistonMovingBlockEntity 并保持"先 load 后 setLevel"的顺序。
  • 弹簧/磁化节点/炼药锅出口的空间引用重建(MagnetizedNodeBuildAdapter.supportOutlet.supportTargetPos/CauldronPos/CauldronState/AttachedDirection 的坐标变换)考虑周全,ScannerDiskNormalizer 也随之同步了 blockPos 与 tick 坐标。
  • 侦测器抑制:isRestoredObserverUpdate 同时校验"记录态 == 世界态"(== 用于 BlockState 恒等,正确),配合 neighborShapeChanged 头部取消,语义与 ObserverBlock.updateShapeFACING == direction 触发条件一致。
  • BlueprintLeashes 同时兼容 leash(新)与 Leash(旧 X/Y/Z 或 UUID)两种格式,并在 link() 里用 getOrCreateKnot 复用已存在的绳结、只把新建的绳结纳入撤销清理 —— 边界处理到位。
  • 导出重名序号 + 回传实际文件名:StructureBlueprintFiles.writeFiles.move(不带 REPLACE_EXISTING)触发 FileAlreadyExistsException 来探测重名,在 Linux 上行为可靠;客户端 isSafeName + .nbt 后缀校验闭合。

📋 声称验证表

声称 状态 对应实现
拖拽平铺投影、松开锁定后再次右键批量粘贴,统一预览与放置范围 BuildingRodClient.beginBlueprintSelection/blueprintOffset/blueprintPlacementsBlueprintPlacement.tileBuildingRodPacket.PLACE_BLUEPRINTSBuildingRodService.blueprintsBuildingRodRenderer 多副本
选区距离锁定、方向调整、尺寸显示 + 中英文翻译 selectionBounds + selection_size overlay、applyControl 方向偏移、en_us/en_ud/BuildingRodLang
统一结构扫描快照采集(运行进度/计划刻/时间基准,兼容 Litematica 计划刻) ⚠️ BlueprintCaptureBlueprintTicksBlueprintRuntimeDataLitematicaImporter 计划刻导入;但服务端/客户端采集路径分裂(⚠️3)
建造后红石/流体/邻居更新,避免误触发侦测器 BuildingCommit.activate/activatePlacedisRestoredObserverUpdate + BlueprintLevelMixin
生物/掉落物/投射物/移动方块/模组实体可建造,恢复运动、乘骑与实体引用 DynamicBuildingEntitiesBlueprintEntitiesisTransient 放宽、Motion/Rotation 变换、VanillaBuildingEntities
树脂封存生物供料,补全实体装备/库存/流体与特殊方块材料 BuildingMaterials.reserveCreatureDynamicBuildingEntities.plan(Mob)insertContents(EquipmentSlot/InventoryCarrier)BlueprintSpecialBlocks
磁铁/铁砧锤/点火工具校验,并显示不支持的具体对象 MagnetizedNodeBuildAdapter.requiredToolOutlet.requiresHammerignitions + 打火石/火焰弹回退、unsupported_type
实体纳入撤销,仅撤销状态未变化且允许修改的建造组 ⚠️ 实体纳入撤销 ✅;"仅撤销状态未变化"未实现(见 ⚠️5)
导出重名自动加序号并反馈实际文件名 StructureBlueprintFiles.write 返回实际名 + StructureScannerFiles/BlueprintClientFiles 校验回传
resolved #4963 / #4964 / #4966、fixed #4968、resolved #4970 ⚠️ 5 个 issue 均仍 open(未合并,属预期)。#4964(范围显示)/#4966(蓝图批量放置)/#4968(磁化节点导致无法粘贴)/#4970(打火石/火焰弹放火)映射明确 ✅;#4963"在空中找点凭空放置"仅由 Ctrl 悬浮 + selectionOffset 间接覆盖,且 ClipContext.Block.COLLIDER 反而要求起点必须命中碰撞方块,请确认是否真正解决

🧪 测试建议

被测目标 推荐场景 优先级
BuildingRegionSnapshot.restore() 建造后挖掉方块→拾取→撤销(校验物品/材料是否重复);另一玩家在区域内挖掘后撤销(掉落物是否被误删) 🔴
BuildingRodService.blueprints() + BlueprintPlacement.tile() 稀疏大尺寸蓝图 × 2×2×2 平铺:盒体积/耗时、undoBounds 是否含空隙、空隙落在保护区内时的报错文案 🔴
BlueprintTicks / BlueprintRuntimeData.restoreClock 计划刻与 PhaseStartGameTime/RollStart 在跨维度/长时间后放置与撤销的时钟重建;capturedAt==0(Litematica 导入)分支 🟡
BuildingMaterials.reserve* 工具被同时当作材料、多组共用单一磁铁/铁砧锤、拴绳/点火消耗不足时的 missing() 清单 🟡
StructureBlueprintFiles.write 同名连续导出 N 次(foo.nbtfoo_1.nbt),临时文件重名/移动失败后的重试 🟢

结论: REQUEST_CHANGES

两项需先明确处理:(1) 区域快照撤销带来的物品复制 / 连带删除他人掉落物,以及 (2) 平铺副本并集包围盒导致的撤销快照体量(含空洞)无上限。其余为警告与改进建议;API/Mixin/访问器层面的高风险点已用官方映射逐条验证通过,未发现编译或启动级问题。

标题建议gh token 失效,本次未自动修改):
Support tiled blueprint projection, region-snapshot undo and runtime/entity restoration 支持蓝图平铺投影、区域快照撤销与运行状态/实体恢复
(现标题未涵盖"区域快照撤销/计划刻与运行状态恢复"这两块主要工作。)


由 Hermes Agent 审查

@Gugle2308

Copy link
Copy Markdown
Collaborator

💾 Self-improvement review: Patched SKILL.md in skill 'building-rod-blueprint-review' (1 replacement). · Patched references/blueprint-repetition-and-runtime-entity-restore-review.md in skill 'building-rod-blueprint-review' (1 replacement). · Patched SKILL.md in skill 'mixin-injection-verification' (1 replacement).

Copilot AI review requested due to automatic review settings September 21, 2026 11:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Gugle2308

Copy link
Copy Markdown
Collaborator

⚠️ Dangerous command requires approval:

cd /tmp && curl -s "https://api.github.com/repos/Anvil-Dev/AnvilCraft/pulls/4971/commits" -o commits.json && python3 -c "
import json
d=json.load(open('commits.json'))
for c in d:
    print(c['sha'][:...

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

已完成核对。以下是审查意见(webhook 会自动发布,我没有执行任何 gh 评论命令)。


代码审查摘要 — PR #4971

操作: synchronize
范围: 61 个文件(58 Java,15 新增,0 删除)/ 5195 行 diff(+2968 / −484)
修订判定(§0 必做项): 撤销子系统已是第三形态——BuildingRodUndoBuildingRegionSnapshot + Receipt 区域快照模型(PlacedGroup/Saved.matches() 状态门已删除),本轮又在三形态基础上加了 receipt.accepted / canRemove / auxiliary / derived / changedPositions / undo_partial。因此前几轮"含实体的组永久不可撤销"的结论已作废,本评论按当前代码重新核对。


🔴 关键

1. 区域快照无条件恢复 ⇒ 「挖走方块并拾取」可稳定复制物品(BuildingRegionSnapshot.canRestore + restore()

  • canRestore() 对每个快照格只做两件事:canModify 权限、以及
    if (current != block.state() && !current.isAir() && fireBlockBreak(...).isCanceled()) return false;
    当前格是空气时直接跳过校验,即"被挖掉的格子"恰恰不设门槛。
  • restore() 恢复的是建造前快照(SavedBlockcommit 里、放置之前采集),所以被挖走的建造方块会被写回"建造前"(通常空气)。
  • 退款走 receipt.materials(本轮改为按 source.reserved 增量重建,等于玩家实际支付的全部材料),且 undo.consumed() 的抵扣恒为空(见 🔴2) ⇒ 材料全额返还。

复现:建造 → 挖掉建造出的方块并立刻拾取掉落 → 撤销 ⇒ 区域回到建造前状态,玩家净得该方块物品 + 材料全额返还
derivedblockDrops / spawnedBy)只对"掉落物实体仍在世界里"的情况生效:remove()find(level, uuid) == null静默 continue,已被拾取的掉落无法召回,也没有任何提示。

建议:撤销退款前校验"该 receipt 对应的建造格是否仍为建造结果"(或 derived 中掉落是否仍存在),不满足时只恢复区域、不退款/只退未受影响的部分;否则请在描述里明示这一取舍(旧注释"避免拆除后来放置的替代品"已随重写删除)。

2. undo.consumed() 的抵扣是 no-op(调用时机在放置之前)

commit 内的顺序是:new BuildingRodUndo(...)(采集区域快照)→ materials.consume()undo.consumed()place.run()
BuildingRegionSnapshot.restoredMaterials() = 「快照内容物 − 当前内容物」,在 place 执行前世界尚未被改动 ⇒ 结果恒空 ⇒ receipt.materials 一次都不会被 shrink
若该抵扣的意图是"扣除被建造覆盖掉的区域内客物",需要移到放置之后(或在 undo() 里调用)。请确认是否为预期。


⚠️ 警告

3. 实体适配器缺失会中止整份蓝图(含全部平铺副本)
BuildingRodService.planBlueprintEntityBuildAdapters.find(probe, transformed).orElse(null) == nullunsupported(player, type.getDescription()); return false;plan.unsupported() 同款;blueprints()if (!planBlueprint(...)) return; ⇒ 规划阶段整体失败,一块不放。触发面由键匹配决定:DynamicBuildingEntities.matches(type, nbt) 只认 BlockState/Health/刷怪蛋/item 或显式类型白名单,因此烟花火箭、风弹、凋灵之首、羊驼唾沫等(NBT 无 item/Health、非 Mob)会命中不了。本轮又把 ItemEntity/ProjectileisTransient 移除(掉落物/投射物可建造,是特性),于是"先前被静默忽略的杂物"现在会 abort 整次建造。建议 continue 跳过并汇总"已跳过 N 个对象",只有方块不可建造时才 abort。

4. BlueprintRuntimeData.takeBlueprintBlockConfiguration.take第一行执行 ⇒ 按类型清洗变死码、值绕过钳制
final CompoundTag runtime = BlueprintRuntimeData.take(entity, source);(首行)→ 末尾 result.merge(runtime)原值盖回。FIELDS 是通用键表(Cooldown/CooldownTicks/Rolling/Faces/Output/RollStart/phase/progress/currentPlacementIndex…),这些键被提前搬走后,后面读 sourceinteger(source, result, "Cooldown", 0, 3)、骰子的 remove(source, "Rolling","Faces","Output","RollStart")、smartPlacer 的 phase/progress 全部读不到 ⇒ 清洗/丢弃逻辑失效。建议"先按类型收集 settings,再取 runtime",或对需要钳制的键单独处理。

5. BlueprintTicks.capture 把接口强转成具体实现
((LevelChunkTicks<Block>) chunk.getBlockTicks()).getAll() —— LevelChunk.getBlockTicks() 声明返回 TickContainerAccess<Block>;任何包装/替换 tick 容器的模组实现都会在导出/撤销快照时抛 ClassCastException(外层只剩一条 warn 或归并成 invalid_structure)。建议 instanceof LevelChunkTicks<Block> 守卫,拿不到就退化为"无计划刻"。流体侧同款。

6. 采集/归一化路径上的「严苛浮点匹配 + throw」
MagnetizedNodeBuildAdapter.support()|pos.getY() + height − entity.pos().y| < 1.0E-5 定位支撑方块,height 来自 getCollisionShape(EmptyBlockGetter.INSTANCE, pos),而实体 Y 是用真实 level 算的 ⇒ 形状依赖世界上下文时永不相等 → throw new IllegalArgumentException整份结构作废。采集路径不要抛,建议降级(跳过该条/同列最近候选)。

7. 撤销快照 = 平铺副本并集包围盒,逐格 canModify 且无体积上限
BuildingRegionSnapshot 构造里对盒内每一格调用 BuildingRodService.canModify,任一格失败就 throw new IllegalArgumentException("Undo area is unavailable"),最终被 blueprints()catch (... | IllegalArgumentException) 归并成 message("invalid_structure") —— 文案与真实原因(他人保护区 / 区块未加载)不符。另外该遍历成本是盒体积,不受 MAX_BLOCKS=4000(只约束方块数)限制,稀疏蓝图平铺后可达 1e5 量级,且同步执行在 materials.consume() 之前。建议按副本分别取并集或对盒体积设限 + 区分消息。

8. blockDrops 不区分破坏者 ⇒ 会连带删除他人在区域内的掉落物
BuildingRodUndo.blockDrops(BlockDropsEvent) 只判 region.contains(pos),不判 event.getBreaker(),并把该次掉落全部计入 derived;撤销时 undo.remove(undo.derived) 无条件 discard。他人(或自动装置)在区域内挖方块的掉落会被静默销毁,同时该格被恢复成快照状态(同一物品两处收益)。建议至少限定为"建造者本人 / 建造产生的掉落"。

9. lang 四层:中文侧仍未同步(本轮新键全部缺)
zh_cn.json 不在 diff 中(grep -c zh_cn /tmp/pr4971.diff = 0)。经目标分支核对:

  • 缺键:message.anvilcraft.building_rod.unsupported_type.undo_partialscreen.anvilcraft.building_rod.selection_size(中文回退英文);
  • 语义过期:message...nothing_to_undo 中文仍是"没有可撤回且未经改动的放置"(英文已改成 No placement to undo);screen...distance 中文仍是"固定蓝图"(英文已变成 "Lock projection / Hold and drag to repeat projections…")。

en_us.json / en_ud.json / data/lang/BuildingRodLang.java 三层已同步,且新键的 en_ud 逐字符翻转正确(已核对 undo_partial / unsupported_type / selection_size 的翻转串)。属"漏翻 + 文案过期",非 #4916 式的占位符错位,故记 ⚠️

10. 平铺的 reach 校验只覆盖两个角点
if (disk == null || !withinReach(player, anchor, 26) || !withinReach(player, last, 26)) return; —— 最后一份副本仍会向 last 之外延伸一个蓝图尺寸(width−1),实际可放到 ~26+size 格外。建议把份数夹到"远角仍在 reach 内"或对远角追加一次校验。

11. BuildingRodRenderer 对每个副本重复整套绘制
copies 循环里每份各做一次绑定/绘制 + 遍历 ENTITIES/PREVIEW_ENTITIES + endBatch();副本数只受总方块数约束(可达成千)。建议超过 N 份只画轮廓盒或合并批量绘制。

12. StructureBlueprintFiles.write 的序号循环无上界 + 放弃原子移动
for (int index = 1; ; index++) 没有终止上界(异常路径未覆盖);为拿到 FileAlreadyExistsException 改为 Files.move(temporary, target)(无 ATOMIC_MOVE/REPLACE_EXISTING)⇒ 进程中断可能留下半写文件。序号算术 name.substring(0, Math.min(name.length() - 4, 128 - suffix.length())) + suffix 本身正确。建议加循环上界 + 保留原子移动(先探测存在性再 move)。

13. 范围/描述不一致(与 #4963/#4964/#4966/#4968/#4970 完全无关的改动混在同一分支)

  • Iris 天象渲染整合(~380 行、7 文件)integration/iris/CelestialIrisRenderer.java(新,283 行)、mixin/compat/IrisStellarMixin.java(新)+ anvilcraft.mixins.json 注册、CelestialForgingAnvilBlockEntityRenderer / CelestialBodyRenderer / PlanetAtmosphereRenderer / StellarEmissionRenderer / RenderSupport
  • 电网充电分配重构PowerGrid.flush + WeatherproofChestplateItemrefreshGridDemand 拆成 clearGridDemand/refreshGridDemand(player, available))+ IonocraftBackpackItem 删调用;
  • 另杂项:PulseGeneratorBlockEntity 注释改写、IonocraftBackpackItem 单行删除、BlockPlacementUtil 多部件放置偏移。
    这些都不在本 PR 描述中。建议拆分为独立 PR(尤其 Iris 部分体量大、无法与蓝图逻辑一并评审);若确属有意合并,请补描述。
    建议标题(本次未自动修改)Support repeated blueprint projection, runtime/entity restoration, and Iris celestial integration 支持蓝图重复投影与运行状态/实体恢复并整合 Iris 天象渲染

💡 建议

  • BuildingMaterials.missing() 仍会改预留状态reserveTool / reserveConsumable / reserveCreature(group.creature, new Group()) 会递增 source.reserved。当前安全仅因为调用方都传 new BuildingMaterials(player);建议拆出只读 missingReport()
  • 缺料提示与预留条件不一致SpawnEggItem.byId(type) == null ? ModBlocks.RESIN_BLOCK.asStack() 给出的是纯净树脂块,而 reserveCreature 要求树脂块带 SAVED_ENTITY 组件且实体类型匹配 ⇒ 玩家照单拿来仍失败。
  • StructureScannerFiles.handle EXPORT 用 sendFile(player, id, name.getBytes(UTF_8)) 复用「文件结果包」携带文件名:客户端 BlueprintClientFiles.receivepacket.total() == packet.bytes().length && isSafeName(...) 反向识别,两侧一致且有 isSafeName 兜底,但语义复用脆弱,建议给结果包加独立字段。
  • BuildingCommit.activateCollectors.toMap(Cell::pos, Cell::state) 在同 pos 重复时抛 IllegalStateException;平铺按整 span 偏移 ⇒ 当前不重叠,但若将来调整 tile() 偏移逻辑需一并核(BlueprintEntityDropsMixinspawnAtLocation 追踪同理,依赖 ItemEntity 唯一性)。
  • SpawnEggItem/生物供料路径fromMaterialmob.finalizeSpawn(..., MobSpawnType.SPAWN_EGG, null) 会走刷怪蛋生成逻辑(可能有额外掉落/属性重置),请确认这是期望的"生物属性完全来自材料"语义。

🟢 看起来不错

  • 材料账目改为按来源增量重建reserve(Group) 末尾把 allocated.materials 清空后按 source.reserved − before[i] 重建、allocated.returnedthis.returned 的本次切片重建 ⇒ 消除了旧版"按 taken.getFirst() 首段计数少还桶"的隐患(跨来源拆分不再少算);同时 8 条失败路径全部统一走 restoreSources(before, toolsBefore)(含 retainedTool 快照),回滚数与失败路径数对齐(已逐条数过)。
  • 工具保留位一致扣减retainedTool ? 1 : 0reserveBlock / reserveItem / reserveContainer 三处一致。
  • 运行状态/计划刻恢复顺序正确BuildingRegionSnapshot.restore()clearBlockEvents + getBlockTicks/getFluidTicks.clearArea,再 quietly 内恢复方块与 BE(BlueprintRuntimeData.rebase 重基相位时钟),最后在 quietly 之外 BuildingCommit.activate(...)(否则恢复的计划刻会被静默 mixin 吞掉);BlueprintTicks.restoresubTickOrder 排序并按当前方块/流体类型匹配后才 scheduleTick
  • BlueprintEntities 的实体引用/运动恢复identities() 用预测量 UUID(nameUUIDFromBytes(old + ":" + anchor))映射别名键,实体自身 UUID 不在映射内;limitMotion 把速度归一到 ≤15.9(正是 Entity.load 归零 >10 分量的规避),restoreMotion 单独回置;link 在 remap 之后才 startRiding
  • BuildingCommit.isRestoredObserverUpdate 用「激活表 + 当前世界状态 + FACING 方向」三重校验抑制建造中侦测器的误触发(neighborShapeChanged HEAD cancel),不误吞真实邻居更新,思路正确。
  • BlueprintCapture 固定 normalize(NORTH, false):新捕获以世界坐标直采,NORTH/false 即恒等变换;旧流水线是"世界→预览帧→按 facing 反向旋转"两次相消,二者同帧 —— 这不是镜像 bug。
  • tile() 的份数/上限估算合理bounds.encapsulate(最后一份) 足以覆盖全域(偏移随 signX/Y/Z 单调);count * (非空气方块数 + 实体数) > MAX_BLOCKS 用每份实体量估算,避免"1 格 × N 份"绕过 4000 上限。
  • 导出回传真实文件名exportBlueprint 返回 String 并回发)解决了"同名文件被覆盖后玩家不知实际文件名"的问题。

📋 声称验证表

声称 状态 证据
拖拽平铺蓝图投影、松开锁定、再次右键批量粘贴 BlueprintPlacement.tileAction.PLACE_BLUEPRINTSBuildingRodClient.beginBlueprintSelection/confirmSelection/blueprintPlacements
统一预览与放置范围限制 ⚠️ 客户端 blueprintPlacements().isEmpty()MAX_BLOCKS 校验齐备,但 reach 只查首尾锚点(见 ⚠️10)
选区距离锁定、方向调整、尺寸显示、中英文翻译 ⚠️ selection_size 已加 + HUD 改造完成;zh_cn 未同步(见 ⚠️9)
统一结构扫描快照采集 BlueprintCapture.capture 直采世界帧 + StructureSaveUtil.buildSnapshot 改走它;客户端预览仍走旧缓存的"导出/预览分叉"请确认(见待确认)
保存并恢复方块运行进度、计划刻与时间基准 ⚠️ BlueprintRuntimeData/BlueprintTicks 机制完整,但 take 时机使按类型清洗变死码(见 ⚠️4)
兼容 Litematica 计划刻导入 ⚠️ LitematicaImporter 有对应改动;delay 语义(相对剩余 vs 绝对)与测试用例建议在描述中补一句
完善建造后红石/流体/邻居更新,避免误触发侦测器 BuildingCommit.activate + isRestoredObserverUpdate + RedstoneWireNetworkManager.restoreBlueprint
扩展生物/掉落物/投射物/移动方块/模组实体建造,恢复运动/乘骑/引用 ⚠️ 覆盖到位,但"不支持的类型会 abort 整份建造"(见 ⚠️3)
树脂封存生物供料、补全装备/库存/流体/特殊方块材料 reserveCreature(树脂优先 2 轮)、EntityBuildAdapters.insertContents 的 Mob 槽位映射、BlueprintSpecialBlocks.extralimitMotion
磁铁、铁砧锤与点火工具需求校验 + 显示不支持建造的对象 requiredTool/requiresHammer/group.tools/reserveTool/ignitions/leads + unsupported_type 提示
实体纳入建造撤销,仅撤销状态未变化且允许修改的建造组 实现已改为无条件区域恢复canRestore 只做权限/破坏事件校验),且会复制物品(见 🔴1);描述未同步
蓝图导出同名自动加序号并反馈实际文件名 StructureBlueprintFiles.write 返回 candidate + StructureScannerFiles.handle 回传(序号循环无上界见 ⚠️12)
resolved #4963/#4964/#4966、fixed #4968、resolved #4970 未逐条核 本轮未拉取 issue 状态做交叉验证(webhook 匿名 API 限额),仅按代码面核对

❓ 待确认(未能实测,需作者确认)

  1. reserveCreature 的树脂返还只写进了本次调用的 allocated.returned,而 reserve 末尾有 allocated.returned.clear() + 从 this.returned 切片重建。若中间没有 allocated.returned → this.returned 的拷贝,则撤销账单(group.returned)不含树脂 ⇒ 撤销不收回,等于白送 1~4 个树脂。请确认该拷贝仍在 reserveCreature 之后执行。
  2. 服务端导出改读实时世界scannedBlocks 被忽略)而客户端预览仍走缓存 ⇒ 预览 ≠ 文件内容;BlueprintTicks.capturelevel.getChunkAt 会强制加载(必要时生成)扫描区内区块,请确认 rangeX/Y/Z 上限下的最坏区块数可接受。
  3. BuildingCommit.set 新增的 serverLevel.onBlockStateChange(pos, previous, state) / state.onBlockStateChange(level, pos, previous) 在 1.21.1 侧的可用性我只做了源码面观察(本地无 1.21.1 反混淆环境),若 CI 未覆盖请留意。

结论: REQUEST_CHANGES — 核心机制(平铺、运行状态/计划刻恢复、实体引用恢复、材料账目重建)设计成体系且多数细节经得起推敲,但 🔴1(区域恢复 + 全额退款 ⇒ 复制物品)与 🔴2(consumed() 抵扣 no-op)直接影响玩家物品守恒,且 PR 描述仍写着"仅撤销状态未变化的建造组"与实现不符;另有 zh_cn 漏翻、实体 abort 整份建造、Iris/电网改动混入范围等需处理。

由 Hermes Agent 审查(本轮已核实项均以代码/目标分支文件为准;未实测项已在"待确认"中标明)

@Gugle2308

Copy link
Copy Markdown
Collaborator

💾 Self-improvement review: Patched references/blueprint-repetition-and-runtime-entity-restore-review.md in skill 'building-rod-blueprint-review' (1 replacement). · Patched SKILL.md in skill 'building-rod-blueprint-review' (1 replacement).

@Gugle2308

Copy link
Copy Markdown
Collaborator

代码审查摘要 — PR #4971

操作: review_requested(state=open, mergeable=true)
范围: 61 个文件(58 Java / 15 新增 / 0 删除),+2907 −423,diff 5195 行
分支: WhereisFff:dev/1.21/fix6Anvil-Dev:dev/1.21/1.6(1.21.x 常规 feature PR,非 26.1 迁移 → 跳过 API 迁移健康度检查)
CI: build ✅ / checkstyle ✅(head 645aa94 的 check-runs)
PR 标题: 准确,无需修改。


🔴 需合并前处理

1. 撤销改为「整区域快照回滚」——与描述不符,且存在刷物品 / 丢物品路径
BuildingRodUndo 构造函数建 BuildingRegionSnapshot(player, undoBounds),把整个 undo 包围盒(含与本次建造无关的位置)的 blockstate + BE saveWithFullMetadata + 区域内实体 NBT 全存下来;restore() 再逐位置重建方块实体(含容器内容)并把实体 load(saved.data())undo() 唯一的闸门是 BuildingRegionSnapshot.canRestore(),它只检查保护权限和「方块状态是否变化」,不比较容器内容/实体数据changedPositions(由 replaced() + LevelChunkMixin 记录)只被 fallingBlock() 用来追踪下落方块,从未参与恢复判定

  • 复制:区域内建造前就存在的箱子 A 里有 8 铁 → 建造(不涉及 A)→ 玩家把铁取出 → 撤销 → A 被快照写回(8 铁回来)+ 玩家身上 8 铁 = 凭空多 8(实体容器/生物库存同理)。
  • 丢失:建造后在区域内新放的方块(自己或他人放的)会被回滚,而回执只覆盖本次建造的材料 → 不返还,等同删除。
  • 旧实现用 Saved.matches(level) 跳过「事后已被改动」的组,新实现的类注释也明说「区域恢复不依赖当前方块或实体仍与蓝图相同」——这是行为回归,且 PR 描述第 9 条「仅撤销状态未变化且允许修改的建造组」已与代码不符。

建议:恢复粒度收敛到 cells(或 changedPositions 白名单),或在检测到域外改动时拒绝撤销并提示,同时更新 PR 描述。

2. BuildingRodUndo.blockDrops 追踪范围过大 → 撤销误删掉落物
blockDrops(BlockDropsEvent) 只按 region.contains(pos) 过滤,把区域内所有方块破坏掉落都塞进 derivedundo()undo.remove(undo.derived) 直接 discard(),且不走 canRemove()/mayInteract()。后果:建造后在区域内挖矿、掉落物还躺在地上时撤销,这些与建造无关的掉落物会被删掉。同文件的 fallingBlock() 却要求 changedPositions.contains(source)——两处口径不一致,建议统一限定到「本次建造产生/改动的位置」。


⚠️ 建议修复

3. BuildingRodUndo.consumed() 时机导致去重逻辑实际不生效(疑似死代码)
调用点 BuildingRodService.commit()new BuildingRodUndo(...) → materials.consume() → undo.consumed() → place.run(),即 BuildingRegionSnapshot.restoredMaterials()(= 快照内容 − 当前内容)在世界尚未改变时计算,before == currentsubtract 恒为 0。提交信息里「按实际消耗记录物品与流体账单,扣除随区域快照恢复的库存,避免重复返还材料」没有真正发生。若确实需要扣除,应放到放置之后计算;若不需要,建议删除以免误导(另:若 BlockEntityContentAdapter 的 NBT 路径与实时路径结果不同,此逻辑反过来会多扣回执材料,建议用测试固定住)。

4. 中文文案未同步
新增 undo_partial / unsupported_type / selection_size 以及改动的 item.building_rod.controlsnothing_to_undodistanceoptimizedtraditional.hint 只进了 generated 的 en_us.json/en_ud.json(en_ud 反向同步 ✅);src/main/resources/.../zh_cn.json 仍是旧文案且缺 3 个新键(de/es/fr/ja/ko/lzh/ru 本就没有 building_rod 键)。若约定由 Weblate 后续同步可忽略,但 PR 描述「同步更新操作提示与中英文翻译」不准确。

5. 扫描快照采集两条路径不一致
服务端 StructureSaveUtil.buildSnapshot() 现在忽略传入的 scannedBlocks,一律 BlueprintCapture.capture(serverLevel, getScanBounds())(= 设定范围内当前世界的最新状态);客户端预览仍走旧的 scannedBlocks 分支。即导出/存盘用「全范围重扫」,预览用「增量缓存」,分层扫描或多 tick 扫描期间两者可能给出不同结果,成本也从「已扫描方块数」变成「设定范围体积」。请确认这是期望行为;若期望统一,建议客户端也走 BlueprintCapture(或至少加注释说明两者差异)。

6. 单次建造的性能与内存占用
每次建造都会为整个 undo 包围盒建快照(逐位置 canModify + state + saveWithFullMetadata + 区域实体 NBT,逐格构造 BoundingBox 清 tick);平铺稀疏蓝图时 bbox 体积可远大于 ≤4000 的 cells。快照与实体引用会一直挂在 HISTORY(WeakHashMap,值为强引用的 ServerLevel/Entity/NBT)直到撤销或玩家对象被回收。加上 commit() 末尾的 BuildingCommit.activate()(逐格 onPlace + clearArea + 6×neighborChanged + updateNeighborShapes/IndirectNeighbourShapes/OutputSignal),建议用 4000 格 + 平铺 8~16 份实测一次 tick 耗时与内存,并考虑只快照本次触及的格子。


💡 建议

  1. BuildingEntityTransform.sanitize 白名单 → 黑名单(整份 NBT 复制,仅删 UUID/Passengers):他人/导入蓝图对非生物实体(盔甲架、矿车、展示框、下落方块……)的 NBT 影响面明显变大。已有的 onlyOpCanSetNbt / COMMAND_BLOCK_MINECART / DynamicBuildingEntities.requiresOperator(校验携带的 BlockState+TileEntityData)覆盖了最危险的一类,但建议补一份「允许由蓝图携带的 NBT 字段」清单,明确 Health/Attributes/Air/Invulnerable 等是否有意放行。
  2. BlueprintRuntimeData.FIELDS 是跨所有 BE 的硬编码通用键名(progress/phase/cd/Powered/Output/Faces…),且会从 NBT 中 move 走再合并回来;第三方 BE 若恰好有同名键会被静默搬运/还原。建议按 BE 类型收敛,或加注释注明已审查的来源。
  3. BlueprintCapture.captureEntitysaveAsPassenger 返回 false 时静默 continue(该实体不进蓝图,无日志),排查「某些实体永远扫不到」会很痛苦,建议加 debug 日志/提示。
  4. StructureBlueprintFiles.write 同名重试时丢掉了 ATOMIC_MOVE(同目录 move 在 POSIX 上仍是 rename,但建议保留原子语义、只捕获 FileAlreadyExistsException 重试);128 - suffix.length() 的长度上限建议加注释。
  5. 范围/拆包:本 PR 混入了与建筑杖无关的改动(Iris 天体合成渲染 CelestialIrisRenderer/IrisStellarMixin/CelestialBodyRenderer.deferPowerGrid 给防水胸甲充电、IonocraftBackpackItem)。建议后续拆分;其中 PowerGrid.flush() 是电网热路径(新增第二次遍历 + 逐玩家重算需求),需要单独的性能验证。
  6. 客户端交互updateTarget()snapshot==null && first!=null && distanceHeld && !floating 时直接 clearSelection(),配合 release()if (distanceHeld) return;,实际是「松开 Ctrl = 放弃选区,需再点一次才放置」——与 item.anvilcraft.building_rod.controls 文案一致,但松开鼠标不会提交,容易误判为已放置,建议提示语再明确一句。

🟢 看起来不错

  • BlueprintPlacement.tile():单轴上限 + count × entries ≤ MAX_BLOCKS 双重限制、超限返回空并 message("too_many")、锚点方向符号处理正确。
  • BlueprintEntities.limitMotion() 等比缩放数学正确(整体压到 ≤15.9,方向不变),并解释了为何要在 load 后用 restoreMotion() 补偿 Entity.load 对 >10 速度分量的清零。
  • 生存模式生物建造走 BlueprintEntities.fromMaterial():以实际材料生成的实体为底,只继承 Pos/Rotation/Motion 与「已付费的连接」,UUID 用 nameUUIDFromBytes 确定性重映射 → 堵住了「一颗刷怪蛋白嫖蓝图装备」的经济漏洞(代价是装备不恢复,注释已说明)。
  • flatten() 对乘客链做 depth > 32 / MAX_ENTITY_ENTRIES 上限并抛异常(放置路径已捕获),anvilcraft:vehicle 重建乘骑关系,路径完整。
  • 拴绳:兼容旧 Leash 复合标签与新的 leash(BlockPos/UUID)双格式,栅栏缺失/无权修改时拒绝建造,撤销时清理无人牵引的绳结。
  • BlueprintTicks:读入侧有 32768 上限、未知类型拒绝、delay clamp;恢复侧按 placed 集合 + 实际方块/流体类型过滤后才 scheduleTick;Litematica 的 PendingBlockTicks/PendingFluidTicks 已接入(含 SubTick 保序)。
  • 侦测器抑制很克制:只在「两端都是本次放置的格子且状态完全一致」时取消 neighborShapeChangedActivation 用 ThreadLocal 保存/恢复旧值(可嵌套)。
  • BlueprintBlockEntities.create() 统一 MOVING_PISTON(PISTON 类型的 id 是 minecraft:piston,此处校验正确),BlueprintBlockConfiguration 对 piston 的 blockState/facing 做了镜像旋转。
  • 工具需求:reserveTool/requiresHammer(磁铁/铁砧锤)用 retainedTool 占位 1 个并在失败时 restoreSources 回滚;missing() 的三处调用都用 new BuildingMaterials(player),不会污染后续真实 reserve

📋 声称验证表

声称 状态 对应实现
拖拽平铺投影 / 松开锁定 / 再右键批量粘贴 (resolved #4966) BlueprintPlacement.tileBuildingRodService.blueprints/planBlueprintBuildingRodPacket.PLACE_BLUEPRINTSBuildingRodClient.beginBlueprintSelection/blueprintPlacements/confirm
选区距离锁定 + 方向调整 + 尺寸显示 (resolved #4963 / #4964) canAdjustDistance/selectionOffset/applyControl(index≥2)selectionBounds() + setOverlayMessage(selection_size)BuildingRodRenderer
统一快照采集 + 运行进度/计划刻/时间基准 + Litematica 计划刻 ⚠️ BlueprintCapture/BlueprintTicks/BlueprintRuntimeData/LitematicaImporter 均到位,但客户端预览仍是旧路径(⚠️5)
建造后红石/流体/邻居更新、避免误触发侦测器 BuildingCommit.activate/activatePlaced/isRestoredObserverUpdate + BlueprintLevelMixin
扩展生物/掉落物/投射物/移动方块/模组实体 + 运动/乘骑/实体引用 DynamicBuildingEntitiesBlueprintEntities.flatten/linkBuildingEntityTransformVanillaBuildingEntities
树脂封存生物供料 + 装备/库存/流体需求 ✅(生存下生物装备按设计不继承) fromMaterialreserveCreatureEntityBuildAdapters.insertContents(Mob)
磁铁/铁砧锤/点火工具校验 + 不支持对象提示 (fixed #4968 / resolved #4970) MagnetizedNodeBuildAdapter.requiredToolDynamicBuildingEntities.Outlet.requiresHammergroup.ignitions → 打火石/火焰弹、unsupported_type
实体纳入撤销 ⚠️ 已纳入(receipt.entities/drops/derived),但区域级回滚带来复制/丢失风险(🔴1、🔴2)
蓝图导出同名自动加序号 + 反馈实际文件名 StructureBlueprintFiles.writeStructureScannerFiles/BlueprintClientFiles 回传名称(含 isSafeName + .nbt 校验)

#4963/#4964/#4966/#4968/#4970 仍为 open,随本 PR 合并关闭,映射均能在 diff 中找到落点。)


🧪 测试建议

被测目标 推荐场景 优先级
BuildingRodUndo.undo + BuildingRegionSnapshot.restore 建造后在区域内取走容器物品 / 放置方块 / 击杀实体 → 断言无复制、无未授权回滚 🔴
BuildingRodUndo.blockDrops/fallingBlock 区域内挖矿留下掉落物 + 建造后撤销 → 断言无关掉落物保留 🔴
BuildingRodUndo.consumed 容器内容 + 回执对账(去重是否生效/是否多扣) 🔴
BlueprintEntities.fromMaterial 生存用刷怪蛋/树脂建造带装备生物 → 断言装备来自材料而非蓝图 🟡
BlueprintEntities.limitMotion/restoreMotion Motion 极大值 (1e6,0,0)、NaN、非 3 元素 → 断言 ≤15.9 且有限,load 后速度保持 🟡
BlueprintPlacement.tile last 与 anchor 同点 / 反向 / 超大 → 空列表 + too_many;平铺包围盒与 cells 数量一致 🟡
BlueprintTicks read/write 未知类型、>32768、负 delay、capturedAt=0(Litematica)→ 拒绝/clamp/回环相等 🟡
StructureSnapshotCodec write→parse 回环含 ticks/capturedAt;旧文件(缺这两个键)兼容 🟡
BuildingMaterials.reserve 工具(锤/磁铁/打火石)保留 1 个不被消耗、restoreSources 回滚、缺料清单含工具与 ×N 数量 🟡
StructureBlueprintFiles.write 连续同名导出 → _1/_2 后缀、名称 ≤128、返回名称可被客户端校验通过 🟢
IrisState/CelestialIrisRenderer 无 Iris、Iris 加载失败、shader reload 后 reset() 无残留缓冲 🟢
PowerGrid.flush 多玩家 + 离线/跨维度组件 → 需求分配不重复计费、getRemaining() 不为负 🟢

结论: REQUEST_CHANGES — 功能面实现完整、覆盖面好(15 个新类、工具/生物/拴绳/实体引用/计划刻都接上了,生存经济漏洞也堵住了),但**撤销语义从「按组校验」改成「整区域回滚」**带来刷物品与误删掉落物两条实际风险,且与 PR 描述不一致;另有 1 处(consumed() 去重)实际未生效、1 处预览/导出采集路径不一致待确认。建议至少修掉 🔴1、🔴2 与 ⚠️3、⚠️4 后再合并。

由 Hermes Agent 审查

@Gu-ZT
Gu-ZT merged commit a378f98 into Anvil-Dev:dev/1.21/1.6 Sep 21, 2026
2 checks passed
@Gugle2308

Copy link
Copy Markdown
Collaborator

操作类型为 closed(该 PR 已于 2026-09-21T12:30:37Z 合并,merged: true),按审查约束第 1 条:closed/labeled 直接停止,不发布审查意见

本轮未做任何代码审查分析、未执行任何 gh 评论/修改命令,也未改动 PR 标题。

建议:如需对已合并的 #4971 做事后审查(post-merge review),请以新的同步事件(例如后续 synchronize/新 PR)或显式指令触发,届时会按完整流程执行。

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

Labels

None yet

Projects

None yet

4 participants