mirror of
				https://github.com/SquidDev-CC/CC-Tweaked
				synced 2025-10-30 21:23:00 +00:00 
			
		
		
		
	Add a system for client-side tests (#1219)
- Add a new ClientJavaExec Gradle task, which is used for client-side
   tests. This:
   - Copies the exec spec from another JavaExec task.
   - Sets some additional system properties to configure on gametest framework.
   - Runs Java inside an X framebuffer (when available), meaning we
     don't need to spin up a new window.
   We also configure this task so that only one instance can run at
   once, meaning we don't spawn multiple MC windows at once!
 - Port our 1.16 client test framework to 1.19. This is mostly the same
   as before, but screenshots no longer do a golden test: they /just/
   write to a folder. Screenshots are compared manually afterwards.
   This is still pretty brittle, and there's a lot of sleeps scattered
   around in the code. It's not clear how well this will play on CI.
 - Roll our own game test loader, rather than relying on the mod loader
   to do it for us. This ensures that loading is consistent between
   platforms (we already had to do some hacks for Forge) and makes it
   easier to provide custom logic for loading client-only tests.
 - Run several client tests (namely those involving monitor rendering)
   against Sodium and Iris too. There's some nastiness here to set up
   new Loom run configurations and automatically configure Iris to use
   Complementary Shaders, but it's not too bad. These tests /don't/ run
   on CI, so it doesn't need to be as reliable.
			
			
This commit is contained in:
		| @@ -10,6 +10,7 @@ import net.minecraft.core.Registry; | ||||
| import net.minecraft.network.FriendlyByteBuf; | ||||
| import net.minecraft.resources.ResourceLocation; | ||||
| import net.minecraft.sounds.SoundEvent; | ||||
| import net.minecraft.world.inventory.MenuType; | ||||
| import net.minecraft.world.item.Item; | ||||
| import net.minecraft.world.item.crafting.RecipeSerializer; | ||||
| import net.minecraft.world.item.enchantment.Enchantment; | ||||
| @@ -31,6 +32,7 @@ public final class Registries { | ||||
|     public static final RegistryWrapper<ArgumentTypeInfo<?, ?>> COMMAND_ARGUMENT_TYPES = PlatformHelper.get().wrap(Registry.COMMAND_ARGUMENT_TYPE_REGISTRY); | ||||
|     public static final RegistryWrapper<SoundEvent> SOUND_EVENTS = PlatformHelper.get().wrap(Registry.SOUND_EVENT_REGISTRY); | ||||
|     public static final RegistryWrapper<RecipeSerializer<?>> RECIPE_SERIALIZERS = PlatformHelper.get().wrap(Registry.RECIPE_SERIALIZER_REGISTRY); | ||||
|     public static final RegistryWrapper<MenuType<?>> MENU = PlatformHelper.get().wrap(Registry.MENU_REGISTRY); | ||||
| 
 | ||||
|     public interface RegistryWrapper<T> extends Iterable<T> { | ||||
|         int getId(T object); | ||||
|   | ||||
| @@ -1,23 +0,0 @@ | ||||
| /* | ||||
|  * This file is part of ComputerCraft - http://www.computercraft.info | ||||
|  * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. | ||||
|  * Send enquiries to dratcliffe@gmail.com | ||||
|  */ | ||||
| package dan200.computercraft.gametest.api; | ||||
| 
 | ||||
| import net.minecraft.gametest.framework.GameTest; | ||||
| 
 | ||||
| import java.lang.annotation.ElementType; | ||||
| import java.lang.annotation.Retention; | ||||
| import java.lang.annotation.RetentionPolicy; | ||||
| import java.lang.annotation.Target; | ||||
| 
 | ||||
| /** | ||||
|  * Marks classes containing {@linkplain GameTest game tests}. | ||||
|  * <p> | ||||
|  * This is used by Forge to automatically load and test classes. | ||||
|  */ | ||||
| @Target(ElementType.TYPE) | ||||
| @Retention(RetentionPolicy.RUNTIME) | ||||
| public @interface GameTestHolder { | ||||
| } | ||||
| @@ -105,7 +105,7 @@ class CCTestCommand { | ||||
|     } | ||||
| 
 | ||||
|     private static Path getSourceComputerPath() { | ||||
|         return TestHooks.sourceDir.resolve("computer"); | ||||
|         return TestHooks.getSourceDir().resolve("computer"); | ||||
|     } | ||||
| 
 | ||||
|     private static int error(CommandSourceStack source, String message) { | ||||
|   | ||||
| @@ -12,6 +12,8 @@ import dan200.computercraft.api.lua.LuaFunction; | ||||
| import dan200.computercraft.gametest.api.ComputerState; | ||||
| import dan200.computercraft.gametest.api.TestExtensionsKt; | ||||
| import net.minecraft.gametest.framework.GameTestSequence; | ||||
| import org.slf4j.Logger; | ||||
| import org.slf4j.LoggerFactory; | ||||
| 
 | ||||
| import javax.annotation.Nullable; | ||||
| import java.util.Optional; | ||||
| @@ -24,6 +26,8 @@ import java.util.Optional; | ||||
|  * @see TestExtensionsKt#thenComputerOk(GameTestSequence, String, String)   To check tests on the computer have passed. | ||||
|  */ | ||||
| public class TestAPI extends ComputerState implements ILuaAPI { | ||||
|     private static final Logger LOG = LoggerFactory.getLogger(TestAPI.class); | ||||
| 
 | ||||
|     private final IComputerSystem system; | ||||
|     private @Nullable String label; | ||||
| 
 | ||||
| @@ -36,10 +40,10 @@ public class TestAPI extends ComputerState implements ILuaAPI { | ||||
|         if (label == null) label = system.getLabel(); | ||||
|         if (label == null) { | ||||
|             label = "#" + system.getID(); | ||||
|             TestHooks.LOG.warn("Computer {} has no label", label); | ||||
|             LOG.warn("Computer {} has no label", label); | ||||
|         } | ||||
| 
 | ||||
|         TestHooks.LOG.info("Computer '{}' has turned on.", label); | ||||
|         LOG.info("Computer '{}' has turned on.", label); | ||||
|         markers.clear(); | ||||
|         error = null; | ||||
|         lookup.put(label, this); | ||||
| @@ -47,7 +51,7 @@ public class TestAPI extends ComputerState implements ILuaAPI { | ||||
| 
 | ||||
|     @Override | ||||
|     public void shutdown() { | ||||
|         TestHooks.LOG.info("Computer '{}' has shut down.", label); | ||||
|         LOG.info("Computer '{}' has shut down.", label); | ||||
|         if (lookup.get(label) == this) lookup.remove(label); | ||||
|     } | ||||
| 
 | ||||
| @@ -58,7 +62,7 @@ public class TestAPI extends ComputerState implements ILuaAPI { | ||||
| 
 | ||||
|     @LuaFunction | ||||
|     public final void fail(String message) throws LuaException { | ||||
|         TestHooks.LOG.error("Computer '{}' failed with {}", label, message); | ||||
|         LOG.error("Computer '{}' failed with {}", label, message); | ||||
|         if (markers.contains(ComputerState.DONE)) throw new LuaException("Cannot call fail/ok multiple times."); | ||||
|         markers.add(ComputerState.DONE); | ||||
|         error = message; | ||||
| @@ -77,6 +81,6 @@ public class TestAPI extends ComputerState implements ILuaAPI { | ||||
| 
 | ||||
|     @LuaFunction | ||||
|     public final void log(String message) { | ||||
|         TestHooks.LOG.info("[Computer '{}'] {}", label, message); | ||||
|         LOG.info("[Computer '{}'] {}", label, message); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,55 +0,0 @@ | ||||
| /* | ||||
|  * This file is part of ComputerCraft - http://www.computercraft.info | ||||
|  * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. | ||||
|  * Send enquiries to dratcliffe@gmail.com | ||||
|  */ | ||||
| package dan200.computercraft.gametest.core; | ||||
| 
 | ||||
| import dan200.computercraft.api.ComputerCraftAPI; | ||||
| import dan200.computercraft.gametest.api.Times; | ||||
| import dan200.computercraft.shared.computer.core.ServerContext; | ||||
| import net.minecraft.core.BlockPos; | ||||
| import net.minecraft.gametest.framework.GameTestRunner; | ||||
| import net.minecraft.gametest.framework.GameTestTicker; | ||||
| import net.minecraft.gametest.framework.StructureUtils; | ||||
| import net.minecraft.server.MinecraftServer; | ||||
| import net.minecraft.world.level.GameRules; | ||||
| import net.minecraft.world.level.Level; | ||||
| import org.slf4j.Logger; | ||||
| import org.slf4j.LoggerFactory; | ||||
| 
 | ||||
| import java.nio.file.Path; | ||||
| import java.nio.file.Paths; | ||||
| 
 | ||||
| public class TestHooks { | ||||
|     public static final Logger LOG = LoggerFactory.getLogger(TestHooks.class); | ||||
| 
 | ||||
|     public static final Path sourceDir = Paths.get(System.getProperty("cctest.sources")).normalize().toAbsolutePath(); | ||||
| 
 | ||||
|     public static void init() { | ||||
|         ServerContext.luaMachine = ManagedComputers.INSTANCE; | ||||
|         ComputerCraftAPI.registerAPIFactory(TestAPI::new); | ||||
|         StructureUtils.testStructuresDir = sourceDir.resolve("structures").toString(); | ||||
|     } | ||||
| 
 | ||||
|     public static void onServerStarted(MinecraftServer server) { | ||||
|         var rules = server.getGameRules(); | ||||
|         rules.getRule(GameRules.RULE_DAYLIGHT).set(false, server); | ||||
| 
 | ||||
|         var world = server.getLevel(Level.OVERWORLD); | ||||
|         if (world != null) world.setDayTime(Times.NOON); | ||||
| 
 | ||||
|         LOG.info("Cleaning up after last run"); | ||||
|         GameTestRunner.clearAllTests(server.overworld(), new BlockPos(0, -60, 0), GameTestTicker.SINGLETON, 200); | ||||
| 
 | ||||
|         // Delete server context and add one with a mutable machine factory. This allows us to set the factory for | ||||
|         // specific test batches without having to reset all computers. | ||||
|         for (var computer : ServerContext.get(server).registry().getComputers()) { | ||||
|             var label = computer.getLabel() == null ? "#" + computer.getID() : computer.getLabel(); | ||||
|             LOG.warn("Unexpected computer {}", label); | ||||
|         } | ||||
| 
 | ||||
|         LOG.info("Importing files"); | ||||
|         CCTestCommand.importFiles(server); | ||||
|     } | ||||
| } | ||||
| @@ -14,4 +14,7 @@ import org.spongepowered.asm.mixin.gen.Accessor; | ||||
| public interface GameTestSequenceAccessor { | ||||
|     @Accessor | ||||
|     GameTestInfo getParent(); | ||||
| 
 | ||||
|     @Accessor | ||||
|     long getLastTick(); | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,23 @@ | ||||
| /* | ||||
|  * This file is part of ComputerCraft - http://www.computercraft.info | ||||
|  * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. | ||||
|  * Send enquiries to dratcliffe@gmail.com | ||||
|  */ | ||||
| package dan200.computercraft.mixin.gametest; | ||||
| 
 | ||||
| import net.minecraft.SharedConstants; | ||||
| import org.spongepowered.asm.mixin.Mixin; | ||||
| import org.spongepowered.asm.mixin.Overwrite; | ||||
| 
 | ||||
| @Mixin(SharedConstants.class) | ||||
| class SharedConstantsMixin { | ||||
|     /** | ||||
|      * Disable DFU initialisation. | ||||
|      * | ||||
|      * @author SquidDev | ||||
|      * @reason This doesn't have any impact on gameplay, and slightly speeds up tests. | ||||
|      */ | ||||
|     @Overwrite | ||||
|     public static void enableDataFixerOptimizations() { | ||||
|     } | ||||
| } | ||||
| @@ -5,18 +5,25 @@ | ||||
|  */ | ||||
| package dan200.computercraft.gametest | ||||
| 
 | ||||
| import dan200.computercraft.api.lua.ObjectArguments | ||||
| import dan200.computercraft.client.gui.AbstractComputerScreen | ||||
| import dan200.computercraft.core.apis.RedstoneAPI | ||||
| import dan200.computercraft.core.apis.TermAPI | ||||
| import dan200.computercraft.core.computer.ComputerSide | ||||
| import dan200.computercraft.gametest.api.* | ||||
| import dan200.computercraft.shared.ModRegistry | ||||
| import dan200.computercraft.test.core.computer.getApi | ||||
| import net.minecraft.core.BlockPos | ||||
| import net.minecraft.gametest.framework.GameTest | ||||
| import net.minecraft.gametest.framework.GameTestHelper | ||||
| import net.minecraft.world.InteractionHand | ||||
| import net.minecraft.world.level.block.Blocks | ||||
| import net.minecraft.world.level.block.LeverBlock | ||||
| import net.minecraft.world.level.block.RedstoneLampBlock | ||||
| import org.junit.jupiter.api.Assertions.assertEquals | ||||
| import org.junit.jupiter.api.Assertions.assertTrue | ||||
| import org.lwjgl.glfw.GLFW | ||||
| 
 | ||||
| @GameTestHolder | ||||
| class Computer_Test { | ||||
|     /** | ||||
|      * Ensures redstone signals do not travel through computers. | ||||
| @@ -50,6 +57,9 @@ class Computer_Test { | ||||
|         thenExecute { context.assertBlockHas(lamp, RedstoneLampBlock.LIT, false, "Lamp should still not be lit") } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check computers propagate redstone to surrounding blocks. | ||||
|      */ | ||||
|     @GameTest | ||||
|     fun Set_and_destroy(context: GameTestHelper) = context.sequence { | ||||
|         val lamp = BlockPos(2, 2, 3) | ||||
| @@ -62,6 +72,9 @@ class Computer_Test { | ||||
|         thenExecute { context.assertBlockHas(lamp, RedstoneLampBlock.LIT, false, "Lamp should not be lit") } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check computers and turtles expose peripherals. | ||||
|      */ | ||||
|     @GameTest | ||||
|     fun Computer_peripheral(context: GameTestHelper) = context.sequence { | ||||
|         thenExecute { | ||||
| @@ -69,4 +82,62 @@ class Computer_Test { | ||||
|             context.assertPeripheral(BlockPos(1, 2, 2), type = "turtle") | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check the client can open the computer UI and interact with it. | ||||
|      */ | ||||
|     @ClientGameTest | ||||
|     fun Open_on_client(context: GameTestHelper) = context.sequence { | ||||
|         // Write "Hello, world!" and then print each event to the terminal. | ||||
|         thenOnComputer { getApi<TermAPI>().write(ObjectArguments("Hello, world!")) } | ||||
|         thenStartComputer { | ||||
|             val term = getApi<TermAPI>().terminal | ||||
|             while (true) { | ||||
|                 val event = pullEvent() | ||||
|                 if (term.cursorY >= term.height) { | ||||
|                     term.scroll(1) | ||||
|                     term.setCursorPos(0, term.height) | ||||
|                 } else { | ||||
|                     term.setCursorPos(0, term.cursorY + 1) | ||||
|                 } | ||||
| 
 | ||||
|                 term.write(event.contentToString()) | ||||
|             } | ||||
|         } | ||||
|         // Teleport the player to the computer and then open it. | ||||
|         thenExecute { | ||||
|             context.positionAt(BlockPos(2, 2, 1)) | ||||
|             val computer = context.getBlockEntity(BlockPos(2, 2, 2), ModRegistry.BlockEntities.COMPUTER_ADVANCED.get()) | ||||
|             computer.use(context.level.randomPlayer!!, InteractionHand.MAIN_HAND) | ||||
|         } | ||||
|         // Assert the terminal is synced to the client. | ||||
|         thenIdle(2) | ||||
|         thenOnClient { | ||||
|             val menu = getOpenMenu(ModRegistry.Menus.COMPUTER.get()) | ||||
|             val term = menu.terminal | ||||
|             assertEquals("Hello, world!", term.getLine(0).toString().trim(), "Terminal contents is synced") | ||||
|             assertTrue(menu.isOn, "Computer is on") | ||||
|         } | ||||
|         // Press a key on the client | ||||
|         thenOnClient { | ||||
|             val screen = minecraft.screen as AbstractComputerScreen<*> | ||||
|             screen.keyPressed(GLFW.GLFW_KEY_A, 0, 0) | ||||
|             screen.keyReleased(GLFW.GLFW_KEY_A, 0, 0) | ||||
|         } | ||||
|         // And assert it is handled and sent back to the client | ||||
|         thenIdle(2) | ||||
|         thenOnClient { | ||||
|             val term = getOpenMenu(ModRegistry.Menus.COMPUTER.get()).terminal | ||||
|             assertEquals( | ||||
|                 "[key, ${GLFW.GLFW_KEY_A}, false]", | ||||
|                 term.getLine(1).toString().trim(), | ||||
|                 "Terminal contents is synced", | ||||
|             ) | ||||
|             assertEquals( | ||||
|                 "[key_up, ${GLFW.GLFW_KEY_A}]", | ||||
|                 term.getLine(2).toString().trim(), | ||||
|                 "Terminal contents is synced", | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -5,14 +5,12 @@ | ||||
|  */ | ||||
| package dan200.computercraft.gametest | ||||
| 
 | ||||
| import dan200.computercraft.gametest.api.GameTestHolder | ||||
| import dan200.computercraft.gametest.api.Timeouts | ||||
| import dan200.computercraft.gametest.api.sequence | ||||
| import dan200.computercraft.gametest.api.thenComputerOk | ||||
| import net.minecraft.gametest.framework.GameTest | ||||
| import net.minecraft.gametest.framework.GameTestHelper | ||||
| 
 | ||||
| @GameTestHolder | ||||
| class CraftOs_Test { | ||||
|     /** | ||||
|      * Sends a rednet message to another a computer and back again. | ||||
|   | ||||
| @@ -21,7 +21,6 @@ import net.minecraft.world.item.Items | ||||
| import net.minecraft.world.level.block.RedStoneWireBlock | ||||
| import org.junit.jupiter.api.Assertions.assertEquals | ||||
| 
 | ||||
| @GameTestHolder | ||||
| class Disk_Drive_Test { | ||||
|     /** | ||||
|      * Ensure audio disks exist and we can play them. | ||||
|   | ||||
| @@ -5,7 +5,6 @@ | ||||
|  */ | ||||
| package dan200.computercraft.gametest | ||||
| 
 | ||||
| import dan200.computercraft.gametest.api.GameTestHolder | ||||
| import dan200.computercraft.gametest.api.Structures | ||||
| import dan200.computercraft.gametest.api.sequence | ||||
| import dan200.computercraft.shared.ModRegistry | ||||
| @@ -16,7 +15,6 @@ import net.minecraft.world.level.block.Blocks | ||||
| import net.minecraft.world.level.block.entity.ChestBlockEntity | ||||
| import net.minecraft.world.level.storage.loot.BuiltInLootTables | ||||
| 
 | ||||
| @GameTestHolder | ||||
| class Loot_Test { | ||||
|     /** | ||||
|      * Test that the loot tables will spawn in treasure disks. | ||||
|   | ||||
| @@ -20,7 +20,6 @@ import net.minecraft.gametest.framework.GameTestHelper | ||||
| import org.junit.jupiter.api.Assertions.assertEquals | ||||
| import kotlin.time.Duration.Companion.milliseconds | ||||
| 
 | ||||
| @GameTestHolder | ||||
| class Modem_Test { | ||||
|     @GameTest | ||||
|     fun Have_peripherals(helper: GameTestHelper) = helper.sequence { | ||||
|   | ||||
| @@ -7,17 +7,20 @@ package dan200.computercraft.gametest | ||||
| 
 | ||||
| import dan200.computercraft.gametest.api.* | ||||
| import dan200.computercraft.shared.ModRegistry | ||||
| import dan200.computercraft.shared.config.Config | ||||
| import dan200.computercraft.shared.peripheral.monitor.MonitorBlock | ||||
| import dan200.computercraft.shared.peripheral.monitor.MonitorEdgeState | ||||
| import dan200.computercraft.shared.peripheral.monitor.MonitorRenderer | ||||
| import net.minecraft.commands.arguments.blocks.BlockInput | ||||
| import net.minecraft.core.BlockPos | ||||
| import net.minecraft.gametest.framework.GameTest | ||||
| import net.minecraft.gametest.framework.GameTestGenerator | ||||
| import net.minecraft.gametest.framework.GameTestHelper | ||||
| import net.minecraft.gametest.framework.TestFunction | ||||
| import net.minecraft.nbt.CompoundTag | ||||
| import net.minecraft.world.level.block.Blocks | ||||
| import java.util.* | ||||
| 
 | ||||
| @GameTestHolder | ||||
| class Monitor_Test { | ||||
|     @GameTest | ||||
|     fun Ensures_valid_on_place(context: GameTestHelper) = context.sequence { | ||||
| @@ -58,4 +61,64 @@ class Monitor_Test { | ||||
|             helper.assertBlockHas(BlockPos(3, 2, 2), MonitorBlock.STATE, MonitorEdgeState.NONE) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Test | ||||
|      */ | ||||
|     @GameTestGenerator | ||||
|     fun Render_monitor_tests(): List<TestFunction> { | ||||
|         val tests = mutableListOf<TestFunction>() | ||||
| 
 | ||||
|         fun addTest(label: String, renderer: MonitorRenderer, time: Long = Times.NOON, tag: String = TestTags.CLIENT) { | ||||
|             if (!TestTags.isEnabled(tag)) return | ||||
| 
 | ||||
|             val className = Monitor_Test::class.java.simpleName.lowercase() | ||||
|             val testName = "$className.render_monitor" | ||||
| 
 | ||||
|             tests.add( | ||||
|                 TestFunction( | ||||
|                     "$testName.$label", | ||||
|                     "$testName.$label", | ||||
|                     testName, | ||||
|                     Timeouts.DEFAULT, | ||||
|                     0, | ||||
|                     true, | ||||
|                 ) { renderMonitor(it, renderer, time) }, | ||||
|             ) | ||||
|         } | ||||
| 
 | ||||
|         addTest("tbo_noon", MonitorRenderer.TBO, Times.NOON) | ||||
|         addTest("tbo_midnight", MonitorRenderer.TBO, Times.MIDNIGHT) | ||||
|         addTest("vbo_noon", MonitorRenderer.VBO, Times.NOON) | ||||
|         addTest("vbo_midnight", MonitorRenderer.VBO, Times.MIDNIGHT) | ||||
| 
 | ||||
|         addTest("sodium_tbo", MonitorRenderer.TBO, tag = "sodium") | ||||
|         addTest("sodium_vbo", MonitorRenderer.VBO, tag = "sodium") | ||||
| 
 | ||||
|         addTest("iris_noon", MonitorRenderer.BEST, Times.NOON, tag = "iris") | ||||
|         addTest("iris_midnight", MonitorRenderer.BEST, Times.MIDNIGHT, tag = "iris") | ||||
| 
 | ||||
|         return tests | ||||
|     } | ||||
| 
 | ||||
|     private fun renderMonitor(helper: GameTestHelper, renderer: MonitorRenderer, time: Long) = helper.sequence { | ||||
|         thenExecute { | ||||
|             Config.monitorRenderer = renderer | ||||
|             helper.level.dayTime = time | ||||
|             helper.positionAtArmorStand() | ||||
| 
 | ||||
|             // Get the monitor and peripheral. This forces us to create a server monitor at this location. | ||||
|             val monitor = helper.getBlockEntity(BlockPos(2, 2, 3), ModRegistry.BlockEntities.MONITOR_ADVANCED.get()) | ||||
|             monitor.peripheral() | ||||
| 
 | ||||
|             val terminal = monitor.cachedServerMonitor!!.terminal!! | ||||
|             terminal.write("Hello, world!") | ||||
|             terminal.setCursorPos(1, 2) | ||||
|             terminal.textColour = 2 | ||||
|             terminal.backgroundColour = 3 | ||||
|             terminal.write("Some coloured text") | ||||
|         } | ||||
| 
 | ||||
|         thenScreenshot() | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,9 @@ | ||||
| package dan200.computercraft.gametest | ||||
| 
 | ||||
| import dan200.computercraft.gametest.api.* | ||||
| import dan200.computercraft.gametest.api.assertBlockHas | ||||
| import dan200.computercraft.gametest.api.assertExactlyItems | ||||
| import dan200.computercraft.gametest.api.getBlockEntity | ||||
| import dan200.computercraft.gametest.api.sequence | ||||
| import dan200.computercraft.shared.ModRegistry | ||||
| import dan200.computercraft.shared.peripheral.printer.PrinterBlock | ||||
| import net.minecraft.core.BlockPos | ||||
| @@ -11,7 +14,6 @@ import net.minecraft.world.item.ItemStack | ||||
| import net.minecraft.world.item.Items | ||||
| import net.minecraft.world.level.block.RedStoneWireBlock | ||||
| 
 | ||||
| @GameTestHolder | ||||
| class Printer_Test { | ||||
|     /** | ||||
|      * Check comparators can read the contents of the disk drive | ||||
|   | ||||
| @@ -5,7 +5,6 @@ | ||||
|  */ | ||||
| package dan200.computercraft.gametest | ||||
| 
 | ||||
| import dan200.computercraft.gametest.api.GameTestHolder | ||||
| import dan200.computercraft.gametest.api.Structures | ||||
| import dan200.computercraft.gametest.api.sequence | ||||
| import dan200.computercraft.shared.ModRegistry | ||||
| @@ -24,7 +23,6 @@ import net.minecraft.world.item.crafting.RecipeType | ||||
| import org.junit.jupiter.api.Assertions.assertEquals | ||||
| import java.util.* | ||||
| 
 | ||||
| @GameTestHolder | ||||
| class Recipe_Test { | ||||
|     /** | ||||
|      * Test that crafting results contain NBT data. | ||||
|   | ||||
| @@ -38,7 +38,6 @@ import org.junit.jupiter.api.Assertions.assertNotEquals | ||||
| import java.util.* | ||||
| import kotlin.time.Duration.Companion.milliseconds | ||||
| 
 | ||||
| @GameTestHolder | ||||
| class Turtle_Test { | ||||
|     @GameTest | ||||
|     fun Unequip_refreshes_peripheral(helper: GameTestHelper) = helper.sequence { | ||||
| @@ -421,7 +420,7 @@ class Turtle_Test { | ||||
|             turtle.back().await().assertArrayEquals(true, message = "Moved turtle forward") | ||||
|             TestHooks.LOG.info("[{}] Finished turtle at {}", testInfo, testInfo.`computercraft$getTick`()) | ||||
|         } | ||||
|         thenIdle(2) // Should happen immediately, but computers might be slow. | ||||
|         thenIdle(4) // Should happen immediately, but computers might be slow. | ||||
|         thenExecute { | ||||
|             assertEquals( | ||||
|                 listOf( | ||||
|   | ||||
| @@ -0,0 +1,28 @@ | ||||
| package dan200.computercraft.gametest.api | ||||
| 
 | ||||
| import net.minecraft.gametest.framework.GameTest | ||||
| 
 | ||||
| /** | ||||
|  * Similar to [GameTest], this annotation defines a method which runs under Minecraft's gametest sequence. | ||||
|  * | ||||
|  * Unlike standard game tests, client game tests are only registered when running under the Minecraft client, and run | ||||
|  * sequentially rather than in parallel. | ||||
|  */ | ||||
| @Target(AnnotationTarget.FUNCTION) | ||||
| @Retention(AnnotationRetention.RUNTIME) | ||||
| annotation class ClientGameTest( | ||||
|     /** | ||||
|      * The template to use for this test, identical to [GameTest.template] | ||||
|      */ | ||||
|     val template: String = "", | ||||
| 
 | ||||
|     /** | ||||
|      * The timeout for this test, identical to [GameTest.timeoutTicks]. | ||||
|      */ | ||||
|     val timeoutTicks: Int = Timeouts.DEFAULT, | ||||
| 
 | ||||
|     /** | ||||
|      * The tag associated with this test, denoting when it should run. | ||||
|      */ | ||||
|     val tag: String = TestTags.CLIENT, | ||||
| ) | ||||
| @@ -0,0 +1,132 @@ | ||||
| package dan200.computercraft.gametest.api | ||||
| 
 | ||||
| import dan200.computercraft.mixin.gametest.GameTestSequenceAccessor | ||||
| import dan200.computercraft.shared.platform.Registries | ||||
| import net.minecraft.client.Minecraft | ||||
| import net.minecraft.client.Screenshot | ||||
| import net.minecraft.client.gui.screens.inventory.MenuAccess | ||||
| import net.minecraft.core.BlockPos | ||||
| import net.minecraft.gametest.framework.GameTestAssertException | ||||
| import net.minecraft.gametest.framework.GameTestHelper | ||||
| import net.minecraft.gametest.framework.GameTestSequence | ||||
| import net.minecraft.server.level.ServerPlayer | ||||
| import net.minecraft.world.entity.EntityType | ||||
| import net.minecraft.world.inventory.AbstractContainerMenu | ||||
| import net.minecraft.world.inventory.MenuType | ||||
| import java.util.concurrent.CompletableFuture | ||||
| import java.util.concurrent.ExecutionException | ||||
| import java.util.concurrent.atomic.AtomicBoolean | ||||
| 
 | ||||
| /** | ||||
|  * Attempt to guess whether all chunks have been rendered. | ||||
|  */ | ||||
| fun Minecraft.isRenderingStable(): Boolean = level != null && player != null && | ||||
|     levelRenderer.isChunkCompiled(player!!.blockPosition()) && levelRenderer.countRenderedChunks() > 10 && | ||||
|     levelRenderer.hasRenderedAllChunks() | ||||
| 
 | ||||
| /** | ||||
|  * Run a task on the client. | ||||
|  */ | ||||
| fun GameTestSequence.thenOnClient(task: ClientTestHelper.() -> Unit): GameTestSequence { | ||||
|     var future: CompletableFuture<Void>? = null | ||||
|     thenExecute { future = Minecraft.getInstance().submit { task(ClientTestHelper()) } } | ||||
|     thenWaitUntil { if (!future!!.isDone) throw GameTestAssertException("Not done task yet") } | ||||
|     thenExecute { | ||||
|         try { | ||||
|             future!!.get() | ||||
|         } catch (e: ExecutionException) { | ||||
|             throw e.cause ?: e | ||||
|         } | ||||
|     } | ||||
|     return this | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Take a screenshot of the current game state. | ||||
|  */ | ||||
| fun GameTestSequence.thenScreenshot(name: String? = null): GameTestSequence { | ||||
|     val suffix = if (name == null) "" else "-$name" | ||||
|     val test = (this as GameTestSequenceAccessor).parent | ||||
|     val fullName = "${test.testName}$suffix" | ||||
| 
 | ||||
|     // Wait until all chunks have been rendered and we're idle for an extended period. | ||||
|     var counter = 0 | ||||
|     thenWaitUntil { | ||||
|         if (Minecraft.getInstance().isRenderingStable()) { | ||||
|             val idleFor = ++counter | ||||
|             if (idleFor <= 20) throw GameTestAssertException("Only idle for $idleFor ticks") | ||||
|         } else { | ||||
|             counter = 0 | ||||
|             throw GameTestAssertException("Waiting for client to finish rendering") | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     // Now disable the GUI, take a screenshot and reenable it. Sleep a little afterwards to ensure the render thread | ||||
|     // has caught up. | ||||
|     thenOnClient { minecraft.options.hideGui = true } | ||||
|     thenIdle(2) | ||||
| 
 | ||||
|     // Take a screenshot and wait for it to have finished. | ||||
|     val hasScreenshot = AtomicBoolean() | ||||
|     thenOnClient { screenshot("$fullName.png") { hasScreenshot.set(true) } } | ||||
|     thenWaitUntil { if (!hasScreenshot.get()) throw GameTestAssertException("Screenshot does not exist") } | ||||
|     thenOnClient { minecraft.options.hideGui = false } | ||||
| 
 | ||||
|     return this | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * "Reset" the current player, ensuring. | ||||
|  */ | ||||
| fun ServerPlayer.setupForTest() { | ||||
|     if (containerMenu != inventoryMenu) closeContainer() | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Position the player at an armor stand. | ||||
|  */ | ||||
| fun GameTestHelper.positionAtArmorStand() { | ||||
|     val stand = getEntity(EntityType.ARMOR_STAND) | ||||
|     val player = level.randomPlayer ?: throw GameTestAssertException("Player does not exist") | ||||
| 
 | ||||
|     player.setupForTest() | ||||
|     player.connection.teleport(stand.x, stand.y, stand.z, stand.yRot, stand.xRot) | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Position the player at a given coordinate. | ||||
|  */ | ||||
| fun GameTestHelper.positionAt(pos: BlockPos) { | ||||
|     val absolutePos = absolutePos(pos) | ||||
|     val player = level.randomPlayer ?: throw GameTestAssertException("Player does not exist") | ||||
| 
 | ||||
|     player.setupForTest() | ||||
|     player.connection.teleport(absolutePos.x + 0.5, absolutePos.y + 0.5, absolutePos.z + 0.5, 0.0f, 0.0f) | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * The equivalent of a [GameTestHelper] on the client. | ||||
|  */ | ||||
| class ClientTestHelper { | ||||
|     val minecraft: Minecraft = Minecraft.getInstance() | ||||
| 
 | ||||
|     fun screenshot(name: String, callback: () -> Unit = {}) { | ||||
|         Screenshot.grab(minecraft.gameDirectory, name, minecraft.mainRenderTarget) { callback() } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the currently open [AbstractContainerMenu], ensuring it is of a specific type. | ||||
|      */ | ||||
|     fun <T : AbstractContainerMenu> getOpenMenu(type: MenuType<T>): T { | ||||
|         fun getName(type: MenuType<*>) = Registries.MENU.getKey(type) | ||||
| 
 | ||||
|         val screen = minecraft.screen | ||||
|         @Suppress("UNCHECKED_CAST") | ||||
|         when { | ||||
|             screen == null -> throw GameTestAssertException("Expected a ${getName(type)} menu, but no screen is open") | ||||
|             screen !is MenuAccess<*> -> throw GameTestAssertException("Expected a ${getName(type)} menu, but a $screen is open") | ||||
|             screen.menu.type != type -> throw GameTestAssertException("Expected a ${getName(type)} menu, but a ${getName(screen.menu.type)} is open") | ||||
|             else -> return screen.menu as T | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -7,6 +7,7 @@ package dan200.computercraft.gametest.api | ||||
| 
 | ||||
| import dan200.computercraft.gametest.core.ManagedComputers | ||||
| import dan200.computercraft.mixin.gametest.GameTestHelperAccessor | ||||
| import dan200.computercraft.mixin.gametest.GameTestInfoAccessor | ||||
| import dan200.computercraft.mixin.gametest.GameTestSequenceAccessor | ||||
| import dan200.computercraft.shared.platform.PlatformHelper | ||||
| import dan200.computercraft.shared.platform.Registries | ||||
| @@ -41,6 +42,8 @@ object Structures { | ||||
| /** Pre-set in-game times */ | ||||
| object Times { | ||||
|     const val NOON: Long = 6000 | ||||
| 
 | ||||
|     const val MIDNIGHT: Long = 18000 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @@ -49,7 +52,9 @@ object Times { | ||||
|  * @see GameTest.timeoutTicks | ||||
|  */ | ||||
| object Timeouts { | ||||
|     private const val SECOND: Int = 20 | ||||
|     const val SECOND: Int = 20 | ||||
| 
 | ||||
|     const val DEFAULT: Int = SECOND * 5 | ||||
| 
 | ||||
|     const val COMPUTER_TIMEOUT: Int = SECOND * 15 | ||||
| } | ||||
| @@ -90,11 +95,18 @@ fun GameTestSequence.thenStartComputer(name: String? = null, action: suspend Lua | ||||
|  * Run a task on a computer and wait for it to finish. | ||||
|  */ | ||||
| fun GameTestSequence.thenOnComputer(name: String? = null, action: suspend LuaTaskContext.() -> Unit): GameTestSequence { | ||||
|     val test = (this as GameTestSequenceAccessor).parent | ||||
|     val self = (this as GameTestSequenceAccessor) | ||||
|     val test = self.parent | ||||
| 
 | ||||
|     val label = test.testName + (if (name == null) "" else ".$name") | ||||
|     var monitor: ManagedComputers.Monitor? = null | ||||
|     thenExecuteFailFast { monitor = ManagedComputers.enqueue(test, label, action) } | ||||
|     thenWaitUntil { if (!monitor!!.isFinished) throw GameTestAssertException("Computer '$label' has not finished yet.") } | ||||
|     thenWaitUntil { | ||||
|         if (!monitor!!.isFinished) { | ||||
|             val runningFor = (test as GameTestInfoAccessor).`computercraft$getTick`() - self.lastTick | ||||
|             throw GameTestAssertException("Computer '$label' has not finished yet (running for $runningFor ticks).") | ||||
|         } | ||||
|     } | ||||
|     thenExecuteFailFast { monitor!!.check() } | ||||
|     return this | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,16 @@ | ||||
| package dan200.computercraft.gametest.api | ||||
| 
 | ||||
| /** | ||||
|  * "Tags" associated with each test, denoting whether a specific test should be registered for the current Minecraft | ||||
|  * session. | ||||
|  * | ||||
|  * This is used to only run some tests on the client, or when a specific mod is loaded. | ||||
|  */ | ||||
| object TestTags { | ||||
|     const val COMMON = "common" | ||||
|     const val CLIENT = "client" | ||||
| 
 | ||||
|     private val tags: Set<String> = System.getProperty("cctest.tags", COMMON).split(',').toSet() | ||||
| 
 | ||||
|     fun isEnabled(tag: String) = tags.contains(tag) | ||||
| } | ||||
| @@ -0,0 +1,189 @@ | ||||
| package dan200.computercraft.gametest.core | ||||
| 
 | ||||
| import dan200.computercraft.gametest.api.Timeouts | ||||
| import dan200.computercraft.gametest.api.isRenderingStable | ||||
| import dan200.computercraft.gametest.api.setupForTest | ||||
| import net.minecraft.client.CloudStatus | ||||
| import net.minecraft.client.Minecraft | ||||
| import net.minecraft.client.ParticleStatus | ||||
| import net.minecraft.client.gui.screens.Screen | ||||
| import net.minecraft.client.gui.screens.TitleScreen | ||||
| import net.minecraft.client.tutorial.TutorialSteps | ||||
| import net.minecraft.core.BlockPos | ||||
| import net.minecraft.core.Registry | ||||
| import net.minecraft.core.RegistryAccess | ||||
| import net.minecraft.gametest.framework.* | ||||
| import net.minecraft.server.MinecraftServer | ||||
| import net.minecraft.sounds.SoundSource | ||||
| import net.minecraft.util.RandomSource | ||||
| import net.minecraft.world.Difficulty | ||||
| import net.minecraft.world.level.DataPackConfig | ||||
| import net.minecraft.world.level.GameRules | ||||
| import net.minecraft.world.level.GameType | ||||
| import net.minecraft.world.level.LevelSettings | ||||
| import net.minecraft.world.level.block.Rotation | ||||
| import net.minecraft.world.level.levelgen.presets.WorldPresets | ||||
| import org.slf4j.Logger | ||||
| import org.slf4j.LoggerFactory | ||||
| import kotlin.system.exitProcess | ||||
| 
 | ||||
| /** | ||||
|  * Client-side hooks for game tests. | ||||
|  * | ||||
|  * This mirrors Minecraft's | ||||
|  */ | ||||
| object ClientTestHooks { | ||||
|     private val LOG: Logger = LoggerFactory.getLogger(ClientTestHooks::class.java) | ||||
| 
 | ||||
|     private const val LEVEL_NAME = "test" | ||||
| 
 | ||||
|     /** | ||||
|      * Time (in ticks) that we wait after the client joins the world | ||||
|      */ | ||||
|     private const val STARTUP_DELAY = 5 * Timeouts.SECOND | ||||
| 
 | ||||
|     /** | ||||
|      * Whether our client-side game test driver is enabled. | ||||
|      */ | ||||
|     private val enabled: Boolean = System.getProperty("cctest.client") != null | ||||
| 
 | ||||
|     private var loadedWorld: Boolean = false | ||||
| 
 | ||||
|     @JvmStatic | ||||
|     fun onOpenScreen(screen: Screen): Boolean = when { | ||||
|         enabled && !loadedWorld && screen is TitleScreen -> { | ||||
|             loadedWorld = true | ||||
|             openWorld() | ||||
|             true | ||||
|         } | ||||
| 
 | ||||
|         else -> false | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Open or create our test world immediately on game launch. | ||||
|      */ | ||||
|     private fun openWorld() { | ||||
|         val minecraft = Minecraft.getInstance() | ||||
| 
 | ||||
|         // Clear some options before we get any further. | ||||
|         minecraft.options.autoJump().set(false) | ||||
|         minecraft.options.cloudStatus().set(CloudStatus.OFF) | ||||
|         minecraft.options.particles().set(ParticleStatus.MINIMAL) | ||||
|         minecraft.options.tutorialStep = TutorialSteps.NONE | ||||
|         minecraft.options.renderDistance().set(6) | ||||
|         minecraft.options.gamma().set(1.0) | ||||
|         minecraft.options.setSoundCategoryVolume(SoundSource.MUSIC, 0.0f) | ||||
|         minecraft.options.setSoundCategoryVolume(SoundSource.AMBIENT, 0.0f) | ||||
| 
 | ||||
|         if (minecraft.levelSource.levelExists(LEVEL_NAME)) { | ||||
|             LOG.info("World already exists, opening.") | ||||
|             minecraft.createWorldOpenFlows().loadLevel(minecraft.screen, LEVEL_NAME) | ||||
|         } else { | ||||
|             LOG.info("World does not exist, creating it.") | ||||
|             val rules = GameRules() | ||||
|             rules.getRule(GameRules.RULE_DOMOBSPAWNING).set(false, null) | ||||
|             rules.getRule(GameRules.RULE_DAYLIGHT).set(false, null) | ||||
|             rules.getRule(GameRules.RULE_WEATHER_CYCLE).set(false, null) | ||||
| 
 | ||||
|             val registries = RegistryAccess.builtinCopy().freeze() | ||||
|             minecraft.createWorldOpenFlows().createFreshLevel( | ||||
|                 LEVEL_NAME, | ||||
|                 LevelSettings("Test Level", GameType.CREATIVE, false, Difficulty.EASY, true, rules, DataPackConfig.DEFAULT), | ||||
|                 registries, | ||||
|                 registries | ||||
|                     .registryOrThrow(Registry.WORLD_PRESET_REGISTRY) | ||||
|                     .getHolderOrThrow(WorldPresets.FLAT).value() | ||||
|                     .createWorldGenSettings(RandomSource.create().nextLong(), false, false), | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private var testTracker: MultipleTestTracker? = null | ||||
|     private var hasFinished: Boolean = false | ||||
|     private var startupDelay: Int = STARTUP_DELAY | ||||
| 
 | ||||
|     @JvmStatic | ||||
|     fun onServerTick(server: MinecraftServer) { | ||||
|         if (!enabled || hasFinished) return | ||||
| 
 | ||||
|         val testTracker = when (val tracker = this.testTracker) { | ||||
|             null -> { | ||||
|                 if (server.overworld().players().isEmpty()) return | ||||
|                 if (!Minecraft.getInstance().isRenderingStable()) return | ||||
|                 if (startupDelay >= 0) { | ||||
|                     // TODO: Is there a better way? Maybe set a flag when the client starts rendering? | ||||
|                     startupDelay-- | ||||
|                     return | ||||
|                 } | ||||
| 
 | ||||
|                 LOG.info("Server ready, starting.") | ||||
| 
 | ||||
|                 val tests = GameTestRunner.runTestBatches( | ||||
|                     GameTestRunner.groupTestsIntoBatches(GameTestRegistry.getAllTestFunctions()), | ||||
|                     BlockPos(0, -60, 0), | ||||
|                     Rotation.NONE, | ||||
|                     server.overworld(), | ||||
|                     GameTestTicker.SINGLETON, | ||||
|                     8, | ||||
|                 ) | ||||
|                 val testTracker = MultipleTestTracker(tests) | ||||
|                 testTracker.addListener( | ||||
|                     object : GameTestListener { | ||||
|                         fun testFinished() { | ||||
|                             for (it in server.playerList.players) it.setupForTest() | ||||
|                         } | ||||
| 
 | ||||
|                         override fun testPassed(test: GameTestInfo) = testFinished() | ||||
|                         override fun testFailed(test: GameTestInfo) = testFinished() | ||||
|                         override fun testStructureLoaded(test: GameTestInfo) = Unit | ||||
|                     }, | ||||
|                 ) | ||||
| 
 | ||||
|                 LOG.info("{} tests are now running!", testTracker.totalCount) | ||||
|                 this.testTracker = testTracker | ||||
|                 testTracker | ||||
|             } | ||||
| 
 | ||||
|             else -> tracker | ||||
|         } | ||||
| 
 | ||||
|         if (server.overworld().gameTime % 20L == 0L) LOG.info(testTracker.progressBar) | ||||
| 
 | ||||
|         if (testTracker.isDone) { | ||||
|             hasFinished = true | ||||
|             LOG.info(testTracker.progressBar) | ||||
| 
 | ||||
|             GlobalTestReporter.finish() | ||||
|             LOG.info("========= {} GAME TESTS COMPLETE ======================", testTracker.totalCount) | ||||
|             if (testTracker.hasFailedRequired()) { | ||||
|                 LOG.info("{} required tests failed :(", testTracker.failedRequiredCount) | ||||
|                 for (test in testTracker.failedRequired) LOG.info("   - {}", test.testName) | ||||
|             } else { | ||||
|                 LOG.info("All {} required tests passed :)", testTracker.totalCount) | ||||
|             } | ||||
|             if (testTracker.hasFailedOptional()) { | ||||
|                 LOG.info("{} optional tests failed", testTracker.failedOptionalCount) | ||||
|                 for (test in testTracker.failedOptional) LOG.info("   - {}", test.testName) | ||||
|             } | ||||
|             LOG.info("====================================================") | ||||
| 
 | ||||
|             // Stop Minecraft *from the client thread*. We need to do this to avoid deadlocks in stopping the server. | ||||
|             val minecraft = Minecraft.getInstance() | ||||
|             minecraft.execute { | ||||
|                 LOG.info("Stopping client.") | ||||
|                 minecraft.level!!.disconnect() | ||||
|                 minecraft.clearLevel() | ||||
|                 minecraft.stop() | ||||
| 
 | ||||
|                 exitProcess( | ||||
|                     when { | ||||
|                         testTracker.totalCount == 0 -> 1 | ||||
|                         testTracker.hasFailedRequired() -> 2 | ||||
|                         else -> 0 | ||||
|                     }, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,168 @@ | ||||
| package dan200.computercraft.gametest.core | ||||
| 
 | ||||
| import dan200.computercraft.api.ComputerCraftAPI | ||||
| import dan200.computercraft.gametest.* | ||||
| import dan200.computercraft.gametest.api.ClientGameTest | ||||
| import dan200.computercraft.gametest.api.TestTags | ||||
| import dan200.computercraft.gametest.api.Times | ||||
| import dan200.computercraft.shared.computer.core.ServerContext | ||||
| import net.minecraft.core.BlockPos | ||||
| import net.minecraft.gametest.framework.* | ||||
| import net.minecraft.server.MinecraftServer | ||||
| import net.minecraft.world.level.GameRules | ||||
| import org.slf4j.Logger | ||||
| import org.slf4j.LoggerFactory | ||||
| import java.io.File | ||||
| import java.lang.reflect.InvocationTargetException | ||||
| import java.lang.reflect.Method | ||||
| import java.lang.reflect.Modifier | ||||
| import java.nio.file.Path | ||||
| import java.nio.file.Paths | ||||
| import java.util.function.Consumer | ||||
| import javax.xml.parsers.ParserConfigurationException | ||||
| 
 | ||||
| object TestHooks { | ||||
|     @JvmField | ||||
|     val LOG: Logger = LoggerFactory.getLogger(TestHooks::class.java) | ||||
| 
 | ||||
|     @JvmStatic | ||||
|     val sourceDir: Path = Paths.get(System.getProperty("cctest.sources")).normalize().toAbsolutePath() | ||||
| 
 | ||||
|     @JvmStatic | ||||
|     fun init() { | ||||
|         ServerContext.luaMachine = ManagedComputers | ||||
|         ComputerCraftAPI.registerAPIFactory(::TestAPI) | ||||
|         StructureUtils.testStructuresDir = sourceDir.resolve("structures").toString() | ||||
| 
 | ||||
|         // Set up our test reporter if configured. | ||||
|         val outputPath = System.getProperty("cctest.gametest-report") | ||||
|         if (outputPath != null) { | ||||
|             try { | ||||
|                 GlobalTestReporter.replaceWith( | ||||
|                     MultiTestReporter( | ||||
|                         JunitTestReporter(File(outputPath)), | ||||
|                         LogTestReporter(), | ||||
|                     ), | ||||
|                 ) | ||||
|             } catch (e: ParserConfigurationException) { | ||||
|                 throw RuntimeException(e) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @JvmStatic | ||||
|     fun onServerStarted(server: MinecraftServer) { | ||||
|         val rules = server.gameRules | ||||
|         rules.getRule(GameRules.RULE_DAYLIGHT).set(false, server) | ||||
|         server.overworld().dayTime = Times.NOON | ||||
| 
 | ||||
|         LOG.info("Cleaning up after last run") | ||||
|         GameTestRunner.clearAllTests(server.overworld(), BlockPos(0, -60, 0), GameTestTicker.SINGLETON, 200) | ||||
| 
 | ||||
|         // Delete server context and add one with a mutable machine factory. This allows us to set the factory for | ||||
|         // specific test batches without having to reset all computers. | ||||
|         for (computer in ServerContext.get(server).registry().computers) { | ||||
|             val label = if (computer.label == null) "#" + computer.id else computer.label!! | ||||
|             LOG.warn("Unexpected computer {}", label) | ||||
|         } | ||||
| 
 | ||||
|         LOG.info("Importing files") | ||||
|         CCTestCommand.importFiles(server) | ||||
|     } | ||||
| 
 | ||||
|     private val testClasses = listOf( | ||||
|         Computer_Test::class.java, | ||||
|         CraftOs_Test::class.java, | ||||
|         Disk_Drive_Test::class.java, | ||||
|         Loot_Test::class.java, | ||||
|         Modem_Test::class.java, | ||||
|         Monitor_Test::class.java, | ||||
|         Printer_Test::class.java, | ||||
|         Recipe_Test::class.java, | ||||
|         Turtle_Test::class.java, | ||||
|     ) | ||||
| 
 | ||||
|     /** | ||||
|      * Register all of our gametests. | ||||
|      * | ||||
|      * This is super nasty, as it bypasses any loader-specific hooks for registering tests. However, it makes it much | ||||
|      * easier to ensure consistent behaviour between loaders (namely making [GameTest.template] point to a | ||||
|      * structure rather than a per-test-class one), as well as supporting our custom client tests. | ||||
|      * | ||||
|      * @param fallbackRegister A fallback function which registers non-test methods (such as [BeforeBatch]). This | ||||
|      * should be [GameTestRegistry.register] or equivalent. | ||||
|      */ | ||||
|     @JvmStatic | ||||
|     fun loadTests(fallbackRegister: Consumer<Method>) { | ||||
|         for (testClass in testClasses) { | ||||
|             for (method in testClass.declaredMethods) { | ||||
|                 registerTest(testClass, method, fallbackRegister) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private val isCi = System.getenv("CI") != null | ||||
| 
 | ||||
|     /** | ||||
|      * Adjust the timeout of a test. This makes it 1.5 times longer when run under CI, as CI servers are less powerful | ||||
|      * than our own. | ||||
|      */ | ||||
|     private fun adjustTimeout(timeout: Int): Int = if (isCi) timeout + (timeout / 2) else timeout | ||||
| 
 | ||||
|     private fun registerTest(testClass: Class<*>, method: Method, fallbackRegister: Consumer<Method>) { | ||||
|         val className = testClass.simpleName.lowercase() | ||||
|         val testName = className + "." + method.name.lowercase() | ||||
| 
 | ||||
|         method.getAnnotation(GameTest::class.java)?.let { testInfo -> | ||||
|             if (!TestTags.isEnabled(TestTags.COMMON)) return | ||||
| 
 | ||||
|             GameTestRegistry.getAllTestFunctions().add( | ||||
|                 TestFunction( | ||||
|                     testInfo.batch, testName, testInfo.template.ifEmpty { testName }, | ||||
|                     StructureUtils.getRotationForRotationSteps(testInfo.rotationSteps), | ||||
|                     adjustTimeout(testInfo.timeoutTicks), | ||||
|                     testInfo.setupTicks, | ||||
|                     testInfo.required, testInfo.requiredSuccesses, testInfo.attempts, | ||||
|                 ) { value -> safeInvoke(method, value) }, | ||||
|             ) | ||||
|             GameTestRegistry.getAllTestClassNames().add(testClass.simpleName) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         method.getAnnotation(ClientGameTest::class.java)?.let { testInfo -> | ||||
|             if (!TestTags.isEnabled(testInfo.tag)) return | ||||
| 
 | ||||
|             GameTestRegistry.getAllTestFunctions().add( | ||||
|                 TestFunction( | ||||
|                     testName, | ||||
|                     testName, | ||||
|                     testInfo.template.ifEmpty { testName }, | ||||
|                     adjustTimeout(testInfo.timeoutTicks), | ||||
|                     0, | ||||
|                     true, | ||||
|                 ) { value -> safeInvoke(method, value) }, | ||||
|             ) | ||||
|             GameTestRegistry.getAllTestClassNames().add(testClass.simpleName) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         fallbackRegister.accept(method) | ||||
|     } | ||||
| 
 | ||||
|     private fun safeInvoke(method: Method, value: Any) { | ||||
|         try { | ||||
|             var instance: Any? = null | ||||
|             if (!Modifier.isStatic(method.modifiers)) { | ||||
|                 instance = method.declaringClass.getConstructor().newInstance() | ||||
|             } | ||||
|             method.invoke(instance, value) | ||||
|         } catch (e: InvocationTargetException) { | ||||
|             when (val cause = e.cause) { | ||||
|                 is RuntimeException -> throw cause | ||||
|                 else -> throw RuntimeException(cause) | ||||
|             } | ||||
|         } catch (e: ReflectiveOperationException) { | ||||
|             throw RuntimeException(e) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,43 @@ | ||||
| package dan200.computercraft.gametest.core | ||||
| 
 | ||||
| import net.minecraft.gametest.framework.GameTestInfo | ||||
| import net.minecraft.gametest.framework.JUnitLikeTestReporter | ||||
| import net.minecraft.gametest.framework.TestReporter | ||||
| import java.io.File | ||||
| import java.io.IOException | ||||
| import java.nio.file.Files | ||||
| import javax.xml.transform.TransformerException | ||||
| 
 | ||||
| /** | ||||
|  * A test reporter which delegates to a list of other reporters. | ||||
|  */ | ||||
| class MultiTestReporter(private val reporters: List<TestReporter>) : TestReporter { | ||||
|     constructor(vararg reporters: TestReporter) : this(listOf(*reporters)) | ||||
| 
 | ||||
|     override fun onTestFailed(test: GameTestInfo) { | ||||
|         for (reporter in reporters) reporter.onTestFailed(test) | ||||
|     } | ||||
| 
 | ||||
|     override fun onTestSuccess(test: GameTestInfo) { | ||||
|         for (reporter in reporters) reporter.onTestSuccess(test) | ||||
|     } | ||||
| 
 | ||||
|     override fun finish() { | ||||
|         for (reporter in reporters) reporter.finish() | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Reports tests to a JUnit XML file. This is equivalent to [JUnitLikeTestReporter], except it ensures the destination | ||||
|  * directory exists. | ||||
|  */ | ||||
| class JunitTestReporter constructor(destination: File) : JUnitLikeTestReporter(destination) { | ||||
|     override fun save(file: File) { | ||||
|         try { | ||||
|             Files.createDirectories(file.toPath().parent) | ||||
|         } catch (e: IOException) { | ||||
|             throw TransformerException("Failed to create parent directory", e) | ||||
|         } | ||||
|         super.save(file) | ||||
|     } | ||||
| } | ||||
| @@ -11,6 +11,7 @@ | ||||
|         "GameTestInfoAccessor", | ||||
|         "GameTestSequenceAccessor", | ||||
|         "GameTestSequenceMixin", | ||||
|         "SharedConstantsMixin", | ||||
|         "TestCommandAccessor" | ||||
|     ] | ||||
| } | ||||
|   | ||||
							
								
								
									
										137
									
								
								projects/common/src/testMod/resources/data/cctest/structures/computer_test.open_on_client.snbt
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								projects/common/src/testMod/resources/data/cctest/structures/computer_test.open_on_client.snbt
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,137 @@ | ||||
| { | ||||
|     DataVersion: 3120, | ||||
|     size: [5, 5, 5], | ||||
|     data: [ | ||||
|         {pos: [0, 0, 0], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [0, 0, 1], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [0, 0, 2], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [0, 0, 3], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [0, 0, 4], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [1, 0, 0], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [1, 0, 1], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [1, 0, 2], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [1, 0, 3], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [1, 0, 4], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [2, 0, 0], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [2, 0, 1], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [2, 0, 2], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [2, 0, 3], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [2, 0, 4], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [3, 0, 0], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [3, 0, 1], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [3, 0, 2], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [3, 0, 3], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [3, 0, 4], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [4, 0, 0], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [4, 0, 1], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [4, 0, 2], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [4, 0, 3], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [4, 0, 4], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [0, 1, 0], state: "minecraft:air"}, | ||||
|         {pos: [0, 1, 1], state: "minecraft:air"}, | ||||
|         {pos: [0, 1, 2], state: "minecraft:air"}, | ||||
|         {pos: [0, 1, 3], state: "minecraft:air"}, | ||||
|         {pos: [0, 1, 4], state: "minecraft:air"}, | ||||
|         {pos: [1, 1, 0], state: "minecraft:air"}, | ||||
|         {pos: [1, 1, 1], state: "minecraft:air"}, | ||||
|         {pos: [1, 1, 2], state: "minecraft:air"}, | ||||
|         {pos: [1, 1, 3], state: "minecraft:air"}, | ||||
|         {pos: [1, 1, 4], state: "minecraft:air"}, | ||||
|         {pos: [2, 1, 0], state: "minecraft:air"}, | ||||
|         {pos: [2, 1, 1], state: "minecraft:air"}, | ||||
|         {pos: [2, 1, 2], state: "computercraft:computer_advanced{facing:north,state:on}", nbt: {ComputerId: 1, Label: "computer_test.open_on_client", On: 1b, id: "computercraft:computer_advanced"}}, | ||||
|         {pos: [2, 1, 3], state: "minecraft:air"}, | ||||
|         {pos: [2, 1, 4], state: "minecraft:air"}, | ||||
|         {pos: [3, 1, 0], state: "minecraft:air"}, | ||||
|         {pos: [3, 1, 1], state: "minecraft:air"}, | ||||
|         {pos: [3, 1, 2], state: "minecraft:air"}, | ||||
|         {pos: [3, 1, 3], state: "minecraft:air"}, | ||||
|         {pos: [3, 1, 4], state: "minecraft:air"}, | ||||
|         {pos: [4, 1, 0], state: "minecraft:air"}, | ||||
|         {pos: [4, 1, 1], state: "minecraft:air"}, | ||||
|         {pos: [4, 1, 2], state: "minecraft:air"}, | ||||
|         {pos: [4, 1, 3], state: "minecraft:air"}, | ||||
|         {pos: [4, 1, 4], state: "minecraft:air"}, | ||||
|         {pos: [0, 2, 0], state: "minecraft:air"}, | ||||
|         {pos: [0, 2, 1], state: "minecraft:air"}, | ||||
|         {pos: [0, 2, 2], state: "minecraft:air"}, | ||||
|         {pos: [0, 2, 3], state: "minecraft:air"}, | ||||
|         {pos: [0, 2, 4], state: "minecraft:air"}, | ||||
|         {pos: [1, 2, 0], state: "minecraft:air"}, | ||||
|         {pos: [1, 2, 1], state: "minecraft:air"}, | ||||
|         {pos: [1, 2, 2], state: "minecraft:air"}, | ||||
|         {pos: [1, 2, 3], state: "minecraft:air"}, | ||||
|         {pos: [1, 2, 4], state: "minecraft:air"}, | ||||
|         {pos: [2, 2, 0], state: "minecraft:air"}, | ||||
|         {pos: [2, 2, 1], state: "minecraft:air"}, | ||||
|         {pos: [2, 2, 2], state: "minecraft:air"}, | ||||
|         {pos: [2, 2, 3], state: "minecraft:air"}, | ||||
|         {pos: [2, 2, 4], state: "minecraft:air"}, | ||||
|         {pos: [3, 2, 0], state: "minecraft:air"}, | ||||
|         {pos: [3, 2, 1], state: "minecraft:air"}, | ||||
|         {pos: [3, 2, 2], state: "minecraft:air"}, | ||||
|         {pos: [3, 2, 3], state: "minecraft:air"}, | ||||
|         {pos: [3, 2, 4], state: "minecraft:air"}, | ||||
|         {pos: [4, 2, 0], state: "minecraft:air"}, | ||||
|         {pos: [4, 2, 1], state: "minecraft:air"}, | ||||
|         {pos: [4, 2, 2], state: "minecraft:air"}, | ||||
|         {pos: [4, 2, 3], state: "minecraft:air"}, | ||||
|         {pos: [4, 2, 4], state: "minecraft:air"}, | ||||
|         {pos: [0, 3, 0], state: "minecraft:air"}, | ||||
|         {pos: [0, 3, 1], state: "minecraft:air"}, | ||||
|         {pos: [0, 3, 2], state: "minecraft:air"}, | ||||
|         {pos: [0, 3, 3], state: "minecraft:air"}, | ||||
|         {pos: [0, 3, 4], state: "minecraft:air"}, | ||||
|         {pos: [1, 3, 0], state: "minecraft:air"}, | ||||
|         {pos: [1, 3, 1], state: "minecraft:air"}, | ||||
|         {pos: [1, 3, 2], state: "minecraft:air"}, | ||||
|         {pos: [1, 3, 3], state: "minecraft:air"}, | ||||
|         {pos: [1, 3, 4], state: "minecraft:air"}, | ||||
|         {pos: [2, 3, 0], state: "minecraft:air"}, | ||||
|         {pos: [2, 3, 1], state: "minecraft:air"}, | ||||
|         {pos: [2, 3, 2], state: "minecraft:air"}, | ||||
|         {pos: [2, 3, 3], state: "minecraft:air"}, | ||||
|         {pos: [2, 3, 4], state: "minecraft:air"}, | ||||
|         {pos: [3, 3, 0], state: "minecraft:air"}, | ||||
|         {pos: [3, 3, 1], state: "minecraft:air"}, | ||||
|         {pos: [3, 3, 2], state: "minecraft:air"}, | ||||
|         {pos: [3, 3, 3], state: "minecraft:air"}, | ||||
|         {pos: [3, 3, 4], state: "minecraft:air"}, | ||||
|         {pos: [4, 3, 0], state: "minecraft:air"}, | ||||
|         {pos: [4, 3, 1], state: "minecraft:air"}, | ||||
|         {pos: [4, 3, 2], state: "minecraft:air"}, | ||||
|         {pos: [4, 3, 3], state: "minecraft:air"}, | ||||
|         {pos: [4, 3, 4], state: "minecraft:air"}, | ||||
|         {pos: [0, 4, 0], state: "minecraft:air"}, | ||||
|         {pos: [0, 4, 1], state: "minecraft:air"}, | ||||
|         {pos: [0, 4, 2], state: "minecraft:air"}, | ||||
|         {pos: [0, 4, 3], state: "minecraft:air"}, | ||||
|         {pos: [0, 4, 4], state: "minecraft:air"}, | ||||
|         {pos: [1, 4, 0], state: "minecraft:air"}, | ||||
|         {pos: [1, 4, 1], state: "minecraft:air"}, | ||||
|         {pos: [1, 4, 2], state: "minecraft:air"}, | ||||
|         {pos: [1, 4, 3], state: "minecraft:air"}, | ||||
|         {pos: [1, 4, 4], state: "minecraft:air"}, | ||||
|         {pos: [2, 4, 0], state: "minecraft:air"}, | ||||
|         {pos: [2, 4, 1], state: "minecraft:air"}, | ||||
|         {pos: [2, 4, 2], state: "minecraft:air"}, | ||||
|         {pos: [2, 4, 3], state: "minecraft:air"}, | ||||
|         {pos: [2, 4, 4], state: "minecraft:air"}, | ||||
|         {pos: [3, 4, 0], state: "minecraft:air"}, | ||||
|         {pos: [3, 4, 1], state: "minecraft:air"}, | ||||
|         {pos: [3, 4, 2], state: "minecraft:air"}, | ||||
|         {pos: [3, 4, 3], state: "minecraft:air"}, | ||||
|         {pos: [3, 4, 4], state: "minecraft:air"}, | ||||
|         {pos: [4, 4, 0], state: "minecraft:air"}, | ||||
|         {pos: [4, 4, 1], state: "minecraft:air"}, | ||||
|         {pos: [4, 4, 2], state: "minecraft:air"}, | ||||
|         {pos: [4, 4, 3], state: "minecraft:air"}, | ||||
|         {pos: [4, 4, 4], state: "minecraft:air"} | ||||
|     ], | ||||
|     entities: [], | ||||
|     palette: [ | ||||
|         "minecraft:polished_andesite", | ||||
|         "minecraft:air", | ||||
|         "computercraft:computer_advanced{facing:north,state:on}" | ||||
|     ] | ||||
| } | ||||
							
								
								
									
										144
									
								
								projects/common/src/testMod/resources/data/cctest/structures/monitor_test.render_monitor.snbt
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								projects/common/src/testMod/resources/data/cctest/structures/monitor_test.render_monitor.snbt
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,144 @@ | ||||
| { | ||||
|     DataVersion: 3120, | ||||
|     size: [5, 5, 5], | ||||
|     data: [ | ||||
|         {pos: [0, 0, 0], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [0, 0, 1], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [0, 0, 2], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [0, 0, 3], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [0, 0, 4], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [1, 0, 0], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [1, 0, 1], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [1, 0, 2], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [1, 0, 3], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [1, 0, 4], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [2, 0, 0], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [2, 0, 1], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [2, 0, 2], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [2, 0, 3], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [2, 0, 4], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [3, 0, 0], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [3, 0, 1], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [3, 0, 2], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [3, 0, 3], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [3, 0, 4], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [4, 0, 0], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [4, 0, 1], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [4, 0, 2], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [4, 0, 3], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [4, 0, 4], state: "minecraft:polished_andesite"}, | ||||
|         {pos: [0, 1, 0], state: "minecraft:air"}, | ||||
|         {pos: [0, 1, 1], state: "minecraft:air"}, | ||||
|         {pos: [0, 1, 2], state: "minecraft:air"}, | ||||
|         {pos: [0, 1, 3], state: "minecraft:air"}, | ||||
|         {pos: [0, 1, 4], state: "minecraft:air"}, | ||||
|         {pos: [1, 1, 0], state: "minecraft:air"}, | ||||
|         {pos: [1, 1, 1], state: "minecraft:air"}, | ||||
|         {pos: [1, 1, 2], state: "minecraft:air"}, | ||||
|         {pos: [1, 1, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:lu}", nbt: {Height: 2, Width: 3, XIndex: 2, YIndex: 0, id: "computercraft:monitor_advanced"}}, | ||||
|         {pos: [1, 1, 4], state: "minecraft:air"}, | ||||
|         {pos: [2, 1, 0], state: "minecraft:air"}, | ||||
|         {pos: [2, 1, 1], state: "minecraft:air"}, | ||||
|         {pos: [2, 1, 2], state: "minecraft:air"}, | ||||
|         {pos: [2, 1, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:lru}", nbt: {Height: 2, Width: 3, XIndex: 1, YIndex: 0, id: "computercraft:monitor_advanced"}}, | ||||
|         {pos: [2, 1, 4], state: "minecraft:air"}, | ||||
|         {pos: [3, 1, 0], state: "minecraft:air"}, | ||||
|         {pos: [3, 1, 1], state: "minecraft:air"}, | ||||
|         {pos: [3, 1, 2], state: "minecraft:air"}, | ||||
|         {pos: [3, 1, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:ru}", nbt: {Height: 2, Width: 3, XIndex: 0, YIndex: 0, id: "computercraft:monitor_advanced"}}, | ||||
|         {pos: [3, 1, 4], state: "minecraft:air"}, | ||||
|         {pos: [4, 1, 0], state: "minecraft:air"}, | ||||
|         {pos: [4, 1, 1], state: "minecraft:air"}, | ||||
|         {pos: [4, 1, 2], state: "minecraft:air"}, | ||||
|         {pos: [4, 1, 3], state: "minecraft:air"}, | ||||
|         {pos: [4, 1, 4], state: "minecraft:air"}, | ||||
|         {pos: [0, 2, 0], state: "minecraft:air"}, | ||||
|         {pos: [0, 2, 1], state: "minecraft:air"}, | ||||
|         {pos: [0, 2, 2], state: "minecraft:air"}, | ||||
|         {pos: [0, 2, 3], state: "minecraft:air"}, | ||||
|         {pos: [0, 2, 4], state: "minecraft:air"}, | ||||
|         {pos: [1, 2, 0], state: "minecraft:air"}, | ||||
|         {pos: [1, 2, 1], state: "minecraft:air"}, | ||||
|         {pos: [1, 2, 2], state: "minecraft:air"}, | ||||
|         {pos: [1, 2, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:ld}", nbt: {Height: 2, Width: 3, XIndex: 2, YIndex: 1, id: "computercraft:monitor_advanced"}}, | ||||
|         {pos: [1, 2, 4], state: "minecraft:air"}, | ||||
|         {pos: [2, 2, 0], state: "minecraft:air"}, | ||||
|         {pos: [2, 2, 1], state: "minecraft:air"}, | ||||
|         {pos: [2, 2, 2], state: "minecraft:air"}, | ||||
|         {pos: [2, 2, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:lrd}", nbt: {Height: 2, Width: 3, XIndex: 1, YIndex: 1, id: "computercraft:monitor_advanced"}}, | ||||
|         {pos: [2, 2, 4], state: "minecraft:air"}, | ||||
|         {pos: [3, 2, 0], state: "minecraft:air"}, | ||||
|         {pos: [3, 2, 1], state: "minecraft:air"}, | ||||
|         {pos: [3, 2, 2], state: "minecraft:air"}, | ||||
|         {pos: [3, 2, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:rd}", nbt: {Height: 2, Width: 3, XIndex: 0, YIndex: 1, id: "computercraft:monitor_advanced"}}, | ||||
|         {pos: [3, 2, 4], state: "minecraft:air"}, | ||||
|         {pos: [4, 2, 0], state: "minecraft:air"}, | ||||
|         {pos: [4, 2, 1], state: "minecraft:air"}, | ||||
|         {pos: [4, 2, 2], state: "minecraft:air"}, | ||||
|         {pos: [4, 2, 3], state: "minecraft:air"}, | ||||
|         {pos: [4, 2, 4], state: "minecraft:air"}, | ||||
|         {pos: [0, 3, 0], state: "minecraft:air"}, | ||||
|         {pos: [0, 3, 1], state: "minecraft:air"}, | ||||
|         {pos: [0, 3, 2], state: "minecraft:air"}, | ||||
|         {pos: [0, 3, 3], state: "minecraft:air"}, | ||||
|         {pos: [0, 3, 4], state: "minecraft:air"}, | ||||
|         {pos: [1, 3, 0], state: "minecraft:air"}, | ||||
|         {pos: [1, 3, 1], state: "minecraft:air"}, | ||||
|         {pos: [1, 3, 2], state: "minecraft:air"}, | ||||
|         {pos: [1, 3, 3], state: "minecraft:air"}, | ||||
|         {pos: [1, 3, 4], state: "minecraft:air"}, | ||||
|         {pos: [2, 3, 0], state: "minecraft:air"}, | ||||
|         {pos: [2, 3, 1], state: "minecraft:air"}, | ||||
|         {pos: [2, 3, 2], state: "minecraft:air"}, | ||||
|         {pos: [2, 3, 3], state: "minecraft:air"}, | ||||
|         {pos: [2, 3, 4], state: "minecraft:air"}, | ||||
|         {pos: [3, 3, 0], state: "minecraft:air"}, | ||||
|         {pos: [3, 3, 1], state: "minecraft:air"}, | ||||
|         {pos: [3, 3, 2], state: "minecraft:air"}, | ||||
|         {pos: [3, 3, 3], state: "minecraft:air"}, | ||||
|         {pos: [3, 3, 4], state: "minecraft:air"}, | ||||
|         {pos: [4, 3, 0], state: "minecraft:air"}, | ||||
|         {pos: [4, 3, 1], state: "minecraft:air"}, | ||||
|         {pos: [4, 3, 2], state: "minecraft:air"}, | ||||
|         {pos: [4, 3, 3], state: "minecraft:air"}, | ||||
|         {pos: [4, 3, 4], state: "minecraft:air"}, | ||||
|         {pos: [0, 4, 0], state: "minecraft:air"}, | ||||
|         {pos: [0, 4, 1], state: "minecraft:air"}, | ||||
|         {pos: [0, 4, 2], state: "minecraft:air"}, | ||||
|         {pos: [0, 4, 3], state: "minecraft:air"}, | ||||
|         {pos: [0, 4, 4], state: "minecraft:air"}, | ||||
|         {pos: [1, 4, 0], state: "minecraft:air"}, | ||||
|         {pos: [1, 4, 1], state: "minecraft:air"}, | ||||
|         {pos: [1, 4, 2], state: "minecraft:air"}, | ||||
|         {pos: [1, 4, 3], state: "minecraft:air"}, | ||||
|         {pos: [1, 4, 4], state: "minecraft:air"}, | ||||
|         {pos: [2, 4, 0], state: "minecraft:air"}, | ||||
|         {pos: [2, 4, 1], state: "minecraft:air"}, | ||||
|         {pos: [2, 4, 2], state: "minecraft:air"}, | ||||
|         {pos: [2, 4, 3], state: "minecraft:air"}, | ||||
|         {pos: [2, 4, 4], state: "minecraft:air"}, | ||||
|         {pos: [3, 4, 0], state: "minecraft:air"}, | ||||
|         {pos: [3, 4, 1], state: "minecraft:air"}, | ||||
|         {pos: [3, 4, 2], state: "minecraft:air"}, | ||||
|         {pos: [3, 4, 3], state: "minecraft:air"}, | ||||
|         {pos: [3, 4, 4], state: "minecraft:air"}, | ||||
|         {pos: [4, 4, 0], state: "minecraft:air"}, | ||||
|         {pos: [4, 4, 1], state: "minecraft:air"}, | ||||
|         {pos: [4, 4, 2], state: "minecraft:air"}, | ||||
|         {pos: [4, 4, 3], state: "minecraft:air"}, | ||||
|         {pos: [4, 4, 4], state: "minecraft:air"} | ||||
|     ], | ||||
|     entities: [ | ||||
|         {blockPos: [2, 1, 0], pos: [2.5773471891671482d, 1.0d, 0.31606672131648317d], nbt: {AbsorptionAmount: 0.0f, Air: 300s, ArmorItems: [{}, {}, {}, {}], Attributes: [{Base: 0.699999988079071d, Name: "minecraft:generic.movement_speed"}], Brain: {memories: {}}, DeathTime: 0s, DisabledSlots: 0, FallDistance: 0.0f, FallFlying: 0b, Fire: 0s, HandItems: [{}, {}], Health: 20.0f, HurtByTimestamp: 0, HurtTime: 0s, Invisible: 1b, Invulnerable: 0b, Marker: 1b, Motion: [0.0d, 0.0d, 0.0d], NoBasePlate: 0b, OnGround: 0b, PortalCooldown: 0, Pos: [-5.422652810832852d, -58.0d, 14.316066721316483d], Pose: {}, Rotation: [0.0f, 0.0f], ShowArms: 0b, Small: 0b, UUID: [I; -1438461995, 798246633, -1208936981, 1180880781], id: "minecraft:armor_stand"}} | ||||
|     ], | ||||
|     palette: [ | ||||
|         "minecraft:polished_andesite", | ||||
|         "minecraft:air", | ||||
|         "computercraft:monitor_advanced{facing:north,orientation:north,state:lu}", | ||||
|         "computercraft:monitor_advanced{facing:north,orientation:north,state:lru}", | ||||
|         "computercraft:monitor_advanced{facing:north,orientation:north,state:ru}", | ||||
|         "computercraft:monitor_advanced{facing:north,orientation:north,state:ld}", | ||||
|         "computercraft:monitor_advanced{facing:north,orientation:north,state:lrd}", | ||||
|         "computercraft:monitor_advanced{facing:north,orientation:north,state:rd}" | ||||
|     ] | ||||
| } | ||||
		Reference in New Issue
	
	Block a user
	 Jonathan Coates
					Jonathan Coates