From f5dbe50e0b80f94e64fd42de4fe16fc4737f9a16 Mon Sep 17 00:00:00 2001 From: turtton Date: Fri, 8 May 2026 20:31:29 +0900 Subject: [PATCH 1/3] feat: add NeoForge multi-loader support via Stonecutter - Add NeoForge build config and Stonecutter loader parameter switching - Abstract Fabric/NeoForge fluid APIs (FluidVariant/FluidStack) with platform types - Implement NeoForge block registration, capabilities, and network payloads - Fix game tests for cross-loader compatibility (setItemInHand, isSameFluid, FLUID_BUCKET) - Fix NeoForge client @SubscribeEvent static method crash (remove @JvmStatic, add Bus.MOD) - Handle UnsupportedOperationException for mock player payload in game tests - Update flake.nix vcsVersion guard for multi-loader version format --- .github/workflows/build.yml | 2 +- build.gradle.kts => build.fabric.gradle.kts | 4 + ...ts => build.fabric.unobfuscated.gradle.kts | 1 + build.neoforge.gradle.kts | 186 ++++++++ flake.nix | 2 +- gradle/libs.versions.toml | 4 + settings.gradle.kts | 9 +- .../connectedtank/ConnectedTankClient.kt | 46 +- .../ConnectedTankDataGenerator.kt | 2 + .../block/ConnectedTankBlockEntityRenderer.kt | 25 +- .../connectedtank/config/CTClientConfig.kt | 10 + .../connectedtank/config/CTConfigScreen.kt | 2 + .../item/ConnectedTankItemRenderer.kt | 31 +- .../test/ConnectedTankGameTest.kt | 408 +++++++++--------- .../test/ConnectedTankGameTestRegistration.kt | 117 +++++ .../data/connectedtank/structure/empty.nbt | Bin 0 -> 101 bytes .../turtton/connectedtank/ConnectedTank.kt | 39 ++ .../connectedtank/block/CTBlockEntityTypes.kt | 6 + .../turtton/connectedtank/block/CTBlocks.kt | 29 ++ .../connectedtank/block/ConnectedTankBlock.kt | 35 +- .../block/ConnectedTankBlockEntity.kt | 22 +- .../connectedtank/block/TankFluidStorage.kt | 57 ++- .../compat/jade/ConnectedTankJadePlugin.kt | 10 +- .../connectedtank/config/CTServerConfig.kt | 12 +- .../connectedtank/fluid/FluidCompat.kt | 56 +++ .../net/turtton/connectedtank/item/CTItems.kt | 8 + .../network/ConfigSyncPayload.kt | 35 ++ .../world/FluidStoragePersistentState.kt | 27 +- .../resources/META-INF/neoforge.mods.toml | 32 ++ stonecutter.gradle.kts | 13 +- 30 files changed, 989 insertions(+), 241 deletions(-) rename build.gradle.kts => build.fabric.gradle.kts (98%) rename build.unobfuscated.gradle.kts => build.fabric.unobfuscated.gradle.kts (99%) create mode 100644 build.neoforge.gradle.kts create mode 100644 src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTestRegistration.kt create mode 100644 src/gametest/resources/data/connectedtank/structure/empty.nbt create mode 100644 src/main/kotlin/net/turtton/connectedtank/fluid/FluidCompat.kt create mode 100644 src/main/resources/META-INF/neoforge.mods.toml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a212724..37cda49 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -66,7 +66,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 - name: Run client game tests - run: ./gradlew :1.21.8:runProductionClientGameTest + run: ./gradlew :1.21.8-fabric:runProductionClientGameTest - name: Upload screenshots if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/build.gradle.kts b/build.fabric.gradle.kts similarity index 98% rename from build.gradle.kts rename to build.fabric.gradle.kts index 34ea6b7..1371781 100644 --- a/build.gradle.kts +++ b/build.fabric.gradle.kts @@ -154,6 +154,7 @@ dependencies { tasks { processResources { + duplicatesStrategy = DuplicatesStrategy.INCLUDE inputs.property("version", project.version) inputs.property("minecraft_version", mcVersion) inputs.property("java_version", 21) @@ -172,6 +173,9 @@ tasks { withType().configureEach { options.release.set(21) } + withType().configureEach { + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } val clientGametestJar = register("clientGametestJar") { from(clientGametestSourceSet.output) archiveClassifier.set("client-gametest") diff --git a/build.unobfuscated.gradle.kts b/build.fabric.unobfuscated.gradle.kts similarity index 99% rename from build.unobfuscated.gradle.kts rename to build.fabric.unobfuscated.gradle.kts index 4030f1b..d33318e 100644 --- a/build.unobfuscated.gradle.kts +++ b/build.fabric.unobfuscated.gradle.kts @@ -127,6 +127,7 @@ dependencies { tasks { processResources { + duplicatesStrategy = DuplicatesStrategy.INCLUDE inputs.property("version", project.version) inputs.property("minecraft_version", mcVersion) inputs.property("java_version", 25) diff --git a/build.neoforge.gradle.kts b/build.neoforge.gradle.kts new file mode 100644 index 0000000..0e4a51d --- /dev/null +++ b/build.neoforge.gradle.kts @@ -0,0 +1,186 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("dev.kikugie.stonecutter") + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.neoforge.moddev) + id("maven-publish") + alias(libs.plugins.mod.publish.plugin) +} + +val mcVersion = stonecutter.current.version + +version = providers.environmentVariable("MOD_VERSION").orElse("0.0.0").get() + "+neoforge-$mcVersion" +group = project.property("maven_group") as String + +base { + archivesName.set(project.property("archives_base_name") as String) +} + +repositories { + maven("https://maven.neoforged.net/releases") { name = "NeoForged" } + maven("https://thedarkcolour.github.io/KotlinForForge/") { name = "KotlinForForge" } + maven { url = uri("https://maven.shedaniel.me") } + maven { url = uri("https://maven.isxander.dev/releases") } + + exclusiveContent { + forRepository { + maven { + name = "Modrinth" + url = uri("https://api.modrinth.com/maven") + } + } + filter { + includeGroup("maven.modrinth") + } + } +} + +val clientSourceSet = sourceSets.create("client") { + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().output +} + +configurations.named(clientSourceSet.compileClasspathConfigurationName) { + extendsFrom(configurations[sourceSets.main.get().compileClasspathConfigurationName]) +} +configurations.named(clientSourceSet.runtimeClasspathConfigurationName) { + extendsFrom(configurations[sourceSets.main.get().runtimeClasspathConfigurationName]) +} + +val gametestSourceSet = sourceSets.create("gametest") { + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().output + kotlin.srcDir("src/gametest/kotlin") + resources.srcDir("src/gametest/resources") +} + +configurations.named(gametestSourceSet.compileClasspathConfigurationName) { + extendsFrom(configurations[sourceSets.main.get().compileClasspathConfigurationName]) +} +configurations.named(gametestSourceSet.runtimeClasspathConfigurationName) { + extendsFrom(configurations[sourceSets.main.get().runtimeClasspathConfigurationName]) +} + +val neoForgeVersion: String = libs.versions.neoforge.mc.get() + +neoForge { + version = neoForgeVersion + + addModdingDependenciesTo(clientSourceSet) + addModdingDependenciesTo(gametestSourceSet) + + mods { + register("connectedtank") { + sourceSet(sourceSets.main.get()) + sourceSet(clientSourceSet) + sourceSet(gametestSourceSet) + } + } + + runs { + create("client") { + client() + sourceSet = clientSourceSet + } + create("server") { + server() + } + create("gameTestServer") { + type = "gameTestServer" + sourceSet = gametestSourceSet + } + } +} + +dependencies { + val yaclVersion = "3.8.2+1.21.11-neoforge" + val jadeVersion = "21.1.7+neoforge" + val reiVersion = "21.11.814" + + implementation("thedarkcolour:kotlinforforge-neoforge:${libs.versions.kff.get()}") + + compileOnly("dev.isxander:yet-another-config-lib:$yaclVersion") + runtimeOnly("dev.isxander:yet-another-config-lib:$yaclVersion") + compileOnly("maven.modrinth:jade:$jadeVersion") + runtimeOnly("maven.modrinth:jade:$jadeVersion") + runtimeOnly("me.shedaniel:RoughlyEnoughItems-neoforge:$reiVersion") +} + +tasks { + processResources { + val modVersion = project.version.toString().substringBefore("+") + inputs.property("version", modVersion) + inputs.property("minecraft_version", mcVersion) + inputs.property("neoforge_version", neoForgeVersion) + inputs.property("java_version", 21) + + filesMatching("META-INF/neoforge.mods.toml") { + expand( + mapOf( + "version" to inputs.properties["version"], + "minecraft_version" to inputs.properties["minecraft_version"], + "neoforge_version" to inputs.properties["neoforge_version"], + "java_version" to inputs.properties["java_version"], + ), + ) + } + } + jar { + inputs.property("archivesName", base.archivesName) + + from("LICENSE") { + rename { "${it}_${inputs.properties["archivesName"]}" } + } + } + withType().configureEach { + options.release.set(21) + } +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_21 + } + jvmToolchain(21) +} + +java { + withSourcesJar() + + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +publishMods { + file.set(tasks.jar.flatMap { it.archiveFile }) + additionalFiles.from(tasks.named("sourcesJar").map { (it as Jar).archiveFile }) + changelog.set(providers.environmentVariable("CHANGELOG").orElse("")) + type.set(STABLE) + modLoaders.add("neoforge") + + modrinth { + projectId.set(providers.environmentVariable("MODRINTH_ID")) + accessToken.set(providers.environmentVariable("MODRINTH_TOKEN")) + minecraftVersions.add(mcVersion) + requires("kotlin-for-forge") + } + curseforge { + projectId.set(providers.environmentVariable("CURSEFORGE_ID")) + accessToken.set(providers.environmentVariable("CURSEFORGE_TOKEN")) + minecraftVersions.add(mcVersion) + requires("kotlin-for-forge") + } +} + +publishing { + publications { + create("mavenJava") { + artifactId = project.property("archives_base_name") as String + from(components["java"]) + } + } + + repositories { + } +} diff --git a/flake.nix b/flake.nix index ee5687a..3f40479 100644 --- a/flake.nix +++ b/flake.nix @@ -48,7 +48,7 @@ stonecutter-version = { enable = true; name = "Stonecutter vcsVersion guard"; - entry = "${pkgs.bash}/bin/bash -c 'if ! grep -q '\"'\"'stonecutter active \"1.21.8\"'\"'\"' stonecutter.gradle.kts; then ${pkgs.gnused}/bin/sed -i '\"'\"'s/stonecutter active \".*\"/stonecutter active \"1.21.8\"/'\"'\"' stonecutter.gradle.kts; echo \"Fixed: stonecutter active version reset to 1.21.8 (vcsVersion). Please re-commit.\"; exit 1; fi'"; + entry = "${pkgs.bash}/bin/bash -c 'if ! grep -q '\"'\"'stonecutter active \"1.21.8-fabric\"'\"'\"' stonecutter.gradle.kts; then ${pkgs.gnused}/bin/sed -i '\"'\"'s/stonecutter active \".*\"/stonecutter active \"1.21.8-fabric\"/'\"'\"' stonecutter.gradle.kts; echo \"Fixed: stonecutter active version reset to 1.21.8-fabric (vcsVersion). Please re-commit.\"; exit 1; fi'"; files = "stonecutter\\.gradle\\.kts$"; language = "system"; pass_filenames = false; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 99eedc5..7b36e6e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,9 @@ fabric-kotlin = "1.13.11+kotlin.2.3.21" kotlin = "2.3.21" spotless = "7.2.1" mod-publish-plugin = "0.8.4" +neoforge-mc = "21.11.42" +neoforge-moddev = "2.0.141" +kff = "6.2.0" [libraries] fabric-loader = { module = "net.fabricmc:fabric-loader", version.ref = "loader" } @@ -16,3 +19,4 @@ fabric-loom-remap = { id = "net.fabricmc.fabric-loom-remap", version.ref = "loom kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } mod-publish-plugin = { id = "me.modmuss50.mod-publish-plugin", version.ref = "mod-publish-plugin" } +neoforge-moddev = { id = "net.neoforged.moddev", version.ref = "neoforge-moddev" } diff --git a/settings.gradle.kts b/settings.gradle.kts index d48ac6f..f266835 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,6 +4,7 @@ pluginManagement { name = "Fabric" setUrl("https://maven.fabricmc.net/") } + maven("https://maven.neoforged.net/releases") { name = "NeoForged" } maven("https://maven.kikugie.dev/releases") { name = "KikuGie" } mavenCentral() gradlePluginPortal() @@ -16,8 +17,10 @@ plugins { stonecutter { create(rootProject) { - versions("1.21.8", "1.21.11") - version("26.1").buildscript("build.unobfuscated.gradle.kts") - vcsVersion = "1.21.8" + version("1.21.8-fabric", "1.21.8").buildscript("build.fabric.gradle.kts") + version("1.21.11-fabric", "1.21.11").buildscript("build.fabric.gradle.kts") + version("1.21.11-neoforge", "1.21.11").buildscript("build.neoforge.gradle.kts") + version("26.1-fabric", "26.1").buildscript("build.fabric.unobfuscated.gradle.kts") + vcsVersion = "1.21.8-fabric" } } diff --git a/src/client/kotlin/net/turtton/connectedtank/ConnectedTankClient.kt b/src/client/kotlin/net/turtton/connectedtank/ConnectedTankClient.kt index d5f1ad0..939ebde 100644 --- a/src/client/kotlin/net/turtton/connectedtank/ConnectedTankClient.kt +++ b/src/client/kotlin/net/turtton/connectedtank/ConnectedTankClient.kt @@ -1,5 +1,6 @@ package net.turtton.connectedtank +//? if fabric { import net.fabricmc.api.ClientModInitializer import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking @@ -7,18 +8,29 @@ import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking import net.fabricmc.fabric.api.client.rendering.v1.BlockRenderLayerMap import net.minecraft.client.renderer.chunk.ChunkSectionLayer //?} +//?} else if neoforge { +/*import net.neoforged.api.distmarker.Dist +import net.neoforged.bus.api.SubscribeEvent +import net.neoforged.fml.common.EventBusSubscriber +import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent +import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent +import net.neoforged.neoforge.client.event.RegisterSpecialModelRendererEvent +import net.neoforged.neoforge.common.NeoForge*/ +//?} import net.minecraft.client.renderer.blockentity.BlockEntityRenderers import net.turtton.connectedtank.block.CTBlockEntityTypes import net.turtton.connectedtank.block.CTBlocks import net.turtton.connectedtank.block.ConnectedTankBlockEntityRenderer -//? if >=1.21.11 +//? if >=1.21.11 { /*import net.turtton.connectedtank.block.ConnectedTankRenderState*/ +//?} import net.turtton.connectedtank.config.CTClientConfig import net.turtton.connectedtank.config.CTServerConfig import net.turtton.connectedtank.config.SyncedServerConfig import net.turtton.connectedtank.item.ConnectedTankItemRenderer import net.turtton.connectedtank.network.ConfigSyncPayload +//? if fabric { object ConnectedTankClient : ClientModInitializer { override fun onInitializeClient() { CTClientConfig.load() @@ -52,3 +64,35 @@ object ConnectedTankClient : ClientModInitializer { } } } +//?} else if neoforge { +/*@EventBusSubscriber(value = [Dist.CLIENT], modid = "connectedtank", bus = EventBusSubscriber.Bus.MOD) +object ConnectedTankClient { + init { + CTClientConfig.load() + ConfigSyncPayload.onConfigReceived = { payload -> + SyncedServerConfig.syncedConfig = CTServerConfig( + tankBucketCapacity = payload.tankBucketCapacity.coerceIn( + 1, + CTServerConfig.MAX_BUCKET_CAPACITY, + ), + tierMultipliers = payload.tierMultipliers.toMutableMap(), + ) + } + NeoForge.EVENT_BUS.addListener { _ -> + SyncedServerConfig.syncedConfig = null + } + } + + @SubscribeEvent + fun onClientSetup(event: FMLClientSetupEvent) { + BlockEntityRenderers.register<_, ConnectedTankRenderState>( + CTBlockEntityTypes.CONNECTED_TANK, + ) { context -> ConnectedTankBlockEntityRenderer(context) } + } + + @SubscribeEvent + fun registerSpecialRenderers(event: RegisterSpecialModelRendererEvent) { + event.register(ConnectedTankItemRenderer.ID, ConnectedTankItemRenderer.Unbaked.CODEC) + } +}*/ +//?} diff --git a/src/client/kotlin/net/turtton/connectedtank/ConnectedTankDataGenerator.kt b/src/client/kotlin/net/turtton/connectedtank/ConnectedTankDataGenerator.kt index c5c2108..21f28f7 100644 --- a/src/client/kotlin/net/turtton/connectedtank/ConnectedTankDataGenerator.kt +++ b/src/client/kotlin/net/turtton/connectedtank/ConnectedTankDataGenerator.kt @@ -1,3 +1,4 @@ +//? if fabric { package net.turtton.connectedtank import com.google.gson.JsonArray @@ -638,3 +639,4 @@ object ConnectedTankDataGenerator : DataGeneratorEntrypoint { } } } +//?} diff --git a/src/client/kotlin/net/turtton/connectedtank/block/ConnectedTankBlockEntityRenderer.kt b/src/client/kotlin/net/turtton/connectedtank/block/ConnectedTankBlockEntityRenderer.kt index d606331..0dc16fd 100644 --- a/src/client/kotlin/net/turtton/connectedtank/block/ConnectedTankBlockEntityRenderer.kt +++ b/src/client/kotlin/net/turtton/connectedtank/block/ConnectedTankBlockEntityRenderer.kt @@ -1,9 +1,13 @@ package net.turtton.connectedtank.block import kotlin.math.max +//? if fabric { import net.fabricmc.fabric.api.transfer.v1.client.fluid.FluidVariantRendering +//?} //? if >=1.21.11 { -/*import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant +/*import net.turtton.connectedtank.fluid.PlatformFluidVariant +import net.turtton.connectedtank.fluid.isBlankVariant +import net.turtton.connectedtank.fluid.getVariantFluid import net.minecraft.client.renderer.blockentity.BlockEntityRenderer import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState @@ -12,6 +16,11 @@ import net.minecraft.client.renderer.SubmitNodeCollector //? if >=26.1 { import net.minecraft.client.renderer.state.level.CameraRenderState import net.minecraft.client.Minecraft +//?} else if neoforge { +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.state.CameraRenderState +import net.minecraft.client.renderer.texture.TextureAtlas +import net.neoforged.neoforge.client.extensions.common.IClientFluidTypeExtensions //?} else { import net.minecraft.client.renderer.state.CameraRenderState //?} @@ -52,18 +61,25 @@ class ConnectedTankBlockEntityRenderer( super.extractRenderState(entity, state, tickDelta, cameraPos, crumbling) state.localFillLevel = entity.localFillLevel - if (entity.localFillLevel <= 0f || entity.fluidVariant.isBlank) { + if (entity.localFillLevel <= 0f || entity.fluidVariant.isBlankVariant()) { state.sprite = null return } //? if >=26.1 { val fluidModels = Minecraft.getInstance().modelManager.fluidStateModelSet - state.sprite = fluidModels.get(entity.fluidVariant.fluid.defaultFluidState()).stillMaterial().sprite() + state.sprite = fluidModels.get(entity.fluidVariant.getVariantFluid().defaultFluidState()).stillMaterial().sprite() + //?} else if neoforge { + val extensions = IClientFluidTypeExtensions.of(entity.fluidVariant.getVariantFluid()) + state.sprite = Minecraft.getInstance().atlasManager.getAtlasOrThrow(TextureAtlas.LOCATION_BLOCKS).getSprite(extensions.getStillTexture()) //?} else { state.sprite = FluidVariantRendering.getSprite(entity.fluidVariant) //?} + //? if neoforge { + val color = IClientFluidTypeExtensions.of(entity.fluidVariant.getVariantFluid()).getTintColor() + //?} else { val color = FluidVariantRendering.getColor(entity.fluidVariant) + //?} state.argb = (0xFF shl 24) or (color and 0x00FFFFFF) val world = entity.level @@ -151,6 +167,7 @@ import net.minecraft.core.BlockPos import net.minecraft.world.phys.Vec3 import net.turtton.connectedtank.config.CTClientConfig import net.turtton.connectedtank.config.CTClientConfig.RenderQuality +import net.turtton.connectedtank.fluid.isBlankVariant import net.turtton.connectedtank.render.FluidRenderHelper import net.turtton.connectedtank.render.NeighborMask import net.turtton.connectedtank.render.WaveParams @@ -171,7 +188,7 @@ class ConnectedTankBlockEntityRenderer( overlay: Int, cameraPos: Vec3, ) { - if (entity.localFillLevel <= 0f || entity.fluidVariant.isBlank) return + if (entity.localFillLevel <= 0f || entity.fluidVariant.isBlankVariant()) return val sprite = FluidVariantRendering.getSprite(entity.fluidVariant) ?: return val color = FluidVariantRendering.getColor(entity.fluidVariant) diff --git a/src/client/kotlin/net/turtton/connectedtank/config/CTClientConfig.kt b/src/client/kotlin/net/turtton/connectedtank/config/CTClientConfig.kt index 5a6cfbc..38709fc 100644 --- a/src/client/kotlin/net/turtton/connectedtank/config/CTClientConfig.kt +++ b/src/client/kotlin/net/turtton/connectedtank/config/CTClientConfig.kt @@ -6,7 +6,11 @@ import com.google.gson.JsonParser import com.google.gson.stream.JsonReader import java.io.StringReader import java.nio.file.Files +//? if fabric { import net.fabricmc.loader.api.FabricLoader +//?} else { +/*import net.neoforged.fml.loading.FMLPaths*/ +//?} import net.turtton.connectedtank.ConnectedTank class CTClientConfig( @@ -30,7 +34,13 @@ class CTClientConfig( companion object { private val GSON: Gson = GsonBuilder().setPrettyPrinting().create() + + //? if fabric { private val CONFIG_DIR = FabricLoader.getInstance().configDir.resolve("connectedtank") + + //?} else { + /*private val CONFIG_DIR = FMLPaths.CONFIGDIR.get().resolve("connectedtank")*/ + //?} private val CONFIG_PATH = CONFIG_DIR.resolve("client.json") @Volatile diff --git a/src/client/kotlin/net/turtton/connectedtank/config/CTConfigScreen.kt b/src/client/kotlin/net/turtton/connectedtank/config/CTConfigScreen.kt index 9950a97..7983788 100644 --- a/src/client/kotlin/net/turtton/connectedtank/config/CTConfigScreen.kt +++ b/src/client/kotlin/net/turtton/connectedtank/config/CTConfigScreen.kt @@ -1,3 +1,4 @@ +//? if fabric { package net.turtton.connectedtank.config import com.terraformersmc.modmenu.api.ConfigScreenFactory @@ -120,3 +121,4 @@ private object CTConfigScreenBuilder { .generateScreen(parent) } } +//?} diff --git a/src/client/kotlin/net/turtton/connectedtank/item/ConnectedTankItemRenderer.kt b/src/client/kotlin/net/turtton/connectedtank/item/ConnectedTankItemRenderer.kt index 154323b..6c22cda 100644 --- a/src/client/kotlin/net/turtton/connectedtank/item/ConnectedTankItemRenderer.kt +++ b/src/client/kotlin/net/turtton/connectedtank/item/ConnectedTankItemRenderer.kt @@ -1,10 +1,14 @@ package net.turtton.connectedtank.item import com.mojang.serialization.MapCodec +//? if fabric { import net.fabricmc.fabric.api.transfer.v1.client.fluid.FluidVariantRendering -import net.fabricmc.fabric.api.transfer.v1.fluid.FluidConstants +//?} +import net.turtton.connectedtank.fluid.FLUID_BUCKET +import net.turtton.connectedtank.fluid.isBlankVariant //? if >=1.21.11 { -/*import java.util.function.Consumer +/*import net.turtton.connectedtank.fluid.getVariantFluid +import java.util.function.Consumer import net.minecraft.client.renderer.rendertype.RenderTypes import net.minecraft.client.renderer.SubmitNodeCollector import net.minecraft.client.renderer.special.SpecialModelRenderer @@ -27,6 +31,10 @@ import net.turtton.connectedtank.render.WaveParams import org.joml.Vector3fc //? if >=26.1 { import net.minecraft.client.Minecraft +//?} else if neoforge { +import net.minecraft.client.Minecraft +import net.minecraft.client.renderer.texture.TextureAtlas +import net.neoforged.neoforge.client.extensions.common.IClientFluidTypeExtensions //?} class ConnectedTankItemRenderer : SpecialModelRenderer { @@ -56,20 +64,27 @@ class ConnectedTankItemRenderer : SpecialModelRenderer { //?} data ?: return val fluidData = data.get(CTDataComponentTypes.TANK_FLUID) ?: return - if (fluidData.variant.isBlank || fluidData.amount <= 0L) return + if (fluidData.variant.isBlankVariant() || fluidData.amount <= 0L) return //? if >=26.1 { val fluidModels = Minecraft.getInstance().modelManager.fluidStateModelSet - val sprite = fluidModels.get(fluidData.variant.fluid.defaultFluidState()).stillMaterial().sprite() + val sprite = fluidModels.get(fluidData.variant.getVariantFluid().defaultFluidState()).stillMaterial().sprite() + //?} else if neoforge { + val extensions = IClientFluidTypeExtensions.of(fluidData.variant.getVariantFluid()) + val sprite = Minecraft.getInstance().atlasManager.getAtlasOrThrow(TextureAtlas.LOCATION_BLOCKS).getSprite(extensions.getStillTexture()) //?} else { val sprite = FluidVariantRendering.getSprite(fluidData.variant) ?: return //?} + //? if neoforge { + val color = IClientFluidTypeExtensions.of(fluidData.variant.getVariantFluid()).getTintColor() + //?} else { val color = FluidVariantRendering.getColor(fluidData.variant) + //?} val argb = (0xFF shl 24) or (color and 0x00FFFFFF) val tankBlock = (data.item as? BlockItem)?.block as? ConnectedTankBlock ?: return val serverConfig = SyncedServerConfig.syncedConfig ?: CTServerConfig.instance - val capacity = serverConfig.getTierCapacity(tankBlock.tier) * FluidConstants.BUCKET + val capacity = serverConfig.getTierCapacity(tankBlock.tier) * FLUID_BUCKET if (capacity <= 0L) return val fillLevel = (fluidData.amount.toFloat() / capacity.toFloat()).coerceIn(0f, 1f) @@ -127,7 +142,9 @@ class ConnectedTankItemRenderer : SpecialModelRenderer { val ID: Identifier = Identifier.fromNamespaceAndPath("connectedtank", "tank_fluid") fun register() { + //? if fabric { SpecialModelRenderers.ID_MAPPER.put(ID, Unbaked.CODEC) + //?} } } } @@ -171,7 +188,7 @@ class ConnectedTankItemRenderer : SpecialModelRenderer { ) { data ?: return val fluidData = data.get(CTDataComponentTypes.TANK_FLUID) ?: return - if (fluidData.variant.isBlank || fluidData.amount <= 0L) return + if (fluidData.variant.isBlankVariant() || fluidData.amount <= 0L) return val sprite = FluidVariantRendering.getSprite(fluidData.variant) ?: return val color = FluidVariantRendering.getColor(fluidData.variant) @@ -179,7 +196,7 @@ class ConnectedTankItemRenderer : SpecialModelRenderer { val tankBlock = (data.item as? BlockItem)?.block as? ConnectedTankBlock ?: return val serverConfig = SyncedServerConfig.syncedConfig ?: CTServerConfig.instance - val capacity = serverConfig.getTierCapacity(tankBlock.tier) * FluidConstants.BUCKET + val capacity = serverConfig.getTierCapacity(tankBlock.tier) * FLUID_BUCKET if (capacity <= 0L) return val fillLevel = (fluidData.amount.toFloat() / capacity.toFloat()).coerceIn(0f, 1f) diff --git a/src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTest.kt b/src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTest.kt index 45bc370..276a89b 100644 --- a/src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTest.kt +++ b/src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTest.kt @@ -1,9 +1,8 @@ package net.turtton.connectedtank.test +//? if fabric { import net.fabricmc.fabric.api.gametest.v1.GameTest -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.world.entity.EntityType import net.minecraft.world.entity.item.ItemEntity import net.minecraft.world.level.block.Blocks @@ -21,6 +20,10 @@ import net.turtton.connectedtank.block.TankFluidStorage import net.turtton.connectedtank.block.TankTier import net.turtton.connectedtank.component.CTDataComponentTypes import net.turtton.connectedtank.config.CTServerConfig +import net.turtton.connectedtank.fluid.FLUID_BUCKET +import net.turtton.connectedtank.fluid.fluidVariantOf +import net.turtton.connectedtank.fluid.insertFluid +import net.turtton.connectedtank.fluid.isSameFluid import net.turtton.connectedtank.item.CTItems import net.turtton.connectedtank.world.FluidStoragePersistentState @@ -41,10 +44,13 @@ object ConnectedTankGameTest { (CTBlocks.ALL_TANKS[CTItems.ALL_TANK_ITEMS.indexOf(it)] as? ConnectedTankBlock)?.tier == tier } val stack = ItemStack(item) + player.setItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND, stack) placeAt(player, stack, basePos.below(), Direction.UP) } + //? if fabric { @GameTest + //?} fun placeSingleTank(context: GameTestHelper) { val tankPos = BlockPos(0, 2, 0) context.placeTank(tankPos) @@ -60,7 +66,9 @@ object ConnectedTankGameTest { context.succeed() } + //? if fabric { @GameTest + //?} fun placeAdjacentTanksShareStorage(context: GameTestHelper) { val pos1 = BlockPos(0, 2, 0) val pos2 = BlockPos(1, 2, 0) @@ -83,7 +91,9 @@ object ConnectedTankGameTest { context.succeed() } + //? if fabric { @GameTest + //?} fun removeTankFromCombinedReducesCapacity(context: GameTestHelper) { val pos1 = BlockPos(0, 2, 0) val pos2 = BlockPos(1, 2, 0) @@ -105,7 +115,9 @@ object ConnectedTankGameTest { context.succeed() } + //? if fabric { @GameTest + //?} fun fluidInsertionPersists(context: GameTestHelper) { val tankPos = BlockPos(0, 2, 0) context.placeTank(tankPos) @@ -113,22 +125,17 @@ object ConnectedTankGameTest { val state = context.getFluidState() val storage = state.getStorage(context.absolutePos(tankPos))!! - val water = FluidVariant.of(Fluids.WATER) - Transaction.openOuter().use { transaction -> - val inserted = storage.insert(water, FluidConstants.BUCKET, transaction) - context.assertTrue( - inserted == FluidConstants.BUCKET, - Component.literal("Should insert exactly 1 bucket"), - ) - transaction.commit() - } + val water = fluidVariantOf(Fluids.WATER) + insertFluid(storage, water, FLUID_BUCKET) - context.assertTrue(storage.amount == FluidConstants.BUCKET, Component.literal("Storage should contain 1 bucket")) - context.assertTrue(storage.variant == water, Component.literal("Storage should contain water")) + context.assertTrue(storage.amount == FLUID_BUCKET, Component.literal("Storage should contain 1 bucket")) + context.assertTrue(storage.variant.isSameFluid(water), Component.literal("Storage should contain water")) context.succeed() } + //? if fabric { @GameTest + //?} fun disconnectedTanksHaveSeparateStorage(context: GameTestHelper) { val pos1 = BlockPos(0, 2, 0) val pos2 = BlockPos(2, 2, 0) // 1 block gap @@ -147,7 +154,9 @@ object ConnectedTankGameTest { context.succeed() } + //? if fabric { @GameTest + //?} fun removeAllTanksRemovesStorage(context: GameTestHelper) { val tankPos = BlockPos(0, 2, 0) context.placeTank(tankPos) @@ -161,7 +170,9 @@ object ConnectedTankGameTest { context.succeed() } + //? if fabric { @GameTest + //?} fun placesBetweenTwoGroupsMergesThem(context: GameTestHelper) { // [Group A] [gap] [Group B] → [Group A] [New Tank] [Group B] → 1 group val posA = BlockPos(0, 2, 0) @@ -190,7 +201,9 @@ object ConnectedTankGameTest { context.succeed() } + //? if fabric { @GameTest + //?} fun mergeGroupsPreservesFluidAmount(context: GameTestHelper) { val posA = BlockPos(0, 2, 0) val posB = BlockPos(2, 2, 0) @@ -198,32 +211,28 @@ object ConnectedTankGameTest { context.placeTank(posB) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) + val water = fluidVariantOf(Fluids.WATER) val storageA = state.getStorage(context.absolutePos(posA))!! - Transaction.openOuter().use { transaction -> - storageA.insert(water, FluidConstants.BUCKET * 2, transaction) - transaction.commit() - } + insertFluid(storageA, water, FLUID_BUCKET * 2) val storageB = state.getStorage(context.absolutePos(posB))!! - Transaction.openOuter().use { transaction -> - storageB.insert(water, FluidConstants.BUCKET * 3, transaction) - transaction.commit() - } + insertFluid(storageB, water, FLUID_BUCKET * 3) val posMid = BlockPos(1, 2, 0) context.placeTank(posMid) val merged = state.getStorage(context.absolutePos(posA))!! context.assertTrue( - merged.amount == FluidConstants.BUCKET * 5, - Component.literal("Merged amount should be 5 buckets but was ${merged.amount / FluidConstants.BUCKET}"), + merged.amount == FLUID_BUCKET * 5, + Component.literal("Merged amount should be 5 buckets but was ${merged.amount / FLUID_BUCKET}"), ) - context.assertTrue(merged.variant == water, Component.literal("Merged variant should be water")) + context.assertTrue(merged.variant.isSameFluid(water), Component.literal("Merged variant should be water")) context.succeed() } + //? if fabric { @GameTest + //?} fun incompatibleGroupsConnectToPriority(context: GameTestHelper) { // 水タンクと溶岩タンクの間に空タンクを置くと、座標優先度で水側に接続 val posA = BlockPos(0, 2, 0) @@ -231,18 +240,15 @@ object ConnectedTankGameTest { context.placeTank(posA) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) - val lava = FluidVariant.of(Fluids.LAVA) + val water = fluidVariantOf(Fluids.WATER) + val lava = fluidVariantOf(Fluids.LAVA) val storageA = state.getStorage(context.absolutePos(posA))!! - Transaction.openOuter().use { transaction -> - storageA.insert(water, FluidConstants.BUCKET, transaction) - transaction.commit() - } + insertFluid(storageA, water, FLUID_BUCKET) val lavaStorage = TankFluidStorage( CTServerConfig.DEFAULT_BUCKET_CAPACITY, - TankFluidStorage.ExistingData(lava, FluidConstants.BUCKET), + TankFluidStorage.ExistingData(lava, FLUID_BUCKET), ) state.addStorage(context.absolutePos(posB), lavaStorage) @@ -255,12 +261,14 @@ object ConnectedTankGameTest { // 座標優先度: posA(0,2,0) < posB(2,2,0) → 空タンクは水グループに接続 context.assertTrue(sA === sMid, Component.literal("Empty tank should connect to water group (higher priority)")) context.assertTrue(sB !== sMid, Component.literal("Lava group should remain separate")) - context.assertTrue(sA!!.variant == water, Component.literal("A should still have water")) - context.assertTrue(sB!!.variant == lava, Component.literal("B should still have lava")) + context.assertTrue(sA!!.variant.isSameFluid(water), Component.literal("A should still have water")) + context.assertTrue(sB!!.variant.isSameFluid(lava), Component.literal("B should still have lava")) context.succeed() } + //? if fabric { @GameTest + //?} fun differentFluidTanksDoNotMerge(context: GameTestHelper) { val pos1 = BlockPos(0, 2, 0) context.placeTank(pos1) @@ -268,25 +276,22 @@ object ConnectedTankGameTest { val state = context.getFluidState() val storage1 = state.getStorage(context.absolutePos(pos1))!! - val water = FluidVariant.of(Fluids.WATER) - Transaction.openOuter().use { transaction -> - storage1.insert(water, FluidConstants.BUCKET, transaction) - transaction.commit() - } + val water = fluidVariantOf(Fluids.WATER) + insertFluid(storage1, water, FLUID_BUCKET) val pos2 = BlockPos(1, 2, 0) val lavaStorage = TankFluidStorage( CTServerConfig.DEFAULT_BUCKET_CAPACITY, - TankFluidStorage.ExistingData(FluidVariant.of(Fluids.LAVA), FluidConstants.BUCKET), + TankFluidStorage.ExistingData(fluidVariantOf(Fluids.LAVA), FLUID_BUCKET), ) state.addStorage(context.absolutePos(pos2), lavaStorage) val s1 = state.getStorage(context.absolutePos(pos1)) val s2 = state.getStorage(context.absolutePos(pos2)) context.assertTrue(s1 !== s2, Component.literal("Tanks with different fluids should not merge")) - context.assertTrue(s1!!.variant == water, Component.literal("First tank should still have water")) + context.assertTrue(s1!!.variant.isSameFluid(water), Component.literal("First tank should still have water")) context.assertTrue( - s2!!.variant == FluidVariant.of(Fluids.LAVA), + s2!!.variant.isSameFluid(fluidVariantOf(Fluids.LAVA)), Component.literal("Second tank should have lava"), ) context.succeed() @@ -294,7 +299,9 @@ object ConnectedTankGameTest { // === 座標優先度・interactedAt テスト === + //? if fabric { @GameTest + //?} fun interactedAtConnectsToSpecifiedGroup(context: GameTestHelper) { // 水グループと溶岩グループの間で、interactedAt で溶岩側を指定 val posA = BlockPos(0, 2, 0) @@ -302,18 +309,15 @@ object ConnectedTankGameTest { context.placeTank(posA) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) - val lava = FluidVariant.of(Fluids.LAVA) + val water = fluidVariantOf(Fluids.WATER) + val lava = fluidVariantOf(Fluids.LAVA) val storageA = state.getStorage(context.absolutePos(posA))!! - Transaction.openOuter().use { transaction -> - storageA.insert(water, FluidConstants.BUCKET, transaction) - transaction.commit() - } + insertFluid(storageA, water, FLUID_BUCKET) val lavaStorage = TankFluidStorage( CTServerConfig.DEFAULT_BUCKET_CAPACITY, - TankFluidStorage.ExistingData(lava, FluidConstants.BUCKET), + TankFluidStorage.ExistingData(lava, FLUID_BUCKET), ) state.addStorage(context.absolutePos(posB), lavaStorage) @@ -327,12 +331,14 @@ object ConnectedTankGameTest { val sB = state.getStorage(context.absolutePos(posB)) context.assertTrue(sB === sMid, Component.literal("Middle should connect to lava group via interactedAt")) context.assertTrue(sA !== sMid, Component.literal("Water group should remain separate")) - context.assertTrue(sA!!.variant == water, Component.literal("A should still have water")) - context.assertTrue(sB!!.variant == lava, Component.literal("B+Mid should have lava")) + context.assertTrue(sA!!.variant.isSameFluid(water), Component.literal("A should still have water")) + context.assertTrue(sB!!.variant.isSameFluid(lava), Component.literal("B+Mid should have lava")) context.succeed() } + //? if fabric { @GameTest + //?} fun interactedAtDoesNotMergeOtherGroups(context: GameTestHelper) { // interactedAt 指定時、他の互換グループはマージしない val posA = BlockPos(0, 2, 0) @@ -358,7 +364,9 @@ object ConnectedTankGameTest { context.succeed() } + //? if fabric { @GameTest + //?} fun coordinatePrioritySelectsLowestCoordinate(context: GameTestHelper) { // Y が低い方が優先される val posBottom = BlockPos(1, 2, 0) @@ -367,19 +375,13 @@ object ConnectedTankGameTest { context.placeTank(posTop) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) - val lava = FluidVariant.of(Fluids.LAVA) + val water = fluidVariantOf(Fluids.WATER) + val lava = fluidVariantOf(Fluids.LAVA) val storageBottom = state.getStorage(context.absolutePos(posBottom))!! - Transaction.openOuter().use { tx -> - storageBottom.insert(water, FluidConstants.BUCKET, tx) - tx.commit() - } + insertFluid(storageBottom, water, FLUID_BUCKET) val storageTop = state.getStorage(context.absolutePos(posTop))!! - Transaction.openOuter().use { tx -> - storageTop.insert(lava, FluidConstants.BUCKET, tx) - tx.commit() - } + insertFluid(storageTop, lava, FLUID_BUCKET) // 中間に空タンクを設置 → Y 昇順で posBottom が優先 val posMid = BlockPos(1, 3, 0) @@ -395,7 +397,9 @@ object ConnectedTankGameTest { // === 分断検出テスト === + //? if fabric { @GameTest + //?} fun breakMiddleOfThreeSplitsIntoTwoGroups(context: GameTestHelper) { val posL = BlockPos(0, 2, 0) val posM = BlockPos(1, 2, 0) @@ -429,7 +433,9 @@ object ConnectedTankGameTest { context.succeed() } + //? if fabric { @GameTest + //?} fun breakCornerOfLShapeSplitsIntoTwo(context: GameTestHelper) { // L 字: (0,2,0) - (1,2,0) - (1,2,1) val posA = BlockPos(0, 2, 0) @@ -450,7 +456,9 @@ object ConnectedTankGameTest { context.succeed() } + //? if fabric { @GameTest + //?} fun breakOneFrom2x2KeepsGroupConnected(context: GameTestHelper) { // 2x2: (0,2,0) (1,2,0) (0,2,1) (1,2,1) → 1 つ破壊 → 残り 3 つは連結 val pos00 = BlockPos(0, 2, 0) @@ -480,7 +488,9 @@ object ConnectedTankGameTest { // === 液体均等分配テスト === + //? if fabric { @GameTest + //?} fun splitEvenFluidDistribution(context: GameTestHelper) { // 30 バケツ / 3 タンク → 破壊タンク 10, 残り各 10 val posL = BlockPos(0, 2, 0) @@ -491,35 +501,34 @@ object ConnectedTankGameTest { context.placeTank(posR) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) + val water = fluidVariantOf(Fluids.WATER) val storage = state.getStorage(context.absolutePos(posL))!! - Transaction.openOuter().use { tx -> - storage.insert(water, FluidConstants.BUCKET * 30, tx) - tx.commit() - } + insertFluid(storage, water, FLUID_BUCKET * 30) val removedData = state.removeStorage(context.absolutePos(posM), context.level) context.assertTrue(removedData != null, Component.literal("Removed data should not be null")) context.assertTrue( - removedData!!.amount == FluidConstants.BUCKET * 10, - Component.literal("Removed share should be 10 buckets but was ${removedData.amount / FluidConstants.BUCKET}"), + removedData!!.amount == FLUID_BUCKET * 10, + Component.literal("Removed share should be 10 buckets but was ${removedData.amount / FLUID_BUCKET}"), ) val sL = state.getStorage(context.absolutePos(posL)) val sR = state.getStorage(context.absolutePos(posR)) context.assertTrue( - sL!!.amount == FluidConstants.BUCKET * 10, - Component.literal("Left should have 10 buckets but was ${sL.amount / FluidConstants.BUCKET}"), + sL!!.amount == FLUID_BUCKET * 10, + Component.literal("Left should have 10 buckets but was ${sL.amount / FLUID_BUCKET}"), ) context.assertTrue( - sR!!.amount == FluidConstants.BUCKET * 10, - Component.literal("Right should have 10 buckets but was ${sR.amount / FluidConstants.BUCKET}"), + sR!!.amount == FLUID_BUCKET * 10, + Component.literal("Right should have 10 buckets but was ${sR.amount / FLUID_BUCKET}"), ) context.succeed() } + //? if fabric { @GameTest + //?} fun splitUnevenFluidDistribution(context: GameTestHelper) { // droplet 単位で端数が出るケース: (10 buckets + 2 droplets) / 3 tanks val posL = BlockPos(0, 2, 0) @@ -530,43 +539,44 @@ object ConnectedTankGameTest { context.placeTank(posR) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) - val totalAmount = FluidConstants.BUCKET * 10 + 2 // 810002 droplets + val water = fluidVariantOf(Fluids.WATER) + val totalAmount = FLUID_BUCKET * 10 + 2 val storage = state.getStorage(context.absolutePos(posL))!! - Transaction.openOuter().use { tx -> - storage.insert(water, totalAmount, tx) - tx.commit() - } + insertFluid(storage, water, totalAmount) val removedData = state.removeStorage(context.absolutePos(posM), context.level) // 位置ベース分配: 同一 Y レベル・同一ティアなので累積丸めで按分 - // 810002 * 2/3 = 540001 (cumulative for posM) - 270000 (posL) = 270001 - val expectedRemoved = 270001L + val expectedPosLCum = totalAmount * 1 / 3 + val expectedPosMCum = totalAmount * 2 / 3 + val expectedRemoved = expectedPosMCum - expectedPosLCum context.assertTrue(removedData != null, Component.literal("Removed data should not be null")) context.assertTrue( removedData!!.amount == expectedRemoved, Component.literal("Removed share should be $expectedRemoved but was ${removedData.amount}"), ) - // remaining = 810002 - 270001 = 540001, 2 tanks (同一 Y レベル) - // 累積丸め: posL = 270000, posR = 270001 (または逆) + val expectedRemaining = totalAmount - expectedRemoved val sL = state.getStorage(context.absolutePos(posL)) val sR = state.getStorage(context.absolutePos(posR)) val leftAmt = sL!!.amount val rightAmt = sR!!.amount context.assertTrue( - leftAmt + rightAmt == 540001L, - Component.literal("Total remaining should be 540001 but was ${leftAmt + rightAmt}"), + leftAmt + rightAmt == expectedRemaining, + Component.literal("Total remaining should be $expectedRemaining but was ${leftAmt + rightAmt}"), ) + val expectedMin = expectedRemaining / 2 + val expectedMax = expectedRemaining - expectedMin context.assertTrue( - (leftAmt == 270001L && rightAmt == 270000L) || (leftAmt == 270000L && rightAmt == 270001L), - Component.literal("Amounts should be 270001+270000 but were $leftAmt+$rightAmt"), + (leftAmt == expectedMax && rightAmt == expectedMin) || (leftAmt == expectedMin && rightAmt == expectedMax), + Component.literal("Amounts should be $expectedMax+$expectedMin but were $leftAmt+$rightAmt"), ) context.succeed() } + //? if fabric { @GameTest + //?} fun noSplitFluidReduction(context: GameTestHelper) { // 2 タンクから 1 つ破壊 (分断なし: 隣接なので分断にはならない) val pos1 = BlockPos(0, 2, 0) @@ -575,55 +585,53 @@ object ConnectedTankGameTest { context.placeTank(pos2) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) + val water = fluidVariantOf(Fluids.WATER) val storage = state.getStorage(context.absolutePos(pos1))!! - Transaction.openOuter().use { tx -> - storage.insert(water, FluidConstants.BUCKET * 20, tx) - tx.commit() - } + insertFluid(storage, water, FLUID_BUCKET * 20) val removedData = state.removeStorage(context.absolutePos(pos2), context.level) context.assertTrue(removedData != null, Component.literal("Removed data should not be null")) context.assertTrue( - removedData!!.amount == FluidConstants.BUCKET * 10, - Component.literal("Removed share should be 10 buckets but was ${removedData.amount / FluidConstants.BUCKET}"), + removedData!!.amount == FLUID_BUCKET * 10, + Component.literal("Removed share should be 10 buckets but was ${removedData.amount / FLUID_BUCKET}"), ) val remaining = state.getStorage(context.absolutePos(pos1)) context.assertTrue( - remaining!!.amount == FluidConstants.BUCKET * 10, - Component.literal("Remaining should have 10 buckets but was ${remaining.amount / FluidConstants.BUCKET}"), + remaining!!.amount == FLUID_BUCKET * 10, + Component.literal("Remaining should have 10 buckets but was ${remaining.amount / FLUID_BUCKET}"), ) context.succeed() } // === DataComponent テスト === + //? if fabric { @GameTest + //?} fun removeStorageReturnsFluidData(context: GameTestHelper) { val tankPos = BlockPos(0, 2, 0) context.placeTank(tankPos) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) + val water = fluidVariantOf(Fluids.WATER) val storage = state.getStorage(context.absolutePos(tankPos))!! - Transaction.openOuter().use { tx -> - storage.insert(water, FluidConstants.BUCKET * 5, tx) - tx.commit() - } + insertFluid(storage, water, FLUID_BUCKET * 5) val result = state.removeStorage(context.absolutePos(tankPos), context.level) context.assertTrue(result != null, Component.literal("Should return ExistingData")) - context.assertTrue(result!!.variant == water, Component.literal("Variant should be water")) + context.assertTrue(result!!.variant.isSameFluid(water), Component.literal("Variant should be water")) context.assertTrue( - result.amount == FluidConstants.BUCKET * 5, - Component.literal("Amount should be 5 buckets but was ${result.amount / FluidConstants.BUCKET}"), + result.amount == FLUID_BUCKET * 5, + Component.literal("Amount should be 5 buckets but was ${result.amount / FLUID_BUCKET}"), ) context.succeed() } + //? if fabric { @GameTest + //?} fun removeEmptyStorageReturnsNull(context: GameTestHelper) { val tankPos = BlockPos(0, 2, 0) context.placeTank(tankPos) @@ -634,11 +642,13 @@ object ConnectedTankGameTest { context.succeed() } + //? if fabric { @GameTest + //?} fun placeFluidTankRestoresStorage(context: GameTestHelper) { val tankPos = BlockPos(0, 2, 0) - val water = FluidVariant.of(Fluids.WATER) - val fluidData = TankFluidStorage.ExistingData(water, FluidConstants.BUCKET * 5) + val water = fluidVariantOf(Fluids.WATER) + val fluidData = TankFluidStorage.ExistingData(water, FLUID_BUCKET * 5) // DataComponent 付きタンクを直接 addStorage で追加 val state = context.getFluidState() @@ -647,41 +657,40 @@ object ConnectedTankGameTest { val restored = state.getStorage(context.absolutePos(tankPos)) context.assertTrue(restored != null, Component.literal("Restored storage should exist")) - context.assertTrue(restored!!.variant == water, Component.literal("Variant should be water")) + context.assertTrue(restored!!.variant.isSameFluid(water), Component.literal("Variant should be water")) context.assertTrue( - restored.amount == FluidConstants.BUCKET * 5, - Component.literal("Amount should be 5 buckets but was ${restored.amount / FluidConstants.BUCKET}"), + restored.amount == FLUID_BUCKET * 5, + Component.literal("Amount should be 5 buckets but was ${restored.amount / FLUID_BUCKET}"), ) context.succeed() } + //? if fabric { @GameTest + //?} fun placeFluidTankMergesWithAdjacent(context: GameTestHelper) { // 隣に水タンクがある状態で、水入りタンクを設置 → 液体量がマージされる val pos1 = BlockPos(0, 2, 0) context.placeTank(pos1) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) + val water = fluidVariantOf(Fluids.WATER) val storage1 = state.getStorage(context.absolutePos(pos1))!! - Transaction.openOuter().use { tx -> - storage1.insert(water, FluidConstants.BUCKET * 3, tx) - tx.commit() - } + insertFluid(storage1, water, FLUID_BUCKET * 3) // 水 2 バケツ入りタンクを隣に追加 val pos2 = BlockPos(1, 2, 0) - val fluidData = TankFluidStorage.ExistingData(water, FluidConstants.BUCKET * 2) + val fluidData = TankFluidStorage.ExistingData(water, FLUID_BUCKET * 2) val newTankStorage = TankFluidStorage(fluid = fluidData) state.addStorage(context.absolutePos(pos2), newTankStorage) val merged = state.getStorage(context.absolutePos(pos1)) context.assertTrue(merged != null, Component.literal("Merged storage should exist")) context.assertTrue( - merged!!.amount == FluidConstants.BUCKET * 5, - Component.literal("Merged amount should be 5 buckets but was ${merged.amount / FluidConstants.BUCKET}"), + merged!!.amount == FLUID_BUCKET * 5, + Component.literal("Merged amount should be 5 buckets but was ${merged.amount / FLUID_BUCKET}"), ) - context.assertTrue(merged.variant == water, Component.literal("Variant should be water")) + context.assertTrue(merged.variant.isSameFluid(water), Component.literal("Variant should be water")) context.succeed() } @@ -702,24 +711,23 @@ object ConnectedTankGameTest { return positions } + //? if fabric { @GameTest + //?} fun verticalStackBottomGetsMoreFluid(context: GameTestHelper) { // 3 段積み: 48 バケツ (50%) → 下=32, 中=16, 上=0 val (posBottom, posMid, posTop) = context.placeVerticalTanks(2, 3, 4) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) + val water = fluidVariantOf(Fluids.WATER) val storage = state.getStorage(context.absolutePos(posBottom))!! val bucketCap = CTServerConfig.DEFAULT_BUCKET_CAPACITY.toLong() - val totalAmount = bucketCap * FluidConstants.BUCKET / 2 * 3 // 50% of total capacity - Transaction.openOuter().use { tx -> - storage.insert(water, totalAmount, tx) - tx.commit() - } + val totalAmount = bucketCap * FLUID_BUCKET / 2 * 3 // 50% of total capacity + insertFluid(storage, water, totalAmount) // 中間タンクを破壊 → 位置ベースで分配 val removedData = state.removeStorage(context.absolutePos(posMid), context.level) - val expectedMid = (bucketCap / 2) * FluidConstants.BUCKET + val expectedMid = (bucketCap / 2) * FLUID_BUCKET context.assertTrue( removedData != null, Component.literal("Removed data should not be null"), @@ -731,7 +739,7 @@ object ConnectedTankGameTest { val sBottom = state.getStorage(context.absolutePos(posBottom)) val sTop = state.getStorage(context.absolutePos(posTop)) - val expectedBottom = bucketCap * FluidConstants.BUCKET + val expectedBottom = bucketCap * FLUID_BUCKET context.assertTrue( sBottom!!.amount == expectedBottom, Component.literal("Bottom should have $expectedBottom but was ${sBottom.amount}"), @@ -743,24 +751,23 @@ object ConnectedTankGameTest { context.succeed() } + //? if fabric { @GameTest + //?} fun verticalStackBreakBottomRedistributes(context: GameTestHelper) { // 3 段積み: 48 バケツ → 下を破壊 // 下=32, 中=16, 上=0 → 下の 32 バケツがドロップ, 残り 16 バケツは中と上に再分配 val (posBottom, posMid, posTop) = context.placeVerticalTanks(2, 3, 4) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) + val water = fluidVariantOf(Fluids.WATER) val storage = state.getStorage(context.absolutePos(posBottom))!! val bucketCap = CTServerConfig.DEFAULT_BUCKET_CAPACITY.toLong() - val totalAmount = bucketCap * FluidConstants.BUCKET / 2 * 3 - Transaction.openOuter().use { tx -> - storage.insert(water, totalAmount, tx) - tx.commit() - } + val totalAmount = bucketCap * FLUID_BUCKET / 2 * 3 + insertFluid(storage, water, totalAmount) val removedData = state.removeStorage(context.absolutePos(posBottom), context.level) - val expectedBottom = bucketCap * FluidConstants.BUCKET + val expectedBottom = bucketCap * FLUID_BUCKET context.assertTrue( removedData != null, Component.literal("Removed data should not be null"), @@ -777,7 +784,7 @@ object ConnectedTankGameTest { sMid === sTop, Component.literal("Mid and Top should share the same storage"), ) - val expectedRemaining = (bucketCap / 2) * FluidConstants.BUCKET + val expectedRemaining = (bucketCap / 2) * FLUID_BUCKET context.assertTrue( sMid!!.amount == expectedRemaining, Component.literal("Remaining group should have $expectedRemaining but was ${sMid.amount}"), @@ -785,20 +792,19 @@ object ConnectedTankGameTest { context.succeed() } + //? if fabric { @GameTest + //?} fun verticalStackEmptyTopGetsNothing(context: GameTestHelper) { // 2 段積み: 容量の 30% → 下のみに入り、上を破壊しても液体なし val (posBottom, posTop) = context.placeVerticalTanks(2, 3) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) + val water = fluidVariantOf(Fluids.WATER) val storage = state.getStorage(context.absolutePos(posBottom))!! val bucketCap = CTServerConfig.DEFAULT_BUCKET_CAPACITY.toLong() - val amount = bucketCap * FluidConstants.BUCKET * 30 / 100 // 30% of single tank - Transaction.openOuter().use { tx -> - storage.insert(water, amount, tx) - tx.commit() - } + val amount = bucketCap * FLUID_BUCKET * 30 / 100 // 30% of single tank + insertFluid(storage, water, amount) val removedData = state.removeStorage(context.absolutePos(posTop), context.level) context.assertTrue( @@ -816,7 +822,9 @@ object ConnectedTankGameTest { // === ティア別容量テスト === + //? if fabric { @GameTest + //?} fun tierCapacityMatchesMultiplier(context: GameTestHelper) { val tankPos = BlockPos(0, 2, 0) context.placeTank(tankPos, TankTier.IRON) @@ -832,7 +840,9 @@ object ConnectedTankGameTest { context.succeed() } + //? if fabric { @GameTest + //?} fun differentTiersConnect(context: GameTestHelper) { val pos1 = BlockPos(0, 2, 0) val pos2 = BlockPos(1, 2, 0) @@ -859,18 +869,17 @@ object ConnectedTankGameTest { // === getPickStack テスト === + //? if fabric { @GameTest + //?} fun pickStackWithIncludeDataContainsFluid(context: GameTestHelper) { val tankPos = BlockPos(0, 2, 0) context.placeTank(tankPos) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) + val water = fluidVariantOf(Fluids.WATER) val storage = state.getStorage(context.absolutePos(tankPos))!! - Transaction.openOuter().use { tx -> - storage.insert(water, FluidConstants.BUCKET * 5, tx) - tx.commit() - } + insertFluid(storage, water, FLUID_BUCKET * 5) val world = context.level val absPos = context.absolutePos(tankPos) @@ -880,26 +889,25 @@ object ConnectedTankGameTest { val fluidData = stack.get(CTDataComponentTypes.TANK_FLUID) context.assertTrue(fluidData != null, Component.literal("Pick stack should have fluid data")) - context.assertTrue(fluidData!!.variant == water, Component.literal("Variant should be water")) + context.assertTrue(fluidData!!.variant.isSameFluid(water), Component.literal("Variant should be water")) context.assertTrue( - fluidData.amount == FluidConstants.BUCKET * 5, - Component.literal("Should have 5 buckets but was ${fluidData.amount / FluidConstants.BUCKET}"), + fluidData.amount == FLUID_BUCKET * 5, + Component.literal("Should have 5 buckets but was ${fluidData.amount / FLUID_BUCKET}"), ) context.succeed() } + //? if fabric { @GameTest + //?} fun pickStackWithoutIncludeDataHasNoFluid(context: GameTestHelper) { val tankPos = BlockPos(0, 2, 0) context.placeTank(tankPos) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) + val water = fluidVariantOf(Fluids.WATER) val storage = state.getStorage(context.absolutePos(tankPos))!! - Transaction.openOuter().use { tx -> - storage.insert(water, FluidConstants.BUCKET * 5, tx) - tx.commit() - } + insertFluid(storage, water, FLUID_BUCKET * 5) val world = context.level val absPos = context.absolutePos(tankPos) @@ -912,7 +920,9 @@ object ConnectedTankGameTest { context.succeed() } + //? if fabric { @GameTest + //?} fun pickStackFromEmptyTankHasNoFluidData(context: GameTestHelper) { val tankPos = BlockPos(0, 2, 0) context.placeTank(tankPos) @@ -928,7 +938,9 @@ object ConnectedTankGameTest { context.succeed() } + //? if fabric { @GameTest + //?} fun pickStackFromConnectedTanksCalculatesShare(context: GameTestHelper) { val pos1 = BlockPos(0, 2, 0) val pos2 = BlockPos(1, 2, 0) @@ -938,12 +950,9 @@ object ConnectedTankGameTest { context.placeTank(pos3) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) + val water = fluidVariantOf(Fluids.WATER) val storage = state.getStorage(context.absolutePos(pos1))!! - Transaction.openOuter().use { tx -> - storage.insert(water, FluidConstants.BUCKET * 30, tx) - tx.commit() - } + insertFluid(storage, water, FLUID_BUCKET * 30) val world = context.level val absPos2 = context.absolutePos(pos2) @@ -953,15 +962,17 @@ object ConnectedTankGameTest { val fluidData = stack.get(CTDataComponentTypes.TANK_FLUID) context.assertTrue(fluidData != null, Component.literal("Pick stack should have fluid data")) - context.assertTrue(fluidData!!.variant == water, Component.literal("Variant should be water")) + context.assertTrue(fluidData!!.variant.isSameFluid(water), Component.literal("Variant should be water")) context.assertTrue( - fluidData.amount == FluidConstants.BUCKET * 10, - Component.literal("Share should be 10 buckets but was ${fluidData.amount / FluidConstants.BUCKET}"), + fluidData.amount == FLUID_BUCKET * 10, + Component.literal("Share should be 10 buckets but was ${fluidData.amount / FLUID_BUCKET}"), ) context.succeed() } + //? if fabric { @GameTest + //?} fun splitDifferentTiersRecalculatesCapacity(context: GameTestHelper) { // BASE - IRON - BASE → IRON を破壊 → BASE 2 つに分断 val posL = BlockPos(0, 2, 0) @@ -1004,18 +1015,17 @@ object ConnectedTankGameTest { return null } + //? if fabric { @GameTest + //?} fun breakSingleTankRetainsFluidInDrop(context: GameTestHelper) { val tankPos = BlockPos(0, 2, 0) context.placeTank(tankPos) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) + val water = fluidVariantOf(Fluids.WATER) val storage = state.getStorage(context.absolutePos(tankPos))!! - Transaction.openOuter().use { tx -> - storage.insert(water, FluidConstants.BUCKET * 5, tx) - tx.commit() - } + insertFluid(storage, water, FLUID_BUCKET * 5) // ブロック破壊 (ドロップ生成あり) context.level.destroyBlock(context.absolutePos(tankPos), true) @@ -1026,26 +1036,25 @@ object ConnectedTankGameTest { val fluidData = droppedStack!!.get(CTDataComponentTypes.TANK_FLUID) context.assertTrue(fluidData != null, Component.literal("Dropped item should have fluid data")) - context.assertTrue(fluidData!!.variant == water, Component.literal("Fluid variant should be water")) + context.assertTrue(fluidData!!.variant.isSameFluid(water), Component.literal("Fluid variant should be water")) context.assertTrue( - fluidData.amount == FluidConstants.BUCKET * 5, - Component.literal("Fluid amount should be 5 buckets but was ${fluidData.amount / FluidConstants.BUCKET}"), + fluidData.amount == FLUID_BUCKET * 5, + Component.literal("Fluid amount should be 5 buckets but was ${fluidData.amount / FLUID_BUCKET}"), ) context.succeed() } + //? if fabric { @GameTest + //?} fun breakTankAndReplaceRestoresFluid(context: GameTestHelper) { val tankPos = BlockPos(0, 2, 0) context.placeTank(tankPos) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) + val water = fluidVariantOf(Fluids.WATER) val storage = state.getStorage(context.absolutePos(tankPos))!! - Transaction.openOuter().use { tx -> - storage.insert(water, FluidConstants.BUCKET * 7, tx) - tx.commit() - } + insertFluid(storage, water, FLUID_BUCKET * 7) // ブロック破壊 context.level.destroyBlock(context.absolutePos(tankPos), true) @@ -1070,15 +1079,17 @@ object ConnectedTankGameTest { // 液体が復元されたことを確認 val restored = state.getStorage(context.absolutePos(tankPos)) context.assertTrue(restored != null, Component.literal("Restored storage should exist")) - context.assertTrue(restored!!.variant == water, Component.literal("Restored variant should be water")) + context.assertTrue(restored!!.variant.isSameFluid(water), Component.literal("Restored variant should be water")) context.assertTrue( - restored.amount == FluidConstants.BUCKET * 7, - Component.literal("Restored amount should be 7 buckets but was ${restored.amount / FluidConstants.BUCKET}"), + restored.amount == FLUID_BUCKET * 7, + Component.literal("Restored amount should be 7 buckets but was ${restored.amount / FLUID_BUCKET}"), ) context.succeed() } + //? if fabric { @GameTest + //?} fun breakMiddleTankRetainsShareInDrop(context: GameTestHelper) { // 3 連結タンク (各 32 バケツ容量) に 30 バケツ注入 → 中央を破壊 → 中央のシェア (10 バケツ) がドロップ val posL = BlockPos(0, 2, 0) @@ -1089,12 +1100,9 @@ object ConnectedTankGameTest { context.placeTank(posR) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) + val water = fluidVariantOf(Fluids.WATER) val storage = state.getStorage(context.absolutePos(posL))!! - Transaction.openOuter().use { tx -> - storage.insert(water, FluidConstants.BUCKET * 30, tx) - tx.commit() - } + insertFluid(storage, water, FLUID_BUCKET * 30) // 中央タンクを破壊 context.level.destroyBlock(context.absolutePos(posM), true) @@ -1106,8 +1114,8 @@ object ConnectedTankGameTest { val fluidData = droppedStack!!.get(CTDataComponentTypes.TANK_FLUID) context.assertTrue(fluidData != null, Component.literal("Dropped item should have fluid data")) context.assertTrue( - fluidData!!.amount == FluidConstants.BUCKET * 10, - Component.literal("Middle share should be 10 buckets but was ${fluidData.amount / FluidConstants.BUCKET}"), + fluidData!!.amount == FLUID_BUCKET * 10, + Component.literal("Middle share should be 10 buckets but was ${fluidData.amount / FLUID_BUCKET}"), ) // 残りのタンクは分断されてそれぞれ 10 バケツ @@ -1115,28 +1123,27 @@ object ConnectedTankGameTest { val sR = state.getStorage(context.absolutePos(posR)) context.assertTrue(sL !== sR, Component.literal("Left and right should be separate groups")) context.assertTrue( - sL!!.amount == FluidConstants.BUCKET * 10, - Component.literal("Left should have 10 buckets but was ${sL.amount / FluidConstants.BUCKET}"), + sL!!.amount == FLUID_BUCKET * 10, + Component.literal("Left should have 10 buckets but was ${sL.amount / FLUID_BUCKET}"), ) context.assertTrue( - sR!!.amount == FluidConstants.BUCKET * 10, - Component.literal("Right should have 10 buckets but was ${sR.amount / FluidConstants.BUCKET}"), + sR!!.amount == FLUID_BUCKET * 10, + Component.literal("Right should have 10 buckets but was ${sR.amount / FLUID_BUCKET}"), ) context.succeed() } + //? if fabric { @GameTest + //?} fun destroyBlockDoesNotLeakStaleFluidData(context: GameTestHelper) { val tankPos = BlockPos(0, 2, 0) context.placeTank(tankPos) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) + val water = fluidVariantOf(Fluids.WATER) val storage = state.getStorage(context.absolutePos(tankPos))!! - Transaction.openOuter().use { tx -> - storage.insert(water, FluidConstants.BUCKET * 5, tx) - tx.commit() - } + insertFluid(storage, water, FLUID_BUCKET * 5) context.level.destroyBlock(context.absolutePos(tankPos), true) @@ -1158,18 +1165,17 @@ object ConnectedTankGameTest { context.succeed() } + //? if fabric { @GameTest + //?} fun survivalMiningRetainsFluid(context: GameTestHelper) { val tankPos = BlockPos(0, 2, 0) context.placeTank(tankPos) val state = context.getFluidState() - val water = FluidVariant.of(Fluids.WATER) + val water = fluidVariantOf(Fluids.WATER) val storage = state.getStorage(context.absolutePos(tankPos))!! - Transaction.openOuter().use { tx -> - storage.insert(water, FluidConstants.BUCKET * 5, tx) - tx.commit() - } + insertFluid(storage, water, FLUID_BUCKET * 5) val player = context.makeMockServerPlayerInLevel() player.gameMode.changeGameModeForPlayer(GameType.SURVIVAL) @@ -1180,10 +1186,10 @@ object ConnectedTankGameTest { val fluidData = droppedStack!!.get(CTDataComponentTypes.TANK_FLUID) context.assertTrue(fluidData != null, Component.literal("Survival mining should retain fluid data")) - context.assertTrue(fluidData!!.variant == water, Component.literal("Fluid variant should be water")) + context.assertTrue(fluidData!!.variant.isSameFluid(water), Component.literal("Fluid variant should be water")) context.assertTrue( - fluidData.amount == FluidConstants.BUCKET * 5, - Component.literal("Fluid amount should be 5 buckets but was ${fluidData.amount / FluidConstants.BUCKET}"), + fluidData.amount == FLUID_BUCKET * 5, + Component.literal("Fluid amount should be 5 buckets but was ${fluidData.amount / FLUID_BUCKET}"), ) context.succeed() } diff --git a/src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTestRegistration.kt b/src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTestRegistration.kt new file mode 100644 index 0000000..8cd1a44 --- /dev/null +++ b/src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTestRegistration.kt @@ -0,0 +1,117 @@ +package net.turtton.connectedtank.test + +//? if neoforge { +/*import com.mojang.serialization.MapCodec +import com.mojang.serialization.codecs.RecordCodecBuilder +import net.minecraft.core.Holder +import net.minecraft.core.Registry +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.gametest.framework.GameTestInstance +import net.minecraft.gametest.framework.TestData +import net.minecraft.gametest.framework.TestEnvironmentDefinition +import net.minecraft.network.chat.Component +import net.minecraft.network.chat.MutableComponent +import net.minecraft.resources.Identifier +import net.minecraft.world.level.block.Rotation +import net.neoforged.bus.api.SubscribeEvent +import net.neoforged.fml.common.EventBusSubscriber +import net.neoforged.neoforge.event.RegisterGameTestsEvent +import java.lang.reflect.Method +import java.util.function.Consumer + +class KotlinGameTestInstance : GameTestInstance { + private val invoker: Consumer + + constructor(invoker: Consumer, info: TestData>) : super(info) { + this.invoker = invoker + } + + constructor(info: TestData>) : super(info) { + this.invoker = NOOP_INVOKER + } + + fun infoData(): TestData> = info() + + override fun run(helper: GameTestHelper) { + invoker.accept(helper) + } + + override fun codec(): MapCodec = CODEC + + override fun typeDescription(): MutableComponent = Component.literal("Kotlin Test") + + companion object { + private val NOOP_INVOKER = Consumer { it.succeed() } + + val CODEC: MapCodec = RecordCodecBuilder.mapCodec { instance -> + instance.group(TestData.CODEC.fieldOf("info").forGetter(KotlinGameTestInstance::infoData)) + .apply(instance, ::KotlinGameTestInstance) + } + } +} + +@EventBusSubscriber(modid = "connectedtank") +object ConnectedTankGameTestRegistration { + private const val MOD_ID = "connectedtank" + private const val EMPTY_STRUCTURE = "$MOD_ID:empty" + private const val MAX_TICKS = 200 + + @SubscribeEvent + fun onRegister(event: net.neoforged.neoforge.registries.RegisterEvent) { + if (event.registryKey == net.minecraft.core.registries.Registries.TEST_INSTANCE_TYPE) { + Registry.register( + BuiltInRegistries.TEST_INSTANCE_TYPE, + Identifier.parse("$MOD_ID:kotlin_test"), + KotlinGameTestInstance.CODEC, + ) + } + } + + @SubscribeEvent + fun onRegisterTests(event: RegisterGameTestsEvent) { + val environment = event.registerEnvironment( + Identifier.parse("$MOD_ID:default"), + TestEnvironmentDefinition.AllOf(emptyList()), + ) + val testMethods = ConnectedTankGameTest::class.java.declaredMethods + .filter { it.parameterCount == 1 && it.parameterTypes[0] == GameTestHelper::class.java } + .filter { it.returnType == Void.TYPE } + .sortedBy { it.name } + for (method in testMethods) { + val name = methodToTestName(method) + val testData = TestData( + environment, + Identifier.parse(EMPTY_STRUCTURE), + MAX_TICKS, + 0, + true, + Rotation.NONE, + ) + val instance = KotlinGameTestInstance(invokerFor(method), testData) + event.registerTest(Identifier.parse(name), instance) + } + } + + private fun methodToTestName(method: Method): String { + val snake = method.name.fold(StringBuilder()) { sb, c -> + if (c.isUpperCase() && sb.isNotEmpty()) sb.append('_').append(c.lowercaseChar()) else sb.append(c.lowercaseChar()) + }.toString() + return "$MOD_ID:$snake" + } + + private fun invokerFor(method: Method): Consumer { + method.isAccessible = true + return Consumer { helper -> + try { + method.invoke(ConnectedTankGameTest, helper) + } catch (e: java.lang.reflect.InvocationTargetException) { + val cause = e.cause ?: e + org.slf4j.LoggerFactory.getLogger("ConnectedTankGameTest") + .error("Test '${method.name}' threw exception", cause) + throw cause + } + } + } +}*/ +//?} diff --git a/src/gametest/resources/data/connectedtank/structure/empty.nbt b/src/gametest/resources/data/connectedtank/structure/empty.nbt new file mode 100644 index 0000000000000000000000000000000000000000..59b6c9aab89868c99b56747f78f9378925449cb0 GIT binary patch literal 101 zcmV-r0Gj_FiwFpXq5WwB|7C4(ba^grVsrrGVqjoq;C4wYNeoLZD$dN$V_*=B CTDataComponentTypes.init() + net.minecraft.core.registries.Registries.BLOCK -> CTBlocks.init() + net.minecraft.core.registries.Registries.BLOCK_ENTITY_TYPE -> CTBlockEntityTypes.init() + net.minecraft.core.registries.Registries.ITEM -> run { CTItems.ALL_TANK_ITEMS } + net.minecraft.core.registries.Registries.CREATIVE_MODE_TAB -> CTItems.init() + net.minecraft.core.registries.Registries.RECIPE_SERIALIZER -> CTRecipeSerializers.init() + } + }*/ + //?} } diff --git a/src/main/kotlin/net/turtton/connectedtank/block/CTBlockEntityTypes.kt b/src/main/kotlin/net/turtton/connectedtank/block/CTBlockEntityTypes.kt index 442a3af..12008ee 100644 --- a/src/main/kotlin/net/turtton/connectedtank/block/CTBlockEntityTypes.kt +++ b/src/main/kotlin/net/turtton/connectedtank/block/CTBlockEntityTypes.kt @@ -1,6 +1,8 @@ package net.turtton.connectedtank.block +//? if fabric { import net.fabricmc.fabric.api.`object`.builder.v1.block.entity.FabricBlockEntityTypeBuilder +//?} import net.minecraft.world.level.block.entity.BlockEntityType import net.minecraft.core.registries.BuiltInRegistries import net.minecraft.core.Registry @@ -11,7 +13,11 @@ object CTBlockEntityTypes { Registry.register( BuiltInRegistries.BLOCK_ENTITY_TYPE, ModIdentifier("connected_tank"), + //? if fabric { FabricBlockEntityTypeBuilder.create(::ConnectedTankBlockEntity, *CTBlocks.ALL_TANKS.toTypedArray()).build(), + //?} else if neoforge { + /*BlockEntityType(::ConnectedTankBlockEntity, *CTBlocks.ALL_TANKS.toTypedArray())*/ + //?} ) fun init() {} diff --git a/src/main/kotlin/net/turtton/connectedtank/block/CTBlocks.kt b/src/main/kotlin/net/turtton/connectedtank/block/CTBlocks.kt index f33cd5e..3078e47 100644 --- a/src/main/kotlin/net/turtton/connectedtank/block/CTBlocks.kt +++ b/src/main/kotlin/net/turtton/connectedtank/block/CTBlocks.kt @@ -1,6 +1,8 @@ package net.turtton.connectedtank.block +//? if fabric { import net.fabricmc.fabric.api.transfer.v1.fluid.FluidStorage +//?} import net.minecraft.world.level.block.state.BlockBehaviour import net.minecraft.world.level.block.Block import net.minecraft.core.registries.BuiltInRegistries @@ -51,6 +53,7 @@ object CTBlocks { } fun init() { + //? if fabric { FluidStorage.SIDED.registerForBlocks({ world, pos, _, _, _ -> val serverWorld = world as? ServerLevel ?: return@registerForBlocks null val state = serverWorld.dataStorage.computeIfAbsent(FluidStoragePersistentState.TYPE) @@ -65,8 +68,34 @@ object CTBlocks { } storage }, *ALL_TANKS.toTypedArray()) + //?} } + //? if neoforge { + /*@net.neoforged.bus.api.SubscribeEvent + @JvmStatic + fun registerCapabilities(event: net.neoforged.neoforge.capabilities.RegisterCapabilitiesEvent) { + event.registerBlock( + net.neoforged.neoforge.capabilities.Capabilities.Fluid.BLOCK, + { world, pos, state, _, _ -> + val serverWorld = world as? ServerLevel ?: return@registerBlock null + val persistentState = serverWorld.dataStorage.computeIfAbsent(FluidStoragePersistentState.TYPE) + val storage = persistentState.getStorage(pos) ?: run { + val block = state.block as? ConnectedTankBlock + val cap = block?.tier?.bucketCapacity ?: CTServerConfig.instance.tankBucketCapacity + TankFluidStorage(cap).also { persistentState.addStorage(pos, it) } + } + storage.onChanged = { + persistentState.setDirty() + syncGroupBlockEntities(serverWorld, pos, persistentState) + } + storage + }, + *ALL_TANKS.toTypedArray(), + ) + }*/ + //?} + fun syncGroupBlockEntities( world: ServerLevel, pos: BlockPos, diff --git a/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankBlock.kt b/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankBlock.kt index 734dd19..e47d824 100644 --- a/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankBlock.kt +++ b/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankBlock.kt @@ -1,10 +1,15 @@ package net.turtton.connectedtank.block import java.util.concurrent.ConcurrentHashMap -import net.fabricmc.fabric.api.transfer.v1.fluid.FluidConstants +//? if fabric { import net.fabricmc.fabric.api.transfer.v1.fluid.FluidStorageUtil import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariantAttributes import net.fabricmc.loader.api.FabricLoader +//?} else if neoforge { +/*import net.neoforged.fml.loading.FMLLoader +import net.neoforged.neoforge.capabilities.Capabilities +import net.neoforged.neoforge.transfer.fluid.FluidUtil*/ +//?} import net.minecraft.world.level.block.Block import net.minecraft.world.level.block.EntityBlock import net.minecraft.world.level.block.state.BlockState @@ -31,6 +36,7 @@ import net.minecraft.world.level.LevelReader import net.minecraft.world.level.ScheduledTickAccess import net.turtton.connectedtank.component.CTDataComponentTypes import net.turtton.connectedtank.config.CTServerConfig +import net.turtton.connectedtank.fluid.FLUID_BUCKET import net.turtton.connectedtank.world.FluidStoragePersistentState class ConnectedTankBlock(val tier: TankTier, settings: Properties) : @@ -185,7 +191,11 @@ class ConnectedTankBlock(val tier: TankTier, settings: Properties) : } override fun useWithoutItem(state: BlockState, world: Level, pos: BlockPos, player: Player, hit: BlockHitResult): InteractionResult { + //? if fabric { if (!FabricLoader.getInstance().isDevelopmentEnvironment) return InteractionResult.PASS + //?} else if neoforge { + /*if (FMLLoader.getCurrent().isProduction) return InteractionResult.PASS*/ + //?} if (world !is ServerLevel) return InteractionResult.SUCCESS val storage = world.dataStorage.computeIfAbsent(FluidStoragePersistentState.TYPE) @@ -199,8 +209,16 @@ class ConnectedTankBlock(val tier: TankTier, settings: Properties) : return InteractionResult.SUCCESS } - val fluidName = if (tankStorage.isResourceBlank) "Empty" else FluidVariantAttributes.getName(tankStorage.variant).string - val buckets = tankStorage.amount.toDouble() / FluidConstants.BUCKET + val fluidName = if (tankStorage.isResourceBlank) { + "Empty" + } else { + //? if fabric { + FluidVariantAttributes.getName(tankStorage.variant).string + //?} else if neoforge { + /*tankStorage.variant.hoverName.string*/ + //?} + } + val buckets = tankStorage.amount.toDouble() / FLUID_BUCKET val capacity = tankStorage.bucketCapacity //? if >=26.1 { /*player.sendOverlayMessage(Component.literal("$fluidName: %.2f / %d buckets".format(buckets, capacity)))*/ @@ -215,6 +233,7 @@ class ConnectedTankBlock(val tier: TankTier, settings: Properties) : val persistentState = world.dataStorage.computeIfAbsent(FluidStoragePersistentState.TYPE) val tankStorage = persistentState.getStorage(pos) ?: return InteractionResult.TRY_WITH_EMPTY_HAND + //? if fabric { val result = FluidStorageUtil.interactWithFluidStorage(tankStorage, player, hand) return if (result) { CTBlocks.syncGroupBlockEntities(world, pos, persistentState) @@ -222,5 +241,15 @@ class ConnectedTankBlock(val tier: TankTier, settings: Properties) : } else { InteractionResult.TRY_WITH_EMPTY_HAND } + //?} else if neoforge { + /*val handler = world.getCapability(Capabilities.Fluid.BLOCK, pos, hit.direction) ?: tankStorage + val actionResult = FluidUtil.interactWithFluidHandler(player, hand, pos, handler) + return if (actionResult) { + CTBlocks.syncGroupBlockEntities(world, pos, persistentState) + InteractionResult.SUCCESS + } else { + InteractionResult.TRY_WITH_EMPTY_HAND + }*/ + //?} } } diff --git a/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankBlockEntity.kt b/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankBlockEntity.kt index e253c69..2491a3b 100644 --- a/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankBlockEntity.kt +++ b/src/main/kotlin/net/turtton/connectedtank/block/ConnectedTankBlockEntity.kt @@ -1,8 +1,11 @@ package net.turtton.connectedtank.block import java.util.UUID -import net.fabricmc.fabric.api.transfer.v1.fluid.FluidConstants +//? if fabric { import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant +//?} else if neoforge { +/*import net.neoforged.neoforge.fluids.FluidStack*/ +//?} import net.minecraft.world.level.block.state.BlockState import net.minecraft.world.level.block.entity.BlockEntity import net.minecraft.nbt.CompoundTag @@ -15,13 +18,16 @@ import net.minecraft.world.level.storage.ValueOutput import net.minecraft.core.UUIDUtil import net.minecraft.core.BlockPos import net.turtton.connectedtank.config.CTServerConfig +import net.turtton.connectedtank.fluid.FLUID_BUCKET +import net.turtton.connectedtank.fluid.PlatformFluidVariant +import net.turtton.connectedtank.fluid.blankFluidVariant import org.joml.Math.clamp class ConnectedTankBlockEntity( pos: BlockPos, state: BlockState, ) : BlockEntity(CTBlockEntityTypes.CONNECTED_TANK, pos, state) { - var fluidVariant: FluidVariant = FluidVariant.blank() + var fluidVariant: PlatformFluidVariant = blankFluidVariant() private set var amount: Long = 0L private set @@ -45,11 +51,11 @@ class ConnectedTankBlockEntity( val amountChanged = amount != storage.amount fluidVariant = storage.variant amount = storage.amount - capacity = storage.bucketCapacity.toLong() * FluidConstants.BUCKET + capacity = storage.bucketCapacity.toLong() * FLUID_BUCKET groupId = newGroupId ?: groupId val posCapacity = (level?.getBlockState(worldPosition)?.block as? ConnectedTankBlock)?.tier?.bucketCapacity ?: CTServerConfig.instance.tankBucketCapacity - val posCapacityDroplets = posCapacity.toLong() * FluidConstants.BUCKET + val posCapacityDroplets = posCapacity.toLong() * FLUID_BUCKET localFillLevel = if (posCapacityDroplets > 0) clamp(0f, 1f, localShare.toFloat() / posCapacityDroplets) else 0f if (variantChanged || amountChanged) { waveStartTick = level?.gameTime ?: 0L @@ -62,7 +68,11 @@ class ConnectedTankBlockEntity( } override fun loadAdditional(view: ValueInput) { + //? if fabric { fluidVariant = view.read("variant", FluidVariant.CODEC).orElse(FluidVariant.blank()) + //?} else if neoforge { + /*fluidVariant = view.read("variant", FluidStack.CODEC).orElse(FluidStack.EMPTY)*/ + //?} amount = view.getLongOr("amount", 0L) capacity = view.getLongOr("capacity", 0L) waveStartTick = view.getLongOr("waveStartTick", 0L) @@ -71,7 +81,11 @@ class ConnectedTankBlockEntity( } override fun saveAdditional(view: ValueOutput) { + //? if fabric { view.store("variant", FluidVariant.CODEC, fluidVariant) + //?} else if neoforge { + /*view.store("variant", FluidStack.CODEC, fluidVariant)*/ + //?} view.putLong("amount", amount) view.putLong("capacity", capacity) view.putLong("waveStartTick", waveStartTick) diff --git a/src/main/kotlin/net/turtton/connectedtank/block/TankFluidStorage.kt b/src/main/kotlin/net/turtton/connectedtank/block/TankFluidStorage.kt index 7b7efc5..63ff370 100644 --- a/src/main/kotlin/net/turtton/connectedtank/block/TankFluidStorage.kt +++ b/src/main/kotlin/net/turtton/connectedtank/block/TankFluidStorage.kt @@ -4,11 +4,20 @@ import com.mojang.serialization.Codec import com.mojang.serialization.codecs.RecordCodecBuilder import java.util.Optional import kotlin.jvm.optionals.getOrNull +//? if fabric { 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.storage.base.SingleVariantStorage +//?} else if neoforge { +/*import net.neoforged.neoforge.fluids.FluidStack +import net.neoforged.neoforge.fluids.FluidType +import net.neoforged.neoforge.transfer.fluid.FluidResource +import net.neoforged.neoforge.transfer.fluid.FluidStacksResourceHandler*/ +//?} import net.turtton.connectedtank.config.CTServerConfig +import net.turtton.connectedtank.fluid.PlatformFluidVariant +//? if fabric { class TankFluidStorage(val bucketCapacity: Int = CTServerConfig.instance.tankBucketCapacity, fluid: ExistingData? = null) : SingleVariantStorage() { constructor(bucketCapacity: Int, fluid: Optional) : this(bucketCapacity, fluid.getOrNull()) @@ -30,7 +39,7 @@ class TankFluidStorage(val bucketCapacity: Int = CTServerConfig.instance.tankBuc onChanged?.invoke() } - data class ExistingData(val variant: FluidVariant, val amount: Long) { + data class ExistingData(val variant: PlatformFluidVariant, val amount: Long) { companion object { val CODEC: Codec = RecordCodecBuilder.create { it.group( @@ -52,3 +61,49 @@ class TankFluidStorage(val bucketCapacity: Int = CTServerConfig.instance.tankBuc } } } +//?} else if neoforge { +/*class TankFluidStorage(val bucketCapacity: Int = CTServerConfig.instance.tankBucketCapacity, fluid: ExistingData? = null) : FluidStacksResourceHandler(1, bucketCapacity * FluidType.BUCKET_VOLUME) { + constructor(bucketCapacity: Int, fluid: Optional) : this(bucketCapacity, fluid.getOrNull()) + + var onChanged: (() -> Unit)? = null + + val fluidStack: FluidStack get() = stacks[0] + val variant: PlatformFluidVariant get() = fluidStack + val amount: Long get() = fluidStack.amount.toLong() + val isResourceBlank: Boolean get() = fluidStack.isEmpty + + init { + if (fluid != null) { + stacks[0] = fluid.variant.copyWithAmount(fluid.amount.toInt()) + } + } + + override fun onContentsChanged(index: Int, previousContents: FluidStack) { + onChanged?.invoke() + } + + data class ExistingData(val variant: PlatformFluidVariant, val amount: Long) { + companion object { + val CODEC: Codec = RecordCodecBuilder.create { + it.group( + FluidStack.CODEC.fieldOf("variant").forGetter(ExistingData::variant), + Codec.LONG.fieldOf("amount").forGetter(ExistingData::amount), + ).apply(it, ::ExistingData) + } + + fun optional(storage: TankFluidStorage): Optional = + if (storage.isResourceBlank) Optional.empty() + else Optional.of(ExistingData(storage.variant, storage.amount)) + } + } + + companion object { + val CODEC: Codec = RecordCodecBuilder.create { instance -> + instance.group( + Codec.INT.fieldOf("size").forGetter(TankFluidStorage::bucketCapacity), + ExistingData.CODEC.optionalFieldOf("fluid").forGetter(ExistingData::optional), + ).apply(instance, ::TankFluidStorage) + } + } +}*/ +//?} diff --git a/src/main/kotlin/net/turtton/connectedtank/compat/jade/ConnectedTankJadePlugin.kt b/src/main/kotlin/net/turtton/connectedtank/compat/jade/ConnectedTankJadePlugin.kt index 14b527f..69e3433 100644 --- a/src/main/kotlin/net/turtton/connectedtank/compat/jade/ConnectedTankJadePlugin.kt +++ b/src/main/kotlin/net/turtton/connectedtank/compat/jade/ConnectedTankJadePlugin.kt @@ -1,6 +1,7 @@ package net.turtton.connectedtank.compat.jade -import net.fabricmc.fabric.api.transfer.v1.fluid.FluidConstants +//? if fabric { +//?} import net.minecraft.server.level.ServerLevel //? if >=1.21.11 { /*import net.minecraft.resources.Identifier as ResourceLocation*/ @@ -9,6 +10,7 @@ import net.minecraft.resources.ResourceLocation //?} import net.turtton.connectedtank.MOD_ID import net.turtton.connectedtank.block.ConnectedTankBlockEntity +import net.turtton.connectedtank.fluid.FLUID_BUCKET import net.turtton.connectedtank.world.FluidStoragePersistentState import snownee.jade.api.Accessor import snownee.jade.api.BlockAccessor @@ -49,12 +51,16 @@ object TankFluidProvider : if (storage.isResourceBlank || storage.amount <= 0) return null val variant = storage.variant + //? if fabric { //? if >=26.1 { /*val fluidObject = JadeFluidObject.of(variant.fluid, storage.amount, variant.componentsPatch)*/ //?} else { val fluidObject = JadeFluidObject.of(variant.fluid, storage.amount, variant.components) //?} - val capacity = storage.bucketCapacity.toLong() * FluidConstants.BUCKET + //?} else if neoforge { + /*val fluidObject = JadeFluidObject.of(variant.fluid, storage.amount, variant.componentsPatch)*/ + //?} + val capacity = storage.bucketCapacity.toLong() * FLUID_BUCKET val data = FluidView.Data(fluidObject, capacity) return listOf(ViewGroup(listOf(data))) } diff --git a/src/main/kotlin/net/turtton/connectedtank/config/CTServerConfig.kt b/src/main/kotlin/net/turtton/connectedtank/config/CTServerConfig.kt index 3a82ae6..6ecc874 100644 --- a/src/main/kotlin/net/turtton/connectedtank/config/CTServerConfig.kt +++ b/src/main/kotlin/net/turtton/connectedtank/config/CTServerConfig.kt @@ -7,7 +7,11 @@ import com.google.gson.reflect.TypeToken import com.google.gson.stream.JsonReader import java.io.StringReader import java.nio.file.Files +//? if fabric { import net.fabricmc.loader.api.FabricLoader +//?} else if neoforge { +/*import net.neoforged.fml.loading.FMLPaths*/ +//?} import net.turtton.connectedtank.ConnectedTank import net.turtton.connectedtank.block.TankTier @@ -32,7 +36,13 @@ class CTServerConfig( const val MAX_BUCKET_CAPACITY = 256 val DEFAULT_TIER_MULTIPLIERS: Map = TankTier.entries.associate { it.name to it.defaultMultiplier } private val GSON: Gson = GsonBuilder().setPrettyPrinting().create() - private val CONFIG_DIR = FabricLoader.getInstance().configDir.resolve("connectedtank") + private val CONFIG_DIR = + //? if fabric { + FabricLoader.getInstance().configDir.resolve("connectedtank") + + //?} else if neoforge { + /*FMLPaths.CONFIGDIR.get().resolve("connectedtank")*/ + //?} private val CONFIG_PATH = CONFIG_DIR.resolve("server.json") @Volatile diff --git a/src/main/kotlin/net/turtton/connectedtank/fluid/FluidCompat.kt b/src/main/kotlin/net/turtton/connectedtank/fluid/FluidCompat.kt new file mode 100644 index 0000000..3b813f7 --- /dev/null +++ b/src/main/kotlin/net/turtton/connectedtank/fluid/FluidCompat.kt @@ -0,0 +1,56 @@ +package net.turtton.connectedtank.fluid + +//? if fabric { +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 +//?} else if neoforge { +/*import net.neoforged.neoforge.fluids.FluidStack +import net.neoforged.neoforge.fluids.FluidType +import net.neoforged.neoforge.transfer.fluid.FluidResource*/ +//?} +import net.minecraft.world.level.material.Fluid +import net.turtton.connectedtank.block.TankFluidStorage + +//? if fabric { +typealias PlatformFluidVariant = FluidVariant + +val FLUID_BUCKET: Long = FluidConstants.BUCKET + +fun blankFluidVariant(): PlatformFluidVariant = FluidVariant.blank() + +fun fluidVariantOf(fluid: Fluid): PlatformFluidVariant = FluidVariant.of(fluid) + +fun PlatformFluidVariant.isBlankVariant(): Boolean = isBlank + +fun PlatformFluidVariant.getVariantFluid(): Fluid = fluid + +fun PlatformFluidVariant.isSameFluid(other: PlatformFluidVariant): Boolean = fluid == other.fluid + +fun insertFluid(storage: TankFluidStorage, variant: PlatformFluidVariant, amount: Long) { + Transaction.openOuter().use { tx -> + storage.insert(variant, amount, tx) + tx.commit() + } +} +//?} else if neoforge { +/*typealias PlatformFluidVariant = FluidStack + +val FLUID_BUCKET: Long = FluidType.BUCKET_VOLUME.toLong() + +fun blankFluidVariant(): PlatformFluidVariant = FluidStack.EMPTY + +fun fluidVariantOf(fluid: Fluid): PlatformFluidVariant = FluidStack(fluid, 1) + +fun PlatformFluidVariant.isBlankVariant(): Boolean = isEmpty + +fun PlatformFluidVariant.getVariantFluid(): Fluid = fluid + +fun PlatformFluidVariant.isSameFluid(other: PlatformFluidVariant): Boolean = fluid == other.fluid + +fun insertFluid(storage: TankFluidStorage, variant: PlatformFluidVariant, amount: Long) { + val current = storage.fluidStack + val newAmount = (current.amount + amount.toInt()).coerceAtMost(storage.bucketCapacity * FluidType.BUCKET_VOLUME) + storage.set(0, FluidResource.of(variant.fluid), newAmount) +}*/ +//?} diff --git a/src/main/kotlin/net/turtton/connectedtank/item/CTItems.kt b/src/main/kotlin/net/turtton/connectedtank/item/CTItems.kt index 71d1fac..117638c 100644 --- a/src/main/kotlin/net/turtton/connectedtank/item/CTItems.kt +++ b/src/main/kotlin/net/turtton/connectedtank/item/CTItems.kt @@ -1,10 +1,14 @@ package net.turtton.connectedtank.item +//? if fabric { //? if >=26.1 { /*import net.fabricmc.fabric.api.creativetab.v1.FabricCreativeModeTab*/ //?} else { import net.fabricmc.fabric.api.itemgroup.v1.FabricItemGroup //?} +//?} else if neoforge { +/*import net.minecraft.world.item.CreativeModeTab*/ +//?} import net.minecraft.world.item.BlockItem import net.minecraft.world.item.Item import net.minecraft.world.item.ItemStack @@ -65,10 +69,14 @@ object CTItems { Registry.register( BuiltInRegistries.CREATIVE_MODE_TAB, ITEM_GROUP_KEY, + //? if fabric { //? if >=26.1 { /*FabricCreativeModeTab.builder()*/ //?} else { FabricItemGroup.builder() + //?} + //?} else if neoforge { + /*CreativeModeTab.builder()*/ //?} .title(Component.translatable("itemGroup.connectedtank.item_group")) .icon { ItemStack(CONNECTED_TANK) } diff --git a/src/main/kotlin/net/turtton/connectedtank/network/ConfigSyncPayload.kt b/src/main/kotlin/net/turtton/connectedtank/network/ConfigSyncPayload.kt index cb5c1c5..ddd0135 100644 --- a/src/main/kotlin/net/turtton/connectedtank/network/ConfigSyncPayload.kt +++ b/src/main/kotlin/net/turtton/connectedtank/network/ConfigSyncPayload.kt @@ -1,8 +1,17 @@ package net.turtton.connectedtank.network +//? if fabric { import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking +//?} else if neoforge { +/*import net.neoforged.bus.api.SubscribeEvent +import net.neoforged.neoforge.common.NeoForge +import net.neoforged.neoforge.event.entity.player.PlayerEvent +import net.neoforged.neoforge.network.PacketDistributor +import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent +import net.minecraft.server.level.ServerPlayer*/ +//?} import net.minecraft.network.RegistryFriendlyByteBuf import net.minecraft.network.codec.StreamCodec import net.minecraft.network.codec.ByteBufCodecs @@ -20,6 +29,8 @@ data class ConfigSyncPayload( companion object { val ID: CustomPacketPayload.Type = CustomPacketPayload.Type(ModIdentifier("config_sync")) + var onConfigReceived: ((ConfigSyncPayload) -> Unit)? = null + private val TIER_MULTIPLIER_CODEC: StreamCodec> = StreamCodec.of( { buf, map -> @@ -52,6 +63,7 @@ data class ConfigSyncPayload( ) fun registerServer() { + //? if fabric { //? if >=26.1 { /*PayloadTypeRegistry.clientboundPlay().register(ID, CODEC)*/ //?} else { @@ -62,13 +74,36 @@ data class ConfigSyncPayload( val payload = ConfigSyncPayload(config.tankBucketCapacity, config.tierMultipliers) ServerPlayNetworking.send(handler.player, payload) } + //?} else if neoforge { + /*NeoForge.EVENT_BUS.addListener { event -> + val player = event.entity as? ServerPlayer ?: return@addListener + val config = CTServerConfig.instance + val payload = ConfigSyncPayload(config.tankBucketCapacity, config.tierMultipliers) + try { + PacketDistributor.sendToPlayer(player, payload) + } catch (_: UnsupportedOperationException) { + } + }*/ + //?} } + //? if neoforge { + /*@SubscribeEvent + @JvmStatic + fun registerPayloadHandler(event: RegisterPayloadHandlersEvent) { + event.registrar("1").playToClient(ID, CODEC) { payload, _ -> onConfigReceived?.invoke(payload) } + }*/ + //?} + fun broadcastToAll(server: MinecraftServer) { val config = CTServerConfig.instance val payload = ConfigSyncPayload(config.tankBucketCapacity, config.tierMultipliers) for (player in server.playerList.players) { + //? if fabric { ServerPlayNetworking.send(player, payload) + //?} else if neoforge { + /*PacketDistributor.sendToPlayer(player, payload)*/ + //?} } } } diff --git a/src/main/kotlin/net/turtton/connectedtank/world/FluidStoragePersistentState.kt b/src/main/kotlin/net/turtton/connectedtank/world/FluidStoragePersistentState.kt index 2195eeb..fb859fc 100644 --- a/src/main/kotlin/net/turtton/connectedtank/world/FluidStoragePersistentState.kt +++ b/src/main/kotlin/net/turtton/connectedtank/world/FluidStoragePersistentState.kt @@ -3,7 +3,8 @@ package net.turtton.connectedtank.world import com.mojang.serialization.Codec import com.mojang.serialization.codecs.RecordCodecBuilder import java.util.UUID -import net.fabricmc.fabric.api.transfer.v1.fluid.FluidConstants +//? if fabric { +//?} import net.minecraft.server.level.ServerLevel import net.minecraft.core.UUIDUtil import net.minecraft.core.BlockPos @@ -13,6 +14,10 @@ import net.turtton.connectedtank.MOD_ID import net.turtton.connectedtank.block.ConnectedTankBlock import net.turtton.connectedtank.block.TankFluidStorage import net.turtton.connectedtank.config.CTServerConfig +import net.turtton.connectedtank.fluid.FLUID_BUCKET +import net.turtton.connectedtank.fluid.PlatformFluidVariant +import net.turtton.connectedtank.fluid.isBlankVariant +import net.turtton.connectedtank.fluid.isSameFluid //? if >=26.1 { /*import net.turtton.connectedtank.extension.ModIdentifier*/ //?} @@ -76,8 +81,8 @@ class FluidStoragePersistentState( if (adjId == primaryId) continue val adjStorage = storageMap[adjId] ?: continue val adjVariant = if (!adjStorage.isResourceBlank) adjStorage.variant else null - val variants = listOfNotNull(newVariant, adjVariant).distinct() - if (variants.size <= 1) { + val compatible = newVariant == null || adjVariant == null || newVariant.isSameFluid(adjVariant) + if (compatible) { primaryId = adjId break } @@ -104,8 +109,8 @@ class FluidStoragePersistentState( if (adjId == primaryId || adjId in idsToMerge) continue val adjStorage = storageMap[adjId] ?: continue val adjVariant = if (!adjStorage.isResourceBlank) adjStorage.variant else null - val variants = listOfNotNull(effectiveVariant, adjVariant).distinct() - if (variants.size <= 1) { + val compatible = effectiveVariant == null || adjVariant == null || effectiveVariant!!.isSameFluid(adjVariant) + if (compatible) { totalBucketCap += adjStorage.bucketCapacity totalAmount += adjStorage.amount idsToMerge.add(adjId) @@ -117,7 +122,7 @@ class FluidStoragePersistentState( } } - val mergedVariant = listOfNotNull(effectiveVariant, newVariant).distinct().firstOrNull() + val mergedVariant = effectiveVariant ?: newVariant val existingData = mergedVariant?.let { TankFluidStorage.ExistingData(it, totalAmount) } val mergedStorage = TankFluidStorage(totalBucketCap, existingData).also { it.onChanged = ::setDirty } @@ -163,7 +168,7 @@ class FluidStoragePersistentState( val removedShare = allShares[pos] ?: 0L val remainingAmount = amount - removedShare - val removedData = if (variant != null && !variant.isBlank && removedShare > 0) { + val removedData = if (variant != null && !variant.isBlankVariant() && removedShare > 0) { TankFluidStorage.ExistingData(variant, removedShare) } else { null @@ -181,7 +186,7 @@ class FluidStoragePersistentState( if (components.size == 1) { // 分断なし val newBucketCap = computeGroupCapacity(groupPositions, world) - val data = if (variant != null && !variant.isBlank && remainingAmount > 0) { + val data = if (variant != null && !variant.isBlankVariant() && remainingAmount > 0) { TankFluidStorage.ExistingData(variant, remainingAmount) } else { null @@ -236,7 +241,7 @@ class FluidStoragePersistentState( private fun splitIntoComponents( components: List>, originalUuid: UUID, - variant: net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant?, + variant: PlatformFluidVariant?, positionShares: Map, world: ServerLevel?, ) { @@ -247,7 +252,7 @@ class FluidStoragePersistentState( val componentAmount = component.sumOf { positionShares[it] ?: 0L } val newBucketCap = computeGroupCapacity(component, world) - val data = if (variant != null && !variant.isBlank && componentAmount > 0) { + val data = if (variant != null && !variant.isBlankVariant() && componentAmount > 0) { TankFluidStorage.ExistingData(variant, componentAmount) } else { null @@ -358,7 +363,7 @@ class FluidStoragePersistentState( } else { defaultBucketCapacity } - return bucketCap.toLong() * FluidConstants.BUCKET + return bucketCap.toLong() * FLUID_BUCKET } private data class PositionalStorageEntry(val pos: BlockPos, val id: UUID) { diff --git a/src/main/resources/META-INF/neoforge.mods.toml b/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..5a9978b --- /dev/null +++ b/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,32 @@ +modLoader = "kotlinforforge" +loaderVersion = "[5,)" +license = "MIT" +issueTrackerURL = "https://github.com/turtton/ConnectedTank/issues" + +[[mods]] +modId = "connectedtank" +version = "${version}" +displayName = "ConnectedTank" +authors = "turtton" +description = "Connected fluid tank mod for Minecraft" + +[[mixins]] +config = "connectedtank.mixins.json" + +[[mixins]] +config = "connectedtank.client.mixins.json" + +[[dependencies.connectedtank]] +modId = "neoforge" +type = "required" +versionRange = "[${neoforge_version},)" +ordering = "NONE" +side = "BOTH" + +[[dependencies.connectedtank]] +modId = "minecraft" +type = "required" +versionRange = "[${minecraft_version},)" +ordering = "NONE" +side = "BOTH" + diff --git a/stonecutter.gradle.kts b/stonecutter.gradle.kts index 9a2b08e..99a3caa 100644 --- a/stonecutter.gradle.kts +++ b/stonecutter.gradle.kts @@ -3,7 +3,14 @@ plugins { alias(libs.plugins.spotless) } -stonecutter active "1.21.8" +stonecutter active "1.21.8-fabric" + +stonecutter { + parameters { + val loader = node.metadata.project.substringAfterLast("-") + constants.match(loader, "fabric", "neoforge") + } +} repositories { mavenCentral() @@ -12,6 +19,10 @@ repositories { spotless { kotlin { target("src/**/*.kt") + targetExclude( + "src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTest.kt", + "src/gametest/kotlin/net/turtton/connectedtank/test/ConnectedTankGameTestRegistration.kt", + ) ktlint().editorConfigOverride( mapOf( "ktlint_standard_import-ordering" to "disabled", From 203187056f0f4e733bcd00b09a548610174c5c4f Mon Sep 17 00:00:00 2001 From: turtton Date: Sat, 9 May 2026 18:03:36 +0900 Subject: [PATCH 2/3] feat: add cross-platform client test framework and fix NeoForge client rendering - Implement coroutine-based client test runner (FabPose-inspired) that works on both Fabric and NeoForge via Stonecutter conditionals - Port all 10 client tests from Fabric-only clientGametest to shared clienttest - Fix NeoForge atlas lookup crash (AtlasIds.BLOCKS vs TextureAtlas.LOCATION_BLOCKS) - Add render_type:cutout to model JSONs for NeoForge tank transparency - Add Xvfb display race condition fix (FileLock) for parallel CI runs - Add PulseAudio SIGABRT workaround (LD_PRELOAD stub) for headless environments - Add Mixins for unfocused pause prevention and narrator suppression - Handle API differences across MC versions (GameRules, Window, KeyboardHandler) --- build.fabric.gradle.kts | 165 ++++++++++++- build.fabric.unobfuscated.gradle.kts | 162 ++++++++++++- build.neoforge.gradle.kts | 192 +++++++++++++++ gradle/libs.versions.toml | 2 + .../connectedtank/ConnectedTankClient.kt | 2 +- .../ConnectedTankDataGenerator.kt | 3 + .../block/ConnectedTankBlockEntityRenderer.kt | 11 +- .../item/ConnectedTankItemRenderer.kt | 5 +- .../MixinAccessibilityOnboardingScreen.java | 23 ++ .../mixin/clienttest/MixinGameRenderer.java | 19 ++ .../mixin/clienttest/MixinMinecraftPause.java | 16 ++ .../connectedtank/test/ClientTestContext.kt | 186 ++++++++++++++ .../connectedtank/test/ClientTestEntry.kt | 34 +++ .../connectedtank/test/ClientTestRunner.kt | 121 +++++++++ .../test/ConnectedTankClientTest.kt | 229 ++++++++++++++++++ .../connectedtank.clienttest.mixins.json | 13 + src/clienttest/resources/fabric.mod.json | 21 ++ .../models/block/connected_tank.json | 1 + .../models/block/connected_tank_item.json | 1 + .../models/block/copper_connected_tank.json | 1 + .../block/copper_connected_tank_item.json | 1 + .../models/block/diamond_connected_tank.json | 1 + .../block/diamond_connected_tank_item.json | 1 + .../models/block/gold_connected_tank.json | 1 + .../block/gold_connected_tank_item.json | 1 + .../models/block/iron_connected_tank.json | 1 + .../block/iron_connected_tank_item.json | 1 + .../block/netherite_connected_tank.json | 1 + .../block/netherite_connected_tank_item.json | 1 + .../models/block/stone_connected_tank.json | 1 + .../block/stone_connected_tank_item.json | 1 + .../models/block/tank_border_down_east.json | 1 + .../models/block/tank_border_down_north.json | 1 + .../models/block/tank_border_down_south.json | 1 + .../models/block/tank_border_down_west.json | 1 + .../models/block/tank_border_east_down.json | 1 + .../models/block/tank_border_east_north.json | 1 + .../models/block/tank_border_east_south.json | 1 + .../models/block/tank_border_east_up.json | 1 + .../models/block/tank_border_north_down.json | 1 + .../models/block/tank_border_north_east.json | 1 + .../models/block/tank_border_north_up.json | 1 + .../models/block/tank_border_north_west.json | 1 + .../models/block/tank_border_south_down.json | 1 + .../models/block/tank_border_south_east.json | 1 + .../models/block/tank_border_south_up.json | 1 + .../models/block/tank_border_south_west.json | 1 + .../models/block/tank_border_up_east.json | 1 + .../models/block/tank_border_up_north.json | 1 + .../models/block/tank_border_up_south.json | 1 + .../models/block/tank_border_up_west.json | 1 + .../models/block/tank_border_west_down.json | 1 + .../models/block/tank_border_west_north.json | 1 + .../models/block/tank_border_west_south.json | 1 + .../models/block/tank_border_west_up.json | 1 + 55 files changed, 1215 insertions(+), 27 deletions(-) create mode 100644 src/clienttest/java/net/turtton/connectedtank/mixin/clienttest/MixinAccessibilityOnboardingScreen.java create mode 100644 src/clienttest/java/net/turtton/connectedtank/mixin/clienttest/MixinGameRenderer.java create mode 100644 src/clienttest/java/net/turtton/connectedtank/mixin/clienttest/MixinMinecraftPause.java create mode 100644 src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestContext.kt create mode 100644 src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestEntry.kt create mode 100644 src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestRunner.kt create mode 100644 src/clienttest/kotlin/net/turtton/connectedtank/test/ConnectedTankClientTest.kt create mode 100644 src/clienttest/resources/connectedtank.clienttest.mixins.json create mode 100644 src/clienttest/resources/fabric.mod.json diff --git a/build.fabric.gradle.kts b/build.fabric.gradle.kts index 1371781..5a28af7 100644 --- a/build.fabric.gradle.kts +++ b/build.fabric.gradle.kts @@ -1,3 +1,4 @@ +import java.io.RandomAccessFile import java.util.concurrent.TimeUnit import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -82,6 +83,23 @@ configurations.named(clientGametestSourceSet.runtimeClasspathConfigurationName) extendsFrom(configurations[sourceSets.getByName("client").runtimeClasspathConfigurationName]) } +val clienttestSourceSet = sourceSets.create("clienttest") { + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().output + compileClasspath += sourceSets.getByName("client").output + runtimeClasspath += sourceSets.getByName("client").output + kotlin.srcDir("src/clienttest/kotlin") +} + +configurations.named(clienttestSourceSet.compileClasspathConfigurationName) { + extendsFrom(configurations[sourceSets.main.get().compileClasspathConfigurationName]) + extendsFrom(configurations[sourceSets.getByName("client").compileClasspathConfigurationName]) +} +configurations.named(clienttestSourceSet.runtimeClasspathConfigurationName) { + extendsFrom(configurations[sourceSets.main.get().runtimeClasspathConfigurationName]) + extendsFrom(configurations[sourceSets.getByName("client").runtimeClasspathConfigurationName]) +} + loom { mods { register("connectedtank-client-test") { @@ -105,6 +123,25 @@ loom { } } +loom { + mods { + register("connectedtank-clienttest") { + sourceSet(clienttestSourceSet) + } + } + + createRemapConfigurations(clienttestSourceSet) + + runs { + register("clientTest") { + inherit(runs.getByName("client")) + source(clienttestSourceSet) + property("connectedtank.clienttest") + runDir("build/run/clientTest") + } + } +} + dependencies { val fabricApiVersion = when (mcVersion) { "1.21.8" -> "0.132.0+1.21.8" @@ -150,6 +187,8 @@ dependencies { modRuntimeOnly("me.shedaniel:RoughlyEnoughItems-fabric:$reiVersion") modCompileOnly("maven.modrinth:jade:$jadeVersion") modRuntimeOnly("maven.modrinth:jade:$jadeVersion") + + "clienttestImplementation"(libs.kotlinx.coroutines.core) } tasks { @@ -274,24 +313,93 @@ fun findXvfb(): String? { } } +/** + * Compile a stub libpulse-simple.so that returns dummy handles to prevent + * flite (TTS) from crashing with SIGABRT when PulseAudio daemon is unavailable. + * flite calls pa_simple_write() without null-checking the handle from pa_simple_new(). + */ +fun ensurePulseStub(): File? { + val stubDir = layout.buildDirectory.dir("pulse-stub").get().asFile + val stubLib = File(stubDir, "libpulse-simple-stub.so") + if (stubLib.exists()) return stubLib + + // Check if gcc is available + val hasGcc = runCatching { + ProcessBuilder("which", "gcc").redirectErrorStream(true).start().waitFor() == 0 + }.getOrDefault(false) + if (!hasGcc) return null + + stubDir.mkdirs() + val stubSrc = File(stubDir, "pulse_stub.c") + stubSrc.writeText( + """ + #include + void *pa_simple_new(const void *s, const char *n, int d, + const char *dev, const char *sn, + const void *ss, const void *map, int *e) { + static char dummy; return &dummy; + } + int pa_simple_write(void *p, const void *data, size_t bytes, int *e) { return 0; } + int pa_simple_drain(void *p, int *e) { return 0; } + void pa_simple_free(void *p) {} + int pa_simple_read(void *p, void *data, size_t bytes, int *e) { return 0; } + size_t pa_simple_get_latency(void *p, int *e) { return 0; } + int pa_simple_flush(void *p, int *e) { return 0; } + """.trimIndent(), + ) + val result = ProcessBuilder("gcc", "-shared", "-fPIC", "-o", stubLib.absolutePath, stubSrc.absolutePath) + .redirectErrorStream(true) + .start() + if (result.waitFor(10, TimeUnit.SECONDS) && result.exitValue() == 0) { + return stubLib + } + return null +} + fun startXvfb(xvfb: String): Pair { for (displayNum in 99..199) { val display = ":$displayNum" if (File("/tmp/.X11-unix/X$displayNum").exists()) continue - val process = ProcessBuilder(xvfb, display, "-screen", "0", "1280x1024x24", "-nolisten", "tcp") - .redirectErrorStream(true) - .start() - - val socketFile = File("/tmp/.X11-unix/X$displayNum") - val deadline = System.currentTimeMillis() + 5_000 - while (System.currentTimeMillis() < deadline) { - if (!process.isAlive) break - if (socketFile.exists()) return process to display - Thread.sleep(100) + // Use file lock to prevent race conditions when multiple Gradle subprojects start Xvfb in parallel + val lockFile = File("/tmp/.xvfb-gradle-lock-$displayNum") + val raf = try { + RandomAccessFile(lockFile, "rw") + } catch (_: Exception) { + continue + } + val lock = try { + raf.channel.tryLock() + } catch (_: Exception) { + raf.close() + continue } + if (lock == null) { + raf.close() + continue + } + + try { + // Double-check socket after acquiring lock + if (File("/tmp/.X11-unix/X$displayNum").exists()) continue + + val process = ProcessBuilder(xvfb, display, "-screen", "0", "1280x1024x24", "-nolisten", "tcp") + .redirectErrorStream(true) + .start() + + val socketFile = File("/tmp/.X11-unix/X$displayNum") + val deadline = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < deadline) { + if (!process.isAlive) break + if (socketFile.exists()) return process to display + Thread.sleep(100) + } - if (process.isAlive) process.destroyForcibly() + if (process.isAlive) process.destroyForcibly() + } finally { + lock.release() + raf.close() + } } error("Failed to start Xvfb: no available display number in :99..:199") } @@ -333,5 +441,40 @@ tasks.named("runClientGameTest") { logger.lifecycle("Started Xvfb on display $display (pid: ${process.pid()})") environment("DISPLAY", display) + environment("PULSE_SERVER", "/dev/null") + environment("ALSOFT_DRIVERS", "null") + ensurePulseStub()?.let { stub -> + environment("LD_PRELOAD", stub.absolutePath) + logger.lifecycle("Using PulseAudio stub: ${stub.absolutePath}") + } + } +} + +tasks.named("runClientTest") { + notCompatibleWithConfigurationCache("Manages Xvfb process lifecycle at execution time") + finalizedBy(cleanupXvfbTask) + + doFirst { + if (!needsXvfb()) return@doFirst + + val xvfb = findXvfb() ?: error( + "No usable DISPLAY found and Xvfb is not installed. " + + "Install Xvfb or run with a display server (e.g., xvfb-run ./gradlew runClientTest)", + ) + + val (process, display) = startXvfb(xvfb) + xvfbState.set(process) + val shutdownHook = Thread { if (process.isAlive) process.destroyForcibly() } + Runtime.getRuntime().addShutdownHook(shutdownHook) + xvfbShutdownHook.set(shutdownHook) + + logger.lifecycle("Started Xvfb on display $display (pid: ${process.pid()})") + environment("DISPLAY", display) + environment("PULSE_SERVER", "/dev/null") + environment("ALSOFT_DRIVERS", "null") + ensurePulseStub()?.let { stub -> + environment("LD_PRELOAD", stub.absolutePath) + logger.lifecycle("Using PulseAudio stub: ${stub.absolutePath}") + } } } diff --git a/build.fabric.unobfuscated.gradle.kts b/build.fabric.unobfuscated.gradle.kts index d33318e..0eae3ce 100644 --- a/build.fabric.unobfuscated.gradle.kts +++ b/build.fabric.unobfuscated.gradle.kts @@ -1,3 +1,4 @@ +import java.io.RandomAccessFile import java.util.concurrent.TimeUnit import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -81,6 +82,23 @@ configurations.named(clientGametestSourceSet.runtimeClasspathConfigurationName) extendsFrom(configurations[sourceSets.getByName("client").runtimeClasspathConfigurationName]) } +val clienttestSourceSet = sourceSets.create("clienttest") { + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().output + compileClasspath += sourceSets.getByName("client").output + runtimeClasspath += sourceSets.getByName("client").output + kotlin.srcDir("src/clienttest/kotlin") +} + +configurations.named(clienttestSourceSet.compileClasspathConfigurationName) { + extendsFrom(configurations[sourceSets.main.get().compileClasspathConfigurationName]) + extendsFrom(configurations[sourceSets.getByName("client").compileClasspathConfigurationName]) +} +configurations.named(clienttestSourceSet.runtimeClasspathConfigurationName) { + extendsFrom(configurations[sourceSets.main.get().runtimeClasspathConfigurationName]) + extendsFrom(configurations[sourceSets.getByName("client").runtimeClasspathConfigurationName]) +} + loom { mods { register("connectedtank-client-test") { @@ -102,6 +120,23 @@ loom { } } +loom { + mods { + register("connectedtank-clienttest") { + sourceSet(clienttestSourceSet) + } + } + + runs { + register("clientTest") { + inherit(runs.getByName("client")) + source(clienttestSourceSet) + property("connectedtank.clienttest") + runDir("build/run/clientTest") + } + } +} + dependencies { val fabricApiVersion = "0.148.0+26.1.2" val yaclVersion = "3.9.3+26.1-fabric" @@ -123,6 +158,8 @@ dependencies { runtimeOnly("maven.modrinth:jei:$jeiVersion") compileOnly("maven.modrinth:jade:$jadeVersion") runtimeOnly("maven.modrinth:jade:$jadeVersion") + + "clienttestImplementation"(libs.kotlinx.coroutines.core) } tasks { @@ -223,24 +260,92 @@ fun findXvfb(): String? { } } +/** + * Compile a stub libpulse-simple.so that returns dummy handles to prevent + * flite (TTS) from crashing with SIGABRT when PulseAudio daemon is unavailable. + * flite calls pa_simple_write() without null-checking the handle from pa_simple_new(). + */ +fun ensurePulseStub(): File? { + val stubDir = layout.buildDirectory.dir("pulse-stub").get().asFile + val stubLib = File(stubDir, "libpulse-simple-stub.so") + if (stubLib.exists()) return stubLib + + val hasGcc = runCatching { + ProcessBuilder("which", "gcc").redirectErrorStream(true).start().waitFor() == 0 + }.getOrDefault(false) + if (!hasGcc) return null + + stubDir.mkdirs() + val stubSrc = File(stubDir, "pulse_stub.c") + stubSrc.writeText( + """ + #include + void *pa_simple_new(const void *s, const char *n, int d, + const char *dev, const char *sn, + const void *ss, const void *map, int *e) { + static char dummy; return &dummy; + } + int pa_simple_write(void *p, const void *data, size_t bytes, int *e) { return 0; } + int pa_simple_drain(void *p, int *e) { return 0; } + void pa_simple_free(void *p) {} + int pa_simple_read(void *p, void *data, size_t bytes, int *e) { return 0; } + size_t pa_simple_get_latency(void *p, int *e) { return 0; } + int pa_simple_flush(void *p, int *e) { return 0; } + """.trimIndent(), + ) + val result = ProcessBuilder("gcc", "-shared", "-fPIC", "-o", stubLib.absolutePath, stubSrc.absolutePath) + .redirectErrorStream(true) + .start() + if (result.waitFor(10, TimeUnit.SECONDS) && result.exitValue() == 0) { + return stubLib + } + return null +} + fun startXvfb(xvfb: String): Pair { for (displayNum in 99..199) { val display = ":$displayNum" if (File("/tmp/.X11-unix/X$displayNum").exists()) continue - val process = ProcessBuilder(xvfb, display, "-screen", "0", "1280x1024x24", "-nolisten", "tcp") - .redirectErrorStream(true) - .start() - - val socketFile = File("/tmp/.X11-unix/X$displayNum") - val deadline = System.currentTimeMillis() + 5_000 - while (System.currentTimeMillis() < deadline) { - if (!process.isAlive) break - if (socketFile.exists()) return process to display - Thread.sleep(100) + // Use file lock to prevent race conditions when multiple Gradle subprojects start Xvfb in parallel + val lockFile = File("/tmp/.xvfb-gradle-lock-$displayNum") + val raf = try { + RandomAccessFile(lockFile, "rw") + } catch (_: Exception) { + continue + } + val lock = try { + raf.channel.tryLock() + } catch (_: Exception) { + raf.close() + continue } + if (lock == null) { + raf.close() + continue + } + + try { + // Double-check socket after acquiring lock + if (File("/tmp/.X11-unix/X$displayNum").exists()) continue + + val process = ProcessBuilder(xvfb, display, "-screen", "0", "1280x1024x24", "-nolisten", "tcp") + .redirectErrorStream(true) + .start() + + val socketFile = File("/tmp/.X11-unix/X$displayNum") + val deadline = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < deadline) { + if (!process.isAlive) break + if (socketFile.exists()) return process to display + Thread.sleep(100) + } - if (process.isAlive) process.destroyForcibly() + if (process.isAlive) process.destroyForcibly() + } finally { + lock.release() + raf.close() + } } error("Failed to start Xvfb: no available display number in :99..:199") } @@ -282,5 +387,40 @@ tasks.named("runClientGameTest") { logger.lifecycle("Started Xvfb on display $display (pid: ${process.pid()})") environment("DISPLAY", display) + environment("PULSE_SERVER", "/dev/null") + environment("ALSOFT_DRIVERS", "null") + ensurePulseStub()?.let { stub -> + environment("LD_PRELOAD", stub.absolutePath) + logger.lifecycle("Using PulseAudio stub: ${stub.absolutePath}") + } + } +} + +tasks.named("runClientTest") { + notCompatibleWithConfigurationCache("Manages Xvfb process lifecycle at execution time") + finalizedBy(cleanupXvfbTask) + + doFirst { + if (!needsXvfb()) return@doFirst + + val xvfb = findXvfb() ?: error( + "No usable DISPLAY found and Xvfb is not installed. " + + "Install Xvfb or run with a display server (e.g., xvfb-run ./gradlew runClientTest)", + ) + + val (process, display) = startXvfb(xvfb) + xvfbState.set(process) + val shutdownHook = Thread { if (process.isAlive) process.destroyForcibly() } + Runtime.getRuntime().addShutdownHook(shutdownHook) + xvfbShutdownHook.set(shutdownHook) + + logger.lifecycle("Started Xvfb on display $display (pid: ${process.pid()})") + environment("DISPLAY", display) + environment("PULSE_SERVER", "/dev/null") + environment("ALSOFT_DRIVERS", "null") + ensurePulseStub()?.let { stub -> + environment("LD_PRELOAD", stub.absolutePath) + logger.lifecycle("Using PulseAudio stub: ${stub.absolutePath}") + } } } diff --git a/build.neoforge.gradle.kts b/build.neoforge.gradle.kts index 0e4a51d..0ce63cf 100644 --- a/build.neoforge.gradle.kts +++ b/build.neoforge.gradle.kts @@ -1,3 +1,5 @@ +import java.io.RandomAccessFile +import java.util.concurrent.TimeUnit import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -36,6 +38,8 @@ repositories { } } +sourceSets.main.get().resources.srcDir("src/main/generated") + val clientSourceSet = sourceSets.create("client") { compileClasspath += sourceSets.main.get().output runtimeClasspath += sourceSets.main.get().output @@ -62,6 +66,23 @@ configurations.named(gametestSourceSet.runtimeClasspathConfigurationName) { extendsFrom(configurations[sourceSets.main.get().runtimeClasspathConfigurationName]) } +val clienttestSourceSet = sourceSets.create("clienttest") { + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().output + compileClasspath += clientSourceSet.output + runtimeClasspath += clientSourceSet.output + kotlin.srcDir("src/clienttest/kotlin") +} + +configurations.named(clienttestSourceSet.compileClasspathConfigurationName) { + extendsFrom(configurations[sourceSets.main.get().compileClasspathConfigurationName]) + extendsFrom(configurations[clientSourceSet.compileClasspathConfigurationName]) +} +configurations.named(clienttestSourceSet.runtimeClasspathConfigurationName) { + extendsFrom(configurations[sourceSets.main.get().runtimeClasspathConfigurationName]) + extendsFrom(configurations[clientSourceSet.runtimeClasspathConfigurationName]) +} + val neoForgeVersion: String = libs.versions.neoforge.mc.get() neoForge { @@ -69,12 +90,14 @@ neoForge { addModdingDependenciesTo(clientSourceSet) addModdingDependenciesTo(gametestSourceSet) + addModdingDependenciesTo(clienttestSourceSet) mods { register("connectedtank") { sourceSet(sourceSets.main.get()) sourceSet(clientSourceSet) sourceSet(gametestSourceSet) + sourceSet(clienttestSourceSet) } } @@ -90,6 +113,12 @@ neoForge { type = "gameTestServer" sourceSet = gametestSourceSet } + create("clientTest") { + client() + sourceSet = clienttestSourceSet + systemProperties.put("connectedtank.clienttest", "true") + systemProperties.put("mixin.configs", "connectedtank.clienttest.mixins.json") + } } } @@ -105,6 +134,8 @@ dependencies { compileOnly("maven.modrinth:jade:$jadeVersion") runtimeOnly("maven.modrinth:jade:$jadeVersion") runtimeOnly("me.shedaniel:RoughlyEnoughItems-neoforge:$reiVersion") + + "clienttestImplementation"(libs.kotlinx.coroutines.core) } tasks { @@ -184,3 +215,164 @@ publishing { repositories { } } + +// Auto-start Xvfb for headless client test execution (Wayland / headless environments) +val xvfbState = objects.property() +val xvfbShutdownHook = objects.property() + +fun needsXvfb(): Boolean { + val display = System.getenv("DISPLAY") + if (display.isNullOrBlank()) return true + if (display.contains(":") && !display.startsWith(":")) return false + val displayNum = display.removePrefix(":").takeWhile { it.isDigit() } + val socket = File("/tmp/.X11-unix/X$displayNum") + return !socket.exists() +} + +fun findXvfb(): String? { + val candidates = listOf("Xvfb", "/usr/bin/Xvfb") + return candidates.firstOrNull { name -> + runCatching { + ProcessBuilder("which", name) + .redirectErrorStream(true) + .start() + .waitFor() == 0 + }.getOrDefault(false) + } +} + +/** + * Compile a stub libpulse-simple.so that returns dummy handles to prevent + * flite (TTS) from crashing with SIGABRT when PulseAudio daemon is unavailable. + * flite calls pa_simple_write() without null-checking the handle from pa_simple_new(). + */ +fun ensurePulseStub(): File? { + val stubDir = layout.buildDirectory.dir("pulse-stub").get().asFile + val stubLib = File(stubDir, "libpulse-simple-stub.so") + if (stubLib.exists()) return stubLib + + val hasGcc = runCatching { + ProcessBuilder("which", "gcc").redirectErrorStream(true).start().waitFor() == 0 + }.getOrDefault(false) + if (!hasGcc) return null + + stubDir.mkdirs() + val stubSrc = File(stubDir, "pulse_stub.c") + stubSrc.writeText( + """ + #include + void *pa_simple_new(const void *s, const char *n, int d, + const char *dev, const char *sn, + const void *ss, const void *map, int *e) { + static char dummy; return &dummy; + } + int pa_simple_write(void *p, const void *data, size_t bytes, int *e) { return 0; } + int pa_simple_drain(void *p, int *e) { return 0; } + void pa_simple_free(void *p) {} + int pa_simple_read(void *p, void *data, size_t bytes, int *e) { return 0; } + size_t pa_simple_get_latency(void *p, int *e) { return 0; } + int pa_simple_flush(void *p, int *e) { return 0; } + """.trimIndent(), + ) + val result = ProcessBuilder("gcc", "-shared", "-fPIC", "-o", stubLib.absolutePath, stubSrc.absolutePath) + .redirectErrorStream(true) + .start() + if (result.waitFor(10, TimeUnit.SECONDS) && result.exitValue() == 0) { + return stubLib + } + return null +} + +fun startXvfb(xvfb: String): Pair { + for (displayNum in 99..199) { + val display = ":$displayNum" + if (File("/tmp/.X11-unix/X$displayNum").exists()) continue + + // Use file lock to prevent race conditions when multiple Gradle subprojects start Xvfb in parallel + val lockFile = File("/tmp/.xvfb-gradle-lock-$displayNum") + val raf = try { + RandomAccessFile(lockFile, "rw") + } catch (_: Exception) { + continue + } + val lock = try { + raf.channel.tryLock() + } catch (_: Exception) { + raf.close() + continue + } + if (lock == null) { + raf.close() + continue + } + + try { + // Double-check socket after acquiring lock + if (File("/tmp/.X11-unix/X$displayNum").exists()) continue + + val process = ProcessBuilder(xvfb, display, "-screen", "0", "1280x1024x24", "-nolisten", "tcp") + .redirectErrorStream(true) + .start() + + val socketFile = File("/tmp/.X11-unix/X$displayNum") + val deadline = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < deadline) { + if (!process.isAlive) break + if (socketFile.exists()) return process to display + Thread.sleep(100) + } + + if (process.isAlive) process.destroyForcibly() + } finally { + lock.release() + raf.close() + } + } + error("Failed to start Xvfb: no available display number in :99..:199") +} + +val cleanupXvfbTask = tasks.register("cleanupXvfb") { + notCompatibleWithConfigurationCache("Manages Xvfb process lifecycle at execution time") + doLast { + xvfbState.orNull?.let { process -> + if (process.isAlive) { + logger.lifecycle("Stopping Xvfb (pid: ${process.pid()})") + process.destroy() + process.waitFor(5, TimeUnit.SECONDS) + if (process.isAlive) process.destroyForcibly() + } + } + xvfbShutdownHook.orNull?.let { hook -> + runCatching { Runtime.getRuntime().removeShutdownHook(hook) } + } + } +} + +tasks.named("runClientTest") { + notCompatibleWithConfigurationCache("Manages Xvfb process lifecycle at execution time") + finalizedBy(cleanupXvfbTask) + + doFirst { + if (!needsXvfb()) return@doFirst + + val xvfb = findXvfb() ?: error( + "No usable DISPLAY found and Xvfb is not installed. " + + "Install Xvfb or run with a display server (e.g., xvfb-run ./gradlew runClientTest)", + ) + + val (process, display) = startXvfb(xvfb) + xvfbState.set(process) + val shutdownHook = Thread { if (process.isAlive) process.destroyForcibly() } + Runtime.getRuntime().addShutdownHook(shutdownHook) + xvfbShutdownHook.set(shutdownHook) + + logger.lifecycle("Started Xvfb on display $display (pid: ${process.pid()})") + environment("DISPLAY", display) + environment("PULSE_SERVER", "/dev/null") + environment("ALSOFT_DRIVERS", "null") + ensurePulseStub()?.let { stub -> + environment("LD_PRELOAD", stub.absolutePath) + logger.lifecycle("Using PulseAudio stub: ${stub.absolutePath}") + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b36e6e..7c66f1a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ loader = "0.19.2" loom = "1.15.5" fabric-kotlin = "1.13.11+kotlin.2.3.21" kotlin = "2.3.21" +kotlinx-coroutines = "1.10.2" spotless = "7.2.1" mod-publish-plugin = "0.8.4" neoforge-mc = "21.11.42" @@ -12,6 +13,7 @@ kff = "6.2.0" [libraries] fabric-loader = { module = "net.fabricmc:fabric-loader", version.ref = "loader" } fabric-language-kotlin = { module = "net.fabricmc:fabric-language-kotlin", version.ref = "fabric-kotlin" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } [plugins] fabric-loom = { id = "net.fabricmc.fabric-loom", version.ref = "loom" } diff --git a/src/client/kotlin/net/turtton/connectedtank/ConnectedTankClient.kt b/src/client/kotlin/net/turtton/connectedtank/ConnectedTankClient.kt index 939ebde..1a6a439 100644 --- a/src/client/kotlin/net/turtton/connectedtank/ConnectedTankClient.kt +++ b/src/client/kotlin/net/turtton/connectedtank/ConnectedTankClient.kt @@ -65,7 +65,7 @@ object ConnectedTankClient : ClientModInitializer { } } //?} else if neoforge { -/*@EventBusSubscriber(value = [Dist.CLIENT], modid = "connectedtank", bus = EventBusSubscriber.Bus.MOD) +/*@EventBusSubscriber(value = [Dist.CLIENT], modid = "connectedtank") object ConnectedTankClient { init { CTClientConfig.load() diff --git a/src/client/kotlin/net/turtton/connectedtank/ConnectedTankDataGenerator.kt b/src/client/kotlin/net/turtton/connectedtank/ConnectedTankDataGenerator.kt index 21f28f7..135a849 100644 --- a/src/client/kotlin/net/turtton/connectedtank/ConnectedTankDataGenerator.kt +++ b/src/client/kotlin/net/turtton/connectedtank/ConnectedTankDataGenerator.kt @@ -162,6 +162,7 @@ object ConnectedTankDataGenerator : DataGeneratorEntrypoint { private fun generateBaseModel(generator: BlockModelGenerators, tierId: String) { val modelId = ResourceLocation.fromNamespaceAndPath("connectedtank", "block/$tierId") val json = JsonObject().apply { + addProperty("render_type", "minecraft:cutout") add( "textures", JsonObject().apply { @@ -185,6 +186,7 @@ object ConnectedTankDataGenerator : DataGeneratorEntrypoint { "block/tank_border_${direction}_$stripDir", ) val json = JsonObject().apply { + addProperty("render_type", "minecraft:cutout") add( "textures", JsonObject().apply { @@ -269,6 +271,7 @@ object ConnectedTankDataGenerator : DataGeneratorEntrypoint { val modelId = ResourceLocation.fromNamespaceAndPath("connectedtank", "block/${tierId}_item") val json = JsonObject().apply { addProperty("parent", "minecraft:block/block") + addProperty("render_type", "minecraft:cutout") add( "textures", JsonObject().apply { diff --git a/src/client/kotlin/net/turtton/connectedtank/block/ConnectedTankBlockEntityRenderer.kt b/src/client/kotlin/net/turtton/connectedtank/block/ConnectedTankBlockEntityRenderer.kt index 0dc16fd..a78de65 100644 --- a/src/client/kotlin/net/turtton/connectedtank/block/ConnectedTankBlockEntityRenderer.kt +++ b/src/client/kotlin/net/turtton/connectedtank/block/ConnectedTankBlockEntityRenderer.kt @@ -19,7 +19,7 @@ import net.minecraft.client.Minecraft //?} else if neoforge { import net.minecraft.client.Minecraft import net.minecraft.client.renderer.state.CameraRenderState -import net.minecraft.client.renderer.texture.TextureAtlas +import net.minecraft.data.AtlasIds import net.neoforged.neoforge.client.extensions.common.IClientFluidTypeExtensions //?} else { import net.minecraft.client.renderer.state.CameraRenderState @@ -71,7 +71,14 @@ class ConnectedTankBlockEntityRenderer( state.sprite = fluidModels.get(entity.fluidVariant.getVariantFluid().defaultFluidState()).stillMaterial().sprite() //?} else if neoforge { val extensions = IClientFluidTypeExtensions.of(entity.fluidVariant.getVariantFluid()) - state.sprite = Minecraft.getInstance().atlasManager.getAtlasOrThrow(TextureAtlas.LOCATION_BLOCKS).getSprite(extensions.getStillTexture()) + val stillTex = extensions.getStillTexture() + val atlas = Minecraft.getInstance().atlasManager.getAtlasOrThrow(AtlasIds.BLOCKS) + state.sprite = atlas.getSprite(stillTex) + if (state.sprite?.contents()?.name()?.path?.contains("missing") == true) { + val logger = org.slf4j.LoggerFactory.getLogger("CT-Debug") + logger.error("Missing sprite for fluid texture: {}", stillTex) + logger.error("Atlas location: {}, sprite count unknown", atlas.location()) + } //?} else { state.sprite = FluidVariantRendering.getSprite(entity.fluidVariant) //?} diff --git a/src/client/kotlin/net/turtton/connectedtank/item/ConnectedTankItemRenderer.kt b/src/client/kotlin/net/turtton/connectedtank/item/ConnectedTankItemRenderer.kt index 6c22cda..8da129e 100644 --- a/src/client/kotlin/net/turtton/connectedtank/item/ConnectedTankItemRenderer.kt +++ b/src/client/kotlin/net/turtton/connectedtank/item/ConnectedTankItemRenderer.kt @@ -33,7 +33,7 @@ import org.joml.Vector3fc import net.minecraft.client.Minecraft //?} else if neoforge { import net.minecraft.client.Minecraft -import net.minecraft.client.renderer.texture.TextureAtlas +import net.minecraft.data.AtlasIds import net.neoforged.neoforge.client.extensions.common.IClientFluidTypeExtensions //?} @@ -71,7 +71,8 @@ class ConnectedTankItemRenderer : SpecialModelRenderer { val sprite = fluidModels.get(fluidData.variant.getVariantFluid().defaultFluidState()).stillMaterial().sprite() //?} else if neoforge { val extensions = IClientFluidTypeExtensions.of(fluidData.variant.getVariantFluid()) - val sprite = Minecraft.getInstance().atlasManager.getAtlasOrThrow(TextureAtlas.LOCATION_BLOCKS).getSprite(extensions.getStillTexture()) + val sprite = Minecraft.getInstance().atlasManager.getAtlasOrThrow(AtlasIds.BLOCKS) + .getSprite(extensions.getStillTexture()) //?} else { val sprite = FluidVariantRendering.getSprite(fluidData.variant) ?: return //?} diff --git a/src/clienttest/java/net/turtton/connectedtank/mixin/clienttest/MixinAccessibilityOnboardingScreen.java b/src/clienttest/java/net/turtton/connectedtank/mixin/clienttest/MixinAccessibilityOnboardingScreen.java new file mode 100644 index 0000000..f6b29f3 --- /dev/null +++ b/src/clienttest/java/net/turtton/connectedtank/mixin/clienttest/MixinAccessibilityOnboardingScreen.java @@ -0,0 +1,23 @@ +package net.turtton.connectedtank.mixin.clienttest; + +import net.minecraft.client.gui.screens.AccessibilityOnboardingScreen; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +// Xvfb: Narrator subsystem unavailable. Skip getNarrator(), run callback directly. +@Mixin(AccessibilityOnboardingScreen.class) +public class MixinAccessibilityOnboardingScreen { + @Inject( + method = "close", + at = + @At( + value = "INVOKE", + target = "Lcom/mojang/text2speech/Narrator;getNarrator()Lcom/mojang/text2speech/Narrator;"), + cancellable = true) + private void suppressNarratorError(boolean dontShowAgain, Runnable callback, CallbackInfo ci) { + callback.run(); + ci.cancel(); + } +} diff --git a/src/clienttest/java/net/turtton/connectedtank/mixin/clienttest/MixinGameRenderer.java b/src/clienttest/java/net/turtton/connectedtank/mixin/clienttest/MixinGameRenderer.java new file mode 100644 index 0000000..77f0246 --- /dev/null +++ b/src/clienttest/java/net/turtton/connectedtank/mixin/clienttest/MixinGameRenderer.java @@ -0,0 +1,19 @@ +package net.turtton.connectedtank.mixin.clienttest; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.GameRenderer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +// MC <26.1: pause-on-unfocus is in GameRenderer.render(). require=0 for >=26.1 compat. +@Mixin(GameRenderer.class) +public class MixinGameRenderer { + @Redirect( + method = "render", + at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Minecraft;isWindowActive()Z"), + require = 0) + private boolean ignoreWindowFocus(Minecraft instance) { + return true; + } +} diff --git a/src/clienttest/java/net/turtton/connectedtank/mixin/clienttest/MixinMinecraftPause.java b/src/clienttest/java/net/turtton/connectedtank/mixin/clienttest/MixinMinecraftPause.java new file mode 100644 index 0000000..f182706 --- /dev/null +++ b/src/clienttest/java/net/turtton/connectedtank/mixin/clienttest/MixinMinecraftPause.java @@ -0,0 +1,16 @@ +package net.turtton.connectedtank.mixin.clienttest; + +import net.minecraft.client.Minecraft; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +// MC >=26.1: pause-on-unfocus moved to Minecraft.pauseIfInactive(). require=0 for <26.1 compat. +@Mixin(Minecraft.class) +public class MixinMinecraftPause { + @Inject(method = "pauseIfInactive", at = @At("HEAD"), cancellable = true, require = 0) + private void suppressPauseOnUnfocus(CallbackInfo ci) { + ci.cancel(); + } +} diff --git a/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestContext.kt b/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestContext.kt new file mode 100644 index 0000000..de8e416 --- /dev/null +++ b/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestContext.kt @@ -0,0 +1,186 @@ +package net.turtton.connectedtank.test + +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine +import net.minecraft.client.Minecraft +import net.minecraft.client.Screenshot +import net.minecraft.client.gui.screens.Screen +import net.minecraft.core.BlockPos +import net.minecraft.server.MinecraftServer +import net.minecraft.world.level.Level +import net.turtton.connectedtank.block.CTBlocks +import net.turtton.connectedtank.block.TankFluidStorage +import net.turtton.connectedtank.fluid.PlatformFluidVariant +import net.turtton.connectedtank.fluid.insertFluid +import net.turtton.connectedtank.world.FluidStoragePersistentState +import org.slf4j.LoggerFactory +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class ClientTestContext(val minecraft: Minecraft) { + private val logger = LoggerFactory.getLogger("ClientTestContext") + + val server: MinecraftServer + get() = minecraft.singleplayerServer ?: error("Not in singleplayer") + + suspend fun waitTicks(ticks: Int) { + repeat(ticks) { delay(50) } + } + + suspend fun waitUntil( + timeoutTicks: Int = 300, + condition: () -> Boolean, + ) { + var waited = 0 + while (!condition()) { + if (waited++ > timeoutTicks) { + error("Timeout waiting for condition after $timeoutTicks ticks") + } + delay(50) + } + } + + fun takeScreenshot(name: String) { + Screenshot.grab( + minecraft.gameDirectory, + "$name.png", + minecraft.mainRenderTarget, + 1, + ) { component -> + logger.info("Screenshot: {}", component.string) + } + } + + suspend fun runOnServer(action: (MinecraftServer) -> Unit) { + suspendCancellableCoroutine { cont -> + server.execute { + if (cont.isActive) { + try { + action(server) + cont.resume(Unit) + } catch (e: Exception) { + cont.resumeWithException(e) + } + } + } + } + } + + suspend fun runCommand(command: String) { + runOnServer { srv -> + srv.commands.performPrefixedCommand( + srv.createCommandSourceStack(), + command, + ) + } + } + + fun pressKey(key: Int) { + //? if >=1.21.11 { + /*val window = minecraft.window.handle() + val keyEvent = net.minecraft.client.input.KeyEvent(key, 0, 0) + val method = minecraft.keyboardHandler.javaClass.getDeclaredMethod( + "keyPress", + Long::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + net.minecraft.client.input.KeyEvent::class.java, + ) + method.isAccessible = true + method.invoke(minecraft.keyboardHandler, window, 1, keyEvent) + method.invoke(minecraft.keyboardHandler, window, 0, keyEvent) + */ + //?} else { + val window = minecraft.window.window + minecraft.keyboardHandler.keyPress(window, key, 0, 1, 0) + minecraft.keyboardHandler.keyPress(window, key, 0, 0, 0) + + //?} + } + + suspend fun waitForScreen( + screenClass: Class, + timeoutTicks: Int = 300, + ) { + waitUntil(timeoutTicks) { screenClass.isInstance(minecraft.screen) } + } + + suspend fun clearArea( + basePos: BlockPos, + sizeX: Int, + sizeY: Int, + sizeZ: Int, + ) { + runOnServer { srv -> + val world = srv.getLevel(Level.OVERWORLD)!! + val state = world.dataStorage.computeIfAbsent(FluidStoragePersistentState.TYPE) + for (x in 0 until sizeX) { + for (y in 0 until sizeY) { + for (z in 0 until sizeZ) { + val pos = basePos.offset(x, y, z) + if (state.getStorage(pos) != null) { + state.removeStorage(pos) + } + world.removeBlock(pos, false) + } + } + } + } + } + + suspend fun placeTank( + pos: BlockPos, + fluid: TankFluidStorage.ExistingData? = null, + ) { + runOnServer { srv -> + val world = srv.getLevel(Level.OVERWORLD)!! + world.setBlockAndUpdate(pos, CTBlocks.CONNECTED_TANK.defaultBlockState()) + val persistentState = world.dataStorage.computeIfAbsent(FluidStoragePersistentState.TYPE) + val storage = TankFluidStorage(fluid = fluid) + persistentState.addStorage(pos, storage) + CTBlocks.syncGroupBlockEntities(world, pos, persistentState) + } + } + + suspend fun placeIsolatedTank( + pos: BlockPos, + fluid: TankFluidStorage.ExistingData, + ) { + runOnServer { srv -> + val world = srv.getLevel(Level.OVERWORLD)!! + world.setBlockAndUpdate(pos, CTBlocks.CONNECTED_TANK.defaultBlockState()) + val persistentState = world.dataStorage.computeIfAbsent(FluidStoragePersistentState.TYPE) + val storage = TankFluidStorage(fluid = fluid) + persistentState.addIsolatedStorage(pos, storage) + CTBlocks.syncGroupBlockEntities(world, pos, persistentState) + } + } + + suspend fun insertFluidAt( + pos: BlockPos, + variant: PlatformFluidVariant, + amount: Long, + ) { + runOnServer { srv -> + val world = srv.getLevel(Level.OVERWORLD)!! + val persistentState = world.dataStorage.computeIfAbsent(FluidStoragePersistentState.TYPE) + val storage = persistentState.getStorage(pos) ?: error("Storage not found at $pos") + insertFluid(storage, variant, amount) + CTBlocks.syncGroupBlockEntities(world, pos, persistentState) + } + } + + suspend fun setupCamera( + x: Double, + y: Double, + z: Double, + yaw: Float, + pitch: Float, + ) { + // tp を 2 回実行: スペクテイターモードの慣性ドリフトで + // 1 回目の tp 後にカメラ位置がずれるのを防ぐ + runCommand("tp @p $x $y $z $yaw $pitch") + waitTicks(3) + runCommand("tp @p $x $y $z $yaw $pitch") + waitTicks(1) + } +} diff --git a/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestEntry.kt b/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestEntry.kt new file mode 100644 index 0000000..14a830c --- /dev/null +++ b/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestEntry.kt @@ -0,0 +1,34 @@ +package net.turtton.connectedtank.test + +//? if fabric { +import net.fabricmc.api.ClientModInitializer + +//?} else if neoforge { +/*import net.neoforged.api.distmarker.Dist +import net.neoforged.bus.api.SubscribeEvent +import net.neoforged.fml.common.EventBusSubscriber +import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent +*/ +//?} + +private const val PROPERTY_KEY = "connectedtank.clienttest" + +//? if fabric { +object ClientTestEntry : ClientModInitializer { + override fun onInitializeClient() { + if (System.getProperty(PROPERTY_KEY) == null) return + ClientTestRunner.start() + } +} + +//?} else if neoforge { +/*@EventBusSubscriber(value = [Dist.CLIENT], modid = "connectedtank") +object ClientTestEntry { + @SubscribeEvent + fun onClientSetup(event: FMLClientSetupEvent) { + if (System.getProperty(PROPERTY_KEY) == null) return + ClientTestRunner.start() + } +} +*/ +//?} diff --git a/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestRunner.kt b/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestRunner.kt new file mode 100644 index 0000000..bc75cfa --- /dev/null +++ b/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestRunner.kt @@ -0,0 +1,121 @@ +package net.turtton.connectedtank.test + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.screens.GenericMessageScreen +import net.minecraft.network.chat.Component +import net.minecraft.world.Difficulty +//? if <26.1 { +import net.minecraft.world.flag.FeatureFlags +//?} +//? if >=1.21.11 && <26.1 { +/*import net.minecraft.world.level.gamerules.GameRules +*/ +//?} else if <1.21.11 { +import net.minecraft.world.level.GameRules + +//?} +import net.minecraft.world.level.GameType +import net.minecraft.world.level.LevelSettings +import net.minecraft.world.level.WorldDataConfiguration +import net.minecraft.world.level.levelgen.WorldOptions +import net.minecraft.world.level.levelgen.presets.WorldPresets +import org.slf4j.LoggerFactory +import kotlin.coroutines.CoroutineContext + +object ClientTestRunner { + private val logger = LoggerFactory.getLogger("ConnectedTankClientTest") + private val minecraft get() = Minecraft.getInstance() + + val clientDispatcher: CoroutineDispatcher = + object : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { + minecraft.execute(block) + } + } + + fun start() { + CoroutineScope(clientDispatcher + CoroutineName("ClientTest")).launch { + var success = false + try { + logger.info("Starting client tests...") + + waitUntil { minecraft.screen != null } + logger.info("Game initialized, creating test world...") + + createTestWorld() + + waitUntil { minecraft.level != null && minecraft.player != null } + waitTicks(60) + + logger.info("Test world loaded, running tests...") + + val context = ClientTestContext(minecraft) + ConnectedTankClientTest.runAll(context) + + success = true + logger.info("All client tests passed!") + } catch (e: Exception) { + logger.error("Client test failed", e) + } finally { + logger.info(if (success) "Tests PASSED" else "Tests FAILED") + // disconnect() は runTick() 内の glfwWaitEventsTimeout() で + // Xvfb 環境下ブロックするため、halt() で強制終了する + Runtime.getRuntime().halt(if (success) 0 else 1) + } + } + } + + private fun createTestWorld() { + //? if >=26.1 { + /*val levelSettings = + LevelSettings( + "connectedtank_test", + GameType.SPECTATOR, + LevelSettings.DifficultySettings(Difficulty.PEACEFUL, false, false), + true, + WorldDataConfiguration.DEFAULT, + )*/ + //?} else { + val levelSettings = + LevelSettings( + "connectedtank_test", + GameType.SPECTATOR, + false, + Difficulty.PEACEFUL, + true, + GameRules(FeatureFlags.DEFAULT_FLAGS), + WorldDataConfiguration.DEFAULT, + ) + //?} + val worldOptions = WorldOptions.testWorldWithRandomSeed() + minecraft.createWorldOpenFlows().createFreshLevel( + "connectedtank_test", + levelSettings, + worldOptions, + { provider -> WorldPresets.createFlatWorldDimensions(provider) }, + GenericMessageScreen(Component.literal("Failed to create test world")), + ) + } + + private suspend fun waitTicks(ticks: Int) { + repeat(ticks) { delay(50) } + } + + private suspend fun waitUntil( + timeoutTicks: Int = 600, + condition: () -> Boolean, + ) { + var waited = 0 + while (!condition()) { + if (waited++ > timeoutTicks) { + error("Timeout waiting for condition after $timeoutTicks ticks") + } + delay(50) + } + } +} diff --git a/src/clienttest/kotlin/net/turtton/connectedtank/test/ConnectedTankClientTest.kt b/src/clienttest/kotlin/net/turtton/connectedtank/test/ConnectedTankClientTest.kt new file mode 100644 index 0000000..beaf558 --- /dev/null +++ b/src/clienttest/kotlin/net/turtton/connectedtank/test/ConnectedTankClientTest.kt @@ -0,0 +1,229 @@ +package net.turtton.connectedtank.test + +import net.minecraft.client.gui.screens.inventory.InventoryScreen +import net.minecraft.core.BlockPos +import net.minecraft.world.item.ItemStack +import net.minecraft.world.level.material.Fluids +import net.turtton.connectedtank.block.CTBlocks +import net.turtton.connectedtank.block.ConnectedTankBlock +import net.turtton.connectedtank.block.TankFluidStorage +import net.turtton.connectedtank.block.TankTier +import net.turtton.connectedtank.component.CTDataComponentTypes +import net.turtton.connectedtank.config.CTClientConfig +import net.turtton.connectedtank.config.CTServerConfig +import net.turtton.connectedtank.fluid.FLUID_BUCKET +import net.turtton.connectedtank.fluid.fluidVariantOf +import org.lwjgl.glfw.GLFW +import org.slf4j.LoggerFactory + +object ConnectedTankClientTest { + private val logger = LoggerFactory.getLogger("ConnectedTankClientTest") + private val TANK_CAPACITY = CTServerConfig.DEFAULT_BUCKET_CAPACITY.toLong() + private val basePos = BlockPos(0, -60, 0) + + suspend fun runAll(context: ClientTestContext) { + context.runCommand("gamemode spectator @p") + context.waitTicks(5) + + // Jade ツールチップの確認は HUD 表示状態が必要なため、F1 (HUD 非表示) の前に実行 + testJadeFluidTooltip(context) + + context.pressKey(GLFW.GLFW_KEY_F1) + context.waitTicks(5) + + testEmptyTank(context) + testFullWaterTank(context) + testHalfWaterTank(context) + testHorizontalConnectedTanks(context) + testVerticalConnectedTanks(context) + testVerticalPartialFillTopFace(context) + testVerticalDifferentFluidsStacked(context) + testVerticalSameFluidDifferentGroups(context) + testItemInventory(context) + } + + private suspend fun takeQualityScreenshots( + context: ClientTestContext, + baseName: String, + ) { + for (quality in CTClientConfig.RenderQuality.entries) { + CTClientConfig.instance.renderQuality = quality + context.waitTicks(5) + context.takeScreenshot("${baseName}_${quality.name.lowercase()}") + } + } + + private suspend fun testEmptyTank(context: ClientTestContext) { + logger.info("Running: testEmptyTank") + context.clearArea(basePos, 3, 3, 3) + context.placeTank(basePos) + context.waitTicks(20) + context.setupCamera(1.8, -58.5, 1.8, 135f, 50f) + context.takeScreenshot("1_empty_tank") + } + + private suspend fun testFullWaterTank(context: ClientTestContext) { + logger.info("Running: testFullWaterTank") + context.clearArea(basePos, 3, 3, 3) + context.placeTank(basePos) + context.insertFluidAt(basePos, fluidVariantOf(Fluids.WATER), FLUID_BUCKET * TANK_CAPACITY) + context.waitTicks(20) + context.setupCamera(1.8, -58.5, 1.8, 135f, 50f) + takeQualityScreenshots(context, "2_full_water_tank") + } + + private suspend fun testHalfWaterTank(context: ClientTestContext) { + logger.info("Running: testHalfWaterTank") + context.clearArea(basePos, 3, 3, 3) + context.placeTank(basePos) + context.insertFluidAt(basePos, fluidVariantOf(Fluids.WATER), FLUID_BUCKET * TANK_CAPACITY / 2) + context.waitTicks(20) + context.setupCamera(1.8, -58.5, 1.8, 135f, 50f) + takeQualityScreenshots(context, "3_half_water_tank") + } + + private suspend fun testHorizontalConnectedTanks(context: ClientTestContext) { + logger.info("Running: testHorizontalConnectedTanks") + context.clearArea(basePos, 3, 3, 3) + val pos1 = basePos + val pos2 = basePos.east() + context.placeTank(pos1) + context.placeTank(pos2) + context.insertFluidAt(pos1, fluidVariantOf(Fluids.WATER), FLUID_BUCKET * TANK_CAPACITY * 2) + context.waitTicks(20) + context.setupCamera(2.5, -58.5, 2.5, 135f, 45f) + takeQualityScreenshots(context, "4_horizontal_connected_tanks") + } + + private suspend fun testVerticalConnectedTanks(context: ClientTestContext) { + logger.info("Running: testVerticalConnectedTanks") + context.clearArea(basePos, 3, 3, 3) + val pos1 = basePos + val pos2 = basePos.above() + context.placeTank(pos1) + context.placeTank(pos2) + context.insertFluidAt(pos1, fluidVariantOf(Fluids.WATER), FLUID_BUCKET * TANK_CAPACITY * 2) + context.waitTicks(20) + context.setupCamera(1.8, -57.0, 1.8, 135f, 45f) + takeQualityScreenshots(context, "5_vertical_connected_tanks") + } + + private suspend fun testVerticalPartialFillTopFace(context: ClientTestContext) { + logger.info("Running: testVerticalPartialFillTopFace") + // 同一液体で下のタンクのみに液体がある場合、上面が正しく描画されることを確認 + context.clearArea(basePos, 3, 3, 3) + val pos1 = basePos + val pos2 = basePos.above() + context.placeTank(pos1) + context.placeTank(pos2) + // 下タンクの半分だけ液体を入れる(上タンクには液体なし) + context.insertFluidAt(pos1, fluidVariantOf(Fluids.WATER), FLUID_BUCKET * TANK_CAPACITY / 2) + context.waitTicks(20) + context.setupCamera(1.8, -57.0, 1.8, 135f, 45f) + takeQualityScreenshots(context, "7_vertical_partial_fill_top_face") + } + + private suspend fun testVerticalDifferentFluidsStacked(context: ClientTestContext) { + logger.info("Running: testVerticalDifferentFluidsStacked") + // 異なる液体のタンクを縦に積んだ場合、両方の液体が描画されることを確認 + context.clearArea(basePos, 3, 3, 3) + val pos1 = basePos + val pos2 = basePos.above() + context.placeTank(pos1) + context.insertFluidAt(pos1, fluidVariantOf(Fluids.WATER), FLUID_BUCKET * TANK_CAPACITY) + context.placeIsolatedTank( + pos2, + TankFluidStorage.ExistingData(fluidVariantOf(Fluids.LAVA), FLUID_BUCKET * TANK_CAPACITY), + ) + context.waitTicks(20) + context.setupCamera(1.8, -57.0, 1.8, 135f, 45f) + takeQualityScreenshots(context, "8_vertical_different_fluids_stacked") + } + + private suspend fun testVerticalSameFluidDifferentGroups(context: ClientTestContext) { + logger.info("Running: testVerticalSameFluidDifferentGroups") + // 同一液体・別グループのタンクが隣接している場合、境界面が描画されることを確認 + context.clearArea(basePos, 3, 3, 3) + val pos1 = basePos + val pos2 = basePos.above() + context.placeTank(pos1) + context.insertFluidAt(pos1, fluidVariantOf(Fluids.WATER), FLUID_BUCKET * TANK_CAPACITY) + context.placeIsolatedTank( + pos2, + TankFluidStorage.ExistingData(fluidVariantOf(Fluids.WATER), FLUID_BUCKET * TANK_CAPACITY), + ) + context.waitTicks(20) + context.setupCamera(1.8, -57.0, 1.8, 135f, 45f) + takeQualityScreenshots(context, "9_vertical_same_fluid_different_groups") + } + + private suspend fun testJadeFluidTooltip(context: ClientTestContext) { + logger.info("Running: testJadeFluidTooltip") + context.clearArea(basePos, 3, 3, 3) + context.placeTank(basePos) + context.insertFluidAt(basePos, fluidVariantOf(Fluids.WATER), FLUID_BUCKET * TANK_CAPACITY / 2) + context.waitTicks(20) + + // タンク中心 (basePos.y + 0.5) にクロスヘアを合わせるため、 + // プレイヤー目線高さ (1.62) を差し引いた足元 Y を計算 + val tankCenterY = basePos.y + 0.5 + val eyeHeight = 1.62 + context.setupCamera(0.5, tankCenterY - eyeHeight, 2.0, 180f, 0f) + // Jade のサーバーデータ取得・描画のために長めに待機 + context.waitTicks(40) + context.takeScreenshot("6_jade_fluid_tooltip") + } + + private suspend fun testItemInventory(context: ClientTestContext) { + logger.info("Running: testItemInventory") + context.runCommand("gamemode survival @p") + context.waitTicks(5) + + context.runOnServer { srv -> + val player = srv.playerList.players.firstOrNull() ?: return@runOnServer + val inventory = player.inventory + inventory.clearContent() + + var slot = 0 + val water = fluidVariantOf(Fluids.WATER) + for (tier in TankTier.entries) { + val block = + CTBlocks.ALL_TANKS.firstOrNull { + (it as? ConnectedTankBlock)?.tier == tier + } ?: continue + val item = block.asItem() + val tierCapacity = CTServerConfig.instance.getTierCapacity(tier) + + val halfStack = + ItemStack(item).also { stack -> + stack.set( + CTDataComponentTypes.TANK_FLUID, + TankFluidStorage.ExistingData(water, FLUID_BUCKET * tierCapacity / 2), + ) + } + if (slot < 36) inventory.setItem(slot++, halfStack) + + val fullStack = + ItemStack(item).also { stack -> + stack.set( + CTDataComponentTypes.TANK_FLUID, + TankFluidStorage.ExistingData(water, FLUID_BUCKET * tierCapacity), + ) + } + if (slot < 36) inventory.setItem(slot++, fullStack) + } + } + context.waitTicks(5) + + context.pressKey(GLFW.GLFW_KEY_E) + context.waitForScreen(InventoryScreen::class.java) + context.waitTicks(10) + context.takeScreenshot("10_item_inventory_all_tiers") + + context.pressKey(GLFW.GLFW_KEY_ESCAPE) + context.waitTicks(3) + + context.runCommand("gamemode spectator @p") + context.waitTicks(5) + } +} diff --git a/src/clienttest/resources/connectedtank.clienttest.mixins.json b/src/clienttest/resources/connectedtank.clienttest.mixins.json new file mode 100644 index 0000000..9685b87 --- /dev/null +++ b/src/clienttest/resources/connectedtank.clienttest.mixins.json @@ -0,0 +1,13 @@ +{ + "required": true, + "package": "net.turtton.connectedtank.mixin.clienttest", + "compatibilityLevel": "JAVA_21", + "client": [ + "MixinGameRenderer", + "MixinMinecraftPause", + "MixinAccessibilityOnboardingScreen" + ], + "injectors": { + "defaultRequire": 0 + } +} diff --git a/src/clienttest/resources/fabric.mod.json b/src/clienttest/resources/fabric.mod.json new file mode 100644 index 0000000..07f054d --- /dev/null +++ b/src/clienttest/resources/fabric.mod.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "id": "connectedtank-clienttest", + "version": "1.0.0", + "name": "ConnectedTank Client Test", + "environment": "client", + "entrypoints": { + "client": [ + { + "value": "net.turtton.connectedtank.test.ClientTestEntry", + "adapter": "kotlin" + } + ] + }, + "mixins": [ + "connectedtank.clienttest.mixins.json" + ], + "depends": { + "connectedtank": "*" + } +} diff --git a/src/main/generated/assets/connectedtank/models/block/connected_tank.json b/src/main/generated/assets/connectedtank/models/block/connected_tank.json index 166954e..562a9e0 100644 --- a/src/main/generated/assets/connectedtank/models/block/connected_tank.json +++ b/src/main/generated/assets/connectedtank/models/block/connected_tank.json @@ -113,6 +113,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "connectedtank:block/connected_tank_frame", "side": "connectedtank:block/connected_tank_side" diff --git a/src/main/generated/assets/connectedtank/models/block/connected_tank_item.json b/src/main/generated/assets/connectedtank/models/block/connected_tank_item.json index a53146a..87f990d 100644 --- a/src/main/generated/assets/connectedtank/models/block/connected_tank_item.json +++ b/src/main/generated/assets/connectedtank/models/block/connected_tank_item.json @@ -594,6 +594,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "frame": "connectedtank:block/connected_tank_frame", "particle": "connectedtank:block/connected_tank_frame", diff --git a/src/main/generated/assets/connectedtank/models/block/copper_connected_tank.json b/src/main/generated/assets/connectedtank/models/block/copper_connected_tank.json index 0ab0e7b..001f153 100644 --- a/src/main/generated/assets/connectedtank/models/block/copper_connected_tank.json +++ b/src/main/generated/assets/connectedtank/models/block/copper_connected_tank.json @@ -113,6 +113,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "connectedtank:block/copper_connected_tank_frame", "side": "connectedtank:block/copper_connected_tank_side" diff --git a/src/main/generated/assets/connectedtank/models/block/copper_connected_tank_item.json b/src/main/generated/assets/connectedtank/models/block/copper_connected_tank_item.json index e15962a..8250d47 100644 --- a/src/main/generated/assets/connectedtank/models/block/copper_connected_tank_item.json +++ b/src/main/generated/assets/connectedtank/models/block/copper_connected_tank_item.json @@ -594,6 +594,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "frame": "connectedtank:block/copper_connected_tank_frame", "particle": "connectedtank:block/copper_connected_tank_frame", diff --git a/src/main/generated/assets/connectedtank/models/block/diamond_connected_tank.json b/src/main/generated/assets/connectedtank/models/block/diamond_connected_tank.json index d8398b4..5d92d63 100644 --- a/src/main/generated/assets/connectedtank/models/block/diamond_connected_tank.json +++ b/src/main/generated/assets/connectedtank/models/block/diamond_connected_tank.json @@ -113,6 +113,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "connectedtank:block/diamond_connected_tank_frame", "side": "connectedtank:block/diamond_connected_tank_side" diff --git a/src/main/generated/assets/connectedtank/models/block/diamond_connected_tank_item.json b/src/main/generated/assets/connectedtank/models/block/diamond_connected_tank_item.json index c9bbf4d..1d7679e 100644 --- a/src/main/generated/assets/connectedtank/models/block/diamond_connected_tank_item.json +++ b/src/main/generated/assets/connectedtank/models/block/diamond_connected_tank_item.json @@ -594,6 +594,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "frame": "connectedtank:block/diamond_connected_tank_frame", "particle": "connectedtank:block/diamond_connected_tank_frame", diff --git a/src/main/generated/assets/connectedtank/models/block/gold_connected_tank.json b/src/main/generated/assets/connectedtank/models/block/gold_connected_tank.json index 6959009..3d30b77 100644 --- a/src/main/generated/assets/connectedtank/models/block/gold_connected_tank.json +++ b/src/main/generated/assets/connectedtank/models/block/gold_connected_tank.json @@ -113,6 +113,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "connectedtank:block/gold_connected_tank_frame", "side": "connectedtank:block/gold_connected_tank_side" diff --git a/src/main/generated/assets/connectedtank/models/block/gold_connected_tank_item.json b/src/main/generated/assets/connectedtank/models/block/gold_connected_tank_item.json index 9cd3e5a..e5a6187 100644 --- a/src/main/generated/assets/connectedtank/models/block/gold_connected_tank_item.json +++ b/src/main/generated/assets/connectedtank/models/block/gold_connected_tank_item.json @@ -594,6 +594,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "frame": "connectedtank:block/gold_connected_tank_frame", "particle": "connectedtank:block/gold_connected_tank_frame", diff --git a/src/main/generated/assets/connectedtank/models/block/iron_connected_tank.json b/src/main/generated/assets/connectedtank/models/block/iron_connected_tank.json index 52e61b8..0968bef 100644 --- a/src/main/generated/assets/connectedtank/models/block/iron_connected_tank.json +++ b/src/main/generated/assets/connectedtank/models/block/iron_connected_tank.json @@ -113,6 +113,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "connectedtank:block/iron_connected_tank_frame", "side": "connectedtank:block/iron_connected_tank_side" diff --git a/src/main/generated/assets/connectedtank/models/block/iron_connected_tank_item.json b/src/main/generated/assets/connectedtank/models/block/iron_connected_tank_item.json index 7b30f85..8bc83b7 100644 --- a/src/main/generated/assets/connectedtank/models/block/iron_connected_tank_item.json +++ b/src/main/generated/assets/connectedtank/models/block/iron_connected_tank_item.json @@ -594,6 +594,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "frame": "connectedtank:block/iron_connected_tank_frame", "particle": "connectedtank:block/iron_connected_tank_frame", diff --git a/src/main/generated/assets/connectedtank/models/block/netherite_connected_tank.json b/src/main/generated/assets/connectedtank/models/block/netherite_connected_tank.json index 18b525a..cdd73f5 100644 --- a/src/main/generated/assets/connectedtank/models/block/netherite_connected_tank.json +++ b/src/main/generated/assets/connectedtank/models/block/netherite_connected_tank.json @@ -113,6 +113,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "connectedtank:block/netherite_connected_tank_frame", "side": "connectedtank:block/netherite_connected_tank_side" diff --git a/src/main/generated/assets/connectedtank/models/block/netherite_connected_tank_item.json b/src/main/generated/assets/connectedtank/models/block/netherite_connected_tank_item.json index b7b5232..ce63710 100644 --- a/src/main/generated/assets/connectedtank/models/block/netherite_connected_tank_item.json +++ b/src/main/generated/assets/connectedtank/models/block/netherite_connected_tank_item.json @@ -594,6 +594,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "frame": "connectedtank:block/netherite_connected_tank_frame", "particle": "connectedtank:block/netherite_connected_tank_frame", diff --git a/src/main/generated/assets/connectedtank/models/block/stone_connected_tank.json b/src/main/generated/assets/connectedtank/models/block/stone_connected_tank.json index 228564d..917727c 100644 --- a/src/main/generated/assets/connectedtank/models/block/stone_connected_tank.json +++ b/src/main/generated/assets/connectedtank/models/block/stone_connected_tank.json @@ -113,6 +113,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "connectedtank:block/stone_connected_tank_frame", "side": "connectedtank:block/stone_connected_tank_side" diff --git a/src/main/generated/assets/connectedtank/models/block/stone_connected_tank_item.json b/src/main/generated/assets/connectedtank/models/block/stone_connected_tank_item.json index 1f9f68e..c132392 100644 --- a/src/main/generated/assets/connectedtank/models/block/stone_connected_tank_item.json +++ b/src/main/generated/assets/connectedtank/models/block/stone_connected_tank_item.json @@ -594,6 +594,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "frame": "connectedtank:block/stone_connected_tank_frame", "particle": "connectedtank:block/stone_connected_tank_frame", diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_down_east.json b/src/main/generated/assets/connectedtank/models/block/tank_border_down_east.json index 0234ed6..493a57f 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_down_east.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_down_east.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_down_north.json b/src/main/generated/assets/connectedtank/models/block/tank_border_down_north.json index 3c2352a..5fcbca5 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_down_north.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_down_north.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_down_south.json b/src/main/generated/assets/connectedtank/models/block/tank_border_down_south.json index 4d7a5d9..8fd2e5b 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_down_south.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_down_south.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_down_west.json b/src/main/generated/assets/connectedtank/models/block/tank_border_down_west.json index 87797ba..a2b9331 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_down_west.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_down_west.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_east_down.json b/src/main/generated/assets/connectedtank/models/block/tank_border_east_down.json index 91fdeec..5b70b4a 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_east_down.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_east_down.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_east_north.json b/src/main/generated/assets/connectedtank/models/block/tank_border_east_north.json index 31a4233..f9501fc 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_east_north.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_east_north.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_east_south.json b/src/main/generated/assets/connectedtank/models/block/tank_border_east_south.json index 72fc4fa..067af14 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_east_south.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_east_south.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_east_up.json b/src/main/generated/assets/connectedtank/models/block/tank_border_east_up.json index f9e3824..5c84613 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_east_up.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_east_up.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_north_down.json b/src/main/generated/assets/connectedtank/models/block/tank_border_north_down.json index 37c62f9..0f9a0f8 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_north_down.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_north_down.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_north_east.json b/src/main/generated/assets/connectedtank/models/block/tank_border_north_east.json index f530885..abc780c 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_north_east.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_north_east.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_north_up.json b/src/main/generated/assets/connectedtank/models/block/tank_border_north_up.json index f1067cf..10b1a11 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_north_up.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_north_up.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_north_west.json b/src/main/generated/assets/connectedtank/models/block/tank_border_north_west.json index 9d25699..20497fa 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_north_west.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_north_west.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_south_down.json b/src/main/generated/assets/connectedtank/models/block/tank_border_south_down.json index 903d094..9c189a7 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_south_down.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_south_down.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_south_east.json b/src/main/generated/assets/connectedtank/models/block/tank_border_south_east.json index 73acdbd..f50e046 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_south_east.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_south_east.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_south_up.json b/src/main/generated/assets/connectedtank/models/block/tank_border_south_up.json index 22307e8..c5dbfa3 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_south_up.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_south_up.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_south_west.json b/src/main/generated/assets/connectedtank/models/block/tank_border_south_west.json index 7df07c6..bfd59c4 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_south_west.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_south_west.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_up_east.json b/src/main/generated/assets/connectedtank/models/block/tank_border_up_east.json index 0f0afff..acb8e9c 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_up_east.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_up_east.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_up_north.json b/src/main/generated/assets/connectedtank/models/block/tank_border_up_north.json index afac8bc..e23f408 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_up_north.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_up_north.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_up_south.json b/src/main/generated/assets/connectedtank/models/block/tank_border_up_south.json index d88515d..cd3663a 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_up_south.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_up_south.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_up_west.json b/src/main/generated/assets/connectedtank/models/block/tank_border_up_west.json index 055b1b8..66ac928 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_up_west.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_up_west.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_west_down.json b/src/main/generated/assets/connectedtank/models/block/tank_border_west_down.json index d011fc2..130619b 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_west_down.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_west_down.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_west_north.json b/src/main/generated/assets/connectedtank/models/block/tank_border_west_north.json index 9381934..abf4cfb 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_west_north.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_west_north.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_west_south.json b/src/main/generated/assets/connectedtank/models/block/tank_border_west_south.json index 5e9ae40..0e36cb5 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_west_south.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_west_south.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } diff --git a/src/main/generated/assets/connectedtank/models/block/tank_border_west_up.json b/src/main/generated/assets/connectedtank/models/block/tank_border_west_up.json index 7c937c5..f05c6d9 100644 --- a/src/main/generated/assets/connectedtank/models/block/tank_border_west_up.json +++ b/src/main/generated/assets/connectedtank/models/block/tank_border_west_up.json @@ -21,6 +21,7 @@ ] } ], + "render_type": "minecraft:cutout", "textures": { "particle": "#frame" } From 3fe87554cb4191f2db0a9c3d39ddf7be25d8a4c4 Mon Sep 17 00:00:00 2001 From: turtton Date: Sat, 9 May 2026 19:23:04 +0900 Subject: [PATCH 3/3] fix: improve client test robustness (command error propagation, screenshot flush wait, mixin require) --- .../net/turtton/connectedtank/test/ClientTestContext.kt | 7 +++---- .../net/turtton/connectedtank/test/ClientTestRunner.kt | 3 +++ .../resources/connectedtank.clienttest.mixins.json | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestContext.kt b/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestContext.kt index de8e416..9da8cf1 100644 --- a/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestContext.kt +++ b/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestContext.kt @@ -68,10 +68,9 @@ class ClientTestContext(val minecraft: Minecraft) { suspend fun runCommand(command: String) { runOnServer { srv -> - srv.commands.performPrefixedCommand( - srv.createCommandSourceStack(), - command, - ) + val trimmed = if (command.startsWith("/")) command.substring(1) else command + val source = srv.createCommandSourceStack() + srv.commands.dispatcher.execute(trimmed, source) } } diff --git a/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestRunner.kt b/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestRunner.kt index bc75cfa..80e1d8a 100644 --- a/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestRunner.kt +++ b/src/clienttest/kotlin/net/turtton/connectedtank/test/ClientTestRunner.kt @@ -63,6 +63,9 @@ object ClientTestRunner { logger.error("Client test failed", e) } finally { logger.info(if (success) "Tests PASSED" else "Tests FAILED") + // スクリーンショットは非同期で保存されるため、 + // 書き込み完了を待ってから終了する + waitTicks(10) // disconnect() は runTick() 内の glfwWaitEventsTimeout() で // Xvfb 環境下ブロックするため、halt() で強制終了する Runtime.getRuntime().halt(if (success) 0 else 1) diff --git a/src/clienttest/resources/connectedtank.clienttest.mixins.json b/src/clienttest/resources/connectedtank.clienttest.mixins.json index 9685b87..f072b51 100644 --- a/src/clienttest/resources/connectedtank.clienttest.mixins.json +++ b/src/clienttest/resources/connectedtank.clienttest.mixins.json @@ -8,6 +8,6 @@ "MixinAccessibilityOnboardingScreen" ], "injectors": { - "defaultRequire": 0 + "defaultRequire": 1 } }