XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

MyDimension

【Java】我的维度模组

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/MyDimension

重构权杖批量操作与历史记录机制

本次重构移除跨tick分帧队列,批量建造/拆除操作在单次交互内即时完成,显著提升流畅性。新增“历史记录”开关,支持独立配置与实时切换,关闭时大幅降低性能开销但不可撤销。协议与数据结构同步升级,协议版本升至4。移除BuilderSurfaceTaskManager,相关逻辑由速率限制器接管。客户端预览渲染策略重构,普通BUILD预览为绿色线框,蓝图部署显示具体模型,并补充单元测试。蓝图保存UI重做,支持异步反馈与错误提示。优化权杖3D模型细节,完善多语言文本。新增历史记录与预览策略相关单元测试。

ce61527
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

24 个文件 +595 -695
Modified README.md +2 -2
@@ -96,11 +96,11 @@ F F F F F
96 96 筑境权杖可在任意维度进行连片铺层、拆层和蓝图施工;主手持有时方块交互距离按服务端配置提升(默认 64 格),但不会增加实体攻击距离。铺层的八邻域遍历只沿准星命中方向上真正可见的连续表面扩展,被地面、背板或其他完整方块遮住的地下部分不会再被预览或施工。潜行右键打开五页工具菜单,中键切换建造/拆除,`Ctrl+右键` 选择蓝图两端点,`Ctrl+Z`/`Ctrl+Y` 撤销或重做。按住 `Ctrl` 时蓝框实时显示候选点;同一次按住期间滚动滚轮后会切换为空气选点,滚轮调整沿视线的距离,松开 `Ctrl` 即重置。部署蓝图时也可用同样方式指定空中锚点。全部快捷操作在菜单中也有入口。
97 97
98 98 - 建造按“绑定锚点 → 主背包 → 快捷栏 → 副手”取材;材料不足的位置保留黄色虚影,右键聚焦任一虚影继续补齐。准星射线进入黄色方格或蓝色选区/部署框的边线与内部体积都会取得唯一焦点并加粗;相连缺料格按 26 邻接视为一组并整体加粗,焦点粗框宽度以约 160 毫秒平滑过渡到普通框的四倍。部署框优先,其余对象按入射距离选择最近者;左键只取消当前焦点,取消部署不会清除源选区。普通建造预览仅显示绿色框,不渲染具体方块;黄色缺料与蓝图投影使用更实的半透明模型,并通过轻微内缩避免相邻投影面共面闪烁。大型预览按 16³ 分区缓存实体 QUADS 线框和投影模型,六面蓝紫裂罅波纹只用于黄色缺料投影;预览距离至少 160 格并随客户端渲染距离提高到最多 512 格,128 格外自动保留完整框架与缺料波纹 LOD,避免近中距离缺块及大型蓝图瞬时烘焙卡顿。
99 - 手动铺层/拆层使用分帧施工任务并共享服务端 `editsPerTick` 预算:默认建造每 tick 最多处理 64 格,因此 256 格铺层分 4 tick;拆除按四倍成本计费,默认每 tick 16 格,因此 64 格拆层同样分 4 tick。整次任务仍合并成一笔撤销事务,材料、掉落和副手耐久账不会拆散。每位玩家只保留一个活动任务,多余快速点击在射线、BFS、供材扫描、历史快照和预览同步前即被合并拒绝,不形成施工队列;蓝图打印仍使用自身的建造上限,不受这项手动施工节流降速。
99 - 手动铺层/拆层在一次交互中立即执行本次设定上限,不再拆成跨 tick 的施工队列。作业页提供每把权杖独立的“历史记录”开关,默认关闭;关闭时不会创建撤销事务、方块实体前后镜像或物资账,从而避开批量施工最重的序列化开销,此时新操作不可撤销/重做。开启后才保留完整事务语义。入口仍会合并拒绝同一时刻的重复快速点击,避免重复射线、BFS 与供材扫描;蓝图自动施工继续使用自身的队列与建造上限。
100 100 - 拆除只有在副手工具类型和等级均合格时才保留正常掉落;每个成功拆除的方块消耗一次工具耐久,不生成经验。副手为空或工具类型不匹配时直接无粒子删除,不走原版 `2001` 破坏特效,也不创建掉落列表。
101 101 - 共鸣供材锚潜行贴附在容器面上使用。它优先访问目标面的 Forge 物品 Capability,并兼容 `WorldlyContainer`、普通 `Container` 和可配置的无侧面 Capability 回退;普通非容器表面会拒绝放置且不扣物品,容器消失或第三方旁路产生非法锚时会正常掉落并保留 UUID/ACL。跨区块重验使用目标区块反向索引唤醒,不扫描方块、不强制加载区块也不常驻 tick。锚点默认私有,可设置公开或白名单。主手持权杖时,只为当前这把权杖有序绑定、且位于当前维度的供材锚绘制紫色方框;客户端用权杖绑定 UUID 与服务端轻量索引快照求交,不扫描附近区块。换物品、换权杖、解绑、换维度或退出世界都会立即清理或重算缓存。
102 102 - 蓝图保存在客户端全局目录 `<游戏目录>/mydimension/blueprints/`,文件扩展名为 `.mindbp`,可跨存档和服务器导入、导出。自动施工每 tick 使用权杖当前“建造上限”作为处理预算;已加载目标跨区块合并成一次供材扫描与历史提交,未加载目标按区块稳定分组并临时租赁,避免以旧的 64 格节流或在区块间逐行往返。服务器会重新验证尺寸、权限、材料、目标方块及完整方块实体 NBT。
103 - 按住 `Alt` 会立即显示裂罅风格的暗紫径向轮盘;九项操作全部使用独立图标(翻转轴、旋转、偏移、重置和保存),选中扇区使用青紫辉光,中央只放大当前图标,不显示动作文本。松开后按原状态机关闭或确认调整。完整源选区存在时始终保留保存图标;服务端选区不再按空闲时间过期,只在重新框选、明确取消、换权杖/维度、退出或停服时清理,保存弹窗允许继续另存或覆盖同一选区。
103 - 按住 `Alt` 会立即显示裂罅风格的暗紫径向轮盘;九项操作全部使用独立图标(翻转轴、旋转、偏移、重置和保存),选中扇区使用青紫辉光,中央只放大当前图标,不显示动作文本。松开后按原状态机关闭或确认调整。完整源选区存在时始终保留保存图标;服务端选区不再按空闲时间过期,只在重新框选、明确取消、换权杖/维度、退出或停服时清理。保存弹窗将方块状态策略独占第一行、保存与取消并排放在第二行;保存后在同一弹窗等待服务端捕获并直接写入本地蓝图库,不再跳转到第二个保存界面。
104 104 - 服务端配置位于 `[builder]`;`enabled=false` 会停用配方、创造栏入口、增距、施工、绑定、远程容器访问和临时区块加载,但不会删除物品、NBT、历史或客户端蓝图库。
105 105 - 权杖与供材锚均使用裂罅风格的原生 3D 模型。权杖采用黑灰虚空石、紫蓝裂晶、扭曲杖身和断裂晶簇,不再使用黄铜、齿轮或扳手机械造型;物品栏以约 45° 斜向展示,第一人称模型缩小并右移以保留视野。模式切换只替换悬浮核心的青蓝/紫蓝反向漩涡动画,本体几何和材质不会整体刷新。供材锚的物品视图为 3D 侧视图,世界模型使用非遮挡几何,避免内缩模型裁掉相邻方块表面。
106 106
Modified src/main/java/com/xfestudio/mydimension/builder/BuilderOperationManager.java +136 -139
@@ -56,10 +56,6 @@ public final class BuilderOperationManager {
56 56
57 57 public static Result executeSurface(ServerPlayer player, ItemStack scepter, BlockHitResult hit) {
58 58 if (!BuilderRuntime.settings().enabled()) return Result.disabled();
59 BuilderSurfaceTaskManager surfaceTasks = BuilderSurfaceTaskManager.get(player.getServer());
60 // Reject repeated click packets before ray validation, BFS planning, history snapshots or
61 // remote inventory access. One player never accumulates a surface-operation backlog.
62 if (surfaceTasks.hasActive(player.getUUID())) return Result.throttled();
63 59 if (PendingBuildData.get(player.getServer()).get(player.getUUID()) != null
64 60 || com.xfestudio.mydimension.builder.blueprint.BlueprintTaskManager.get(player.getServer())
65 61 .hasActive(player.getUUID())) {
@@ -96,10 +92,8 @@ public final class BuilderOperationManager {
96 92
97 93 List<SurfacePlanner.Candidate> locked = lockSurfaceStates(player, plan.candidates(),
98 94 hit.getDirection(), mode);
99 if (!surfaceTasks.start(player, scepter, mode, locked, hit.getDirection())) {
100 return Result.throttled();
101 }
102 return new Result(0, 0, 0, plan.truncated(), null, true);
95 return executeImmediateSurface(player, scepter, mode, locked, hit.getDirection(),
96 UUID.randomUUID(), plan.truncated());
103 97 }
104 98
105 99 public static Result resumePending(ServerPlayer player, ItemStack scepter) {
@@ -119,28 +113,70 @@ public final class BuilderOperationManager {
119 113 .map(entry -> new SurfacePlanner.Candidate(entry.pos(), entry.pos(), entry.state(), 0)).toList();
120 114 BuilderMode mode = task.type() == BuilderTransaction.Type.DEMOLISH
121 115 ? BuilderMode.DEMOLISH : BuilderMode.BUILD;
122 BuilderSurfaceTaskManager manager = BuilderSurfaceTaskManager.get(player.getServer());
123 if (!manager.start(player, scepter, task.transactionId(), mode, candidates, Direction.UP)) {
116 long now = player.getServer().overworld().getGameTime();
117 UUID scepterId = RealmwrightData.id(scepter);
118 if (!BuilderSurfaceRateLimiter.tryAcquire(player.getServer(), player.getUUID(), scepterId,
119 mode, now, candidates.size(), BuilderRuntime.settings().editsPerTick())) {
124 120 return Result.throttled();
125 121 }
126 data.remove(player.getUUID());
127 return new Result(0, task.missing().size(), 0, false, null, true);
122 return executeImmediateSurface(player, scepter, mode, candidates, Direction.UP,
123 task.transactionId(), false);
124 }
125
126 /**
127 * Executes an entire manually requested surface in this server task. History
128 * capture is an explicit per-scepter opt-in; with it disabled this path never
129 * serializes block entities or constructs a transaction ledger.
130 */
131 private static Result executeImmediateSurface(ServerPlayer player, ItemStack scepter,
132 BuilderMode mode,
133 List<SurfacePlanner.Candidate> candidates,
134 Direction face, UUID transactionId,
135 boolean truncated) {
136 BuilderTransaction.Type type = mode == BuilderMode.BUILD
137 ? BuilderTransaction.Type.BUILD : BuilderTransaction.Type.DEMOLISH;
138 boolean captureHistory = RealmwrightData.recordsHistory(scepter);
139 if (captureHistory && !prepareHistory(player, scepter, transactionId, type,
140 candidates, face, false, Map.of())) {
141 return new Result(0, 0, 0, truncated,
142 "message.mydimension.builder.history_budget_exceeded", true);
143 }
144
145 Execution execution = mode == BuilderMode.BUILD
146 ? build(player, scepter, candidates, face, transactionId, false, Map.of(), captureHistory)
147 : demolish(player, scepter, candidates, transactionId, captureHistory);
148 if (captureHistory) refreshFinalAfterImages(player.serverLevel(), execution);
149 if (captureHistory && !recordTransaction(player, scepter, type, transactionId, execution)) {
150 return new Result(execution.changedCount, execution.missing.size(), execution.blocked,
151 truncated, null, true);
152 }
153
154 PendingBuildData pending = PendingBuildData.get(player.getServer());
155 pending.remove(player.getUUID());
156 if (!execution.missing.isEmpty()) {
157 pending.put(player.getUUID(), new PendingBuildData.Task(RealmwrightData.id(scepter),
158 transactionId, player.level().dimension(), type, execution.missing,
159 System.currentTimeMillis()));
160 }
161 player.displayClientMessage(Component.translatable("message.mydimension.builder.result",
162 execution.changedCount, execution.missing.size(), execution.blocked), true);
163 return new Result(execution.changedCount, execution.missing.size(), execution.blocked,
164 truncated, null, true);
128 165 }
129 166
130 167 public static boolean cancelPending(ServerPlayer player, ItemStack scepter) {
131 boolean activeSurface = BuilderSurfaceTaskManager.get(player.getServer()).cancel(player, scepter);
132 168 boolean activeBlueprint = com.xfestudio.mydimension.builder.blueprint.BlueprintTaskManager
133 169 .get(player.getServer()).cancel(player, scepter);
134 170 PendingBuildData data = PendingBuildData.get(player.getServer());
135 171 PendingBuildData.Task task = data.get(player.getUUID());
136 172 if (task == null || !task.scepterId().equals(RealmwrightData.id(scepter))) {
137 return activeSurface || activeBlueprint;
173 return activeBlueprint;
138 174 }
139 175 data.remove(player.getUUID());
140 176 return true;
141 177 }
142 178
143 /** Locks BlockItem placement semantics at click time before a surface task is split across ticks. */
179 /** Locks BlockItem placement semantics once for every target in this click. */
144 180 static List<SurfacePlanner.Candidate> lockSurfaceStates(ServerPlayer player,
145 181 List<SurfacePlanner.Candidate> candidates,
146 182 Direction face, BuilderMode mode) {
@@ -158,9 +194,10 @@ public final class BuilderOperationManager {
158 194 private static Execution build(ServerPlayer player, ItemStack scepter,
159 195 List<SurfacePlanner.Candidate> candidates, Direction face,
160 196 UUID transactionId, boolean allowOffhandOverride,
161 Map<BlockPos, net.minecraft.nbt.CompoundTag> blockEntityTags) {
197 Map<BlockPos, net.minecraft.nbt.CompoundTag> blockEntityTags,
198 boolean captureHistory) {
162 199 ServerLevel level = player.serverLevel();
163 Execution result = new Execution(player.getOffhandItem().copy());
200 Execution result = new Execution(player.getOffhandItem().copy(), captureHistory);
164 201 List<BuildAttempt> attempts = new ArrayList<>();
165 202 boolean free = isFree(player);
166 203 for (SurfacePlanner.Candidate candidate : candidates) {
@@ -262,10 +299,12 @@ public final class BuilderOperationManager {
262 299 return true;
263 300 }
264 301
265 WorldDelta.Snapshot before;
302 BlockState beforeState;
303 WorldDelta.Snapshot before = null;
266 304 BlockSnapshot forgeSnapshot;
267 305 try {
268 before = WorldDelta.snapshot(level, pos);
306 beforeState = level.getBlockState(pos);
307 if (result.captureHistory) before = WorldDelta.snapshot(level, pos);
269 308 forgeSnapshot = BlockSnapshot.create(level.dimension(), level, pos);
270 309 } catch (Throwable throwable) {
271 310 result.blocked++;
@@ -278,7 +317,7 @@ public final class BuilderOperationManager {
278 317 placed = level.setBlock(pos, desired, Block.UPDATE_ALL);
279 318 if (placed) {
280 319 BlockEvent.EntityPlaceEvent event = new BlockEvent.EntityPlaceEvent(forgeSnapshot,
281 before.state(), player);
320 beforeState, player);
282 321 if (MinecraftForge.EVENT_BUS.post(event)) {
283 322 forgeSnapshot.restore(true, false);
284 323 placed = false;
@@ -306,22 +345,29 @@ public final class BuilderOperationManager {
306 345 result.blocked++;
307 346 return true;
308 347 }
309 WorldDelta.Snapshot after;
310 try {
311 after = WorldDelta.snapshot(level, pos);
312 } catch (Throwable throwable) {
313 try { forgeSnapshot.restore(true, false); } catch (Throwable ignored) { }
314 result.blocked++;
315 MyDimension.LOGGER.warn("Builder rolled back placement at {} because its after-image failed",
316 pos, throwable);
317 return true;
348 WorldDelta.Snapshot after = null;
349 if (result.captureHistory) {
350 try {
351 after = WorldDelta.snapshot(level, pos);
352 } catch (Throwable throwable) {
353 try { forgeSnapshot.restore(true, false); } catch (Throwable ignored) { }
354 result.blocked++;
355 MyDimension.LOGGER.warn("Builder rolled back placement at {} because its after-image failed",
356 pos, throwable);
357 return true;
358 }
318 359 }
319 360 if (pool != null) {
320 361 pool.available -= costCount;
321 addLedgerStack(result.debits, pool.template.copyWithCount(costCount));
362 if (result.captureHistory) {
363 addLedgerStack(result.debits, pool.template.copyWithCount(costCount));
364 }
365 }
366 if (result.captureHistory) {
367 result.changed.add(new WorldDelta(pos, before.state(), before.blockEntity(),
368 after.state(), after.blockEntity()));
322 369 }
323 result.changed.add(new WorldDelta(pos, before.state(), before.blockEntity(),
324 after.state(), after.blockEntity()));
370 result.changedCount++;
325 371 return true;
326 372 }
327 373
@@ -340,9 +386,10 @@ public final class BuilderOperationManager {
340 386 }
341 387
342 388 private static Execution demolish(ServerPlayer player, ItemStack scepter,
343 List<SurfacePlanner.Candidate> candidates, UUID transactionId) {
389 List<SurfacePlanner.Candidate> candidates, UUID transactionId,
390 boolean captureHistory) {
344 391 ServerLevel level = player.serverLevel();
345 Execution result = new Execution(player.getOffhandItem().copy());
392 Execution result = new Execution(player.getOffhandItem().copy(), captureHistory);
346 393 for (SurfacePlanner.Candidate candidate : candidates) {
347 394 BlockPos pos = candidate.target();
348 395 BlockState state;
@@ -363,7 +410,7 @@ public final class BuilderOperationManager {
363 410 }
364 411 toolBeforeBlock = player.getOffhandItem().copy();
365 412 tool = checkTool(state, toolBeforeBlock);
366 beforeImages = snapshotDemolitionArea(level, pos, state);
413 beforeImages = captureHistory ? snapshotDemolitionArea(level, pos, state) : Map.of();
367 414 BlockEntity blockEntity = level.getBlockEntity(pos);
368 415 normalDrops = tool.canDrop
369 416 ? Block.getDrops(state, level, pos, blockEntity, player, toolBeforeBlock) : List.of();
@@ -391,35 +438,38 @@ public final class BuilderOperationManager {
391 438 capturedDrops = List.of();
392 439 }
393 440 } catch (Throwable throwable) {
394 restoreSnapshots(level, beforeImages);
441 if (captureHistory) restoreSnapshots(level, beforeImages);
395 442 result.blocked++;
396 443 MyDimension.LOGGER.warn("Builder rolled back failed demolition at {}", pos, throwable);
397 444 continue;
398 445 }
399 446 if (!removed) {
400 restoreSnapshots(level, beforeImages);
447 if (captureHistory) restoreSnapshots(level, beforeImages);
401 448 result.blocked++;
402 449 continue;
403 450 }
404 451
405 Map<BlockPos, WorldDelta.Snapshot> afterImages = new java.util.LinkedHashMap<>();
406 try {
407 for (BlockPos affected : beforeImages.keySet()) {
408 afterImages.put(affected, WorldDelta.snapshot(level, affected));
452 Map<BlockPos, WorldDelta.Snapshot> afterImages = Map.of();
453 if (captureHistory) {
454 afterImages = new java.util.LinkedHashMap<>();
455 try {
456 for (BlockPos affected : beforeImages.keySet()) {
457 afterImages.put(affected, WorldDelta.snapshot(level, affected));
458 }
459 } catch (Throwable throwable) {
460 restoreSnapshots(level, beforeImages);
461 result.blocked++;
462 MyDimension.LOGGER.warn("Builder rolled back demolition at {} because its after-image failed",
463 pos, throwable);
464 continue;
409 465 }
410 } catch (Throwable throwable) {
411 restoreSnapshots(level, beforeImages);
412 result.blocked++;
413 MyDimension.LOGGER.warn("Builder rolled back demolition at {} because its after-image failed",
414 pos, throwable);
415 continue;
416 466 }
417 467
418 468 if (tool.canDrop) {
419 469 List<ItemStack> drops = new ArrayList<>(normalDrops.size() + capturedDrops.size());
420 470 normalDrops.forEach(stack -> drops.add(stack.copy()));
421 471 capturedDrops.forEach(stack -> drops.add(stack.copy()));
422 drops.forEach(drop -> addLedgerStack(result.credits, drop));
472 if (captureHistory) drops.forEach(drop -> addLedgerStack(result.credits, drop));
423 473 dropOverflowAt(level, pos, transactionId, BuilderMaterials.insert(player, scepter, drops));
424 474 }
425 475 // Settle the Nth block's drops with the pre-damage tool, then apply exactly one durability point.
@@ -428,11 +478,16 @@ public final class BuilderOperationManager {
428 478 player.getOffhandItem().hurtAndBreak(1, player,
429 479 broken -> broken.broadcastBreakEvent(InteractionHand.OFF_HAND));
430 480 }
431 for (Map.Entry<BlockPos, WorldDelta.Snapshot> entry : beforeImages.entrySet()) {
432 WorldDelta.Snapshot after = afterImages.get(entry.getKey());
433 if (entry.getValue().equals(after)) continue;
434 result.changed.add(new WorldDelta(entry.getKey(), entry.getValue().state(),
435 entry.getValue().blockEntity(), after.state(), after.blockEntity()));
481 if (captureHistory) {
482 for (Map.Entry<BlockPos, WorldDelta.Snapshot> entry : beforeImages.entrySet()) {
483 WorldDelta.Snapshot after = afterImages.get(entry.getKey());
484 if (entry.getValue().equals(after)) continue;
485 result.changed.add(new WorldDelta(entry.getKey(), entry.getValue().state(),
486 entry.getValue().blockEntity(), after.state(), after.blockEntity()));
487 }
488 result.changedCount = result.changed.size();
489 } else {
490 result.changedCount++;
436 491 }
437 492 }
438 493 result.offhandAfter = player.getOffhandItem().copy();
@@ -649,42 +704,6 @@ public final class BuilderOperationManager {
649 704 return player.isCreative() && BuilderRuntime.settings().creativeBypassesCosts();
650 705 }
651 706
652 static SurfaceAccumulator beginSurfaceExecution(ServerPlayer player) {
653 return new SurfaceAccumulator(new Execution(player.getOffhandItem().copy()));
654 }
655
656 /** Executes one bounded slice while the whole-task PREPARED reservation remains durable. */
657 static SurfaceBatchResult executeSurfaceBatch(ServerPlayer player, ItemStack scepter,
658 BuilderMode mode, List<SurfacePlanner.Candidate> candidates, Direction face,
659 UUID transactionId, SurfaceAccumulator accumulator) {
660 Execution execution = mode == BuilderMode.BUILD
661 ? build(player, scepter, candidates, face, transactionId, false, Map.of())
662 : demolish(player, scepter, candidates, transactionId);
663 accumulator.merge(execution);
664 // Later candidates in this same slice may legitimately update the state of an earlier
665 // connected block (fences, walls, redstone shapes, and similar neighbour-aware blocks).
666 // Capture that final in-slice image immediately. The task manager validates it at the
667 // beginning of the next tick, so changes made between slices are treated as conflicts
668 // instead of being silently absorbed into the transaction.
669 accumulator.refreshAfter(player.serverLevel());
670 return new SurfaceBatchResult(execution.changed.size(), List.copyOf(execution.missing),
671 execution.blocked);
672 }
673
674 /** Reserves enough history space for the complete surface before any batch changes the world. */
675 static boolean prepareSurfaceTask(ServerPlayer player, ItemStack scepter, UUID transactionId,
676 BuilderTransaction.Type type,
677 List<SurfacePlanner.Candidate> candidates, Direction face) {
678 return prepareHistory(player, scepter, transactionId, type, candidates, face, false, Map.of());
679 }
680
681 static boolean commitSurfaceTask(ServerPlayer player, ItemStack scepter, BuilderTransaction.Type type,
682 UUID transactionId, SurfaceAccumulator accumulator,
683 ServerLevel executionLevel, boolean worldConflict) {
684 return recordTransaction(player, scepter, type, transactionId, accumulator.execution,
685 executionLevel, !worldConflict);
686 }
687
688 707 /** Executes one rate-limited part of an already validated blueprint. */
689 708 public static BlueprintBatchResult executeBlueprintBatch(ServerPlayer player, ItemStack scepter,
690 709 List<com.xfestudio.mydimension.builder.blueprint.BlueprintPlacementPlan.PlannedBlock> blocks,
@@ -704,10 +723,11 @@ public final class BuilderOperationManager {
704 723 "message.mydimension.builder.history_budget_exceeded"), true);
705 724 return new BlueprintBatchResult(0, deferred, 0, false);
706 725 }
707 Execution execution = build(player, scepter, candidates, Direction.UP, transactionId, false, blockEntities);
726 Execution execution = build(player, scepter, candidates, Direction.UP, transactionId, false,
727 blockEntities, true);
708 728 boolean committed = recordTransaction(player, scepter, BuilderTransaction.Type.BLUEPRINT, transactionId,
709 729 execution);
710 return new BlueprintBatchResult(execution.changed.size(), List.copyOf(execution.missing), execution.blocked,
730 return new BlueprintBatchResult(execution.changedCount, List.copyOf(execution.missing), execution.blocked,
711 731 committed);
712 732 }
713 733
@@ -755,6 +775,7 @@ public final class BuilderOperationManager {
755 775 execution.changed.clear();
756 776 execution.debits.clear();
757 777 execution.credits.clear();
778 execution.changedCount = 0;
758 779 }
759 780 player.displayClientMessage(Component.translatable(
760 781 "message.mydimension.builder.history_commit_failed"), true);
@@ -763,6 +784,26 @@ public final class BuilderOperationManager {
763 784 return true;
764 785 }
765 786
787 /** Captures neighbour-aware final states only when undo recording was explicitly enabled. */
788 private static void refreshFinalAfterImages(ServerLevel level, Execution execution) {
789 if (execution.changed.isEmpty()) return;
790 List<WorldDelta> refreshed = new ArrayList<>(execution.changed.size());
791 try {
792 for (WorldDelta delta : execution.changed) {
793 WorldDelta.Snapshot after = WorldDelta.snapshot(level, delta.pos());
794 refreshed.add(new WorldDelta(delta.pos(), delta.beforeState(), delta.beforeBlockEntity(),
795 after.state(), after.blockEntity()));
796 }
797 } catch (Throwable throwable) {
798 // Each edit already owns a valid immediate after-image. Keep that safe
799 // fallback rather than stranding a durable PREPARED entry.
800 MyDimension.LOGGER.warn("Builder could not refresh final neighbour-aware history images", throwable);
801 return;
802 }
803 execution.changed.clear();
804 execution.changed.addAll(refreshed);
805 }
806
766 807 private static boolean prepareHistory(ServerPlayer player, ItemStack scepter, UUID transactionId,
767 808 BuilderTransaction.Type type,
768 809 List<SurfacePlanner.Candidate> candidates, Direction face,
@@ -946,11 +987,14 @@ public final class BuilderOperationManager {
946 987 private final List<ItemStack> debits = new ArrayList<>();
947 988 private final List<ItemStack> credits = new ArrayList<>();
948 989 private final ItemStack offhandBefore;
990 private final boolean captureHistory;
949 991 private ItemStack offhandAfter = ItemStack.EMPTY;
992 private int changedCount;
950 993 private int blocked;
951 994
952 private Execution(ItemStack offhandBefore) {
995 private Execution(ItemStack offhandBefore, boolean captureHistory) {
953 996 this.offhandBefore = offhandBefore;
997 this.captureHistory = captureHistory;
954 998 }
955 999 }
956 1000
@@ -958,53 +1002,6 @@ public final class BuilderOperationManager {
958 1002 boolean committed) {
959 1003 }
960 1004
961 public record SurfaceBatchResult(int changed, List<PendingBuildData.Entry> missing, int blocked) {
962 }
963
964 static final class SurfaceAccumulator {
965 private final Execution execution;
966
967 private SurfaceAccumulator(Execution execution) {
968 this.execution = execution;
969 }
970
971 ItemStack initialOffhand() {
972 return execution.offhandBefore.copy();
973 }
974
975 boolean matchesAfter(ServerLevel level) {
976 try {
977 for (WorldDelta delta : execution.changed) {
978 if (!delta.matchesAfter(level)) return false;
979 }
980 return true;
981 } catch (RuntimeException exception) {
982 return false;
983 }
984 }
985
986 void refreshAfter(ServerLevel level) {
987 if (execution.changed.isEmpty()) return;
988 List<WorldDelta> refreshed = new ArrayList<>(execution.changed.size());
989 for (WorldDelta delta : execution.changed) {
990 WorldDelta.Snapshot after = WorldDelta.snapshot(level, delta.pos());
991 refreshed.add(new WorldDelta(delta.pos(), delta.beforeState(), delta.beforeBlockEntity(),
992 after.state(), after.blockEntity()));
993 }
994 execution.changed.clear();
995 execution.changed.addAll(refreshed);
996 }
997
998 private void merge(Execution batch) {
999 execution.changed.addAll(batch.changed);
1000 execution.missing.addAll(batch.missing);
1001 batch.debits.forEach(stack -> addLedgerStack(execution.debits, stack));
1002 batch.credits.forEach(stack -> addLedgerStack(execution.credits, stack));
1003 execution.offhandAfter = batch.offhandAfter.copy();
1004 execution.blocked += batch.blocked;
1005 }
1006 }
1007
1008 1005 private record BuildAttempt(SurfacePlanner.Candidate candidate, BlockState desired, ItemStack cost,
1009 1006 net.minecraft.nbt.CompoundTag blockEntityTag) {
1010 1007 }
Modified src/main/java/com/xfestudio/mydimension/builder/BuilderSurfaceRateLimiter.java +8 -1
@@ -17,6 +17,7 @@ import java.util.WeakHashMap;
17 17 * merely move the lag spike into later ticks.</p>
18 18 */
19 19 public final class BuilderSurfaceRateLimiter {
20 private static final int DEMOLISH_COST_UNITS = 4;
20 21 private static final Map<MinecraftServer, PlayerWindows> WINDOWS = new WeakHashMap<>();
21 22
22 23 private BuilderSurfaceRateLimiter() {
@@ -52,7 +53,7 @@ public final class BuilderSurfaceRateLimiter {
52 53 }
53 54
54 55 static int delayTicks(BuilderMode mode, int candidateCount, int editsPerTick) {
55 long candidates = Math.max(1L, BuilderSurfaceTaskManager.budgetCost(mode, candidateCount));
56 long candidates = Math.max(1L, budgetCost(mode, candidateCount));
56 57 long budget = Math.max(1L, editsPerTick);
57 58 return (int) Math.min(Integer.MAX_VALUE, (candidates + budget - 1L) / budget);
58 59 }
@@ -80,6 +81,12 @@ public final class BuilderSurfaceRateLimiter {
80 81 }
81 82 }
82 83
84 static int budgetCost(BuilderMode mode, int blocks) {
85 int count = Math.max(0, blocks);
86 if (mode != BuilderMode.DEMOLISH) return count;
87 return (int) Math.min(Integer.MAX_VALUE, (long) count * DEMOLISH_COST_UNITS);
88 }
89
83 90 /**
84 91 * One admission window per player. Scepter and mode remain explicit parameters so callers cannot
85 92 * accidentally reintroduce an independent budget when the execution context changes.
Deleted src/main/java/com/xfestudio/mydimension/builder/BuilderSurfaceTaskManager.java +0 -294
@@ -1,294 +0,0 @@
1 package com.xfestudio.mydimension.builder;
2
3 import com.xfestudio.mydimension.builder.history.BuilderHistoryData;
4 import com.xfestudio.mydimension.builder.history.BuilderTransaction;
5 import com.xfestudio.mydimension.registry.ModItems;
6 import net.minecraft.core.Direction;
7 import net.minecraft.network.chat.Component;
8 import net.minecraft.resources.ResourceKey;
9 import net.minecraft.server.MinecraftServer;
10 import net.minecraft.server.level.ServerLevel;
11 import net.minecraft.server.level.ServerPlayer;
12 import net.minecraft.world.item.ItemStack;
13 import net.minecraft.world.level.Level;
14
15 import java.util.ArrayList;
16 import java.util.HashMap;
17 import java.util.List;
18 import java.util.Map;
19 import java.util.UUID;
20 import java.util.WeakHashMap;
21
22 /**
23 * Bounded server-tick executor for manually selected build/demolish surfaces.
24 * A player can own at most one task, so rapid click packets are coalesced into
25 * the already-running operation rather than forming an unbounded backlog.
26 */
27 public final class BuilderSurfaceTaskManager {
28 private static final int DEMOLISH_COST_UNITS = 4;
29 private static final Map<MinecraftServer, BuilderSurfaceTaskManager> INSTANCES = new WeakHashMap<>();
30
31 private final MinecraftServer server;
32 private final Map<UUID, ActiveTask> active = new HashMap<>();
33
34 private BuilderSurfaceTaskManager(MinecraftServer server) {
35 this.server = server;
36 }
37
38 public static synchronized BuilderSurfaceTaskManager get(MinecraftServer server) {
39 return INSTANCES.computeIfAbsent(server, BuilderSurfaceTaskManager::new);
40 }
41
42 public boolean hasActive(UUID playerId) {
43 return active.containsKey(playerId);
44 }
45
46 public boolean start(ServerPlayer player, ItemStack scepter, BuilderMode mode,
47 List<SurfacePlanner.Candidate> candidates, Direction face) {
48 return start(player, scepter, UUID.randomUUID(), mode, candidates, face);
49 }
50
51 public boolean start(ServerPlayer player, ItemStack scepter, UUID transactionId, BuilderMode mode,
52 List<SurfacePlanner.Candidate> candidates, Direction face) {
53 UUID playerId = player.getUUID();
54 if (candidates.isEmpty() || active.containsKey(playerId)) return false;
55 BuilderTransaction.Type type = mode == BuilderMode.BUILD
56 ? BuilderTransaction.Type.BUILD : BuilderTransaction.Type.DEMOLISH;
57 if (!BuilderOperationManager.prepareSurfaceTask(player, scepter, transactionId, type,
58 candidates, face)) {
59 player.displayClientMessage(Component.translatable(
60 "message.mydimension.builder.history_budget_exceeded"), true);
61 return false;
62 }
63 active.put(playerId, new ActiveTask(playerId, transactionId, RealmwrightData.id(scepter),
64 player.level().dimension(), mode, candidates, face,
65 scepter.copy(), BuilderOperationManager.beginSurfaceExecution(player)));
66 return true;
67 }
68
69 public void tick() {
70 if (!BuilderRuntime.settings().enabled()) {
71 finishAll();
72 return;
73 }
74 List<ServerPlayer> runnable = new ArrayList<>();
75 for (ServerPlayer player : server.getPlayerList().getPlayers()) {
76 ActiveTask task = active.get(player.getUUID());
77 if (task != null && canRun(player, task)) runnable.add(player);
78 }
79 if (runnable.isEmpty()) return;
80
81 int totalBudget = Math.max(1, BuilderRuntime.settings().editsPerTick());
82 int baseShare = Math.max(1, totalBudget / runnable.size());
83 int remaining = totalBudget;
84 for (int index = 0; index < runnable.size() && remaining > 0; index++) {
85 int playersLeft = runnable.size() - index;
86 int share = Math.max(1, Math.min(remaining,
87 index == runnable.size() - 1 ? remaining : Math.max(baseShare, remaining / playersLeft)));
88 ServerPlayer player = runnable.get(index);
89 int used = tickPlayer(player, active.get(player.getUUID()), share);
90 remaining -= Math.max(0, used);
91 }
92 }
93
94 private int tickPlayer(ServerPlayer player, ActiveTask task, int budgetUnits) {
95 if (task == null) return 0;
96 if (task.cursor > 0 && !task.accumulator.matchesAfter(player.serverLevel())) {
97 // Something outside this task changed an already completed slice. Stop before touching
98 // more blocks and retain a non-executable conflict record; never widen the after-image
99 // to include somebody else's work.
100 task.worldConflict = true;
101 finish(player, task);
102 return 0;
103 }
104 int count = batchSize(task.mode, budgetUnits);
105 int end = Math.min(task.candidates.size(), task.cursor + count);
106 if (end <= task.cursor) return 0;
107 List<SurfacePlanner.Candidate> batch = task.candidates.subList(task.cursor, end);
108 BuilderOperationManager.SurfaceBatchResult result;
109 try {
110 result = BuilderOperationManager.executeSurfaceBatch(player, player.getMainHandItem(),
111 task.mode, batch, task.face, task.transactionId, task.accumulator);
112 } catch (RuntimeException exception) {
113 com.xfestudio.mydimension.MyDimension.LOGGER.warn(
114 "Builder surface task {} stopped while finalizing a bounded slice",
115 task.transactionId, exception);
116 task.worldConflict = true;
117 finish(player, task);
118 return 0;
119 }
120 task.changed += result.changed();
121 task.blocked += result.blocked();
122 task.missing.addAll(result.missing());
123 task.expectedOffhand = player.getOffhandItem().copy();
124 task.cursor = end;
125 if (task.cursor >= task.candidates.size()) finish(player, task);
126 return budgetCost(task.mode, batch.size());
127 }
128
129 private boolean canRun(ServerPlayer player, ActiveTask task) {
130 ItemStack scepter = player.getMainHandItem();
131 return scepter.is(ModItems.REALMWRIGHT_SCEPTER.get())
132 && task.scepterId.equals(RealmwrightData.id(scepter))
133 && task.dimension.equals(player.level().dimension())
134 && offhandMatches(task.expectedOffhand, player.getOffhandItem())
135 && PendingBuildData.get(server).get(player.getUUID()) == null
136 && !com.xfestudio.mydimension.builder.blueprint.BlueprintTaskManager.get(server)
137 .hasActive(player.getUUID());
138 }
139
140 private void finish(ServerPlayer player, ActiveTask task) {
141 active.remove(player.getUUID());
142 if (!settle(player, task)) {
143 BuilderNetworkBridge.sync(player);
144 return;
145 }
146 if (task.worldConflict) {
147 player.displayClientMessage(Component.translatable(
148 "message.mydimension.builder.surface_world_conflict"), true);
149 BuilderNetworkBridge.sync(player);
150 return;
151 }
152 if (!task.missing.isEmpty()) {
153 PendingBuildData.get(server).put(player.getUUID(), new PendingBuildData.Task(task.scepterId,
154 task.transactionId, task.dimension, task.mode == BuilderMode.BUILD
155 ? BuilderTransaction.Type.BUILD : BuilderTransaction.Type.DEMOLISH,
156 task.missing, System.currentTimeMillis()));
157 }
158 player.displayClientMessage(Component.translatable("message.mydimension.builder.result",
159 task.changed, task.missing.size(), task.blocked), true);
160 BuilderNetworkBridge.sync(player);
161 }
162
163 public boolean cancel(ServerPlayer player, ItemStack scepter) {
164 ActiveTask task = active.get(player.getUUID());
165 if (task == null || !task.scepterId.equals(RealmwrightData.id(scepter))) return false;
166 active.remove(player.getUUID());
167 settle(player, task);
168 return true;
169 }
170
171 public boolean cancel(ServerPlayer player, ItemStack scepter, UUID transactionId) {
172 ActiveTask task = active.get(player.getUUID());
173 if (task == null || !task.transactionId.equals(transactionId)
174 || !task.scepterId.equals(RealmwrightData.id(scepter))) return false;
175 active.remove(player.getUUID());
176 settle(player, task);
177 return true;
178 }
179
180 public Status status(ServerPlayer player) {
181 ActiveTask task = active.get(player.getUUID());
182 if (task == null) return new Status(null, null, 0, 0, 0);
183 return new Status(task.transactionId, task.mode, task.cursor,
184 task.candidates.size(), task.missing.size());
185 }
186
187 public void removePlayer(ServerPlayer player) {
188 ActiveTask task = active.remove(player.getUUID());
189 if (task != null) settle(player, task);
190 }
191
192 private void finishAll() {
193 for (ActiveTask task : List.copyOf(active.values())) {
194 ServerPlayer player = server.getPlayerList().getPlayer(task.playerId);
195 if (player != null) settle(player, task);
196 // With no live player, retain PREPARED in SavedData. Restart recovery will expose a
197 // conflict marker rather than silently making partially changed blocks non-undoable.
198 }
199 active.clear();
200 }
201
202 private boolean settle(ServerPlayer player, ActiveTask task) {
203 if (!task.prepared) return task.committed;
204 ServerLevel executionLevel = server.getLevel(task.dimension);
205 if (executionLevel == null) {
206 // Keep PREPARED durable. Loading it after restart produces a conflict marker instead of
207 // ever committing or compensating these coordinates in the player's current dimension.
208 return false;
209 }
210 if (!task.worldConflict && !task.accumulator.matchesAfter(executionLevel)) {
211 task.worldConflict = true;
212 }
213 BuilderTransaction.Type type = task.mode == BuilderMode.BUILD
214 ? BuilderTransaction.Type.BUILD : BuilderTransaction.Type.DEMOLISH;
215 ItemStack held = player.getMainHandItem();
216 ItemStack settlementScepter = held.is(ModItems.REALMWRIGHT_SCEPTER.get())
217 && task.scepterId.equals(RealmwrightData.id(held)) ? held : task.scepterSnapshot;
218 task.committed = BuilderOperationManager.commitSurfaceTask(player, settlementScepter, type,
219 task.transactionId, task.accumulator, executionLevel, task.worldConflict);
220 task.prepared = false;
221 if (task.committed && task.worldConflict) {
222 BuilderHistoryData.get(server).conflictApplied(task.playerId(), task.scepterId,
223 task.transactionId);
224 }
225 return task.committed;
226 }
227
228 public static synchronized void shutdown(MinecraftServer server) {
229 BuilderSurfaceTaskManager manager = INSTANCES.remove(server);
230 if (manager != null) manager.finishAll();
231 }
232
233 static int batchSize(BuilderMode mode, int editsPerTick) {
234 int budget = Math.max(1, editsPerTick);
235 return mode == BuilderMode.DEMOLISH ? Math.max(1, budget / DEMOLISH_COST_UNITS) : budget;
236 }
237
238 static int budgetCost(BuilderMode mode, int blocks) {
239 int count = Math.max(0, blocks);
240 return mode == BuilderMode.DEMOLISH ? saturatedMultiply(count, DEMOLISH_COST_UNITS) : count;
241 }
242
243 static boolean offhandMatches(ItemStack expected, ItemStack actual) {
244 return ItemStack.matches(expected, actual);
245 }
246
247 private static int saturatedMultiply(int value, int multiplier) {
248 long result = (long) value * multiplier;
249 return (int) Math.min(Integer.MAX_VALUE, result);
250 }
251
252 public record Status(UUID transactionId, BuilderMode mode, int completed, int total, int missing) {
253 }
254
255 private static final class ActiveTask {
256 private final UUID playerId;
257 private final UUID transactionId;
258 private final UUID scepterId;
259 private final ResourceKey<Level> dimension;
260 private final BuilderMode mode;
261 private final List<SurfacePlanner.Candidate> candidates;
262 private final Direction face;
263 private final List<PendingBuildData.Entry> missing = new ArrayList<>();
264 private boolean prepared = true;
265 private boolean committed;
266 private final ItemStack scepterSnapshot;
267 private final BuilderOperationManager.SurfaceAccumulator accumulator;
268 private ItemStack expectedOffhand;
269 private int cursor;
270 private int changed;
271 private int blocked;
272 private boolean worldConflict;
273
274 private ActiveTask(UUID playerId, UUID transactionId, UUID scepterId, ResourceKey<Level> dimension,
275 BuilderMode mode, List<SurfacePlanner.Candidate> candidates,
276 Direction face, ItemStack scepterSnapshot,
277 BuilderOperationManager.SurfaceAccumulator accumulator) {
278 this.playerId = playerId;
279 this.transactionId = transactionId;
280 this.scepterId = scepterId;
281 this.dimension = dimension;
282 this.mode = mode;
283 this.candidates = List.copyOf(candidates);
284 this.face = face;
285 this.scepterSnapshot = scepterSnapshot;
286 this.accumulator = accumulator;
287 this.expectedOffhand = accumulator.initialOffhand();
288 }
289
290 private UUID playerId() {
291 return playerId;
292 }
293 }
294 }
Modified src/main/java/com/xfestudio/mydimension/builder/RealmwrightData.java +22 -1
@@ -17,8 +17,9 @@ public final class RealmwrightData {
17 17 private static final String MATCH = "Match";
18 18 private static final String BUILD_LIMIT = "BuildLimit";
19 19 private static final String DEMOLISH_LIMIT = "DemolishLimit";
20 private static final String RECORD_HISTORY = "RecordHistory";
20 21 private static final String ANCHORS = "Anchors";
21 private static final int DATA_VERSION = 1;
22 private static final int DATA_VERSION = 2;
22 23
23 24 public static final int DEFAULT_BUILD_LIMIT = 256;
24 25 public static final int DEFAULT_DEMOLISH_LIMIT = 64;
@@ -88,6 +89,26 @@ public final class RealmwrightData {
88 89 writeRoot(stack, tag);
89 90 }
90 91
92 /**
93 * Whether new operations performed by this individual scepter should retain
94 * undo data. Absence deliberately means false so existing scepters receive
95 * the low-overhead behaviour after upgrading.
96 */
97 public static boolean recordsHistory(ItemStack stack) {
98 return recordsHistory(readRoot(stack));
99 }
100
101 static boolean recordsHistory(CompoundTag tag) {
102 return tag != null && tag.contains(RECORD_HISTORY, Tag.TAG_BYTE)
103 && tag.getBoolean(RECORD_HISTORY);
104 }
105
106 public static void setRecordsHistory(ItemStack stack, boolean value) {
107 CompoundTag tag = root(stack);
108 tag.putBoolean(RECORD_HISTORY, value);
109 writeRoot(stack, tag);
110 }
111
91 112 public static List<UUID> anchors(ItemStack stack) {
92 113 CompoundTag tag = readRoot(stack);
93 114 if (tag == null || !tag.contains(ANCHORS, Tag.TAG_LIST)) {
Modified src/main/java/com/xfestudio/mydimension/builder/blueprint/BlueprintServerEvents.java +0 -5
@@ -2,7 +2,6 @@ package com.xfestudio.mydimension.builder.blueprint;
2 2
3 3 import com.xfestudio.mydimension.MyDimension;
4 4 import com.xfestudio.mydimension.builder.BuilderSurfaceRateLimiter;
5 import com.xfestudio.mydimension.builder.BuilderSurfaceTaskManager;
6 5 import net.minecraft.server.level.ServerPlayer;
7 6 import net.minecraftforge.event.TickEvent;
8 7 import net.minecraftforge.event.entity.player.PlayerEvent;
@@ -19,7 +18,6 @@ public final class BlueprintServerEvents {
19 18 public static void serverTick(TickEvent.ServerTickEvent event) {
20 19 if (event.phase == TickEvent.Phase.END) {
21 20 BlueprintServerService.get(event.getServer()).tick();
22 BuilderSurfaceTaskManager.get(event.getServer()).tick();
23 21 BlueprintTaskManager.get(event.getServer()).tick();
24 22 }
25 23 }
@@ -28,7 +26,6 @@ public final class BlueprintServerEvents {
28 26 public static void playerLogout(PlayerEvent.PlayerLoggedOutEvent event) {
29 27 if (event.getEntity() instanceof ServerPlayer player) {
30 28 BuilderSurfaceRateLimiter.removePlayer(player.getServer(), player.getUUID());
31 BuilderSurfaceTaskManager.get(player.getServer()).removePlayer(player);
32 29 BlueprintTaskManager.get(player.getServer()).pausePlayer(player);
33 30 BlueprintServerService.get(player.getServer()).removePlayer(player);
34 31 }
@@ -38,14 +35,12 @@ public final class BlueprintServerEvents {
38 35 public static void playerChangedDimension(PlayerEvent.PlayerChangedDimensionEvent event) {
39 36 if (event.getEntity() instanceof ServerPlayer player) {
40 37 BlueprintServerService.get(player.getServer()).clearSelection(player.getUUID());
41 BuilderSurfaceTaskManager.get(player.getServer()).removePlayer(player);
42 38 }
43 39 }
44 40
45 41 @SubscribeEvent
46 42 public static void serverStopping(ServerStoppingEvent event) {
47 43 BuilderSurfaceRateLimiter.shutdown(event.getServer());
48 BuilderSurfaceTaskManager.shutdown(event.getServer());
49 44 BlueprintTaskManager.shutdown(event.getServer());
50 45 BlueprintServerService.shutdown(event.getServer());
51 46 }
Modified src/main/java/com/xfestudio/mydimension/builder/blueprint/BlueprintServerService.java +1 -5
@@ -240,8 +240,6 @@ public final class BlueprintServerService {
240 240 || !queued.scepterId().equals(RealmwrightData.id(scepter))
241 241 || !queued.dimension().equals(player.level().dimension())
242 242 || PendingBuildData.get(server).get(player.getUUID()) != null
243 || com.xfestudio.mydimension.builder.BuilderSurfaceTaskManager.get(server)
244 .hasActive(player.getUUID())
245 243 || BlueprintTaskManager.get(server).hasActive(player.getUUID())) {
246 244 return Optional.empty();
247 245 }
@@ -400,9 +398,7 @@ public final class BlueprintServerService {
400 398
401 399 private boolean hasWorkConflict(ServerPlayer player) {
402 400 if (PendingBuildData.get(server).get(player.getUUID()) != null
403 || BlueprintTaskManager.get(server).hasActive(player.getUUID())
404 || com.xfestudio.mydimension.builder.BuilderSurfaceTaskManager.get(server)
405 .hasActive(player.getUUID())) {
401 || BlueprintTaskManager.get(server).hasActive(player.getUUID())) {
406 402 return true;
407 403 }
408 404 Queue<QueuedPlacement> queued = placementQueues.get(player.getUUID());
Modified src/main/java/com/xfestudio/mydimension/client/builder/BuilderClientCommand.java +3 -0
@@ -36,6 +36,9 @@ public sealed interface BuilderClientCommand {
36 36 record SetLimits(int buildLimit, int demolishLimit) implements BuilderClientCommand {
37 37 }
38 38
39 record SetHistoryRecording(boolean enabled) implements BuilderClientCommand {
40 }
41
39 42 record SelectBlueprintPoint(Target target) implements BuilderClientCommand {
40 43 }
41 44
Modified src/main/java/com/xfestudio/mydimension/client/builder/BuilderClientNetworkBridge.java +23 -5
@@ -13,7 +13,6 @@ import com.xfestudio.mydimension.builder.blueprint.BlueprintSaveMode;
13 13 import com.xfestudio.mydimension.builder.blueprint.BlueprintTransform;
14 14 import com.xfestudio.mydimension.builder.blueprint.client.ClientBlueprintTransfers;
15 15 import com.xfestudio.mydimension.client.builder.blueprint.BlueprintCaptureScreen;
16 import com.xfestudio.mydimension.client.builder.blueprint.BlueprintSaveScreen;
17 16 import com.xfestudio.mydimension.client.builder.blueprint.ClientBlueprintLibrary;
18 17 import com.xfestudio.mydimension.network.blueprint.BlueprintCaptureRequestPacket;
19 18 import com.xfestudio.mydimension.network.blueprint.BlueprintPlaceRequestPacket;
@@ -75,6 +74,7 @@ public final class BuilderClientNetworkBridge implements BuilderClientBridge,
75 74 UUID selected = selectedBlueprintId;
76 75 if (java.util.Objects.equals(value.selectedBlueprintId(), selected)) return value;
77 76 return new BuilderClientSnapshot(value.enabled(), value.mode(), value.surfaceMatch(),
77 value.historyRecording(),
78 78 value.buildLimit(), value.demolishLimit(), value.maximumBuildLimit(),
79 79 value.maximumDemolishLimit(), value.reach(), value.status(), value.activeJobId(),
80 80 value.completedBlocks(), value.totalBlocks(), value.canUndo(), value.canRedo(),
@@ -97,6 +97,8 @@ public final class BuilderClientNetworkBridge implements BuilderClientBridge,
97 97 } else if (command instanceof BuilderClientCommand.SetLimits value) {
98 98 ModNetwork.CHANNEL.sendToServer(BuilderCommandPacket.setLimits(
99 99 value.buildLimit(), value.demolishLimit()));
100 } else if (command instanceof BuilderClientCommand.SetHistoryRecording value) {
101 ModNetwork.CHANNEL.sendToServer(BuilderCommandPacket.setHistoryRecording(value.enabled()));
100 102 } else if (command instanceof BuilderClientCommand.UseTarget value) {
101 103 switch (value.kind()) {
102 104 case AUTO -> ModNetwork.CHANNEL.sendToServer(BuilderCommandPacket.use(target(value.target())));
@@ -151,6 +153,7 @@ public final class BuilderClientNetworkBridge implements BuilderClientBridge,
151 153 BuilderClientSnapshot.HistoryStatus.valueOf(entry.status().name())))
152 154 .toList();
153 155 snapshot = new BuilderClientSnapshot(packet.enabled(), packet.mode(), packet.surfaceMatch(),
156 packet.historyRecording(),
154 157 packet.buildLimit(), packet.demolishLimit(), packet.maximumBuildLimit(),
155 158 packet.maximumDemolishLimit(), packet.reach(), packet.status(), packet.activeJobId(),
156 159 packet.completedBlocks(), packet.totalBlocks(), packet.canUndo(), packet.canRedo(),
@@ -230,13 +233,20 @@ public final class BuilderClientNetworkBridge implements BuilderClientBridge,
230 233 }
231 234 }
232 235
233 public static void requestCapture(String name, BlueprintSaveMode mode) {
236 public static boolean requestCapture(String name, BlueprintSaveMode mode) {
234 237 BuilderPreviewState.Snapshot preview = BuilderPreviewState.get().snapshot();
235 if (preview == null || preview.selection().first() == null || preview.selection().second() == null) return;
238 if (preview == null || preview.selection().first() == null
239 || preview.selection().second() == null || INSTANCE.pendingCapture != null) return false;
236 240 UUID request = UUID.randomUUID();
237 241 INSTANCE.pendingCapture = request;
238 242 ModNetwork.CHANNEL.sendToServer(new BlueprintCaptureRequestPacket(request,
239 243 preview.selection().first(), preview.selection().second(), mode, name, true));
244 return true;
245 }
246
247 /** Stops routing a late capture response into a dialog the player already closed. */
248 public static void cancelPendingCapture() {
249 INSTANCE.pendingCapture = null;
240 250 }
241 251
242 252 public static void clientTick() {
@@ -326,11 +336,19 @@ public final class BuilderClientNetworkBridge implements BuilderClientBridge,
326 336 BLUEPRINT_TRANSFERS.takeDownload(captureId);
327 337 if (download.isPresent()) {
328 338 pendingCapture = null;
329 BlueprintSaveScreen.open(download.get().blueprint());
339 Minecraft minecraft = Minecraft.getInstance();
340 if (minecraft.screen instanceof BlueprintCaptureScreen captureScreen) {
341 captureScreen.acceptCapture(download.get().blueprint());
342 }
330 343 } else BLUEPRINT_TRANSFERS.takeResult(captureId).ifPresent(result -> {
331 344 if (!result.success()) {
332 345 pendingCapture = null;
333 showBlueprintMessage(result.message());
346 Minecraft minecraft = Minecraft.getInstance();
347 if (minecraft.screen instanceof BlueprintCaptureScreen captureScreen) {
348 captureScreen.captureFailed(result.message());
349 } else {
350 showBlueprintMessage(result.message());
351 }
334 352 }
335 353 });
336 354 }
Modified src/main/java/com/xfestudio/mydimension/client/builder/BuilderClientSnapshot.java +2 -1
@@ -12,6 +12,7 @@ public record BuilderClientSnapshot(
12 12 boolean enabled,
13 13 BuilderMode mode,
14 14 SurfaceMatchMode surfaceMatch,
15 boolean historyRecording,
15 16 int buildLimit,
16 17 int demolishLimit,
17 18 int maximumBuildLimit,
@@ -28,7 +29,7 @@ public record BuilderClientSnapshot(
28 29 UUID selectedBlueprintId
29 30 ) {
30 31 public static final BuilderClientSnapshot EMPTY = new BuilderClientSnapshot(
31 true, BuilderMode.BUILD, SurfaceMatchMode.SAME_BLOCK,
32 true, BuilderMode.BUILD, SurfaceMatchMode.SAME_BLOCK, false,
32 33 256, 64, 4096, 1024, 64, "", null,
33 34 0, 0, false, false, List.of(), List.of(), null
34 35 );
Modified src/main/java/com/xfestudio/mydimension/client/builder/BuilderPreviewSectionMeshCache.java +27 -14
@@ -99,11 +99,14 @@ final class BuilderPreviewSectionMeshCache {
99 99 }
100 100 if (source != null
101 101 && source.dimension().equals(snapshot.dimension())
102 && source.cells().equals(snapshot.cells())) {
102 && source.cells().equals(snapshot.cells())
103 && source.blueprintPreview() == snapshot.blueprintPreview()) {
103 104 source = snapshot;
104 105 return;
105 106 }
106 107
108 boolean includeBuildGhosts = snapshot.blueprintPreview();
109
107 110 Map<SectionKey, List<BuilderPreviewState.Cell>> grouped = new LinkedHashMap<>();
108 111 LongSet missingGhostPositions = new LongOpenHashSet();
109 112 for (BuilderPreviewState.Cell cell : snapshot.cells()) {
@@ -115,12 +118,14 @@ final class BuilderPreviewSectionMeshCache {
115 118 Map<SectionKey, SectionMesh> replacement = new LinkedHashMap<>(grouped.size());
116 119 for (Map.Entry<SectionKey, List<BuilderPreviewState.Cell>> entry : grouped.entrySet()) {
117 120 SectionMesh existing = previous.remove(entry.getKey());
118 if (existing != null && existing.matches(entry.getValue(), missingGhostPositions)) {
121 if (existing != null && existing.matches(
122 entry.getValue(), missingGhostPositions, includeBuildGhosts)) {
119 123 replacement.put(entry.getKey(), existing);
120 124 } else {
121 125 if (existing != null) existing.close();
122 126 replacement.put(entry.getKey(), new SectionMesh(
123 entry.getKey(), entry.getValue(), missingGhostPositions));
127 entry.getKey(), entry.getValue(), missingGhostPositions,
128 includeBuildGhosts));
124 129 }
125 130 }
126 131 previous.values().forEach(SectionMesh::close);
@@ -136,6 +141,7 @@ final class BuilderPreviewSectionMeshCache {
136 141 private final AABB bounds;
137 142 private final List<BuilderPreviewState.Cell> cells;
138 143 private final List<WaveCell> waveCells;
144 private final boolean includeBuildGhosts;
139 145 private final boolean hasGhosts;
140 146
141 147 @Nullable private VertexBuffer outlineBuffer;
@@ -145,7 +151,7 @@ final class BuilderPreviewSectionMeshCache {
145 151 private int waveCursor;
146 152
147 153 private SectionMesh(SectionKey key, List<BuilderPreviewState.Cell> cells,
148 LongSet missingGhostPositions) {
154 LongSet missingGhostPositions, boolean includeBuildGhosts) {
149 155 originX = key.x() * SECTION_SIZE;
150 156 originY = key.y() * SECTION_SIZE;
151 157 originZ = key.z() * SECTION_SIZE;
@@ -153,16 +159,18 @@ final class BuilderPreviewSectionMeshCache {
153 159 originX + SECTION_SIZE, originY + SECTION_SIZE, originZ + SECTION_SIZE)
154 160 .inflate(0.02D);
155 161 this.cells = List.copyOf(cells);
162 this.includeBuildGhosts = includeBuildGhosts;
156 163 waveCells = createWaveCells(cells, missingGhostPositions, originX, originY, originZ);
157 // BUILD previews deliberately remain wireframes. Besides matching the tool's visual
158 // language, excluding them here prevents the adjacent full-cube faces that used to
159 // shimmer at green-frame junctions.
160 hasGhosts = cells.stream().anyMatch(BuilderPreviewSectionMeshCache::isGhostCell);
164 // A manual surface BUILD is deliberately only a green wireframe. Blueprint placement
165 // uses the same BUILD kind, but must retain the concrete block projection so the player
166 // can inspect the copied palette before committing it.
167 hasGhosts = cells.stream().anyMatch(cell -> isGhostCell(cell, includeBuildGhosts));
161 168 }
162 169
163 170 private boolean matches(List<BuilderPreviewState.Cell> replacement,
164 LongSet missingGhostPositions) {
165 return cells.equals(replacement)
171 LongSet missingGhostPositions, boolean replacementBuildGhosts) {
172 return includeBuildGhosts == replacementBuildGhosts
173 && cells.equals(replacement)
166 174 && waveCells.equals(createWaveCells(
167 175 replacement, missingGhostPositions, originX, originY, originZ));
168 176 }
@@ -225,7 +233,7 @@ final class BuilderPreviewSectionMeshCache {
225 233 PoseStack pose = new PoseStack();
226 234 for (int index = start; index < end; index++) {
227 235 BuilderPreviewState.Cell cell = cells.get(index);
228 if (!isGhostCell(cell)) continue;
236 if (!isGhostCell(cell, includeBuildGhosts)) continue;
229 237 pose.pushPose();
230 238 pose.translate(cell.pos().getX() - originX + GHOST_MODEL_INSET,
231 239 cell.pos().getY() - originY + GHOST_MODEL_INSET,
@@ -373,13 +381,18 @@ final class BuilderPreviewSectionMeshCache {
373 381 && !cell.state().isAir();
374 382 }
375 383
376 /** Only material projections need a concrete model; BUILD is always a green wireframe. */
377 private static boolean isGhostCell(BuilderPreviewState.Cell cell) {
378 return cell.ghost() && cell.kind() != BuilderPreviewState.Kind.BUILD
384 /** BUILD models are concrete only inside blueprint copy/deployment snapshots. */
385 static boolean isGhostCell(BuilderPreviewState.Cell cell, boolean blueprintPreview) {
386 return cell.ghost()
387 && permitsGhostKind(cell.kind(), blueprintPreview)
379 388 && !cell.state().isAir()
380 389 && cell.state().getRenderShape() != RenderShape.INVISIBLE;
381 390 }
382 391
392 static boolean permitsGhostKind(BuilderPreviewState.Kind kind, boolean blueprintPreview) {
393 return kind != BuilderPreviewState.Kind.BUILD || blueprintPreview;
394 }
395
383 396 private static List<WaveCell> createWaveCells(List<BuilderPreviewState.Cell> cells,
384 397 LongSet missingGhostPositions,
385 398 int originX, int originY, int originZ) {
Modified src/main/java/com/xfestudio/mydimension/client/builder/BuilderToolScreen.java +16 -1
@@ -29,6 +29,7 @@ public class BuilderToolScreen extends Screen {
29 29 private BuilderClientSnapshot snapshot = BuilderClientSnapshot.EMPTY;
30 30 private BuilderMode localMode = BuilderMode.BUILD;
31 31 private SurfaceMatchMode localMatch = SurfaceMatchMode.SAME_BLOCK;
32 private boolean localHistoryRecording;
32 33 private EditBox buildLimit;
33 34 private EditBox demolishLimit;
34 35 private EditBox aclPlayer;
@@ -51,6 +52,7 @@ public class BuilderToolScreen extends Screen {
51 52 if (!snapshot.enabled() && tab != Tab.BLUEPRINTS && tab != Tab.SETTINGS) tab = Tab.BLUEPRINTS;
52 53 localMode = snapshot.mode();
53 54 localMatch = snapshot.surfaceMatch();
55 localHistoryRecording = snapshot.historyRecording();
54 56 addTabs();
55 57 switch (tab) {
56 58 case OPERATIONS -> initOperations();
@@ -107,6 +109,13 @@ public class BuilderToolScreen extends Screen {
107 109 addRenderableWidget(Button.builder(Component.translatable("screen.mydimension.realmwright.apply"), button -> applyLimits())
108 110 .bounds(left + 264, top + 55, 104, 20).build());
109 111
112 Button history = Button.builder(historyRecordingLabel(), button -> {
113 localHistoryRecording = !localHistoryRecording;
114 button.setMessage(historyRecordingLabel());
115 BuilderClientServices.send(new BuilderClientCommand.SetHistoryRecording(localHistoryRecording));
116 }).bounds(left, top + 82, 172, 20).build();
117 addRenderableWidget(history);
118
110 119 Button cancel = Button.builder(Component.translatable("screen.mydimension.realmwright.cancel_job"), button -> {
111 120 BuilderClientServices.send(new BuilderClientCommand.CancelActive(snapshot.activeJobId()));
112 121 BuilderPreviewState.get().clearLocalWorkflow();
@@ -329,7 +338,7 @@ public class BuilderToolScreen extends Screen {
329 338 left + 132, top + 42, 0xFFBFC9D8, false);
330 339 String progress = snapshot.totalBlocks() <= 0 ? snapshot.status()
331 340 : snapshot.completedBlocks() + " / " + snapshot.totalBlocks() + " " + snapshot.status();
332 graphics.drawString(font, progress, left, top + 88, 0xFFE7EDF7, false);
341 graphics.drawString(font, progress, left, top + 140, 0xFFE7EDF7, false);
333 342 }
334 343 case SUPPLIES -> {
335 344 if (snapshot.anchors().isEmpty()) {
@@ -450,6 +459,12 @@ public class BuilderToolScreen extends Screen {
450 459 ? "screen.mydimension.realmwright.match.same" : "screen.mydimension.realmwright.match.any");
451 460 }
452 461
462 private Component historyRecordingLabel() {
463 return Component.translatable(localHistoryRecording
464 ? "screen.mydimension.realmwright.history_recording.on"
465 : "screen.mydimension.realmwright.history_recording.off");
466 }
467
453 468 private Component anchorLabel(BuilderClientSnapshot.AnchorView anchor) {
454 469 BlockPos pos = BlockPos.of(anchor.packedPos());
455 470 return Component.literal(anchor.name() + " " + anchor.status().name().toLowerCase(java.util.Locale.ROOT)
Modified src/main/java/com/xfestudio/mydimension/client/builder/blueprint/BlueprintCaptureScreen.java +93 -17
Modified src/main/java/com/xfestudio/mydimension/network/ModNetwork.java +4 -1
Modified src/main/java/com/xfestudio/mydimension/network/builder/BuilderCommandPacket.java +9 -10
Modified src/main/java/com/xfestudio/mydimension/network/builder/BuilderPreviewPacket.java +0 -8
Modified src/main/java/com/xfestudio/mydimension/network/builder/BuilderSnapshotPacket.java +10 -14
Modified src/main/resources/assets/mydimension/lang/en_us.json +6 -0
Modified src/main/resources/assets/mydimension/lang/zh_cn.json +6 -0
Modified src/main/resources/assets/mydimension/models/item/realmwright_scepter_base.json +162 -128
Modified src/test/java/com/xfestudio/mydimension/builder/BuilderSurfaceRateLimiterTest.java +1 -0
Deleted src/test/java/com/xfestudio/mydimension/builder/BuilderSurfaceTaskManagerBudgetTest.java +0 -49
Added src/test/java/com/xfestudio/mydimension/builder/RealmwrightHistorySettingTest.java +36 -0
Added src/test/java/com/xfestudio/mydimension/client/builder/BuilderPreviewProjectionPolicyTest.java +28 -0