From f37dfd38f11f1612ce7ffaf061761f5d9374c7f1 Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 9 Mar 2026 16:57:42 +0900 Subject: [PATCH 1/6] feat: Add client gametest with screenshot-based tank rendering verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clientGametest ソースセットを手動作成し、サーバー側 gametest と分離 - FabricClientGameTest を実装し、5 つのスクリーンショットテストを追加 - 空タンク、水満タン、水半分、水平連結、縦積み連結 - CI に client-game-test ジョブを追加(スクリーンショットをアーティファクトとして保存) - CTBlocks.syncGroupBlockEntities の可視性を internal → public に変更 (テストモジュールからのアクセスに必要) - .gitignore に /net/ を追加(Loom デコンパイルソース除外) --- .github/workflows/build.yml | 26 +++ .gitignore | 4 + build.gradle.kts | 44 +++++ .../test/ConnectedTankClientGameTest.kt | 161 ++++++++++++++++++ src/clientGametest/resources/fabric.mod.json | 18 ++ .../turtton/connectedtank/block/CTBlocks.kt | 2 +- 6 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 src/clientGametest/kotlin/net/turtton/connectedtank/test/ConnectedTankClientGameTest.kt create mode 100644 src/clientGametest/resources/fabric.mod.json 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..b3c533f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -56,6 +56,7 @@ fabricApi { createSourceSet = true modId = "connectedtank-test" enableGameTests = true + enableClientGameTests = false eula = true } } @@ -64,6 +65,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) @@ -104,6 +145,9 @@ tasks { named("updateDaemonJvm") { languageVersion = JavaLanguageVersion.of(21) } + register("runProductionClientGameTest") { + jvmArgs.add("-Dfabric.client.gametest") + } } 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..562a1a4 --- /dev/null +++ b/src/clientGametest/kotlin/net/turtton/connectedtank/test/ConnectedTankClientGameTest.kt @@ -0,0 +1,161 @@ +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.world.FluidStoragePersistentState +import org.apache.commons.lang3.function.FailableConsumer +import org.lwjgl.glfw.GLFW + +object ConnectedTankClientGameTest : FabricClientGameTest { + 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 * 10) + 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 * 5) + 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 * 10) + 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 * 10) + 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), From 5a77564aad06069548c36c3eb8fffc3ae6bfc0c3 Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 9 Mar 2026 17:48:21 +0900 Subject: [PATCH 2/6] fix: Add mod dependencies to productionRuntimeMods for CI client gametest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit productionRuntimeMods configuration が空だったため、 ClientProductionRunTask 実行時に fabric-api と fabric-language-kotlin が ロードされず起動に失敗していた。 --- build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index b3c533f..555095e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -115,6 +115,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) From 6770438026a5ba0fcfa5f644c58347d78fc4b13a Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 9 Mar 2026 17:54:40 +0900 Subject: [PATCH 3/6] fix: Configure runProductionClientGameTest with test mod and run directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clientGametest ソースセットの jar を remap して getMods() に追加し、 runDir と testModResourcesPath を設定。これにより CI 上で テスト mod が正しくロードされ、スクリーンショットも期待するパスに出力される。 --- build.gradle.kts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 555095e..fabb05f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -148,8 +148,23 @@ tasks { 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") + } register("runProductionClientGameTest") { jvmArgs.add("-Dfabric.client.gametest") + jvmArgs.add( + "-Dfabric.client.gametest.testModResourcesPath=${file("src/clientGametest/resources").absolutePath}", + ) + getMods().from(remapClientGametestJar) + getRunDir().set(project.layout.projectDirectory.dir("build/run/clientGameTest")) } } From dedd25b80eabdb587352d938a56894d6bbf72c61 Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 9 Mar 2026 18:02:07 +0900 Subject: [PATCH 4/6] fix: Resolve remap conflict by using distinct classifier for remapped jar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RemapJarTask は Jar を継承しているため、同じ archiveClassifier だと 入力 jar と出力先が同じパスになり remap 時にファイルが破壊されていた。 classpath も clientGametest ソースセットのものを設定。 --- build.gradle.kts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index fabb05f..310dffd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -156,7 +156,9 @@ tasks { inputFile.set(clientGametestJar.flatMap { it.archiveFile }) sourceNamespace.set("named") targetNamespace.set("intermediary") - archiveClassifier.set("client-gametest") + archiveClassifier.set("client-gametest-remapped") + classpath.from(clientGametestSourceSet.compileClasspath) + addNestedDependencies.set(false) } register("runProductionClientGameTest") { jvmArgs.add("-Dfabric.client.gametest") From bef55ff90fb8a1ce4337a91489fc892c62d0e60d Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 9 Mar 2026 18:16:25 +0900 Subject: [PATCH 5/6] fix: Use correct tank capacity in client gametest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit デフォルト容量は 32 バケツだが、テストでは 10 バケツしか入れていなかった。 CTServerConfig.DEFAULT_BUCKET_CAPACITY を参照して正しい量を注入するよう修正。 --- .../connectedtank/test/ConnectedTankClientGameTest.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/clientGametest/kotlin/net/turtton/connectedtank/test/ConnectedTankClientGameTest.kt b/src/clientGametest/kotlin/net/turtton/connectedtank/test/ConnectedTankClientGameTest.kt index 562a1a4..35bb52d 100644 --- a/src/clientGametest/kotlin/net/turtton/connectedtank/test/ConnectedTankClientGameTest.kt +++ b/src/clientGametest/kotlin/net/turtton/connectedtank/test/ConnectedTankClientGameTest.kt @@ -12,11 +12,13 @@ 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) }) } @@ -120,7 +122,7 @@ object ConnectedTankClientGameTest : FabricClientGameTest { 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 * 10) + 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") @@ -129,7 +131,7 @@ object ConnectedTankClientGameTest : FabricClientGameTest { 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 * 5) + 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") @@ -141,7 +143,7 @@ object ConnectedTankClientGameTest : FabricClientGameTest { val pos2 = basePos.east() placeTank(server, pos1) placeTank(server, pos2) - insertFluid(server, pos1, FluidVariant.of(Fluids.WATER), FluidConstants.BUCKET * 10) + 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") @@ -153,7 +155,7 @@ object ConnectedTankClientGameTest : FabricClientGameTest { val pos2 = basePos.up() placeTank(server, pos1) placeTank(server, pos2) - insertFluid(server, pos1, FluidVariant.of(Fluids.WATER), FluidConstants.BUCKET * 10) + 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") From c31dd3c0a767a283ad93f6c38738c64c05cc12ce Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 9 Mar 2026 18:34:26 +0900 Subject: [PATCH 6/6] build: Suppress UnstableAPIUsages --- build.gradle.kts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 310dffd..3a4acbd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -52,6 +52,7 @@ fabricApi { configureDataGeneration { client = true } + @Suppress("UnstableApiUsage") configureTests { createSourceSet = true modId = "connectedtank-test" @@ -145,6 +146,7 @@ tasks { withType().configureEach { options.release.set(21) } + @Suppress("UnstableApiUsage") named("updateDaemonJvm") { languageVersion = JavaLanguageVersion.of(21) } @@ -160,13 +162,14 @@ tasks { 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}", ) - getMods().from(remapClientGametestJar) - getRunDir().set(project.layout.projectDirectory.dir("build/run/clientGameTest")) + mods.from(remapClientGametestJar) + runDir.set(project.layout.projectDirectory.dir("build/run/clientGameTest")) } }