返回提交历史
Modified
README.md
+2
-2
Modified
src/main/java/com/xfestudio/mydimension/builder/BuilderOperationManager.java
+136
-139
Modified
src/main/java/com/xfestudio/mydimension/builder/BuilderSurfaceRateLimiter.java
+8
-1
Deleted
src/main/java/com/xfestudio/mydimension/builder/BuilderSurfaceTaskManager.java
+0
-294
Modified
src/main/java/com/xfestudio/mydimension/builder/RealmwrightData.java
+22
-1
Modified
src/main/java/com/xfestudio/mydimension/builder/blueprint/BlueprintServerEvents.java
+0
-5
Modified
src/main/java/com/xfestudio/mydimension/builder/blueprint/BlueprintServerService.java
+1
-5
Modified
src/main/java/com/xfestudio/mydimension/client/builder/BuilderClientCommand.java
+3
-0
Modified
src/main/java/com/xfestudio/mydimension/client/builder/BuilderClientNetworkBridge.java
+23
-5
Modified
src/main/java/com/xfestudio/mydimension/client/builder/BuilderClientSnapshot.java
+2
-1
Modified
src/main/java/com/xfestudio/mydimension/client/builder/BuilderPreviewSectionMeshCache.java
+27
-14
Modified
src/main/java/com/xfestudio/mydimension/client/builder/BuilderToolScreen.java
+16
-1
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
XFEstudio/MyDimension
重构权杖批量操作与历史记录机制
本次重构移除跨tick分帧队列,批量建造/拆除操作在单次交互内即时完成,显著提升流畅性。新增“历史记录”开关,支持独立配置与实时切换,关闭时大幅降低性能开销但不可撤销。协议与数据结构同步升级,协议版本升至4。移除BuilderSurfaceTaskManager,相关逻辑由速率限制器接管。客户端预览渲染策略重构,普通BUILD预览为绿色线框,蓝图部署显示具体模型,并补充单元测试。蓝图保存UI重做,支持异步反馈与错误提示。优化权杖3D模型细节,完善多语言文本。新增历史记录与预览策略相关单元测试。
ce61527
代码差异
24 个文件
+595
-695
@@ -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
@@ -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
}
@@ -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.
@@ -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
}
@@ -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)) {
@@ -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
}
@@ -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());
@@ -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
@@ -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
}
@@ -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
);
@@ -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) {
@@ -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)