Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +68 to +69
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workflow runs a Minecraft client task on ubuntu-24.04 without setting up an X/GL context (e.g., via xvfb-run). On GitHub-hosted Linux runners, GUI/OpenGL apps typically fail with missing DISPLAY/GL errors unless wrapped in Xvfb (and sometimes mesa libs). Consider running the Gradle command under xvfb-run -a (and installing any required packages) to make the CI job reliable.

Suggested change
- name: Run client game tests
run: ./gradlew runProductionClientGameTest
- name: Install Xvfb and Mesa
run: |
sudo apt-get update
sudo apt-get install -y xvfb mesa-utils
- name: Run client game tests
run: xvfb-run -a ./gradlew runProductionClientGameTest

Copilot uses AI. Check for mistakes.
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: Client Game Test Screenshots
path: build/run/clientGameTest/screenshots/
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ bin/

run/

# loom decompiled sources

/net/

# datagen

src/main/generated/.cache/
Expand Down
67 changes: 67 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@ fabricApi {
configureDataGeneration {
client = true
}
@Suppress("UnstableApiUsage")
configureTests {
createSourceSet = true
modId = "connectedtank-test"
enableGameTests = true
enableClientGameTests = false
eula = true
}
}
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -101,9 +146,31 @@ tasks {
withType<JavaCompile>().configureEach {
options.release.set(21)
}
@Suppress("UnstableApiUsage")
named<UpdateDaemonJvm>("updateDaemonJvm") {
languageVersion = JavaLanguageVersion.of(21)
}
val clientGametestJar = register<Jar>("clientGametestJar") {
from(clientGametestSourceSet.output)
archiveClassifier.set("client-gametest")
}
val remapClientGametestJar = register<net.fabricmc.loom.task.RemapJarTask>("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<net.fabricmc.loom.task.prod.ClientProductionRunTask>("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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TANK_CAPACITY is a regular val but is named like a compile-time constant (all-caps with underscores). To match Kotlin naming conventions and avoid implying const val semantics, rename this to lowerCamelCase (or make it a const val if you can express it as a compile-time constant).

Suggested change
private val TANK_CAPACITY = CTServerConfig.DEFAULT_BUCKET_CAPACITY.toLong()
private val tankCapacity = CTServerConfig.DEFAULT_BUCKET_CAPACITY.toLong()

Copilot uses AI. Check for mistakes.
private fun TestServerContext.onServer(action: (MinecraftServer) -> Unit) {
runOnServer(FailableConsumer<MinecraftServer, RuntimeException> { 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")
}
}
18 changes: 18 additions & 0 deletions src/clientGametest/resources/fabric.mod.json
Original file line number Diff line number Diff line change
@@ -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": "*"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ object CTBlocks {
}, CONNECTED_TANK)
}

internal fun syncGroupBlockEntities(
fun syncGroupBlockEntities(
world: ServerWorld,
pos: BlockPos,
state: FluidStoragePersistentState = world.persistentStateManager.getOrCreate(FluidStoragePersistentState.TYPE),
Expand Down