diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e5efa75..ff1e1bb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,3 +47,29 @@ jobs: with: name: Artifacts path: build/libs/ + + client-game-test: + name: Client Game Test + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - name: setup jdk + uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 + with: + java-version: '21' + distribution: 'microsoft' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 + - name: Run client game tests + run: ./gradlew runProductionClientGameTest + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: Client Game Test Screenshots + path: build/run/clientGameTest/screenshots/ diff --git a/.gitignore b/.gitignore index 5570e01..172dca5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,10 @@ bin/ run/ +# loom decompiled sources + +/net/ + # datagen src/main/generated/.cache/ diff --git a/build.gradle.kts b/build.gradle.kts index f03a8d0..3a4acbd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -52,10 +52,12 @@ fabricApi { configureDataGeneration { client = true } + @Suppress("UnstableApiUsage") configureTests { createSourceSet = true modId = "connectedtank-test" enableGameTests = true + enableClientGameTests = false eula = true } } @@ -64,6 +66,46 @@ sourceSets.named("gametest") { kotlin.srcDir("src/gametest/kotlin") } +val clientGametestSourceSet = sourceSets.create("clientGametest") { + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().output + compileClasspath += sourceSets.getByName("client").output + runtimeClasspath += sourceSets.getByName("client").output + kotlin.srcDir("src/clientGametest/kotlin") +} + +configurations.named(clientGametestSourceSet.compileClasspathConfigurationName) { + extendsFrom(configurations[sourceSets.main.get().compileClasspathConfigurationName]) + extendsFrom(configurations[sourceSets.getByName("client").compileClasspathConfigurationName]) +} +configurations.named(clientGametestSourceSet.runtimeClasspathConfigurationName) { + extendsFrom(configurations[sourceSets.main.get().runtimeClasspathConfigurationName]) + extendsFrom(configurations[sourceSets.getByName("client").runtimeClasspathConfigurationName]) +} + +loom { + mods { + register("connectedtank-client-test") { + sourceSet(clientGametestSourceSet) + } + } + + createRemapConfigurations(clientGametestSourceSet) + + runs { + register("clientGameTest") { + inherit(runs.getByName("client")) + source(clientGametestSourceSet) + property("fabric.client.gametest") + property( + "fabric.client.gametest.testModResourcesPath", + file("src/clientGametest/resources").absolutePath, + ) + runDir("build/run/clientGameTest") + } + } +} + dependencies { // To change the versions see the gradle/libs.versions.toml file minecraft(libs.minecraft) @@ -74,6 +116,9 @@ dependencies { modImplementation(libs.fabric.api) modImplementation(libs.fabric.language.kotlin) + "productionRuntimeMods"(libs.fabric.api) + "productionRuntimeMods"(libs.fabric.language.kotlin) + modCompileOnly(libs.yacl) modRuntimeOnly(libs.yacl) modCompileOnly(libs.modmenu) @@ -101,9 +146,31 @@ tasks { withType().configureEach { options.release.set(21) } + @Suppress("UnstableApiUsage") named("updateDaemonJvm") { languageVersion = JavaLanguageVersion.of(21) } + val clientGametestJar = register("clientGametestJar") { + from(clientGametestSourceSet.output) + archiveClassifier.set("client-gametest") + } + val remapClientGametestJar = register("remapClientGametestJar") { + inputFile.set(clientGametestJar.flatMap { it.archiveFile }) + sourceNamespace.set("named") + targetNamespace.set("intermediary") + archiveClassifier.set("client-gametest-remapped") + classpath.from(clientGametestSourceSet.compileClasspath) + addNestedDependencies.set(false) + } + @Suppress("UnstableApiUsage") + register("runProductionClientGameTest") { + jvmArgs.add("-Dfabric.client.gametest") + jvmArgs.add( + "-Dfabric.client.gametest.testModResourcesPath=${file("src/clientGametest/resources").absolutePath}", + ) + mods.from(remapClientGametestJar) + runDir.set(project.layout.projectDirectory.dir("build/run/clientGameTest")) + } } kotlin { diff --git a/src/clientGametest/kotlin/net/turtton/connectedtank/test/ConnectedTankClientGameTest.kt b/src/clientGametest/kotlin/net/turtton/connectedtank/test/ConnectedTankClientGameTest.kt new file mode 100644 index 0000000..35bb52d --- /dev/null +++ b/src/clientGametest/kotlin/net/turtton/connectedtank/test/ConnectedTankClientGameTest.kt @@ -0,0 +1,163 @@ +package net.turtton.connectedtank.test + +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext +import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext +import net.fabricmc.fabric.api.transfer.v1.fluid.FluidConstants +import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant +import net.fabricmc.fabric.api.transfer.v1.transaction.Transaction +import net.minecraft.fluid.Fluids +import net.minecraft.server.MinecraftServer +import net.minecraft.util.math.BlockPos +import net.minecraft.world.World +import net.turtton.connectedtank.block.CTBlocks +import net.turtton.connectedtank.block.TankFluidStorage +import net.turtton.connectedtank.config.CTServerConfig +import net.turtton.connectedtank.world.FluidStoragePersistentState +import org.apache.commons.lang3.function.FailableConsumer +import org.lwjgl.glfw.GLFW + +object ConnectedTankClientGameTest : FabricClientGameTest { + private val TANK_CAPACITY = CTServerConfig.DEFAULT_BUCKET_CAPACITY.toLong() + private fun TestServerContext.onServer(action: (MinecraftServer) -> Unit) { + runOnServer(FailableConsumer { action(it) }) + } + + override fun runTest(context: ClientGameTestContext) { + context.worldBuilder().create().use { singleplayer -> + val server = singleplayer.server + singleplayer.clientWorld.waitForChunksRender() + + server.runCommand("gamemode spectator @p") + context.waitTicks(5) + context.input.pressKey(GLFW.GLFW_KEY_F1) + context.waitTicks(5) + + testEmptyTank(context, server) + testFullWaterTank(context, server) + testHalfWaterTank(context, server) + testHorizontalConnectedTanks(context, server) + testVerticalConnectedTanks(context, server) + } + } + + private fun clearArea(server: TestServerContext, basePos: BlockPos, sizeX: Int, sizeY: Int, sizeZ: Int) { + server.onServer { srv -> + val world = srv.getWorld(World.OVERWORLD)!! + val state = world.persistentStateManager.getOrCreate(FluidStoragePersistentState.TYPE) + for (x in 0 until sizeX) { + for (y in 0 until sizeY) { + for (z in 0 until sizeZ) { + val pos = basePos.add(x, y, z) + if (state.getStorage(pos) != null) { + state.removeStorage(pos) + } + world.removeBlock(pos, false) + } + } + } + } + } + + private fun placeTank( + server: TestServerContext, + pos: BlockPos, + fluid: TankFluidStorage.ExistingData? = null, + ) { + server.onServer { srv -> + val world = srv.getWorld(World.OVERWORLD)!! + world.setBlockState(pos, CTBlocks.CONNECTED_TANK.defaultState) + val persistentState = world.persistentStateManager.getOrCreate(FluidStoragePersistentState.TYPE) + val storage = TankFluidStorage(fluid = fluid) + persistentState.addStorage(pos, storage) + CTBlocks.syncGroupBlockEntities(world, pos, persistentState) + } + } + + private fun insertFluid( + server: TestServerContext, + pos: BlockPos, + variant: FluidVariant, + amount: Long, + ) { + server.onServer { srv -> + val world = srv.getWorld(World.OVERWORLD)!! + val persistentState = world.persistentStateManager.getOrCreate(FluidStoragePersistentState.TYPE) + val storage = persistentState.getStorage(pos) ?: error("Storage not found at $pos") + Transaction.openOuter().use { tx -> + storage.insert(variant, amount, tx) + tx.commit() + } + CTBlocks.syncGroupBlockEntities(world, pos, persistentState) + } + } + + private fun setupCamera( + context: ClientGameTestContext, + server: TestServerContext, + x: Double, + y: Double, + z: Double, + yaw: Float, + pitch: Float, + ) { + // tp を 2 回実行: スペクテイターモードの慣性ドリフトで + // 1 回目の tp 後にカメラ位置がずれるのを防ぐ + server.runCommand("tp @p $x $y $z $yaw $pitch") + context.waitTicks(3) + server.runCommand("tp @p $x $y $z $yaw $pitch") + context.waitTicks(1) + } + + private val basePos = BlockPos(0, -60, 0) + + private fun testEmptyTank(context: ClientGameTestContext, server: TestServerContext) { + clearArea(server, basePos, 3, 3, 3) + placeTank(server, basePos) + context.waitTicks(20) + setupCamera(context, server, 1.8, -58.5, 1.8, 135f, 50f) + context.takeScreenshot("1_empty_tank") + } + + private fun testFullWaterTank(context: ClientGameTestContext, server: TestServerContext) { + clearArea(server, basePos, 3, 3, 3) + placeTank(server, basePos) + insertFluid(server, basePos, FluidVariant.of(Fluids.WATER), FluidConstants.BUCKET * TANK_CAPACITY) + context.waitTicks(20) + setupCamera(context, server, 1.8, -58.5, 1.8, 135f, 50f) + context.takeScreenshot("2_full_water_tank") + } + + private fun testHalfWaterTank(context: ClientGameTestContext, server: TestServerContext) { + clearArea(server, basePos, 3, 3, 3) + placeTank(server, basePos) + insertFluid(server, basePos, FluidVariant.of(Fluids.WATER), FluidConstants.BUCKET * TANK_CAPACITY / 2) + context.waitTicks(20) + setupCamera(context, server, 1.8, -58.5, 1.8, 135f, 50f) + context.takeScreenshot("3_half_water_tank") + } + + private fun testHorizontalConnectedTanks(context: ClientGameTestContext, server: TestServerContext) { + clearArea(server, basePos, 3, 3, 3) + val pos1 = basePos + val pos2 = basePos.east() + placeTank(server, pos1) + placeTank(server, pos2) + insertFluid(server, pos1, FluidVariant.of(Fluids.WATER), FluidConstants.BUCKET * TANK_CAPACITY * 2) + context.waitTicks(20) + setupCamera(context, server, 2.5, -58.5, 2.5, 135f, 45f) + context.takeScreenshot("4_horizontal_connected_tanks") + } + + private fun testVerticalConnectedTanks(context: ClientGameTestContext, server: TestServerContext) { + clearArea(server, basePos, 3, 3, 3) + val pos1 = basePos + val pos2 = basePos.up() + placeTank(server, pos1) + placeTank(server, pos2) + insertFluid(server, pos1, FluidVariant.of(Fluids.WATER), FluidConstants.BUCKET * TANK_CAPACITY * 2) + context.waitTicks(20) + setupCamera(context, server, 1.8, -57.0, 1.8, 135f, 45f) + context.takeScreenshot("5_vertical_connected_tanks") + } +} diff --git a/src/clientGametest/resources/fabric.mod.json b/src/clientGametest/resources/fabric.mod.json new file mode 100644 index 0000000..44e64ca --- /dev/null +++ b/src/clientGametest/resources/fabric.mod.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "id": "connectedtank-client-test", + "version": "1.0.0", + "name": "ConnectedTank Client Test", + "environment": "client", + "entrypoints": { + "fabric-client-gametest": [ + { + "value": "net.turtton.connectedtank.test.ConnectedTankClientGameTest", + "adapter": "kotlin" + } + ] + }, + "depends": { + "connectedtank": "*" + } +} diff --git a/src/main/kotlin/net/turtton/connectedtank/block/CTBlocks.kt b/src/main/kotlin/net/turtton/connectedtank/block/CTBlocks.kt index 63bd8f7..ec05bad 100644 --- a/src/main/kotlin/net/turtton/connectedtank/block/CTBlocks.kt +++ b/src/main/kotlin/net/turtton/connectedtank/block/CTBlocks.kt @@ -39,7 +39,7 @@ object CTBlocks { }, CONNECTED_TANK) } - internal fun syncGroupBlockEntities( + fun syncGroupBlockEntities( world: ServerWorld, pos: BlockPos, state: FluidStoragePersistentState = world.persistentStateManager.getOrCreate(FluidStoragePersistentState.TYPE),