diff --git a/.gitignore b/.gitignore index 75c6db86..13a01769 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ # IntelliJ out/ + +# Eclipse / VS Code Java language server build output +bin/ # mpeltonen/sbt-idea plugin .idea_modules/ diff --git a/common/src/main/java/dev/ryanhcode/sable/api/SubLevelAssemblyHelper.java b/common/src/main/java/dev/ryanhcode/sable/api/SubLevelAssemblyHelper.java index 83ac4711..38373185 100644 --- a/common/src/main/java/dev/ryanhcode/sable/api/SubLevelAssemblyHelper.java +++ b/common/src/main/java/dev/ryanhcode/sable/api/SubLevelAssemblyHelper.java @@ -402,6 +402,11 @@ public static void moveBlocks(final ServerLevel level, final AssemblyTransform t container.setLootTable(null); } Clearable.tryClear(blockEntity); + if (blockEntity != null && !(blockEntity instanceof Clearable)) { + // The destination copy already owns the contents; leaving them in the source block entity + // would make the destruction below drop (or void) a duplicate via onRemove(...). + level.removeBlockEntity(block); + } } final LevelChunk chunk = resultingAccelerator.getChunk(SectionPos.blockToSectionCoord(newPos.getX()), SectionPos.blockToSectionCoord(newPos.getZ())); diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java index e1a56349..7f838ec7 100644 --- a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java +++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java @@ -154,7 +154,7 @@ public void processSubLevelRemovals() { for (final SubLevel subLevel : this.allSubLevels) { if (subLevel instanceof final ServerSubLevel serverSubLevel) { if (!serverSubLevel.isRemoved() && serverSubLevel.getMassTracker().isInvalid()) { - serverSubLevel.getPlot().destroyAllBlocks(); + serverSubLevel.getPlot().destroyAllBlocks(false); serverSubLevel.markRemoved(); } } diff --git a/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/ServerLevelPlot.java b/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/ServerLevelPlot.java index f0e57705..b1a84348 100644 --- a/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/ServerLevelPlot.java +++ b/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/ServerLevelPlot.java @@ -291,8 +291,12 @@ public void kickAllEntities() { /** * Destroys all blocks within the plot + * + * @param dropItems whether the destroyed blocks should drop their items. Mass invalidation must pass + * {@code false}: at that point the plot only ever contains air and zero-mass blocks, which the + * disassembly sweep has already moved back to the world. */ - public void destroyAllBlocks() { + public void destroyAllBlocks(boolean dropItems) { if (this.localBounds == null || this.localBounds == BoundingBox3i.EMPTY) { return; } @@ -305,7 +309,20 @@ public void destroyAllBlocks() { for (int z = bounds.minZ(); z <= bounds.maxZ(); z++) { final BlockPos pos = new BlockPos(x, y, z); - level.destroyBlock(pos, true); + if (dropItems) { + level.destroyBlock(pos, true); + } else { + final BlockState state = level.getBlockState(pos); + if (state.isAir()) { + continue; + } + // Detach the block entity first so onRemove(...) cannot spill container contents. Neighbor + // updates are suppressed as well: nothing needs to react to a wholesale plot deletion, and + // updates can pop foreign blocks living in adjacent plot grid cells (dropping them). + level.removeBlockEntity(pos); + level.setBlock(pos, state.getFluidState().createLegacyBlock(), + Block.UPDATE_CLIENTS | Block.UPDATE_MOVE_BY_PISTON); + } } } } diff --git a/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/heat/SubLevelHeatMapManager.java b/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/heat/SubLevelHeatMapManager.java index 6816fda1..38e787ed 100644 --- a/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/heat/SubLevelHeatMapManager.java +++ b/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/heat/SubLevelHeatMapManager.java @@ -243,7 +243,7 @@ private void split() { // Protect against split sub-levels that have zero mass. if (subLevel.getSelfMassTracker().getCenterOfMass() == null || subLevel.getSelfMassTracker().getMass() <= 0.0) { - subLevel.getPlot().destroyAllBlocks(); + subLevel.getPlot().destroyAllBlocks(true); final SubLevelContainer container = Objects.requireNonNull(SubLevelContainer.getContainer(level)); container.removeSubLevel(subLevel, SubLevelRemovalReason.REMOVED); diff --git a/common/src/main/java/dev/ryanhcode/sable/sublevel/system/SubLevelPhysicsSystem.java b/common/src/main/java/dev/ryanhcode/sable/sublevel/system/SubLevelPhysicsSystem.java index d4e68fee..c742ea46 100644 --- a/common/src/main/java/dev/ryanhcode/sable/sublevel/system/SubLevelPhysicsSystem.java +++ b/common/src/main/java/dev/ryanhcode/sable/sublevel/system/SubLevelPhysicsSystem.java @@ -531,7 +531,7 @@ public void updateMassDataFromBlockChange(final SubLevel subLevel, final BlockPo if (oldMass != 0.0) massTracker.addBlockMass(level, oldState, globalBlockPos, -oldMass, oldInertia); if (!subLevel.isRemoved() && massTracker.isInvalid()) { - serverSubLevel.getPlot().destroyAllBlocks(); + serverSubLevel.getPlot().destroyAllBlocks(false); serverSubLevel.markRemoved(); return; } diff --git a/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/AssemblyTest.java b/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/AssemblyTest.java index 5933724c..f6e84112 100644 --- a/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/AssemblyTest.java +++ b/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/AssemblyTest.java @@ -24,11 +24,14 @@ import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.WallTorchBlock; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.properties.BlockStateProperties; import net.minecraft.world.level.block.state.properties.Property; @@ -118,6 +121,81 @@ public static void testBrittleBreaking(final GameTestHelper helper) { }); } + /** + * Regression test: demolishing an invalidated (massless) plot must not drop anything — the weightless blocks + * left in the plot are duplicates of blocks the disassembly sweep has already moved back to the world. + */ + @GameTest(template = "brittlebreak") + public static void testMasslessInvalidationDoesNotDrop(final GameTestHelper helper) { + final ServerLevel level = helper.getLevel(); + final ServerSubLevelContainer plotContainer = SubLevelContainer.getContainer(level); + if (plotContainer == null) { + throw new IllegalStateException("Plot container not found in level"); + } + + final BlockPos min = helper.absolutePos(new BlockPos(0, 1, 0)); + final BlockPos max = helper.absolutePos(new BlockPos(2, 3, 2)); + final BoundingBox3i bounds = new BoundingBox3i( + min.getX(), min.getY(), min.getZ(), + max.getX(), max.getY(), max.getZ() + ); + + // A single stone with a zero-mass wall torch on each of its four sides (covering every facing). + final BlockPos stonePos = new BlockPos(1, 1, 1); + + // Reset the template area — the floor layer included, or stray template decorations can drop during the + // run — so the test does not depend on template contents. Shape updates are suppressed (flag 16): clearing + // with normal flags would pop the template's adjacent redstone components and drop their items. + for (final BlockPos pos : BlockPos.betweenClosed(new BlockPos(0, 0, 0), new BlockPos(2, 3, 2))) { + level.setBlock(helper.absolutePos(pos), Blocks.AIR.defaultBlockState(), + Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE); + } + helper.setBlock(stonePos, Blocks.STONE.defaultBlockState()); + for (final Direction direction : Direction.Plane.HORIZONTAL) { + helper.setBlock(stonePos.relative(direction), + Blocks.WALL_TORCH.defaultBlockState().setValue(WallTorchBlock.FACING, direction)); + } + + final ServerSubLevel subLevel = SubLevelAssemblyHelper.assembleBlocks(level, min, BlockPos.betweenClosed(min, max), bounds); + + helper.runAtTickTime(10, () -> { + final Level plot = subLevel.getLevel(); + final BoundingBox3ic plotBounds = subLevel.getPlot().getBoundingBox(); + + // Remove the only mass-bearing block. Shape updates are suppressed (flag 16), matching silent + // disassembly sweeps, so the zero-mass torches are not popped first. This invalidates the mass + // tracker, and the physics system demolishes the plot through the real invalidation path. + for (final BlockPos pos : BlockPos.betweenClosed(plotBounds.minX(), plotBounds.minY(), plotBounds.minZ(), + plotBounds.maxX(), plotBounds.maxY(), plotBounds.maxZ())) { + if (plot.getBlockState(pos).isAir() || plot.getBlockState(pos).getBlock() == Blocks.WALL_TORCH) { + continue; + } + plot.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE); + } + + // Fallback demolition in case the mass update hook did not fire; a no-op if the plot is already gone. + // Demolishing spawns the drops at the plot's position in the level, far away from the test structure, + // so the assertion has to cover the whole level. The template reset above keeps it free of stray items. + helper.runAtTickTime(20, () -> { + if (!subLevel.isRemoved()) { + subLevel.getPlot().destroyAllBlocks(false); + } + + helper.runAtTickTime(30, () -> { + // Only the torches this test placed count as evidence: the shared plot grid can pop foreign + // decorations from neighbouring grid content during assembly, which drops unrelated items. + final List droppedTorches = level.getEntities(EntityType.ITEM, + itemEntity -> itemEntity.getItem().is(Blocks.WALL_TORCH.asItem())); + if (!droppedTorches.isEmpty()) { + helper.fail("Demolishing the invalidated plot dropped " + droppedTorches.size() + + " zero-mass wall torch item(s)"); + } + helper.succeed(); + }); + }); + }); + } + @GameTest(template = "allblocks", required = false, manualOnly = true, timeoutTicks = 30_000_000) public static void testAllBlocks(final GameTestHelper helper) { final boolean failOnFirstError = false;