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 c95b7400..b864c07a 100644 --- a/common/src/main/java/dev/ryanhcode/sable/api/SubLevelAssemblyHelper.java +++ b/common/src/main/java/dev/ryanhcode/sable/api/SubLevelAssemblyHelper.java @@ -10,6 +10,7 @@ import dev.ryanhcode.sable.companion.math.BoundingBox3ic; import dev.ryanhcode.sable.companion.math.JOMLConversion; import dev.ryanhcode.sable.companion.math.Pose3d; +import dev.ryanhcode.sable.index.SableTags; import dev.ryanhcode.sable.platform.SableAssemblyPlatform; import dev.ryanhcode.sable.sublevel.ServerSubLevel; import dev.ryanhcode.sable.sublevel.SubLevel; @@ -348,11 +349,19 @@ public static void moveBlocks(final ServerLevel level, final AssemblyTransform t tag.putInt("z", newPos.getZ()); } - if (blockEntity instanceof final RandomizableContainer container) { - container.setLootTable(null); - } - if (blockEntity instanceof final Clearable clearable) { - clearable.clearContent(); + if (state.is(SableTags.SILENT_ASSEMBLY_REMOVAL)) { + level.removeBlockEntity(block); + } else { + // This is the "correct" way to remove a block from the world, but many mods do not implement + // Clearable correctly. The above tag exists to allow this issue to be "fixed" on a case-by-case + // basis without updating a mod's code + // + // A real solution is to implement Clearable on all block entities that can be cleared in the + // same way as Vanilla MC. See SetBlockCommand + if (blockEntity instanceof final RandomizableContainer container) { + container.setLootTable(null); + } + Clearable.tryClear(blockEntity); } final LevelChunk chunk = resultingAccelerator.getChunk(SectionPos.blockToSectionCoord(newPos.getX()), SectionPos.blockToSectionCoord(newPos.getZ())); diff --git a/common/src/main/java/dev/ryanhcode/sable/index/SableTags.java b/common/src/main/java/dev/ryanhcode/sable/index/SableTags.java index 22dad65a..490f8dcc 100644 --- a/common/src/main/java/dev/ryanhcode/sable/index/SableTags.java +++ b/common/src/main/java/dev/ryanhcode/sable/index/SableTags.java @@ -36,6 +36,11 @@ public class SableTags { Sable.sablePath("paddles") ); + public static final TagKey SILENT_ASSEMBLY_REMOVAL = TagKey.create( + Registries.BLOCK, + Sable.sablePath("silent_assembly_removal") + ); + public static void register() { // no-op } diff --git a/common/src/main/resources/data/sable/structure/assemblytest.allblocks.nbt b/common/src/main/resources/data/sable/structure/assemblytest.allblocks.nbt new file mode 100644 index 00000000..6c9be514 Binary files /dev/null and b/common/src/main/resources/data/sable/structure/assemblytest.allblocks.nbt differ diff --git a/common/src/main/resources/data/sable/tags/block/silent_assembly_removal.json b/common/src/main/resources/data/sable/tags/block/silent_assembly_removal.json new file mode 100644 index 00000000..dcce4456 --- /dev/null +++ b/common/src/main/resources/data/sable/tags/block/silent_assembly_removal.json @@ -0,0 +1,22 @@ +{ + "replace": false, + "values": [ + { "id": "createaddition:rolling_mill", "required": false }, + { "id": "rubberworks:compressor", "required": false }, + { "id": "dndesires:smart_hopper", "required": false }, + { "id": "dndesires:roll_table", "required": false }, + { "id": "createfood:ration_box", "required": false }, + { "id": "createfood:cloth_sack", "required": false }, + { "id": "mechanical_botany:mechanical_insolator", "required": false }, + { "id": "mechanical_botany:mechanical_composter", "required": false }, + { "id": "create_connected:item_silo", "required": false }, + { "id": "create_integrated_farming:chicken_roost", "required": false }, + { "id": "create_new_age:reactor_fuel_acceptor", "required": false }, + { "id": "trading_floor:trading_depot", "required": false }, + { "id": "create_fantasizing:transporter", "required": false }, + { "id": "createdieselgenerators:bulk_fermenter", "required": false }, + { "id": "createrailwaysnavigator:navigator_lectern", "required": false }, + { "id": "pipeorgans:tracker_bar", "required": false }, + { "id": "dndecor:colored_storage_container", "required": false } + ] +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 755eff36..7291fab9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,7 +26,7 @@ fabric_version=0.110.0+1.21.1 fabric_loader_version=0.16.9 # NeoForge, see https://projects.neoforged.net/neoforged/neoforge for new versions -neoforge_version=21.1.220 +neoforge_version=21.1.228 neoforge_loader_version_range=[4,) # Dependencies 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 79d44724..5933724c 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 @@ -1,32 +1,63 @@ package dev.ryanhcode.sable.neoforge.gametest; +import static dev.ryanhcode.sable.neoforge.gametest.SableTestHelper.removeSubLevel; import dev.ryanhcode.sable.Sable; import dev.ryanhcode.sable.api.SubLevelAssemblyHelper; -import dev.ryanhcode.sable.companion.math.BoundingBox3i; -import dev.ryanhcode.sable.companion.math.BoundingBox3ic; import dev.ryanhcode.sable.api.sublevel.ServerSubLevelContainer; import dev.ryanhcode.sable.api.sublevel.SubLevelContainer; +import dev.ryanhcode.sable.companion.math.BoundingBox3i; +import dev.ryanhcode.sable.companion.math.BoundingBox3ic; import dev.ryanhcode.sable.sublevel.ServerSubLevel; import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem; import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.NonNullList; +import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestAssertException; import net.minecraft.gametest.framework.GameTestAssertPosException; import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.ComponentUtils; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +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.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.Property; +import net.minecraft.world.level.entity.EntityTypeTest; +import net.neoforged.neoforge.capabilities.Capabilities; import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.items.IItemHandler; +import net.neoforged.neoforge.items.IItemHandlerModifiable; +import org.jetbrains.annotations.Nullable; import org.joml.Quaterniond; import org.joml.Vector3d; import org.joml.Vector3i; import org.joml.Vector3ic; - -import java.util.ArrayList; -import java.util.List; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; @GameTestHolder(Sable.MOD_ID) public final class AssemblyTest { + private static final Direction[] CAPABILITY_DIRECTIONS = { + null, + Direction.DOWN, + Direction.UP, + Direction.NORTH, + Direction.SOUTH, + Direction.WEST, + Direction.EAST + }; + @GameTest(template = "brittlebreak") public static void testBrittleBreaking(final GameTestHelper helper) { final ServerLevel level = helper.getLevel(); @@ -36,9 +67,6 @@ public static void testBrittleBreaking(final GameTestHelper helper) { } final SubLevelPhysicsSystem physicsSystem = plotContainer.physicsSystem(); - if (physicsSystem == null) { - throw new IllegalStateException("Plot container does not have physics"); - } final BlockPos min = helper.absolutePos(new BlockPos(0, 1, 0)); final BlockPos max = helper.absolutePos(new BlockPos(2, 3, 2)); @@ -89,4 +117,252 @@ public static void testBrittleBreaking(final GameTestHelper helper) { } }); } + + @GameTest(template = "allblocks", required = false, manualOnly = true, timeoutTicks = 30_000_000) + public static void testAllBlocks(final GameTestHelper helper) { + final boolean failOnFirstError = false; + final boolean fastTest = true; + final Set skip = Set.of( + ResourceLocation.fromNamespaceAndPath("copycats", "wrapped_copycat") + ); + final Set illegalInventories = Set.of( + ResourceLocation.fromNamespaceAndPath("create_new_age", "reactor_fuel_acceptor"), + ResourceLocation.fromNamespaceAndPath("farmersdelight", "cooking_pot") // Unsure why this failed + ); + + final ServerLevel level = helper.getLevel(); + final ServerSubLevelContainer plotContainer = SubLevelContainer.getContainer(level); + if (plotContainer == null) { + throw new IllegalStateException("Plot container not found in level"); + } + + final SubLevelPhysicsSystem physicsSystem = plotContainer.physicsSystem(); + final ItemStack insertStack = new ItemStack(Items.OCELOT_SPAWN_EGG); + + final BlockPos pos = helper.absolutePos(new BlockPos(2, 3, 2)); + final BlockPos onPos = pos.below(); + final Set invalidBlocks = new HashSet<>(); + final Set failures = new HashSet<>(); + + final TestProgressBar progressBar = new TestProgressBar(level.getServer().getPlayerList()); + final AtomicLong completedItems = new AtomicLong(); + + long tick = 0; + long tests = 0; + for (final Map.Entry, Block> entry : BuiltInRegistries.BLOCK.entrySet()) { + final ResourceLocation blockId = entry.getKey().location(); + if (skip.contains(blockId)) { + continue; + } + + final Block block = entry.getValue(); + for (final BlockState state : block.getStateDefinition().getPossibleStates()) { + // Bug with lecterns. If the block state is set, but there isn't a book it will try to drop an air item + if (state.is(Blocks.LECTERN) && state.getValue(BlockStateProperties.HAS_BOOK)) { + continue; + } + + boolean hasInventory = false; + for (@Nullable final Direction direction : CAPABILITY_DIRECTIONS) { + final IItemHandler inventory = level.getCapability(Capabilities.ItemHandler.BLOCK, pos, state, level.getBlockEntity(pos), direction); + if (inventory != null) { + hasInventory = true; + break; + } + } + + if (!hasInventory && !state.hasBlockEntity()) { + continue; + } + + tests++; + helper.runAtTickTime(tick += 2, () -> { + level.setBlock(onPos, Blocks.STONE.defaultBlockState(), 2); + level.setBlock(pos, state, 2); + + // The block was unstable and can't be placed in this configuration + if (isInvalidState(level.getBlockState(pos))) { + helper.killAllEntities(); + invalidBlocks.add(block); + return; + } + + final List startEntities = level.getEntities(EntityTypeTest.forClass(Entity.class), helper.getBounds(), Entity::isAlive); + final NonNullList[] startingInventory = new NonNullList[CAPABILITY_DIRECTIONS.length]; + + for (int i = 0; i < CAPABILITY_DIRECTIONS.length; i++) { + final Direction direction = CAPABILITY_DIRECTIONS[i]; + final IItemHandler inventory = level.getCapability(Capabilities.ItemHandler.BLOCK, pos, state, level.getBlockEntity(pos), direction); + + if (inventory != null) { + try { + final int slots = inventory.getSlots(); + if (inventory instanceof final IItemHandlerModifiable modifiable) { + for (int slot = 0; slot < slots; slot++) { + try { + modifiable.setStackInSlot(slot, insertStack.copy()); + } catch (final Throwable ignored) { + inventory.insertItem(slot, insertStack.copy(), false); + } + } + } else { + for (int slot = 0; slot < slots; slot++) { + inventory.insertItem(slot, insertStack.copy(), false); + } + } + + final NonNullList list = NonNullList.withSize(slots, ItemStack.EMPTY); + for (int slot = 0; slot < slots; slot++) { + list.set(slot, inventory.getStackInSlot(slot).copy()); + } + startingInventory[i] = list; + } catch (final Throwable t) { + t.printStackTrace(); + helper.fail(formatBlockState(state).getString() + " failed. Unable to insert items successfully for face " + direction, pos); + } + } + } + + helper.runAfterDelay(1, () -> { + final ServerSubLevel subLevel = SubLevelAssemblyHelper.assembleBlocks(level, pos, List.of(pos, onPos), new BoundingBox3i( + onPos.getX(), + onPos.getY(), + onPos.getZ(), + pos.getX() + 1, + pos.getY() + 1, + pos.getZ() + 1)); + physicsSystem.getPipeline().teleport(subLevel, + new Vector3d(pos.getX() + 0.5, + pos.getY() + 1.0, + pos.getZ() + 0.5), + helper.getTestRotation().rotation().transformation().getNormalizedRotation(new Quaterniond())); + + final BlockPos centerBlock = subLevel.getPlot().getCenterBlock(); + + if (isInvalidState(level.getBlockState(centerBlock))) { + invalidBlocks.add(block); + helper.killAllEntities(); + removeSubLevel(plotContainer, subLevel); + progressBar.update(completedItems.incrementAndGet()); + return; + } + + final List resultEntities = level.getEntities(EntityTypeTest.forClass(Entity.class), helper.getBounds(), Entity::isAlive); + if (startEntities.size() != resultEntities.size()) { + if (failOnFirstError) { + final List names = new ArrayList<>(resultEntities.size()); + for (final Entity entity : resultEntities) { + if (!startEntities.contains(entity)) { + names.add(entity.getDisplayName()); + } + } + final String formattedEntities = ComponentUtils.formatList(names, Component.literal(", ")).getString(); + helper.fail(formatBlockState(state).getString() + " failed. Expected " + startEntities.size() + " entities to exist, found " + formattedEntities); + return; + } + failures.add(block); + helper.killAllEntities(); + } + + if (!illegalInventories.contains(blockId)) { + for (int i = 0; i < CAPABILITY_DIRECTIONS.length; i++) { + final Direction direction = CAPABILITY_DIRECTIONS[i]; + final IItemHandler inventory = level.getCapability(Capabilities.ItemHandler.BLOCK, centerBlock, state, level.getBlockEntity(centerBlock), direction); + final NonNullList starting = startingInventory[i]; + + if (inventory != null) { + if (starting == null) { + final String stateString = formatBlockState(state).getString(); + helper.fail(stateString + " failed. Expected no inventory for face " + direction + ", found items"); + return; + } + + if (starting.size() != inventory.getSlots()) { + final String stateString = formatBlockState(state).getString(); + helper.fail(stateString + " failed. Expected " + starting.size() + " inventory slots for face " + direction + ", found " + inventory.getSlots()); + return; + } + + try { + for (int slot = 0; slot < starting.size(); slot++) { + if (!ItemStack.isSameItemSameComponents(starting.get(slot), inventory.getStackInSlot(slot))) { + final String stateString = formatBlockState(state).getString(); + final String expectedStack = starting.get(slot).toString(); + final String foundStack = inventory.getStackInSlot(slot).toString(); + helper.fail(stateString + " failed. Expected slot " + slot + " for face " + direction + " to be " + expectedStack + " found " + foundStack); + return; + } + } + } catch (final GameTestAssertException e) { + throw e; + } catch (final Throwable t) { + t.printStackTrace(); + helper.fail(formatBlockState(state).getString() + " failed. Unable to get items successfully for face " + direction); + } + } else if (starting != null) { + final String stateString = formatBlockState(state).getString(); + helper.fail(stateString + " failed. Expected inventory items for face " + direction + ", found none"); + return; + } + } + } + + removeSubLevel(plotContainer, subLevel); + progressBar.update(completedItems.incrementAndGet()); + }); + }); + + if (fastTest) { + break; + } + } + } + + progressBar.begin(tests); + + helper.runAtTickTime(tick + 1, () -> { + progressBar.end(); + + if (!invalidBlocks.isEmpty()) { + final List names = new ArrayList<>(invalidBlocks.size()); + for (final Block block : invalidBlocks) { + names.add(String.valueOf(BuiltInRegistries.BLOCK.getKey(block))); + } + final String formattedLines = String.join("\n", names); + Sable.LOGGER.info("Skipped blocks:\n{}", formattedLines); + } + + if (!failures.isEmpty()) { + final List names = new ArrayList<>(failures.size()); + for (final Block block : failures) { + names.add(String.valueOf(BuiltInRegistries.BLOCK.getKey(block))); + } + final String formattedLines = String.join("\n", names); + helper.fail(failures.size() + " blocks failed.\n" + formattedLines); + } + + helper.succeed(); + }); + } + + private static Component formatBlockState(final BlockState state) { + final MutableComponent name = Component.literal(String.valueOf(BuiltInRegistries.BLOCK.getKey(state.getBlock()))); + + final Collection> properties = state.getProperties(); + if (!properties.isEmpty()) { + final StringBuilder propertiesString = new StringBuilder("["); + for (final Property property : properties) { + final Object value = state.getValue(property); + propertiesString.append(property.getName()).append("=").append(value).append(","); + } + propertiesString.setCharAt(propertiesString.length() - 1, ']'); + name.append(propertiesString.toString()); + } + + return name; + } + + private static boolean isInvalidState(final BlockState state) { + return state.isAir() || state.getFluidState().createLegacyBlock() == state; + } } diff --git a/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/SableTestHelper.java b/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/SableTestHelper.java index 28546106..ba641844 100644 --- a/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/SableTestHelper.java +++ b/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/SableTestHelper.java @@ -1,10 +1,11 @@ package dev.ryanhcode.sable.neoforge.gametest; -import dev.ryanhcode.sable.companion.math.Pose3d; import dev.ryanhcode.sable.api.sublevel.SubLevelContainer; +import dev.ryanhcode.sable.companion.math.Pose3d; import dev.ryanhcode.sable.sublevel.ServerSubLevel; import dev.ryanhcode.sable.sublevel.SubLevel; import dev.ryanhcode.sable.sublevel.plot.LevelPlot; +import dev.ryanhcode.sable.sublevel.storage.SubLevelRemovalReason; import net.minecraft.core.BlockPos; import net.minecraft.gametest.framework.GameTestHelper; import net.minecraft.world.level.ChunkPos; @@ -12,9 +13,9 @@ import net.minecraft.world.level.block.Rotation; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.phys.AABB; +import org.joml.Vector2i; import org.joml.Vector3d; import org.joml.Vector3dc; - import java.util.function.Consumer; public final class SableTestHelper { @@ -78,4 +79,10 @@ public static boolean isInBounds(final GameTestHelper helper, final Vector3dc gl final AABB box = helper.getBounds(); return box.contains(globalPosition.x(), globalPosition.y(), globalPosition.z()); } + + public static void removeSubLevel(final SubLevelContainer container, final ServerSubLevel subLevel) { + final LevelPlot plot = subLevel.getPlot(); + final Vector2i origin = container.getOrigin(); + container.removeSubLevel(plot.plotPos.x - origin.x, plot.plotPos.z - origin.y, SubLevelRemovalReason.REMOVED); + } } diff --git a/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/TestProgressBar.java b/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/TestProgressBar.java new file mode 100644 index 00000000..5d2da59e --- /dev/null +++ b/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/TestProgressBar.java @@ -0,0 +1,41 @@ +package dev.ryanhcode.sable.neoforge.gametest; + +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerBossEvent; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.players.PlayerList; +import net.minecraft.world.BossEvent; + +public class TestProgressBar { + + private final ServerBossEvent bossEvent; + private final PlayerList playerList; + private long maxItems; + + public TestProgressBar(final PlayerList playerList) { + this.bossEvent = new ServerBossEvent(Component.literal("Test Progress"), BossEvent.BossBarColor.RED, BossEvent.BossBarOverlay.PROGRESS); + this.playerList = playerList; + } + + private void updateVisible() { + for (final ServerPlayer player : this.playerList.getPlayers()) { + this.bossEvent.addPlayer(player); + } + } + + public void begin(final long maxItems) { + this.maxItems = maxItems; + this.bossEvent.setName(Component.literal("Test Progress: 0 / " + maxItems)); + this.updateVisible(); + } + + public void update(final long items) { + this.updateVisible(); + this.bossEvent.setName(Component.literal("Test Progress: " + items + " / " + this.maxItems)); + this.bossEvent.setProgress((float) items / this.maxItems); + } + + public void end() { + this.bossEvent.removeAllPlayers(); + } +}