package dev.cobblesurvival.selftest import com.mojang.brigadier.builder.LiteralArgumentBuilder import dev.cobblesurvival.CobbleSurvival import net.minecraft.commands.CommandSourceStack import net.minecraft.commands.Commands import net.minecraft.core.BlockPos import net.minecraft.gametest.framework.GameTestInfo import net.minecraft.gametest.framework.GameTestRunner import net.minecraft.gametest.framework.GameTestTicker import net.minecraft.gametest.framework.RetryOptions import net.minecraft.gametest.framework.StructureGridSpawner import net.minecraft.gametest.framework.TestFunction import net.minecraft.network.chat.Component import net.minecraft.server.MinecraftServer import net.minecraft.world.level.ChunkPos import net.minecraft.world.level.biome.Biomes import net.minecraft.world.level.block.Rotation import net.neoforged.neoforge.common.NeoForge import net.neoforged.neoforge.event.tick.ServerTickEvent /** * One in-game test: [body] sets it up through its [Scenario] and must end in a pass or a failure within [maxTicks]. * [islandOnly] tests need the island world and are skipped elsewhere (tools/selftest.py uses a flat world). * Tests of one [batch] run side by side; batches run one after another (a test that changes the world around it, * like the biome or the time of day, gets a batch of its own). */ class SelfTestCase( val name: String, val maxTicks: Int, val body: (Scenario) -> Unit, val islandOnly: Boolean = false, val batch: String = CobbleSurvival.MOD_ID, ) /** * In-game tests on a real server with all of the pack's mods and configs: `/cobblesurvival selftest run` starts them * on a platform high above spawn, `/cobblesurvival selftest status` reports. They use the vanilla GameTest * framework, which NeoForge only switches on in a development environment, so this object creates and ticks the * tests itself. Only there when the server runs with `-Dcobblesurvival.selftest=true` (tools/quick_check.py); * players never see the command. */ object SelfTest { val enabled: Boolean = java.lang.Boolean.getBoolean("cobblesurvival.selftest") private const val TEMPLATE = "cobblesurvival:selftest/empty" private const val PLATFORM_Y = 200 private const val TESTS_PER_ROW = 4 private const val FORCED_CHUNKS = 9 private val cases: List get() = PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all() private var running: List = emptyList() private var forced: List = emptyList() private var summary = "selftest not run" fun register() { if (enabled) NeoForge.EVENT_BUS.addListener(::onServerTick) } fun command(): LiteralArgumentBuilder = Commands.literal("selftest") .requires { it.hasPermission(4) } .then(Commands.literal("run").executes { context -> val reply = start(context.source.server) context.source.sendSuccess({ Component.literal(reply) }, false) 1 }) .then(Commands.literal("status").executes { context -> val done = running.count { it.result != null } val reply = if (running.isNotEmpty()) "selftest running: $done/${running.size} done" else summary context.source.sendSuccess({ Component.literal(reply) }, false) 1 }) private fun start(server: MinecraftServer): String { if (running.isNotEmpty()) return "selftest already running" val level = server.overworld() val spawn = level.sharedSpawnPos val origin = BlockPos(spawn.x, PLATFORM_Y, spawn.z) val corner = ChunkPos(origin) forced = (0 until FORCED_CHUNKS).flatMap { dx -> (0 until FORCED_CHUNKS).map { dz -> ChunkPos(corner.x + dx, corner.z + dz) } } forced.forEach { level.setChunkForced(it.x, it.z, true) } // Only the island world type puts basalt deltas into the overworld. val island = level.chunkSource.generator.biomeSource.possibleBiomes().any { it.`is`(Biomes.BASALT_DELTAS) } val (runnable, skipped) = cases.partition { island || !it.islandOnly } val scenarios = runnable.mapIndexed { index, case -> Scenario(case, index) } val infos = scenarios.map { scenario -> val function = TestFunction(scenario.case.batch, "${CobbleSurvival.MOD_ID}.${scenario.case.name}", TEMPLATE, scenario.case.maxTicks, 0L, true) { helper -> scenario.begin(helper) } GameTestInfo(function, Rotation.NONE, level, RetryOptions.noRetries()).also { it.addListener(scenario) } } running = scenarios GameTestTicker.SINGLETON.clear() GameTestRunner.Builder.fromInfo(infos, level) .newStructureSpawner(StructureGridSpawner(origin, TESTS_PER_ROW, false)) .build() .start() val skippedNote = if (skipped.isEmpty()) "" else ", skipped ${skipped.size} that need the island world" CobbleSurvival.LOGGER.info("selftest started {} tests at {}{}", infos.size, origin.toShortString(), skippedNote) return "selftest started ${infos.size} tests$skippedNote" } private fun onServerTick(event: ServerTickEvent.Post) { if (running.isEmpty()) return try { GameTestTicker.SINGLETON.tick() } catch (e: Exception) { // Only assertion failures are handled by the framework; anything else would crash the server. CobbleSurvival.LOGGER.error("selftest: a test threw; ending the run", e) running.forEach { it.abort(e) } } if (running.any { it.result == null }) return val failed = running.filter { !it.passed } summary = "selftest finished: ${running.size - failed.size}/${running.size} passed" + failed.joinToString("") { "; FAILED ${it.case.name}: ${it.result}" } if (failed.isEmpty()) CobbleSurvival.LOGGER.info(summary) else CobbleSurvival.LOGGER.error(summary) GameTestTicker.SINGLETON.clear() val level = event.server.overworld() forced.forEach { level.setChunkForced(it.x, it.z, false) } running = emptyList() } }