diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e67b3031..008c0416 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,30 +21,3 @@ jobs: cache-read-only: ${{ github.ref != 'refs/heads/main' }} - name: Build with Gradle run: ./gradlew build - - name: Test with Gradle - run: ./gradlew test - platform-compat: - name: Check ${{ matrix.platform }} compatibility modules - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - platform: Fabric - task: checkFabricPlatformCompat - - platform: NeoForge - task: checkNeoForgePlatformCompat - steps: - - name: Checkout sources - uses: actions/checkout@v7.0.1 - - name: Setup Java - uses: actions/setup-java@v6 - with: - distribution: 'temurin' - java-version: 25 - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 - with: - cache-read-only: ${{ github.ref != 'refs/heads/main' }} - - name: Check compatibility modules - run: ./gradlew ${{ matrix.task }} diff --git a/.github/workflows/maven-publish.yml b/.github/workflows/maven-publish.yml index d689d3b4..88dfc317 100644 --- a/.github/workflows/maven-publish.yml +++ b/.github/workflows/maven-publish.yml @@ -21,4 +21,4 @@ jobs: env: REPOSITORY_USER: ${{ secrets.REPOSITORY_USER }} REPOSITORY_TOKEN: ${{ secrets.REPOSITORY_TOKEN }} - run: ./gradlew publish publishPlatformCompat + run: ./gradlew publish diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..ba0feee8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,102 @@ +# Contributing + +## Building + +Run Gradle from the repository root: + +```sh +./gradlew build +``` + +This compiles, tests, and packages every module, including the Fabric and NeoForge artifacts for every supported +Minecraft version. To work on a single version, build only its project: + +```sh +./gradlew :fabric:versions:1.21.10:build +./gradlew :neoforge:versions:1.21.10:build +``` + +## Minecraft versions + +Fabric and NeoForge are built and published once per Minecraft version, as +`dev.faststats.metrics::+mc`. Each artifact only declares the +Minecraft version it was built for. + +### Where the code lives + +| Directory | Contents | +|---------------------------------|--------------------------------------------------------------| +| `fabric/`, `neoforge/` | Platform code that is the same for every Minecraft version | +| `onboarding/` | Onboarding code without any Minecraft dependency | +| `fabric/versions/` | Fabric code that depends on the Minecraft version | +| `neoforge/versions/` | NeoForge code that depends on the Minecraft version | +| `onboarding/versions/` | The consent screen and its mixins, compiled into both of them | + +The directories in `/versions` are called variants. A variant is named after the first Minecraft version +that needs its code, and every later version is built from it until the next variant. For example, Fabric 1.21.10 is +built from `fabric/versions/1.21.9` and `onboarding/versions/1.21.9`. + +Each Minecraft version still gets its own Gradle project, `::versions:`, which compiles the variants +against exactly that version. Their directories in `/versions` are created by Gradle and only contain build +output. + +The onboarding sources are compiled into the Fabric and NeoForge projects rather than built on their own. Files that +are identical between onboarding variants only exist in the oldest variant using them. The other variants reference +them through the `sharedSources` table in `build-logic/src/main/kotlin/faststats.onboarding-sources.gradle.kts`. + +### Naming + +- **Variant directories:** the Minecraft version, e.g. `1.21.9` or `26.1`. +- **Fabric and NeoForge packages:** the variant with underscores, e.g. `dev.faststats.fabric.compat.v1_21_9`. Each + variant registers its `CompatibilityLayerImpl` in + `src/main/resources/META-INF/services/dev.faststats..compat.CompatibilityLayer`. +- **Onboarding packages:** the same in every variant, `dev.faststats.screen.minecraft` and + `dev.faststats.screen.minecraft.mixin`, so files can be shared between variants. +- **Version catalog entries:** the Minecraft version with `x` instead of dots, e.g. `minecraft-v1x21x10`, + `fabric-api-v1x21x10` and `neoforge-v1x21x10`. + +## Adding a Minecraft version + +1. Add the version to `gradle/libs.versions.toml` (the release numbers below are placeholders): + + ```toml + [versions] + neoforge-v26x4 = "26.4.0.1" + + [libraries] + minecraft-v26x4 = "com.mojang:minecraft:26.4" + fabric-api-v26x4 = "net.fabricmc.fabric-api:fabric-api:0.160.0+26.4" + ``` + +2. Add it to `minecraftVersions` in `build-logic/src/main/kotlin/MinecraftVersions.kt`, after the newest version: + + ```kotlin + MinecraftVersion("26.4", java = 25, obfuscated = false), + ``` + + - `java`: the Java version the game requires. The code is compiled for it. + - `obfuscated`: whether the game is obfuscated and has to be remapped. Versions before 26.1 are. + - `fabricApi`: only needed when there is no Fabric API release for exactly this version, e.g. + `fabricApi = "26.1.2"`. + +3. Build the new version: + + ```sh + ./gradlew :fabric:versions:26.4:build :neoforge:versions:26.4:build + ``` + +If it builds, the version is done. It is published with the next release. + +### When the code needs changes + +If a version doesn't compile, or needs different behavior, give it its own variant on the platform that changed: + +1. Copy the closest older variant, e.g. `fabric/versions/26.1` to `fabric/versions/26.4`. +2. For Fabric and NeoForge, rename the package to the new variant, e.g. `v26_1` to `v26_4`, and update the class name + in its `META-INF/services` file. +3. For onboarding, the new variant only needs the files that change. For every file it takes unchanged from an older + variant, add it to that file's `sharedSources` entry, or add an entry if the file doesn't have one yet. +4. Fix the code and build every version from the new one onwards, as they now use the new variant too. + +A variant only replaces the code of its own platform. A new Fabric variant doesn't affect NeoForge or onboarding, and +the other way around. diff --git a/README.md b/README.md index f533a77c..a8905f93 100644 --- a/README.md +++ b/README.md @@ -23,14 +23,17 @@ Use `build` for the reusable FastStats libraries: ``` Library jars are written to each module's `build/libs` directory. Fabric and NeoForge compatibility artifacts are -published under stable Maven artifact IDs with Minecraft range suffixes: +published under stable Maven artifact IDs for every supported Minecraft version: ```text -dev.faststats.metrics:fabric:+mc26.1-26.2 -dev.faststats.metrics:neoforge:+mc26.1-26.2 +dev.faststats.metrics:fabric:+mc26.1.2 +dev.faststats.metrics:neoforge:+mc26.1.2 ``` -Use `checkPlatformCompat` to compile all Fabric and NeoForge compatibility modules. +Each artifact only declares the Minecraft version it was built for in its `fabric.mod.json` or `neoforge.mods.toml`. + +See [CONTRIBUTING.md](CONTRIBUTING.md) for how the version-specific code is organized and how to add a Minecraft +version. ### Building everything @@ -40,10 +43,3 @@ To compile and test all modules with the standard lifecycle, run: ./gradlew build ``` -### Platform compatibility checks - -Compile all published platform compatibility modules with: - -```sh -./gradlew checkPlatformCompat -``` diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index bc0172f0..210bf6b5 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -1,3 +1,19 @@ plugins { `kotlin-dsl` } + +repositories { + gradlePluginPortal() + maven("https://maven.fabricmc.net/") + maven("https://maven.neoforged.net/releases") +} + +fun plugin(plugin: Provider) = plugin.map { + "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version}" +} + +dependencies { + implementation(plugin(libs.plugins.fabric.loom)) + implementation(plugin(libs.plugins.moddev)) + implementation(plugin(libs.plugins.shadow)) +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts index b2de3ce3..619668b0 100644 --- a/build-logic/settings.gradle.kts +++ b/build-logic/settings.gradle.kts @@ -4,4 +4,9 @@ dependencyResolutionManagement { repositories { gradlePluginPortal() } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } } diff --git a/build-logic/src/main/kotlin/MinecraftVersions.kt b/build-logic/src/main/kotlin/MinecraftVersions.kt new file mode 100644 index 00000000..67060742 --- /dev/null +++ b/build-logic/src/main/kotlin/MinecraftVersions.kt @@ -0,0 +1,101 @@ +import org.gradle.api.Project +import java.io.File + +/** + * A Minecraft version the mod platforms are compiled and published for. + * + * @param java the Java version the game runs on + * @param obfuscated whether the game is obfuscated and the jars have to be remapped + * @param fabricApi the Fabric API release to compile against + */ +class MinecraftVersion( + val name: String, + val java: Int, + val obfuscated: Boolean = true, + fabricApi: String = name, +) { + // the version catalog names its entries like minecraft-v1x21x10 + val catalogName = catalogName(name) + val fabricApiCatalogName = catalogName(fabricApi) + + override fun toString() = name +} + +private fun catalogName(version: String) = "v" + version.replace('.', 'x') + +// every supported Minecraft version, oldest first +// a version without its own directory in /versions is built from the closest older one with sources +val minecraftVersions = listOf( + MinecraftVersion("1.16.1", java = 17), + MinecraftVersion("1.16.2", java = 17), + MinecraftVersion("1.16.3", java = 17), + MinecraftVersion("1.16.4", java = 17), + MinecraftVersion("1.16.5", java = 17), + MinecraftVersion("1.17", java = 17), + MinecraftVersion("1.17.1", java = 17), + MinecraftVersion("1.18", java = 17), + MinecraftVersion("1.18.1", java = 17), + MinecraftVersion("1.18.2", java = 17), + MinecraftVersion("1.19", java = 17), + MinecraftVersion("1.19.1", java = 17), + MinecraftVersion("1.19.2", java = 17), + MinecraftVersion("1.19.3", java = 17), + MinecraftVersion("1.19.4", java = 17), + MinecraftVersion("1.20", java = 17), + MinecraftVersion("1.20.1", java = 17), + MinecraftVersion("1.20.2", java = 17), + MinecraftVersion("1.20.3", java = 17), + MinecraftVersion("1.20.4", java = 17), + MinecraftVersion("1.20.5", java = 21), + MinecraftVersion("1.20.6", java = 21), + MinecraftVersion("1.21", java = 21), + MinecraftVersion("1.21.1", java = 21), + MinecraftVersion("1.21.2", java = 21), + MinecraftVersion("1.21.3", java = 21), + MinecraftVersion("1.21.4", java = 21), + MinecraftVersion("1.21.5", java = 21), + MinecraftVersion("1.21.6", java = 21), + MinecraftVersion("1.21.7", java = 21), + MinecraftVersion("1.21.8", java = 21), + MinecraftVersion("1.21.9", java = 21), + MinecraftVersion("1.21.10", java = 21), + MinecraftVersion("1.21.11", java = 21), + MinecraftVersion("26.1", java = 25, obfuscated = false, fabricApi = "26.1.2"), + MinecraftVersion("26.1.1", java = 25, obfuscated = false, fabricApi = "26.1.2"), + MinecraftVersion("26.1.2", java = 25, obfuscated = false), + MinecraftVersion("26.2", java = 25, obfuscated = false), + MinecraftVersion("26.3", java = 25, obfuscated = false), +) + +private val platformVersions = mapOf( + "fabric" to minecraftVersions, + "neoforge" to minecraftVersions.dropWhile { it.name != "1.20.6" }, + "onboarding" to minecraftVersions, +) + +// the platforms with a project per Minecraft version, the onboarding sources are compiled into them +val minecraftPlatforms = setOf("fabric", "neoforge") + +fun minecraftVersions(platform: String): List = platformVersions.getValue(platform) + +// projects are named ::versions: +val Project.minecraftPlatform: String get() = path.split(':')[1] + +val Project.minecraftVersion: MinecraftVersion + get() = minecraftVersions(minecraftPlatform).single { it.name == name } + +/** + * The version whose sources [version] is built from, the closest version up to it with its own directory. + */ +fun Project.minecraftVariant( + platform: String = minecraftPlatform, + version: MinecraftVersion = minecraftVersion, +): MinecraftVersion = minecraftVersions(platform) + .takeWhile { it.name != version.name } + .plus(version) + .last { variantDirectory(platform, it).resolve("src").isDirectory } + +fun Project.variantDirectory( + platform: String = minecraftPlatform, + variant: MinecraftVersion = minecraftVariant(platform), +): File = rootProject.file("$platform/versions/${variant.name}") diff --git a/build-logic/src/main/kotlin/PublishingLock.kt b/build-logic/src/main/kotlin/PublishingLock.kt new file mode 100644 index 00000000..02198da1 --- /dev/null +++ b/build-logic/src/main/kotlin/PublishingLock.kt @@ -0,0 +1,10 @@ +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters + +/** + * Lets only one publish task run at a time. + * + * The Minecraft version projects of a platform publish the same artifact in different versions, + * publishing them in parallel corrupts the shared maven-metadata.xml. + */ +abstract class PublishingLock : BuildService diff --git a/build-logic/src/main/kotlin/faststats.fabric-compat.gradle.kts b/build-logic/src/main/kotlin/faststats.fabric-compat.gradle.kts new file mode 100644 index 00000000..fafc9ffe --- /dev/null +++ b/build-logic/src/main/kotlin/faststats.fabric-compat.gradle.kts @@ -0,0 +1,60 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import net.fabricmc.loom.api.LoomGradleExtensionAPI +import net.fabricmc.loom.task.RemapJarTask + +plugins { + id("faststats.minecraft-version") + id("faststats.onboarding-sources") + id("faststats.shadow-conventions") + id("maven-publish") +} + +// version projects are configured by their parent before they have a catalog of their own +val libs = rootProject.the().named("libs") +val minecraft = minecraftVersion + +pluginManager.apply(if (minecraft.obfuscated) "net.fabricmc.fabric-loom-remap" else "net.fabricmc.fabric-loom") +val loom = the() + +extra.set("publishArtifactId", "fabric") +extra.set("publishDocsUrl", "https://docs.faststats.dev/java/platform/fabric") +extra.set("publishVersionSuffix", "mc$minecraft") + +tasks.processResources { + from(project(":fabric").file("src/main/resources")) + val version = provider { project.version.toString() } + val minecraftVersion = minecraft.name + inputs.property("version", version) + inputs.property("minecraft", minecraftVersion) + filesMatching("fabric.mod.json") { + expand("version" to version.get(), "minecraft" to minecraftVersion) + } +} + +dependencies { + add("minecraft", libs.findLibrary("minecraft-${minecraft.catalogName}").get()) + if (minecraft.obfuscated) add("mappings", loom.officialMojangMappings()) + + compileOnly(libs.findLibrary("fabric-api-${minecraft.fabricApiCatalogName}").get()) + compileOnly(libs.findLibrary("fabric-loader").get()) + compileOnly(libs.findLibrary("sponge-mixin").get()) + + implementation(project(":core")) + implementation(project(":config")) + implementation(project(":onboarding")) + implementation(project(":fabric", "runtimeElements")) +} + +pluginManager.withPlugin("net.fabricmc.fabric-loom-remap") { + val shadowJar = tasks.named("shadowJar") { + destinationDirectory.set(layout.buildDirectory.dir("devlibs")) + archiveClassifier.set("all") + } + val remapJar = tasks.named("remapJar") { + inputFile.set(shadowJar.flatMap { it.archiveFile }) + } + configurations.named("shadowRuntimeElements") { + outgoing.artifacts.clear() + outgoing.artifact(remapJar) + } +} diff --git a/build-logic/src/main/kotlin/faststats.java-conventions.gradle.kts b/build-logic/src/main/kotlin/faststats.java-conventions.gradle.kts index 689d4343..445966b8 100644 --- a/build-logic/src/main/kotlin/faststats.java-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/faststats.java-conventions.gradle.kts @@ -17,11 +17,13 @@ java { val generateFastStatsProperties = tasks.register("generateFastStatsProperties") { description = "Generates the META-INF/faststats.properties file" val outputDir = layout.buildDirectory.dir("generated/resources/faststats") + val version = provider { project.version.toString() } + inputs.property("version", version) outputs.dir(outputDir) doLast { val file = outputDir.get().file("META-INF/faststats.properties").asFile file.parentFile.mkdirs() - file.writeText("version=${project.version}\n") + file.writeText("version=${version.get()}\n") } } @@ -72,6 +74,14 @@ tasks.javadoc { } } +val publishingLock = gradle.sharedServices.registerIfAbsent("publishingLock", PublishingLock::class) { + maxParallelUsages.set(1) +} + +tasks.withType().configureEach { + usesService(publishingLock) +} + afterEvaluate { val publishArtifactId = ownProperty("publishArtifactId") if (!plugins.hasPlugin("maven-publish") && publishArtifactId == null) return@afterEvaluate @@ -104,7 +114,8 @@ afterEvaluate { } } - from(components["java"]) + // shadow-conventions projects publish the shadowed jar along with their shadow dependencies + from(components[if (plugins.hasPlugin("com.gradleup.shadow")) "shadow" else "java"]) } repositories { diff --git a/build-logic/src/main/kotlin/faststats.minecraft-version.gradle.kts b/build-logic/src/main/kotlin/faststats.minecraft-version.gradle.kts new file mode 100644 index 00000000..f3ad012f --- /dev/null +++ b/build-logic/src/main/kotlin/faststats.minecraft-version.gradle.kts @@ -0,0 +1,21 @@ +// compiles the sources of the version's variant against that exact Minecraft version +plugins { + id("java") +} + +val minecraft = minecraftVersion +val variant = variantDirectory() + +extra.set("minecraftVariant", minecraftVariant().name) + +java.toolchain.languageVersion = JavaLanguageVersion.of(minecraft.java) + +tasks.compileJava { + options.release.set(minecraft.java) +} + +// the project of the variant's own version already uses its sources +if (variant != projectDir) sourceSets.main { + java.srcDir(variant.resolve("src/main/java")) + resources.srcDir(variant.resolve("src/main/resources")) +} diff --git a/build-logic/src/main/kotlin/faststats.mod-projects.settings.gradle.kts b/build-logic/src/main/kotlin/faststats.mod-projects.settings.gradle.kts index 9ab26674..8174f93c 100644 --- a/build-logic/src/main/kotlin/faststats.mod-projects.settings.gradle.kts +++ b/build-logic/src/main/kotlin/faststats.mod-projects.settings.gradle.kts @@ -1,14 +1,22 @@ include( "fabric", "neoforge", + "onboarding" ) -fun includeVersionModules(platform: String) { - file("$platform/versions") - .listFiles { file -> file.isDirectory && file.resolve("build.gradle.kts").isFile } - ?.sortedBy { it.name } - ?.forEach { include("$platform:versions:${it.name}") } +(minecraftPlatforms + "onboarding").forEach { platform -> + file("$platform/versions").listFiles { file -> file.resolve("src").isDirectory }?.forEach { variant -> + require(minecraftVersions(platform).any { it.name == variant.name }) { + "$platform/versions/${variant.name} is not a supported Minecraft version for $platform" + } + } } -includeVersionModules("fabric") -includeVersionModules("neoforge") +// one project per platform and Minecraft version, see MinecraftVersions.kt +minecraftPlatforms.forEach { platform -> + minecraftVersions(platform).forEach { version -> + val path = ":$platform:versions:${version.name}" + include(path) + project(path).projectDir = file("$platform/versions/${version.name}").apply { mkdirs() } + } +} diff --git a/build-logic/src/main/kotlin/faststats.neoforge-compat.gradle.kts b/build-logic/src/main/kotlin/faststats.neoforge-compat.gradle.kts new file mode 100644 index 00000000..57611b28 --- /dev/null +++ b/build-logic/src/main/kotlin/faststats.neoforge-compat.gradle.kts @@ -0,0 +1,42 @@ +plugins { + id("net.neoforged.moddev") + id("faststats.minecraft-version") + id("faststats.onboarding-sources") + id("faststats.shadow-conventions") + id("maven-publish") +} + +// version projects are configured by their parent before they have a catalog of their own +val libs = rootProject.the().named("libs") +val minecraft = minecraftVersion + +extra.set("publishArtifactId", "neoforge") +extra.set("publishDocsUrl", "https://docs.faststats.dev/java/platform/neoforge") +extra.set("publishVersionSuffix", "mc$minecraft") + +tasks.processResources { + from(project(":neoforge").file("src/main/resources")) + val version = provider { project.version.toString() } + val range = "[${minecraft.name}]" + inputs.property("version", version) + inputs.property("minecraft", range) + filesMatching("META-INF/neoforge.mods.toml") { + expand("version" to version.get(), "minecraft" to range) + } +} + +neoForge { + version = libs.findVersion("neoforge-${minecraft.catalogName}").get().requiredVersion +} + +configurations.configureEach { + resolutionStrategy.force(libs.findLibrary("gson").get()) +} + +dependencies { + compileOnly(libs.findLibrary("neoforged-bus").get()) + implementation(project(":core")) + implementation(project(":config")) + implementation(project(":onboarding")) + implementation(project(":neoforge")) +} diff --git a/build-logic/src/main/kotlin/faststats.onboarding-sources.gradle.kts b/build-logic/src/main/kotlin/faststats.onboarding-sources.gradle.kts new file mode 100644 index 00000000..e3c7392e --- /dev/null +++ b/build-logic/src/main/kotlin/faststats.onboarding-sources.gradle.kts @@ -0,0 +1,66 @@ +// compiles the onboarding sources for the project's Minecraft version into the platform jar +plugins { + id("java") +} + +val variant = minecraftVariant("onboarding") +val directory = variantDirectory("onboarding", variant) + +// files that are identical between variants only exist in the oldest variant using them +class SharedSource(val owner: String, val path: String, vararg val users: String) + +val sharedSources = listOf( + SharedSource( + "1.16.1", "java/dev/faststats/screen/minecraft/ConsentScreen.java", + "1.16.2" + ), + SharedSource( + "1.16.1", "java/dev/faststats/screen/minecraft/mixin/OptionsScreenMixin.java", + "1.16.2" + ), + SharedSource( + "1.16.1", "java/dev/faststats/screen/minecraft/mixin/TitleScreenMixin.java", + "1.16.2", "1.17", "1.19", "1.19.3", "1.19.4", "1.20", "1.20.2", "1.20.3", "1.20.5", "1.21.4", "1.21.9" + ), + SharedSource( + "1.16.1", "resources/faststats-onboarding.mixins.json", + "1.16.2", "1.17", "1.19" + ), + SharedSource( + "1.17", "java/dev/faststats/screen/minecraft/TextArea.java", + "1.19" + ), + SharedSource( + "1.19.3", "resources/faststats-onboarding.mixins.json", + "1.19.4", "1.20", "1.20.2", "1.20.3" + ), + SharedSource( + "1.19.4", "java/dev/faststats/screen/minecraft/mixin/TelemetryInfoScreenMixin.java", + "1.20", "1.20.2", "1.20.3", "1.20.5", "1.21.4", "1.21.9" + ), + SharedSource( + "1.20", "java/dev/faststats/screen/minecraft/TextArea.java", + "1.20.2", "1.20.3", "1.20.5" + ), + SharedSource( + "1.20.5", "resources/faststats-onboarding.mixins.json", + "1.21.4", "1.21.9" + ), + SharedSource( + "1.21.4", "java/dev/faststats/screen/minecraft/ConsentScreen.java", + "1.21.9" + ), +) + +sourceSets.main { + java.srcDir(directory.resolve("src/main/java")) + resources.srcDir(directory.resolve("src/main/resources")) + + sharedSources.filter { variant.name in it.users }.forEach { shared -> + val root = shared.path.substringBefore('/') + val source = objects.sourceDirectorySet("shared", "shared onboarding sources from ${shared.owner}") + source.srcDir(rootProject.file("onboarding/versions/${shared.owner}/src/main/$root")) + source.include(shared.path.substringAfter('/')) + (if (root == "java") java else resources).source(source) + } +} diff --git a/build-logic/src/main/kotlin/faststats.project-structure.settings.gradle.kts b/build-logic/src/main/kotlin/faststats.project-structure.settings.gradle.kts index 10ea54f1..91b48b4a 100644 --- a/build-logic/src/main/kotlin/faststats.project-structure.settings.gradle.kts +++ b/build-logic/src/main/kotlin/faststats.project-structure.settings.gradle.kts @@ -1,11 +1,3 @@ -pluginManagement { - repositories { - maven("https://maven.fabricmc.net/") - maven("https://maven.neoforged.net/releases") - gradlePluginPortal() - } -} - rootProject.name = "faststats-java" pluginManager.apply("faststats.core-projects") diff --git a/build-logic/src/main/kotlin/faststats.root-conventions.gradle.kts b/build-logic/src/main/kotlin/faststats.root-conventions.gradle.kts deleted file mode 100644 index 56e37636..00000000 --- a/build-logic/src/main/kotlin/faststats.root-conventions.gradle.kts +++ /dev/null @@ -1,38 +0,0 @@ -plugins { - id("java") -} - -subprojects { - pluginManager.apply("faststats.java-conventions") -} - -fun platformCompatProjects(platform: String) = subprojects.filter { project -> - project.path.startsWith(":$platform:versions:") -} - -tasks.register("checkFabricPlatformCompat") { - group = "verification" - description = "Compiles all Fabric platform compatibility modules." - dependsOn(platformCompatProjects("fabric").map { "${it.path}:compileJava" }) -} - -tasks.register("checkNeoForgePlatformCompat") { - group = "verification" - description = "Compiles all NeoForge platform compatibility modules." - dependsOn(platformCompatProjects("neoforge").map { "${it.path}:compileJava" }) -} - -tasks.register("checkPlatformCompat") { - group = "verification" - description = "Compiles all platform compatibility modules." - dependsOn(tasks.named("checkFabricPlatformCompat"), tasks.named("checkNeoForgePlatformCompat")) -} - -tasks.register("publishPlatformCompat") { - group = "publishing" - description = "Publishes all platform compatibility modules." - dependsOn( - platformCompatProjects("fabric").map { "${it.path}:publish" } + - platformCompatProjects("neoforge").map { "${it.path}:publish" } - ) -} diff --git a/build-logic/src/main/kotlin/faststats.shadow-conventions.gradle.kts b/build-logic/src/main/kotlin/faststats.shadow-conventions.gradle.kts new file mode 100644 index 00000000..99e3ea93 --- /dev/null +++ b/build-logic/src/main/kotlin/faststats.shadow-conventions.gradle.kts @@ -0,0 +1,88 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import org.gradle.kotlin.dsl.support.serviceOf + +plugins { + id("java-library") + id("com.gradleup.shadow") +} + +val shadowClasspath = configurations.resolvable("shadowClasspath") { + extendsFrom(configurations.api.get(), configurations.implementation.get(), configurations.runtimeOnly.get()) + attributes { + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) + attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.LIBRARY)) + attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements.JAR)) + attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.EXTERNAL)) + } +} + +tasks.jar { + archiveClassifier.set("dev") +} + +tasks.named("shadowJar") { + configurations.set(shadowClasspath.map { listOf(it) }) + archiveClassifier.set("") + filesMatching("META-INF/services/**") { + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + mergeServiceFiles() +} + +shadow { + addTargetJvmVersionAttribute.set(false) +} +configurations.named("shadowRuntimeElements") { + attributes.attributeProvider( + TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, + tasks.compileJava.flatMap { it.options.release }, + ) +} + +fun shadowSources(filter: (ProjectComponentIdentifier) -> Boolean = { true }) = files(shadowClasspath.map { classpath -> + classpath.incoming.artifactView { + withVariantReselection() + componentFilter { it is ProjectComponentIdentifier && filter(it) } + attributes { + attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.DOCUMENTATION)) + attribute(DocsType.DOCS_TYPE_ATTRIBUTE, objects.named(DocsType.SOURCES)) + } + }.files +}) + +val archives = serviceOf() + +// takes the archive operations as a parameter, as the configuration cache can't store a reference to this script +fun CopySpec.fromSourcesJars(sources: FileCollection, archives: ArchiveOperations) { + from(sources.elements.map { files -> files.map { archives.zipTree(it) } }) { + exclude("module-info.java", "META-INF/**") + } +} + +tasks.named("sourcesJar") { + val sources = shadowSources() + dependsOn(sources) + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + fromSourcesJars(sources, archives) +} + +val javadocSources = tasks.register("javadocSources") { + val sources = shadowSources { !it.projectPath.contains(":versions:") } + dependsOn(sources) + // split packages across the shaded projects would otherwise clash, e.g. their package-info.java + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from(sourceSets.main.map { it.allJava }) { exclude("module-info.java") } + fromSourcesJars(sources, archives) + include("**/*.java") + into(layout.buildDirectory.dir("javadocSources")) +} + +tasks.named("javadoc") { + setSource(javadocSources) + classpath += shadowClasspath.get() +} + +components.named("shadow") { + addVariantsFromConfiguration(configurations["sourcesElements"]) {} + addVariantsFromConfiguration(configurations["javadocElements"]) {} +} diff --git a/build.gradle.kts b/build.gradle.kts index 1193eb60..722c0492 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,7 @@ plugins { - id("faststats.root-conventions") - id("com.gradleup.shadow") version "9.6.1" apply false - kotlin("jvm") version "2.4.21" apply false + id("faststats.java-conventions") apply false +} + +subprojects { + pluginManager.apply("faststats.java-conventions") } diff --git a/bukkit/build.gradle.kts b/bukkit/build.gradle.kts index f0e9ba4f..c42284d0 100644 --- a/bukkit/build.gradle.kts +++ b/bukkit/build.gradle.kts @@ -22,5 +22,5 @@ repositories { dependencies { api(project(":core")) implementation(project(":config")) - compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") + compileOnly(libs.paper.api) } diff --git a/bungeecord/build.gradle.kts b/bungeecord/build.gradle.kts index 54b2613c..be28badd 100644 --- a/bungeecord/build.gradle.kts +++ b/bungeecord/build.gradle.kts @@ -19,5 +19,5 @@ repositories { dependencies { api(project(":core")) implementation(project(":config")) - compileOnly("net.md-5:bungeecord-api:26.1-R0.1-SNAPSHOT") + compileOnly(libs.bungeecord.api) } diff --git a/config/src/main/java/dev/faststats/config/SimpleConfig.java b/config/src/main/java/dev/faststats/config/SimpleConfig.java index b5b72417..0c4f8d29 100644 --- a/config/src/main/java/dev/faststats/config/SimpleConfig.java +++ b/config/src/main/java/dev/faststats/config/SimpleConfig.java @@ -9,7 +9,6 @@ import org.jspecify.annotations.Nullable; import java.io.IOException; -import java.io.OutputStreamWriter; import java.nio.file.Files; import java.nio.file.Path; import java.util.Properties; @@ -21,16 +20,9 @@ import static java.nio.charset.StandardCharsets.UTF_8; @ApiStatus.Internal -public record SimpleConfig( - UUID serverId, - boolean enabled, - boolean additionalMetrics, - boolean debug, - boolean submitMetrics, - boolean errorTracking, - boolean firstRun -) implements Config { +public final class SimpleConfig implements Config { private static final int CONFIG_VERSION = 3; + private static final String CONSENT_SCREEN_SHOWN = "consent-screen-shown"; private static final String COMMENT = """ FastStats (https://faststats.dev) collects pseudonymous usage statistics and errors. @@ -58,14 +50,124 @@ public record SimpleConfig( Learn more at: https://faststats.dev/info Since this is your first start with FastStats, submission will not start - until you restart the server to allow you to opt out if you prefer."""; + until after a restart, to allow you to opt out if you prefer."""; + + private final Path file; + private final UUID serverId; + private final boolean debug; + private final boolean firstRun; + private final boolean client; + private volatile boolean additionalMetrics; + private volatile boolean enabled; + private volatile boolean errorTracking; + private volatile boolean submitMetrics; + private volatile boolean consentScreenShown; + + public SimpleConfig( + final Path file, + final UUID serverId, + final boolean enabled, + final boolean additionalMetrics, + final boolean debug, + final boolean submitMetrics, + final boolean errorTracking, + final boolean firstRun, + final boolean client, + final boolean consentScreenShown + ) { + this.file = file; + this.serverId = serverId; + this.enabled = enabled; + this.additionalMetrics = additionalMetrics; + this.debug = debug; + this.submitMetrics = submitMetrics; + this.errorTracking = errorTracking; + this.firstRun = firstRun; + this.client = client; + this.consentScreenShown = consentScreenShown; + } + + @Override + public UUID serverId() { + return serverId; + } + + @Override + public boolean enabled() { + return enabled; + } + + public void enabled(final boolean enabled) { + this.enabled = enabled; + } + + @Override + public boolean additionalMetrics() { + return additionalMetrics; + } + + public void additionalMetrics(final boolean additionalMetrics) { + this.additionalMetrics = additionalMetrics; + } + + @Override + public boolean debug() { + return debug; + } + + @Override + public boolean submitMetrics() { + return submitMetrics; + } + + public void submitMetrics(final boolean submitMetrics) { + this.submitMetrics = submitMetrics; + } + + @Override + public boolean errorTracking() { + return errorTracking; + } + + public void errorTracking(final boolean errorTracking) { + this.errorTracking = errorTracking; + } + + public boolean firstRun() { + return firstRun; + } + + public boolean consentScreenShown() { + return consentScreenShown; + } + + public void consentScreenShown(final boolean consentScreenShown) { + this.consentScreenShown = consentScreenShown; + } + + /** + * @return {@code false} if FastStats was disabled using {@code -Dfaststats.enabled=false} + */ + public static boolean enabledFlag() { + return Boolean.parseBoolean(System.getProperty("faststats.enabled", "true")); + } @Contract(mutates = "io") public static SimpleConfig read(final Path file, final LoggerFactory factory) throws RuntimeException { + return read(file, factory, false); + } + + /** + * Reads the config from the given file. + * + * @param client whether the config belongs to a client, only clients store client specific options + */ + @Contract(mutates = "io") + public static SimpleConfig read(final Path file, final LoggerFactory factory, final boolean client) throws RuntimeException { final var logger = factory.getLogger(SimpleConfig.class); final var debugFlag = Boolean.getBoolean("faststats.debug"); - final var enabledFlag = Boolean.parseBoolean(System.getProperty("faststats.enabled", "true")); + final var enabledFlag = enabledFlag(); final var properties = readOrEmpty(file); final var firstRun = properties == null; @@ -83,6 +185,7 @@ public static SimpleConfig read(final Path file, final LoggerFactory factory) th final var errorTracking = parse(properties, saveConfig, "submitErrors", () -> true, Boolean::parseBoolean, logger); final var additionalMetrics = parse(properties, saveConfig, "submitAdditionalMetrics", () -> true, Boolean::parseBoolean, logger); final var debug = parse(properties, saveConfig, "debug", () -> false, Boolean::parseBoolean, logger); + final var consentScreenShown = client && parse(properties, saveConfig, CONSENT_SCREEN_SHOWN, () -> false, Boolean::parseBoolean, logger); if (configVersion == null || configVersion < CONFIG_VERSION) saveConfig.set(true); else if (configVersion > CONFIG_VERSION) saveConfig.set(false); @@ -91,8 +194,7 @@ public static SimpleConfig read(final Path file, final LoggerFactory factory) th if (configVersion != null && configVersion < CONFIG_VERSION) logger.info("Updating config version from %s to %s", configVersion, CONFIG_VERSION); Files.createDirectories(file.getParent()); - try (final var out = Files.newOutputStream(file); - final var writer = new OutputStreamWriter(out, UTF_8)) { + try (final var writer = Files.newBufferedWriter(file, UTF_8)) { final var store = new Properties(); store.setProperty("enabled", Boolean.toString(enabled)); @@ -103,6 +205,7 @@ public static SimpleConfig read(final Path file, final LoggerFactory factory) th store.setProperty("serverId", serverId.toString()); store.setProperty("debug", Boolean.toString(debug)); + if (client) store.setProperty(CONSENT_SCREEN_SHOWN, Boolean.toString(consentScreenShown)); store.setProperty("configVersion", Integer.toString(CONFIG_VERSION)); store.store(writer, COMMENT); @@ -112,16 +215,49 @@ public static SimpleConfig read(final Path file, final LoggerFactory factory) th } return new SimpleConfig( + file, serverId, enabled && enabledFlag, enabled && enabledFlag && additionalMetrics, debug || debugFlag, enabled && enabledFlag && submitMetrics, enabled && enabledFlag && errorTracking, - firstRun + firstRun, + client, + consentScreenShown ); } + @Contract(mutates = "io") + public synchronized void persist() throws RuntimeException { + var properties = readOrEmpty(file); + if (properties == null) { + // the file was deleted after it was read, recreate it + properties = new Properties(); + properties.setProperty("serverId", serverId.toString()); + properties.setProperty("debug", Boolean.toString(debug)); + properties.setProperty("configVersion", Integer.toString(CONFIG_VERSION)); + } + + properties.setProperty("enabled", Boolean.toString(enabled())); + properties.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics())); + properties.setProperty("submitErrors", Boolean.toString(errorTracking())); + properties.setProperty("submitMetrics", Boolean.toString(submitMetrics())); + if (client) properties.setProperty(CONSENT_SCREEN_SHOWN, Boolean.toString(consentScreenShown())); + else properties.remove(CONSENT_SCREEN_SHOWN); + + try { + Files.createDirectories(file.getParent()); + } catch (final IOException e) { + throw new RuntimeException("Failed to create metrics config directory", e); + } + try (final var writer = Files.newBufferedWriter(file, UTF_8)) { + properties.store(writer, COMMENT); + } catch (final IOException e) { + throw new RuntimeException("Failed to save metrics config", e); + } + } + // fixme: this code sucks ass @Contract(value = "_, _, _, !null, _, _-> !null") private static @Nullable T parse( @@ -180,4 +316,19 @@ public boolean preSubmissionStart(final SimpleContext context) { } return true; } + + @Override + public String toString() { + return "SimpleConfig{" + + "serverId=" + serverId + + ", debug=" + debug + + ", firstRun=" + firstRun + + ", client=" + client + + ", consentScreenShown=" + consentScreenShown + + ", additionalMetrics=" + additionalMetrics + + ", enabled=" + enabled + + ", errorTracking=" + errorTracking + + ", submitMetrics=" + submitMetrics + + '}'; + } } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 95db3de8..5e45ecfa 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -12,13 +12,13 @@ tasks.compileJava { } dependencies { - compileOnlyApi("com.google.code.gson:gson:2.14.0") - compileOnlyApi("org.jetbrains:annotations:26.1.0") - compileOnlyApi("org.jspecify:jspecify:1.0.0") + compileOnlyApi(libs.gson) + compileOnlyApi(libs.jetbrains.annotations) + compileOnlyApi(libs.jspecify) testImplementation(project(":config")) - testImplementation("com.google.code.gson:gson:2.14.0") + testImplementation(libs.gson) testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation(platform("org.junit:junit-bom:6.1.3")) + testImplementation(platform(libs.junit.bom)) testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 6d5a0de3..db72ab96 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -24,6 +24,7 @@ public non-sealed abstract class SimpleContext implements FastStatsContext { private final Logger logger; protected volatile boolean ready = false; + public volatile boolean submissionActive; private @Nullable Metrics metrics; private @Nullable FeatureFlagService featureFlagService; @@ -56,14 +57,33 @@ protected SimpleContext(final Factory factory, final LoggerFactory loggerF @MustBeInvokedByOverriders protected final void initializeServices(final Factory factory) throws IllegalStateException { + initializeServices(factory, false); + } + + /** + * Initializes service descriptions before consent so this context can participate in a live lifecycle registry. + */ + @MustBeInvokedByOverriders + protected final void initializeManagedServices(final Factory factory) throws IllegalStateException { + initializeServices(factory, true); + } + + private void initializeServices(final Factory factory, final boolean lifecycleManaged) { if (factory.metrics == null && factory.errorTracker == null && factory.featureFlagService == null) throw new IllegalStateException("Context created without any service attached, was this intentional?"); - if (!preSubmissionStart()) return; + final var start = preSubmissionStart(); + if (!lifecycleManaged && !start) return; - this.metrics = config.submitMetrics() && factory.metrics != null ? factory.metrics.apply(metricsFactory()) : null; - this.errorTrackerService = config.errorTracking() && factory.errorTracker != null ? new SimpleErrorTrackerService(this, factory.errorTracker) : null; + if (factory.metrics != null && (lifecycleManaged || config.submitMetrics())) { + final var metricsFactory = metricsFactory(); + this.metrics = factory.metrics.apply(metricsFactory); + } + this.errorTrackerService = factory.errorTracker != null && (lifecycleManaged || config.errorTracking()) + ? new SimpleErrorTrackerService(this, factory.errorTracker) + : null; this.featureFlagService = factory.featureFlagService != null ? factory.featureFlagService.apply(new SimpleFeatureFlagService.Factory(this)) : null; + this.submissionActive = start; final var features = new HashSet(3); features.add("metrics=" + (metrics != null ? "yes" : "no")); @@ -161,6 +181,7 @@ public void shutdown() { if (errorTrackerService != null) errorTrackerService.shutdown(); if (featureFlagService instanceof final SimpleFeatureFlagService service) service.shutdown(); if (metrics instanceof final SimpleMetrics simpleMetrics) simpleMetrics.shutdown(); + this.submissionActive = false; ready = false; } diff --git a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java index 2ad89d8e..819abccc 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java @@ -74,6 +74,7 @@ private static void handleUncaughtException(final Thread thread, final Throwable } private void submit() { + if (!context.submissionActive || !context.getConfig().errorTracking()) return; try { final var data = createData(); if (data == null) return; @@ -112,7 +113,7 @@ private void submit() { return data; } - private void clear() { + public void clear() { globalErrorTracker.clear(); errorTrackers.forEach(SimpleErrorTracker::clear); } diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 3209d333..709cea58 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -11,6 +11,7 @@ import java.util.HashSet; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; @ApiStatus.Internal public abstract class SimpleMetrics extends SubmissionService implements Metrics { @@ -26,7 +27,7 @@ public abstract class SimpleMetrics extends SubmissionService implements Metrics @Contract(mutates = "io") protected SimpleMetrics(final Factory factory) { super(factory.context); - this.metrics = context.getConfig().additionalMetrics() ? Set.copyOf(factory.metrics) : Set.of(); + this.metrics = Set.copyOf(factory.metrics); this.flush = factory.flush; } @@ -43,6 +44,7 @@ public boolean isClientApplication() { } private boolean submit() { + if (!context.submissionActive || !context.getConfig().submitMetrics()) return false; try { if (submit(url, createData(), "metrics")) { if (flush != null) flush.run(); @@ -78,6 +80,7 @@ private void appendInternalData(final JsonObject metrics) { } private void appendCustomData(final JsonObject metrics) { + if (!context.getConfig().additionalMetrics()) return; this.metrics.forEach(metric -> { try { if (metrics.has(metric.getId())) { @@ -124,6 +127,11 @@ protected void shutdown() { } } + @Contract(pure = true) + public Stream> stream() { + return metrics.stream(); + } + public abstract static class Factory implements Metrics.Factory { private @Nullable Runnable flush; private final Set> metrics = new HashSet<>(0); diff --git a/core/src/main/java/dev/faststats/internal/Logger.java b/core/src/main/java/dev/faststats/internal/Logger.java index be4bcf74..4b79dd89 100644 --- a/core/src/main/java/dev/faststats/internal/Logger.java +++ b/core/src/main/java/dev/faststats/internal/Logger.java @@ -23,7 +23,7 @@ default void debug(final LogLevel level, @PrintFormat final String message, @Nul } String caller(); - + LoggerFactory factory(); void print(LogLevel level, @Nullable Throwable t, String message); diff --git a/fabric/build.gradle.kts b/fabric/build.gradle.kts index 4663ecde..57764c81 100644 --- a/fabric/build.gradle.kts +++ b/fabric/build.gradle.kts @@ -1,7 +1,10 @@ extra.set("moduleName", "dev.faststats.fabric") +extra.set("publishArtifactId", "fabric") +extra.set("publishDocsUrl", "https://docs.faststats.dev/java/platform/fabric") plugins { - id("net.fabricmc.fabric-loom") version "1.15-SNAPSHOT" + id("net.fabricmc.fabric-loom") + id("maven-publish") } java.toolchain.languageVersion = JavaLanguageVersion.of(25) @@ -15,37 +18,23 @@ configurations.compileClasspath { } tasks.processResources { - inputs.property("version", project.version) + val version = provider { project.version.toString() } + inputs.property("version", version) filesMatching("fabric.mod.json") { - expand("version" to project.version) - } -} - -allprojects { - if (project.path == ":fabric:versions") return@allprojects - apply { plugin("maven-publish") } - extra.set("publishArtifactId", "fabric") - extra.set("publishDocsUrl", "https://docs.faststats.dev/java/platform/fabric") -} - -subprojects { - dependencies { - compileOnlyApi(project(":fabric")) - } - - tasks.jar { - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - from(project(":fabric").sourceSets["main"].output) - from(project(":config").sourceSets["main"].output) - from(project(":core").sourceSets["main"].output) + expand("version" to version.get(), "minecraft" to "*") } } dependencies { compileOnlyApi(project(":core")) compileOnly(project(":config")) - minecraft("com.mojang:minecraft:26.1.2") - compileOnly("net.fabricmc.fabric-api:fabric-api:0.150.0+26.1.2") - compileOnly("net.fabricmc:fabric-loader:0.19.5") + compileOnly(project(":onboarding")) + minecraft(libs.minecraft.v26x1x2) + compileOnly(libs.fabric.api.v26x1x2) + compileOnly(libs.fabric.loader) +} + +subprojects { + if (parent?.path == ":fabric:versions") pluginManager.apply("faststats.fabric-compat") } diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java index 4bf33bcb..4914456a 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -7,14 +7,17 @@ import dev.faststats.config.SimpleConfig; import dev.faststats.fabric.compat.CompatibilityLayer; import dev.faststats.internal.PlatformLoggerFactory; +import dev.faststats.screen.registry.FastStatsRegistry; +import net.fabricmc.api.EnvType; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; import org.jetbrains.annotations.Contract; -import java.util.Set; +import java.nio.file.Path; import java.util.ServiceLoader; +import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -35,18 +38,19 @@ public final class FabricContext extends SimpleContext { private final Set> tasks = new CopyOnWriteArraySet<>(); private final CompatibilityLayer compatibilityLayer; private final ModContainer mod; + private final boolean consentRequired; private FabricContext(final Factory factory, final dev.faststats.internal.LoggerFactory loggerFactory, final String modId, @Token final String token) { - super(factory, loggerFactory, SimpleConfig.read(FabricLoader.getInstance().getConfigDir() - .resolve("faststats").resolve("config.properties"), loggerFactory - ), "fabric", token); + super(factory, loggerFactory, SimpleConfig.read(configPath(), loggerFactory, isClient()), "fabric", token); this.mod = FabricLoader.getInstance().getModContainer(modId).orElseThrow(() -> { return new IllegalArgumentException("Mod not found: " + modId); }); this.compatibilityLayer = ServiceLoader.load(CompatibilityLayer.class) .findFirst() .orElseThrow(() -> new IllegalStateException("No Fabric compatibility layer found")); - initializeServices(factory); + this.consentRequired = factory.consentRequired; + initializeManagedServices(factory); + FastStatsRegistry.instance().register(this, factory.consentRequired); switch (FabricLoader.getInstance().getEnvironmentType()) { case CLIENT -> { ready(); @@ -75,7 +79,14 @@ public Metrics create() throws IllegalStateException { @Override protected boolean preSubmissionStart() { - return ((SimpleConfig) getConfig()).preSubmissionStart(this); + final var config = (SimpleConfig) getConfig(); + if (!config.preSubmissionStart(this)) return false; + if (!consentRequired || config.consentScreenShown()) return true; + return !isClient(); + } + + private static boolean isClient() { + return FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT; } @Override @@ -90,21 +101,43 @@ public void scheduleAtFixedRate(final Runnable task, final long initialDelay, fi @Override public void shutdown() { + FastStatsRegistry.instance().unregister(this); super.shutdown(); tasks.forEach(task -> task.cancel(true)); tasks.clear(); executor.shutdown(); } + private static Path configPath() { + return FabricLoader.getInstance().getConfigDir().resolve("faststats").resolve("config.properties"); + } + public static final class Factory extends SimpleContext.Factory { private final String modId; private final @Token String token; + private boolean consentRequired = false; public Factory(final String modId, @Token final String token) { this.modId = modId; this.token = token; } + /** + * Requires the user to explicitly give consent on the client. + *

+ * When at least one mod requires consent, the consent screen is opened + * on the client until it was shown once, nothing is submitted before that. + * By default, no consent screen is shown. + * + * @return this factory + * @since 0.31.0 + */ + @Contract(value = " -> this", mutates = "this") + public Factory requireConsent() { + this.consentRequired = true; + return this; + } + @Override public FabricContext create() { final var logger = org.slf4j.LoggerFactory.getLogger(modId); diff --git a/fabric/src/main/java/module-info.java b/fabric/src/main/java/module-info.java index 7a85bdcf..8732474b 100644 --- a/fabric/src/main/java/module-info.java +++ b/fabric/src/main/java/module-info.java @@ -7,6 +7,7 @@ requires com.google.gson; requires dev.faststats.config; + requires dev.faststats.onboarding; requires dev.faststats; requires java.logging; requires net.fabricmc.loader; diff --git a/fabric/src/main/resources/fabric.mod.json b/fabric/src/main/resources/fabric.mod.json index f827eb97..ef3bb05e 100644 --- a/fabric/src/main/resources/fabric.mod.json +++ b/fabric/src/main/resources/fabric.mod.json @@ -16,9 +16,12 @@ "David | NonSwag" ], "environment": "*", + "mixins": [ + "faststats-onboarding.mixins.json" + ], "depends": { "fabricloader": ">=0.18.4", - "minecraft": "*", + "minecraft": "${minecraft}", "fabric-api": "*" } } diff --git a/fabric/versions/1.16.1-1.17.1/build.gradle.kts b/fabric/versions/1.16.1-1.17.1/build.gradle.kts deleted file mode 100644 index 7890afcc..00000000 --- a/fabric/versions/1.16.1-1.17.1/build.gradle.kts +++ /dev/null @@ -1,33 +0,0 @@ -extra.set("moduleName", "dev.faststats.fabric.compat.v1_16_1") -extra.set("publishVersionSuffix", "mc1.16.1-1.17.1") - -plugins { - id("net.fabricmc.fabric-loom-remap") -} - -java.toolchain.languageVersion = JavaLanguageVersion.of(17) - -tasks.compileJava { - options.release.set(17) -} - -dependencies { - // minecraft("com.mojang:minecraft:1.16.1") - // minecraft("com.mojang:minecraft:1.16.2") - // minecraft("com.mojang:minecraft:1.16.3") - // minecraft("com.mojang:minecraft:1.16.4") - // minecraft("com.mojang:minecraft:1.16.5") - // minecraft("com.mojang:minecraft:1.17") - minecraft("com.mojang:minecraft:1.17.1") - mappings(loom.officialMojangMappings()) - - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.18.0+build.387-1.16.1") // 1.16.1 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.20.1+build.401-1.16") // 1.16.2 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.25.0+build.415-1.16") // 1.16.3 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.29.3+1.16") // 1.16.4 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.42.0+1.16") // 1.16.5 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.36.0+1.17") // 1.17 - compileOnly("net.fabricmc.fabric-api:fabric-api:0.46.1+1.17") // 1.17.1 - - compileOnly("net.fabricmc:fabric-loader:0.19.5") -} diff --git a/fabric/versions/1.16.1-1.17.1/src/main/java/module-info.java b/fabric/versions/1.16.1-1.17.1/src/main/java/module-info.java deleted file mode 100644 index 2f4b4325..00000000 --- a/fabric/versions/1.16.1-1.17.1/src/main/java/module-info.java +++ /dev/null @@ -1,13 +0,0 @@ -import dev.faststats.fabric.compat.v1_16_1.CompatibilityLayerImpl; -import org.jspecify.annotations.NullMarked; - -@NullMarked -module dev.faststats.fabric.compat.v1_16_1 { - exports dev.faststats.fabric.compat.v1_16_1; - - requires dev.faststats.fabric; - - requires static org.jspecify; - - provides dev.faststats.fabric.compat.CompatibilityLayer with CompatibilityLayerImpl; -} diff --git a/fabric/versions/1.16.1-1.17.1/src/main/java/dev/faststats/fabric/compat/v1_16_1/CompatibilityLayerImpl.java b/fabric/versions/1.16.1/src/main/java/dev/faststats/fabric/compat/v1_16_1/CompatibilityLayerImpl.java similarity index 100% rename from fabric/versions/1.16.1-1.17.1/src/main/java/dev/faststats/fabric/compat/v1_16_1/CompatibilityLayerImpl.java rename to fabric/versions/1.16.1/src/main/java/dev/faststats/fabric/compat/v1_16_1/CompatibilityLayerImpl.java diff --git a/fabric/versions/1.16.1-1.17.1/src/main/resources/META-INF/services/dev.faststats.fabric.compat.CompatibilityLayer b/fabric/versions/1.16.1/src/main/resources/META-INF/services/dev.faststats.fabric.compat.CompatibilityLayer similarity index 100% rename from fabric/versions/1.16.1-1.17.1/src/main/resources/META-INF/services/dev.faststats.fabric.compat.CompatibilityLayer rename to fabric/versions/1.16.1/src/main/resources/META-INF/services/dev.faststats.fabric.compat.CompatibilityLayer diff --git a/fabric/versions/1.18-1.21.8/build.gradle.kts b/fabric/versions/1.18-1.21.8/build.gradle.kts deleted file mode 100644 index 1781025d..00000000 --- a/fabric/versions/1.18-1.21.8/build.gradle.kts +++ /dev/null @@ -1,71 +0,0 @@ -extra.set("moduleName", "dev.faststats.fabric.compat.v1_18") -extra.set("publishVersionSuffix", "mc1.18-1.21.8") - -plugins { - id("net.fabricmc.fabric-loom-remap") -} - -java.toolchain.languageVersion = JavaLanguageVersion.of(21) - -tasks.compileJava { - options.release.set(17) -} - -configurations.compileClasspath { - attributes.attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 21) -} - -dependencies { - // minecraft("com.mojang:minecraft:1.18") - // minecraft("com.mojang:minecraft:1.18.1") - // minecraft("com.mojang:minecraft:1.18.2") - // minecraft("com.mojang:minecraft:1.19") - // minecraft("com.mojang:minecraft:1.19.1") - // minecraft("com.mojang:minecraft:1.19.2") - // minecraft("com.mojang:minecraft:1.19.3") - // minecraft("com.mojang:minecraft:1.19.4") - // minecraft("com.mojang:minecraft:1.20") - // minecraft("com.mojang:minecraft:1.20.1") - // minecraft("com.mojang:minecraft:1.20.2") - // minecraft("com.mojang:minecraft:1.20.3") - // minecraft("com.mojang:minecraft:1.20.4") - // minecraft("com.mojang:minecraft:1.20.5") - // minecraft("com.mojang:minecraft:1.20.6") - // minecraft("com.mojang:minecraft:1.21") - // minecraft("com.mojang:minecraft:1.21.1") - // minecraft("com.mojang:minecraft:1.21.2") - // minecraft("com.mojang:minecraft:1.21.3") - // minecraft("com.mojang:minecraft:1.21.4") - // minecraft("com.mojang:minecraft:1.21.5") - // minecraft("com.mojang:minecraft:1.21.6") - // minecraft("com.mojang:minecraft:1.21.7") - minecraft("com.mojang:minecraft:1.21.8") - mappings(loom.officialMojangMappings()) - - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.44.0+1.18") // 1.18 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.46.6+1.18") // 1.18.1 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.77.0+1.18.2") // 1.18.2 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.58.0+1.19") // 1.19 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.58.5+1.19.1") // 1.19.1 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.77.0+1.19.2") // 1.19.2 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.76.1+1.19.3") // 1.19.3 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.87.2+1.19.4") // 1.19.4 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.83.0+1.20") // 1.20 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.92.9+1.20.1") // 1.20.1 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.91.6+1.20.2") // 1.20.2 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.91.1+1.20.3") // 1.20.3 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.97.3+1.20.4") // 1.20.4 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.97.8+1.20.5") // 1.20.5 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.100.8+1.20.6") // 1.20.6 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.102.0+1.21") // 1.21 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.116.13+1.21") // 1.21.1 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.106.1+1.21.2") // 1.21.2 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.114.1+1.21.3") // 1.21.3 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.119.4+1.21.4") // 1.21.4 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.128.2+1.21.5") // 1.21.5 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.128.2+1.21.6") // 1.21.6 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.129.0+1.21.7") // 1.21.7 - compileOnly("net.fabricmc.fabric-api:fabric-api:0.136.1+1.21.8") // 1.21.8 - - compileOnly("net.fabricmc:fabric-loader:0.19.5") -} diff --git a/fabric/versions/1.18-1.21.8/src/main/java/module-info.java b/fabric/versions/1.18-1.21.8/src/main/java/module-info.java deleted file mode 100644 index ae8e4213..00000000 --- a/fabric/versions/1.18-1.21.8/src/main/java/module-info.java +++ /dev/null @@ -1,13 +0,0 @@ -import dev.faststats.fabric.compat.v1_18.CompatibilityLayerImpl; -import org.jspecify.annotations.NullMarked; - -@NullMarked -module dev.faststats.fabric.compat.v1_18 { - exports dev.faststats.fabric.compat.v1_18; - - requires dev.faststats.fabric; - - requires static org.jspecify; - - provides dev.faststats.fabric.compat.CompatibilityLayer with CompatibilityLayerImpl; -} diff --git a/fabric/versions/1.18-1.21.8/src/main/java/dev/faststats/fabric/compat/v1_18/CompatibilityLayerImpl.java b/fabric/versions/1.18/src/main/java/dev/faststats/fabric/compat/v1_18/CompatibilityLayerImpl.java similarity index 100% rename from fabric/versions/1.18-1.21.8/src/main/java/dev/faststats/fabric/compat/v1_18/CompatibilityLayerImpl.java rename to fabric/versions/1.18/src/main/java/dev/faststats/fabric/compat/v1_18/CompatibilityLayerImpl.java diff --git a/fabric/versions/1.18-1.21.8/src/main/resources/META-INF/services/dev.faststats.fabric.compat.CompatibilityLayer b/fabric/versions/1.18/src/main/resources/META-INF/services/dev.faststats.fabric.compat.CompatibilityLayer similarity index 100% rename from fabric/versions/1.18-1.21.8/src/main/resources/META-INF/services/dev.faststats.fabric.compat.CompatibilityLayer rename to fabric/versions/1.18/src/main/resources/META-INF/services/dev.faststats.fabric.compat.CompatibilityLayer diff --git a/fabric/versions/1.21.9-1.21.11/build.gradle.kts b/fabric/versions/1.21.9-1.21.11/build.gradle.kts deleted file mode 100644 index ce96a080..00000000 --- a/fabric/versions/1.21.9-1.21.11/build.gradle.kts +++ /dev/null @@ -1,25 +0,0 @@ -extra.set("moduleName", "dev.faststats.fabric.compat.v1_21_9") -extra.set("publishVersionSuffix", "mc1.21.9-1.21.11") - -plugins { - id("net.fabricmc.fabric-loom-remap") -} - -java.toolchain.languageVersion = JavaLanguageVersion.of(21) - -tasks.compileJava { - options.release.set(21) -} - -dependencies { - // minecraft("com.mojang:minecraft:1.21.9") - // minecraft("com.mojang:minecraft:1.21.10") - minecraft("com.mojang:minecraft:1.21.11") - mappings(loom.officialMojangMappings()) - - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.134.1+1.21.9") // 1.21.9 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.138.4+1.21.10") // 1.21.10 - compileOnly("net.fabricmc.fabric-api:fabric-api:0.141.4+1.21.11") // 1.21.11 - - compileOnly("net.fabricmc:fabric-loader:0.19.5") -} diff --git a/fabric/versions/1.21.9-1.21.11/src/main/java/module-info.java b/fabric/versions/1.21.9-1.21.11/src/main/java/module-info.java deleted file mode 100644 index 52d89f7e..00000000 --- a/fabric/versions/1.21.9-1.21.11/src/main/java/module-info.java +++ /dev/null @@ -1,13 +0,0 @@ -import dev.faststats.fabric.compat.v1_21_9.CompatibilityLayerImpl; -import org.jspecify.annotations.NullMarked; - -@NullMarked -module dev.faststats.fabric.compat.v1_21_9 { - exports dev.faststats.fabric.compat.v1_21_9; - - requires dev.faststats.fabric; - - requires static org.jspecify; - - provides dev.faststats.fabric.compat.CompatibilityLayer with CompatibilityLayerImpl; -} diff --git a/fabric/versions/1.21.9-1.21.11/src/main/java/dev/faststats/fabric/compat/v1_21_9/CompatibilityLayerImpl.java b/fabric/versions/1.21.9/src/main/java/dev/faststats/fabric/compat/v1_21_9/CompatibilityLayerImpl.java similarity index 100% rename from fabric/versions/1.21.9-1.21.11/src/main/java/dev/faststats/fabric/compat/v1_21_9/CompatibilityLayerImpl.java rename to fabric/versions/1.21.9/src/main/java/dev/faststats/fabric/compat/v1_21_9/CompatibilityLayerImpl.java diff --git a/fabric/versions/1.21.9-1.21.11/src/main/resources/META-INF/services/dev.faststats.fabric.compat.CompatibilityLayer b/fabric/versions/1.21.9/src/main/resources/META-INF/services/dev.faststats.fabric.compat.CompatibilityLayer similarity index 100% rename from fabric/versions/1.21.9-1.21.11/src/main/resources/META-INF/services/dev.faststats.fabric.compat.CompatibilityLayer rename to fabric/versions/1.21.9/src/main/resources/META-INF/services/dev.faststats.fabric.compat.CompatibilityLayer diff --git a/fabric/versions/26.1-26.3/build.gradle.kts b/fabric/versions/26.1-26.3/build.gradle.kts deleted file mode 100644 index d253bbb5..00000000 --- a/fabric/versions/26.1-26.3/build.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -extra.set("moduleName", "dev.faststats.fabric.compat.v26_1") -extra.set("publishVersionSuffix", "mc26.1-26.3") - -plugins { - id("net.fabricmc.fabric-loom") version "1.15-SNAPSHOT" -} - -java.toolchain.languageVersion = JavaLanguageVersion.of(25) - -tasks.compileJava { - options.release.set(25) -} - -dependencies { - // minecraft("com.mojang:minecraft:26.1") - // minecraft("com.mojang:minecraft:26.1.1") - // minecraft("com.mojang:minecraft:26.1.2") - // minecraft("com.mojang:minecraft:26.2") - minecraft("com.mojang:minecraft:26.3-snapshot-1") - - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.153.0+26.1.2") // 26.1-26.1.2 - // compileOnly("net.fabricmc.fabric-api:fabric-api:0.153.0+26.2") // 26.2 - compileOnly("net.fabricmc.fabric-api:fabric-api:0.153.1+26.3") // 26.3 - - compileOnly("net.fabricmc:fabric-loader:0.19.5") -} diff --git a/fabric/versions/26.1-26.3/src/main/java/module-info.java b/fabric/versions/26.1-26.3/src/main/java/module-info.java deleted file mode 100644 index a57d0d4d..00000000 --- a/fabric/versions/26.1-26.3/src/main/java/module-info.java +++ /dev/null @@ -1,13 +0,0 @@ -import dev.faststats.fabric.compat.v26_1.CompatibilityLayerImpl; -import org.jspecify.annotations.NullMarked; - -@NullMarked -module dev.faststats.fabric.compat.v26_1 { - exports dev.faststats.fabric.compat.v26_1; - - requires dev.faststats.fabric; - - requires static org.jspecify; - - provides dev.faststats.fabric.compat.CompatibilityLayer with CompatibilityLayerImpl; -} diff --git a/fabric/versions/26.1-26.3/src/main/java/dev/faststats/fabric/compat/v26_1/CompatibilityLayerImpl.java b/fabric/versions/26.1/src/main/java/dev/faststats/fabric/compat/v26_1/CompatibilityLayerImpl.java similarity index 100% rename from fabric/versions/26.1-26.3/src/main/java/dev/faststats/fabric/compat/v26_1/CompatibilityLayerImpl.java rename to fabric/versions/26.1/src/main/java/dev/faststats/fabric/compat/v26_1/CompatibilityLayerImpl.java diff --git a/fabric/versions/26.1-26.3/src/main/resources/META-INF/services/dev.faststats.fabric.compat.CompatibilityLayer b/fabric/versions/26.1/src/main/resources/META-INF/services/dev.faststats.fabric.compat.CompatibilityLayer similarity index 100% rename from fabric/versions/26.1-26.3/src/main/resources/META-INF/services/dev.faststats.fabric.compat.CompatibilityLayer rename to fabric/versions/26.1/src/main/resources/META-INF/services/dev.faststats.fabric.compat.CompatibilityLayer diff --git a/gradle.properties b/gradle.properties index 16538d3a..35eac445 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,3 @@ -version=0.31.0 -org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m +version=0.31.0-pre2 +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m +org.gradle.configuration-cache=true diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index 6c1139ec..b58c088a 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -9,4 +9,4 @@ toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd11 toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect -toolchainVersion=21 +toolchainVersion=25 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..38b96d4a --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,126 @@ +[versions] +# NeoForge versions are set through the neoForge { version = ... } extension +neoforge-v1x20x6 = "20.6.139" +neoforge-v1x21 = "21.0.167" +neoforge-v1x21x1 = "21.1.234" +neoforge-v1x21x2 = "21.2.1-beta" +neoforge-v1x21x3 = "21.3.96" +neoforge-v1x21x4 = "21.4.157" +neoforge-v1x21x5 = "21.5.97" +neoforge-v1x21x6 = "21.6.20-beta" +neoforge-v1x21x7 = "21.7.25-beta" +neoforge-v1x21x8 = "21.8.53" +neoforge-v1x21x9 = "21.9.16-beta" +neoforge-v1x21x10 = "21.10.64" +neoforge-v1x21x11 = "21.11.42" +neoforge-v26x1 = "26.1.0.19-beta" +neoforge-v26x1x1 = "26.1.1.15-beta" +neoforge-v26x1x2 = "26.1.2.116" +neoforge-v26x2 = "26.2.0.90" +neoforge-v26x3 = "26.3.0.76" + +[libraries] +gson = "com.google.code.gson:gson:2.14.0" +jetbrains-annotations = "org.jetbrains:annotations:26.1.0" +jspecify = "org.jspecify:jspecify:1.0.0" +junit-bom = "org.junit:junit-bom:6.1.3" + +# platforms +bungeecord-api = "net.md-5:bungeecord-api:26.1-R0.1-SNAPSHOT" +hytale-server = "com.hypixel.hytale:Server:+" +minestom = "net.minestom:minestom:2026.10.07-26.2" +nukkit = "cn.nukkit:nukkit:1.0-SNAPSHOT" +paper-api = "io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT" +spongeapi = "org.spongepowered:spongeapi:8.3.0-SNAPSHOT" +velocity-api = "com.velocitypowered:velocity-api:4.2.0" + +# mod loaders +fabric-loader = "net.fabricmc:fabric-loader:0.19.5" +modlauncher = "cpw.mods:modlauncher:10.0.9" +log4j-api = "org.apache.logging.log4j:log4j-api:2.24.3" +neoforged-bus = "net.neoforged:bus:8.0.5" +sponge-mixin = "net.fabricmc:sponge-mixin:0.17.3+mixin.0.8.7" + +# minecraft versions compiled against by the version-ranged modules +minecraft-v1x16x1 = "com.mojang:minecraft:1.16.1" +minecraft-v1x16x2 = "com.mojang:minecraft:1.16.2" +minecraft-v1x16x3 = "com.mojang:minecraft:1.16.3" +minecraft-v1x16x4 = "com.mojang:minecraft:1.16.4" +minecraft-v1x16x5 = "com.mojang:minecraft:1.16.5" +minecraft-v1x17 = "com.mojang:minecraft:1.17" +minecraft-v1x17x1 = "com.mojang:minecraft:1.17.1" +minecraft-v1x18 = "com.mojang:minecraft:1.18" +minecraft-v1x18x1 = "com.mojang:minecraft:1.18.1" +minecraft-v1x18x2 = "com.mojang:minecraft:1.18.2" +minecraft-v1x19 = "com.mojang:minecraft:1.19" +minecraft-v1x19x1 = "com.mojang:minecraft:1.19.1" +minecraft-v1x19x2 = "com.mojang:minecraft:1.19.2" +minecraft-v1x19x3 = "com.mojang:minecraft:1.19.3" +minecraft-v1x19x4 = "com.mojang:minecraft:1.19.4" +minecraft-v1x20 = "com.mojang:minecraft:1.20" +minecraft-v1x20x1 = "com.mojang:minecraft:1.20.1" +minecraft-v1x20x2 = "com.mojang:minecraft:1.20.2" +minecraft-v1x20x3 = "com.mojang:minecraft:1.20.3" +minecraft-v1x20x4 = "com.mojang:minecraft:1.20.4" +minecraft-v1x20x5 = "com.mojang:minecraft:1.20.5" +minecraft-v1x20x6 = "com.mojang:minecraft:1.20.6" +minecraft-v1x21 = "com.mojang:minecraft:1.21" +minecraft-v1x21x1 = "com.mojang:minecraft:1.21.1" +minecraft-v1x21x2 = "com.mojang:minecraft:1.21.2" +minecraft-v1x21x3 = "com.mojang:minecraft:1.21.3" +minecraft-v1x21x4 = "com.mojang:minecraft:1.21.4" +minecraft-v1x21x5 = "com.mojang:minecraft:1.21.5" +minecraft-v1x21x6 = "com.mojang:minecraft:1.21.6" +minecraft-v1x21x7 = "com.mojang:minecraft:1.21.7" +minecraft-v1x21x8 = "com.mojang:minecraft:1.21.8" +minecraft-v1x21x9 = "com.mojang:minecraft:1.21.9" +minecraft-v1x21x10 = "com.mojang:minecraft:1.21.10" +minecraft-v1x21x11 = "com.mojang:minecraft:1.21.11" +minecraft-v26x1 = "com.mojang:minecraft:26.1" +minecraft-v26x1x1 = "com.mojang:minecraft:26.1.1" +minecraft-v26x1x2 = "com.mojang:minecraft:26.1.2" +minecraft-v26x2 = "com.mojang:minecraft:26.2" +minecraft-v26x3 = "com.mojang:minecraft:26.3-snapshot-1" + +fabric-api-v1x16x1 = "net.fabricmc.fabric-api:fabric-api:0.18.0+build.387-1.16.1" +fabric-api-v1x16x2 = "net.fabricmc.fabric-api:fabric-api:0.20.1+build.401-1.16" +fabric-api-v1x16x3 = "net.fabricmc.fabric-api:fabric-api:0.25.0+build.415-1.16" +fabric-api-v1x16x4 = "net.fabricmc.fabric-api:fabric-api:0.29.3+1.16" +fabric-api-v1x16x5 = "net.fabricmc.fabric-api:fabric-api:0.42.0+1.16" +fabric-api-v1x17 = "net.fabricmc.fabric-api:fabric-api:0.36.0+1.17" +fabric-api-v1x17x1 = "net.fabricmc.fabric-api:fabric-api:0.46.1+1.17" +fabric-api-v1x18 = "net.fabricmc.fabric-api:fabric-api:0.44.0+1.18" +fabric-api-v1x18x1 = "net.fabricmc.fabric-api:fabric-api:0.46.6+1.18" +fabric-api-v1x18x2 = "net.fabricmc.fabric-api:fabric-api:0.77.0+1.18.2" +fabric-api-v1x19 = "net.fabricmc.fabric-api:fabric-api:0.58.0+1.19" +fabric-api-v1x19x1 = "net.fabricmc.fabric-api:fabric-api:0.58.5+1.19.1" +fabric-api-v1x19x2 = "net.fabricmc.fabric-api:fabric-api:0.77.0+1.19.2" +fabric-api-v1x19x3 = "net.fabricmc.fabric-api:fabric-api:0.76.1+1.19.3" +fabric-api-v1x19x4 = "net.fabricmc.fabric-api:fabric-api:0.87.2+1.19.4" +fabric-api-v1x20 = "net.fabricmc.fabric-api:fabric-api:0.83.0+1.20" +fabric-api-v1x20x1 = "net.fabricmc.fabric-api:fabric-api:0.92.9+1.20.1" +fabric-api-v1x20x2 = "net.fabricmc.fabric-api:fabric-api:0.91.6+1.20.2" +fabric-api-v1x20x3 = "net.fabricmc.fabric-api:fabric-api:0.91.1+1.20.3" +fabric-api-v1x20x4 = "net.fabricmc.fabric-api:fabric-api:0.97.3+1.20.4" +fabric-api-v1x20x5 = "net.fabricmc.fabric-api:fabric-api:0.97.8+1.20.5" +fabric-api-v1x20x6 = "net.fabricmc.fabric-api:fabric-api:0.100.8+1.20.6" +fabric-api-v1x21 = "net.fabricmc.fabric-api:fabric-api:0.102.0+1.21" +fabric-api-v1x21x1 = "net.fabricmc.fabric-api:fabric-api:0.116.13+1.21.1" +fabric-api-v1x21x2 = "net.fabricmc.fabric-api:fabric-api:0.106.1+1.21.2" +fabric-api-v1x21x3 = "net.fabricmc.fabric-api:fabric-api:0.114.1+1.21.3" +fabric-api-v1x21x4 = "net.fabricmc.fabric-api:fabric-api:0.119.4+1.21.4" +fabric-api-v1x21x5 = "net.fabricmc.fabric-api:fabric-api:0.128.2+1.21.5" +fabric-api-v1x21x6 = "net.fabricmc.fabric-api:fabric-api:0.128.2+1.21.6" +fabric-api-v1x21x7 = "net.fabricmc.fabric-api:fabric-api:0.129.0+1.21.7" +fabric-api-v1x21x8 = "net.fabricmc.fabric-api:fabric-api:0.136.1+1.21.8" +fabric-api-v1x21x9 = "net.fabricmc.fabric-api:fabric-api:0.134.1+1.21.9" +fabric-api-v1x21x10 = "net.fabricmc.fabric-api:fabric-api:0.138.4+1.21.10" +fabric-api-v1x21x11 = "net.fabricmc.fabric-api:fabric-api:0.141.4+1.21.11" +fabric-api-v26x1x2 = "net.fabricmc.fabric-api:fabric-api:0.153.0+26.1.2" +fabric-api-v26x2 = "net.fabricmc.fabric-api:fabric-api:0.153.0+26.2" +fabric-api-v26x3 = "net.fabricmc.fabric-api:fabric-api:0.153.1+26.3" + +[plugins] +fabric-loom = { id = "net.fabricmc.fabric-loom", version = "1.15-SNAPSHOT" } +moddev = { id = "net.neoforged.moddev", version = "2.0.148" } +shadow = { id = "com.gradleup.shadow", version = "9.6.1" } diff --git a/hytale/build.gradle.kts b/hytale/build.gradle.kts index f3da4b70..4df57eb6 100644 --- a/hytale/build.gradle.kts +++ b/hytale/build.gradle.kts @@ -19,5 +19,5 @@ repositories { dependencies { api(project(":core")) implementation(project(":config")) - compileOnly("com.hypixel.hytale:Server:+") + compileOnly(libs.hytale.server) } diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java index ae756d8d..a326ca24 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java @@ -9,8 +9,8 @@ final class HytaleMetricsImpl extends SimpleMetrics { private final String pluginVersion; - - HytaleMetricsImpl(final Factory factory, JavaPlugin plugin) throws IllegalStateException { + + HytaleMetricsImpl(final Factory factory, final JavaPlugin plugin) throws IllegalStateException { super(factory); this.pluginVersion = plugin.getManifest().getVersion().toString(); } diff --git a/minestom/build.gradle.kts b/minestom/build.gradle.kts index c8619f05..fe34014a 100644 --- a/minestom/build.gradle.kts +++ b/minestom/build.gradle.kts @@ -14,7 +14,7 @@ tasks.compileJava { dependencies { api(project(":core")) implementation(project(":config")) - compileOnly("net.minestom:minestom:2026.10.07-26.2") { + compileOnly(libs.minestom) { attributes { attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) } diff --git a/neoforge/build.gradle.kts b/neoforge/build.gradle.kts index 7f500096..bfe0e37f 100644 --- a/neoforge/build.gradle.kts +++ b/neoforge/build.gradle.kts @@ -1,7 +1,11 @@ extra.set("moduleName", "dev.faststats.neoforge") +extra.set("publishArtifactId", "neoforge") +extra.set("publishDocsUrl", "https://docs.faststats.dev/java/platform/neoforge") plugins { - id("net.neoforged.moddev") version "2.0.148" + id("net.neoforged.moddev") + id("faststats.shadow-conventions") + id("maven-publish") } java.toolchain.languageVersion = JavaLanguageVersion.of(25) @@ -19,50 +23,28 @@ configurations.runtimeClasspath { } tasks.processResources { + val version = provider { project.version.toString() } + inputs.property("version", version) filesMatching("META-INF/neoforge.mods.toml") { - expand("version" to project.version) - } -} - -allprojects { - if (project.path == ":neoforge:versions") return@allprojects - apply { plugin("maven-publish") } - extra.set("publishArtifactId", "neoforge") - extra.set("publishDocsUrl", "https://docs.faststats.dev/java/platform/neoforge") -} - -subprojects { - apply { plugin("net.neoforged.moddev") } - - dependencies { - compileOnly("net.neoforged:bus:8.0.5") - compileOnlyApi(project(":neoforge")) - } - - tasks.jar { - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - from(project(":neoforge").sourceSets["main"].output) - from(project(":config").sourceSets["main"].output) - from(project(":core").sourceSets["main"].output) + expand("version" to version.get(), "minecraft" to "[1.20.6,)") } } neoForge { - version = "21.8.53" // lowest bound, 1.20.6 + version = libs.versions.neoforge.v1x21x8.get() // lowest bound, 1.20.6 } configurations.configureEach { - resolutionStrategy.force("com.google.code.gson:gson:2.14.0") + resolutionStrategy.force(libs.gson) } dependencies { - compileOnlyApi(project(":core")) - compileOnly(project(":config")) - compileOnly("net.neoforged:bus:8.0.5") + implementation(project(":core")) + implementation(project(":config")) + compileOnlyApi(project(":onboarding")) + compileOnly(libs.neoforged.bus) } -tasks.jar { - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - from(project(":config").sourceSets["main"].output) - from(project(":core").sourceSets["main"].output) +subprojects { + if (parent?.path == ":neoforge:versions") pluginManager.apply("faststats.neoforge-compat") } diff --git a/neoforge/src/main/java/dev/faststats/neoforge/NeoForgeContext.java b/neoforge/src/main/java/dev/faststats/neoforge/NeoForgeContext.java index bd88b98c..a6a6100b 100644 --- a/neoforge/src/main/java/dev/faststats/neoforge/NeoForgeContext.java +++ b/neoforge/src/main/java/dev/faststats/neoforge/NeoForgeContext.java @@ -1,5 +1,6 @@ package dev.faststats.neoforge; +import dev.faststats.screen.registry.FastStatsRegistry; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; @@ -17,6 +18,7 @@ import net.neoforged.neoforgespi.language.IModInfo; import org.jetbrains.annotations.Contract; +import java.nio.file.Path; import java.util.ServiceLoader; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; @@ -39,18 +41,24 @@ public final class NeoForgeContext extends SimpleContext { private final Set> tasks = new CopyOnWriteArraySet<>(); private final CompatibilityLayer compatibilityLayer; private final IModInfo mod; + private final boolean consentRequired; private NeoForgeContext(final Factory factory, final LoggerFactory loggerFactory, final String modId, @Token final String token) { - super(factory, loggerFactory, SimpleConfig.read(FMLPaths.CONFIGDIR.get() - .resolve("faststats").resolve("config.properties"), loggerFactory - ), "neoforge", token); + this(factory, loggerFactory, modId, token, ServiceLoader.load(CompatibilityLayer.class) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No NeoForge compatibility layer found"))); + } + + private NeoForgeContext(final Factory factory, final LoggerFactory loggerFactory, final String modId, @Token final String token, final CompatibilityLayer compatibilityLayer) { + super(factory, loggerFactory, SimpleConfig.read(configPath(), loggerFactory, + compatibilityLayer.getEnvironment() == CompatibilityLayer.Environment.CLIENT), "neoforge", token); this.mod = ModList.get().getModContainerById(modId).map(ModContainer::getModInfo).orElseThrow(() -> { return new IllegalArgumentException("Mod not found: " + modId); }); - this.compatibilityLayer = ServiceLoader.load(CompatibilityLayer.class) - .findFirst() - .orElseThrow(() -> new IllegalStateException("No NeoForge compatibility layer found")); - initializeServices(factory); + this.compatibilityLayer = compatibilityLayer; + this.consentRequired = factory.consentRequired; + initializeManagedServices(factory); + FastStatsRegistry.instance().register(this, factory.consentRequired); switch (compatibilityLayer.getEnvironment()) { case CLIENT -> ready(); case SERVER -> { @@ -76,7 +84,10 @@ public Metrics create() throws IllegalStateException { @Override protected boolean preSubmissionStart() { - return ((SimpleConfig) getConfig()).preSubmissionStart(this); + final var config = (SimpleConfig) getConfig(); + if (!config.preSubmissionStart(this)) return false; + if (!consentRequired || config.consentScreenShown()) return true; + return compatibilityLayer.getEnvironment() != CompatibilityLayer.Environment.CLIENT; } @Override @@ -91,21 +102,43 @@ protected void scheduleAtFixedRate(final Runnable task, final long initialDelay, @Override public void shutdown() { + FastStatsRegistry.instance().unregister(this); super.shutdown(); tasks.forEach(task -> task.cancel(true)); tasks.clear(); executor.shutdown(); } + private static Path configPath() { + return FMLPaths.CONFIGDIR.get().resolve("faststats").resolve("config.properties"); + } + public static final class Factory extends SimpleContext.Factory { private final String modId; private final @Token String token; + private boolean consentRequired = false; public Factory(final String modId, @Token final String token) { this.modId = modId; this.token = token; } + /** + * Requires the user to explicitly give consent on the client. + *

+ * When at least one mod requires consent, the consent screen is opened + * on the client until it was shown once, nothing is submitted before that. + * By default, no consent screen is shown. + * + * @return this factory + * @since 0.31.0 + */ + @Contract(value = " -> this", mutates = "this") + public Factory requireConsent() { + this.consentRequired = true; + return this; + } + @Override public NeoForgeContext create() { final var logger = org.slf4j.LoggerFactory.getLogger(modId); diff --git a/neoforge/src/main/java/module-info.java b/neoforge/src/main/java/module-info.java index cc654700..c39a651c 100644 --- a/neoforge/src/main/java/module-info.java +++ b/neoforge/src/main/java/module-info.java @@ -7,14 +7,15 @@ requires com.google.gson; requires dev.faststats.config; + requires dev.faststats.onboarding; requires dev.faststats; requires fml_loader; + requires java.net.http; requires net.neoforged.bus; requires org.slf4j; requires static org.jetbrains.annotations; requires static org.jspecify; - requires java.net.http; uses dev.faststats.neoforge.compat.CompatibilityLayer; } diff --git a/neoforge/src/main/resources/META-INF/neoforge.mods.toml b/neoforge/src/main/resources/META-INF/neoforge.mods.toml index a2682566..de24f1b3 100644 --- a/neoforge/src/main/resources/META-INF/neoforge.mods.toml +++ b/neoforge/src/main/resources/META-INF/neoforge.mods.toml @@ -8,6 +8,16 @@ modId = "faststats" version = "${version}" displayName = "FastStats" description = "Analytics for developers" -logoFile = "faststats.png" +iconFile = "faststats.png" authors = "David | NonSwag" displayURL = "https://faststats.dev/mods" + +[[mixins]] +config = "faststats-onboarding.mixins.json" + +[[dependencies.faststats]] +modId = "minecraft" +type = "required" +versionRange = "${minecraft}" +ordering = "NONE" +side = "BOTH" diff --git a/neoforge/versions/1.20.6-1.21.8/build.gradle.kts b/neoforge/versions/1.20.6-1.21.8/build.gradle.kts deleted file mode 100644 index 4001e1df..00000000 --- a/neoforge/versions/1.20.6-1.21.8/build.gradle.kts +++ /dev/null @@ -1,25 +0,0 @@ -extra.set("moduleName", "dev.faststats.neoforge.compat.v1_20_6") -extra.set("publishVersionSuffix", "mc1.20.6-1.21.8") - -java.toolchain.languageVersion = JavaLanguageVersion.of(21) - -tasks.compileJava { - options.release.set(21) -} - -neoForge { - // version = "20.6.139" // 1.20.6 - // version = "21.0.167" // 1.21 - // version = "21.1.234" // 1.21.1 - // version = "21.2.1-beta" // 1.21.2 - // version = "21.3.96" // 1.21.3 - // version = "21.4.157" // 1.21.4 - // version = "21.5.97" // 1.21.5 - // version = "21.6.20-beta" // 1.21.6 - // version = "21.7.25-beta" // 1.21.7 - version = "21.8.53" // 1.21.8 -} - -configurations.configureEach { - resolutionStrategy.force("com.google.code.gson:gson:2.14.0") -} diff --git a/neoforge/versions/1.20.6-1.21.8/src/main/java/module-info.java b/neoforge/versions/1.20.6-1.21.8/src/main/java/module-info.java deleted file mode 100644 index a3de003d..00000000 --- a/neoforge/versions/1.20.6-1.21.8/src/main/java/module-info.java +++ /dev/null @@ -1,12 +0,0 @@ -import dev.faststats.neoforge.compat.v1_20_6.CompatibilityLayerImpl; -import org.jspecify.annotations.NullMarked; - -@NullMarked -module dev.faststats.neoforge.compat.v1_20_6 { - requires dev.faststats.neoforge; - - requires static org.jspecify; - requires fml_loader; - - provides dev.faststats.neoforge.compat.CompatibilityLayer with CompatibilityLayerImpl; -} diff --git a/neoforge/versions/1.20.6-1.21.8/src/main/java/dev/faststats/neoforge/compat/v1_20_6/CompatibilityLayerImpl.java b/neoforge/versions/1.20.6/src/main/java/dev/faststats/neoforge/compat/v1_20_6/CompatibilityLayerImpl.java similarity index 100% rename from neoforge/versions/1.20.6-1.21.8/src/main/java/dev/faststats/neoforge/compat/v1_20_6/CompatibilityLayerImpl.java rename to neoforge/versions/1.20.6/src/main/java/dev/faststats/neoforge/compat/v1_20_6/CompatibilityLayerImpl.java diff --git a/neoforge/versions/1.20.6-1.21.8/src/main/resources/META-INF/services/dev.faststats.neoforge.compat.CompatibilityLayer b/neoforge/versions/1.20.6/src/main/resources/META-INF/services/dev.faststats.neoforge.compat.CompatibilityLayer similarity index 100% rename from neoforge/versions/1.20.6-1.21.8/src/main/resources/META-INF/services/dev.faststats.neoforge.compat.CompatibilityLayer rename to neoforge/versions/1.20.6/src/main/resources/META-INF/services/dev.faststats.neoforge.compat.CompatibilityLayer diff --git a/neoforge/versions/1.21.9-1.21.11/build.gradle.kts b/neoforge/versions/1.21.9-1.21.11/build.gradle.kts deleted file mode 100644 index 216d9edb..00000000 --- a/neoforge/versions/1.21.9-1.21.11/build.gradle.kts +++ /dev/null @@ -1,18 +0,0 @@ -extra.set("moduleName", "dev.faststats.neoforge.compat.v1_21_9") -extra.set("publishVersionSuffix", "mc1.21.9-1.21.11") - -java.toolchain.languageVersion = JavaLanguageVersion.of(21) - -tasks.compileJava { - options.release.set(21) -} - -neoForge { - // version = "21.9.16-beta" // 1.21.9 - // version = "21.10.64" // 1.21.10 - version = "21.11.42" // 1.21.11 -} - -configurations.configureEach { - resolutionStrategy.force("com.google.code.gson:gson:2.14.0") -} diff --git a/neoforge/versions/1.21.9-1.21.11/src/main/java/module-info.java b/neoforge/versions/1.21.9-1.21.11/src/main/java/module-info.java deleted file mode 100644 index 9dc3d86d..00000000 --- a/neoforge/versions/1.21.9-1.21.11/src/main/java/module-info.java +++ /dev/null @@ -1,12 +0,0 @@ -import dev.faststats.neoforge.compat.v1_21_9.CompatibilityLayerImpl; -import org.jspecify.annotations.NullMarked; - -@NullMarked -module dev.faststats.neoforge.compat.v1_21_9 { - requires dev.faststats.neoforge; - - requires static org.jspecify; - requires fml_loader; - - provides dev.faststats.neoforge.compat.CompatibilityLayer with CompatibilityLayerImpl; -} diff --git a/neoforge/versions/1.21.9-1.21.11/src/main/java/dev/faststats/neoforge/compat/v1_21_9/CompatibilityLayerImpl.java b/neoforge/versions/1.21.9/src/main/java/dev/faststats/neoforge/compat/v1_21_9/CompatibilityLayerImpl.java similarity index 100% rename from neoforge/versions/1.21.9-1.21.11/src/main/java/dev/faststats/neoforge/compat/v1_21_9/CompatibilityLayerImpl.java rename to neoforge/versions/1.21.9/src/main/java/dev/faststats/neoforge/compat/v1_21_9/CompatibilityLayerImpl.java diff --git a/neoforge/versions/1.21.9-1.21.11/src/main/resources/META-INF/services/dev.faststats.neoforge.compat.CompatibilityLayer b/neoforge/versions/1.21.9/src/main/resources/META-INF/services/dev.faststats.neoforge.compat.CompatibilityLayer similarity index 100% rename from neoforge/versions/1.21.9-1.21.11/src/main/resources/META-INF/services/dev.faststats.neoforge.compat.CompatibilityLayer rename to neoforge/versions/1.21.9/src/main/resources/META-INF/services/dev.faststats.neoforge.compat.CompatibilityLayer diff --git a/neoforge/versions/26.1-26.2/build.gradle.kts b/neoforge/versions/26.1-26.2/build.gradle.kts deleted file mode 100644 index 33f383bf..00000000 --- a/neoforge/versions/26.1-26.2/build.gradle.kts +++ /dev/null @@ -1,19 +0,0 @@ -extra.set("moduleName", "dev.faststats.neoforge.compat.v26_1") -extra.set("publishVersionSuffix", "mc26.1-26.2") - -java.toolchain.languageVersion = JavaLanguageVersion.of(25) - -tasks.compileJava { - options.release.set(25) -} - -neoForge { - // version = "26.1.0.19-beta" // 26.1 - // version = "26.1.1.15-beta" // 26.1.1 - // version = "26.1.2.76" // 26.1.2 - version = "26.2.0.7-beta" // 26.2 -} - -configurations.configureEach { - resolutionStrategy.force("com.google.code.gson:gson:2.14.0") -} diff --git a/neoforge/versions/26.1-26.2/src/main/java/module-info.java b/neoforge/versions/26.1-26.2/src/main/java/module-info.java deleted file mode 100644 index cf64ccb7..00000000 --- a/neoforge/versions/26.1-26.2/src/main/java/module-info.java +++ /dev/null @@ -1,12 +0,0 @@ -import dev.faststats.neoforge.compat.v26_1.CompatibilityLayerImpl; -import org.jspecify.annotations.NullMarked; - -@NullMarked -module dev.faststats.neoforge.compat.v26_1 { - requires dev.faststats.neoforge; - - requires static org.jspecify; - requires fml_loader; - - provides dev.faststats.neoforge.compat.CompatibilityLayer with CompatibilityLayerImpl; -} diff --git a/neoforge/versions/26.1-26.2/src/main/java/dev/faststats/neoforge/compat/v26_1/CompatibilityLayerImpl.java b/neoforge/versions/26.1/src/main/java/dev/faststats/neoforge/compat/v26_1/CompatibilityLayerImpl.java similarity index 100% rename from neoforge/versions/26.1-26.2/src/main/java/dev/faststats/neoforge/compat/v26_1/CompatibilityLayerImpl.java rename to neoforge/versions/26.1/src/main/java/dev/faststats/neoforge/compat/v26_1/CompatibilityLayerImpl.java diff --git a/neoforge/versions/26.1-26.2/src/main/resources/META-INF/services/dev.faststats.neoforge.compat.CompatibilityLayer b/neoforge/versions/26.1/src/main/resources/META-INF/services/dev.faststats.neoforge.compat.CompatibilityLayer similarity index 100% rename from neoforge/versions/26.1-26.2/src/main/resources/META-INF/services/dev.faststats.neoforge.compat.CompatibilityLayer rename to neoforge/versions/26.1/src/main/resources/META-INF/services/dev.faststats.neoforge.compat.CompatibilityLayer diff --git a/nukkit/build.gradle.kts b/nukkit/build.gradle.kts index 2525b95c..3bfd6169 100644 --- a/nukkit/build.gradle.kts +++ b/nukkit/build.gradle.kts @@ -20,5 +20,5 @@ repositories { dependencies { api(project(":core")) implementation(project(":config")) - compileOnly("cn.nukkit:nukkit:1.0-SNAPSHOT") + compileOnly(libs.nukkit) } diff --git a/onboarding/build.gradle.kts b/onboarding/build.gradle.kts new file mode 100644 index 00000000..c2505401 --- /dev/null +++ b/onboarding/build.gradle.kts @@ -0,0 +1,13 @@ +extra.set("moduleName", "dev.faststats.onboarding") + +java.toolchain.languageVersion = JavaLanguageVersion.of(25) + +tasks.compileJava { + options.release.set(17) +} + +dependencies { + api(project(":config")) + api(project(":core")) + compileOnlyApi(libs.jspecify) +} diff --git a/onboarding/src/main/java/dev/faststats/screen/onboarding/ConsentState.java b/onboarding/src/main/java/dev/faststats/screen/onboarding/ConsentState.java new file mode 100644 index 00000000..2c799b94 --- /dev/null +++ b/onboarding/src/main/java/dev/faststats/screen/onboarding/ConsentState.java @@ -0,0 +1,93 @@ +package dev.faststats.screen.onboarding; + +import dev.faststats.Config; +import dev.faststats.config.SimpleConfig; +import dev.faststats.screen.registry.FastStatsRegistry; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +public final class ConsentState { + private static final Executor PERSISTENCE = Executors.newSingleThreadExecutor(runnable -> { + final var thread = new Thread(runnable, "faststats-config-writer"); + thread.setDaemon(true); + return thread; + }); + + private volatile boolean submitMetrics; + private volatile boolean additionalMetrics; + private volatile boolean errorTracking; + + public ConsentState(final Config config) { + this(config.submitMetrics(), config.additionalMetrics(), config.errorTracking()); + } + + ConsentState(final boolean submitMetrics, final boolean additionalMetrics, final boolean errorTracking) { + this.submitMetrics = submitMetrics; + this.additionalMetrics = submitMetrics && additionalMetrics; + this.errorTracking = errorTracking; + } + + public boolean submitMetrics() { + return submitMetrics; + } + + public void submitMetrics(final boolean enabled) { + submitMetrics = enabled; + if (!enabled) additionalMetrics = false; + } + + public boolean additionalMetrics() { + return submitMetrics && additionalMetrics; + } + + public void additionalMetrics(final boolean enabled) { + additionalMetrics = submitMetrics && enabled; + } + + public boolean errorTracking() { + return errorTracking; + } + + public void errorTracking(final boolean enabled) { + errorTracking = enabled; + } + + public void selectAll(final boolean selected) { + submitMetrics = selected; + additionalMetrics = selected; + errorTracking = selected; + } + + public boolean editable() { + return SimpleConfig.enabledFlag(); + } + + public synchronized void apply(final FastStatsRegistry registry) { + if (!editable()) return; + + final var contexts = registry.contexts(); + if (contexts.isEmpty()) return; + + final var enabled = submitMetrics || errorTracking; + contexts.forEach(context -> { + final var config = (SimpleConfig) context.getConfig(); + config.submitMetrics(submitMetrics); + config.additionalMetrics(additionalMetrics()); + config.errorTracking(errorTracking); + config.enabled(enabled); + config.consentScreenShown(true); + context.submissionActive = enabled; + }); + + final var context = contexts.get(0); + final var config = (SimpleConfig) context.getConfig(); + PERSISTENCE.execute(() -> { + try { + config.persist(); + } catch (final Throwable t) { + context.getLoggerFactory().getLogger(ConsentState.class).error("Failed to save FastStats consent", t); + } + }); + } +} diff --git a/onboarding/src/main/java/dev/faststats/screen/onboarding/ConsentText.java b/onboarding/src/main/java/dev/faststats/screen/onboarding/ConsentText.java new file mode 100644 index 00000000..f17e0b73 --- /dev/null +++ b/onboarding/src/main/java/dev/faststats/screen/onboarding/ConsentText.java @@ -0,0 +1,95 @@ +package dev.faststats.screen.onboarding; + +import dev.faststats.screen.registry.FastStatsRegistry; +import org.jspecify.annotations.Nullable; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +/** + * The text and translation keys of the consent screen, described without Minecraft classes. + *

+ * Every version turns the {@link Segment segments} into a component, links are shown in aqua and underlined. + */ +public final class ConsentText { + public static final URI MODS_URL = URI.create("https://faststats.dev/mods"); + public static final URI ABUSE_URL = URI.create("https://faststats.dev/abuse"); + public static final URI INFO_URL = URI.create("https://faststats.dev/info"); + public static final URI PRIVACY_URL = URI.create("https://faststats.dev/privacy"); + + public static final String BUTTON = "FastStats"; + public static final String TITLE = "faststats.consent.title"; + public static final String SUBMIT_METRICS = "faststats.consent.submit_metrics"; + public static final String SUBMIT_ADDITIONAL_METRICS = "faststats.consent.submit_additional_metrics"; + public static final String SUBMIT_ERRORS = "faststats.consent.submit_errors"; + public static final String SELECT_ALL = "faststats.consent.select_all"; + public static final String UNSELECT_ALL = "faststats.consent.unselect_all"; + public static final String DONE = "gui.done"; + + private ConsentText() { + } + + /** + * @return the description and the mods using FastStats, reflecting the given consent + */ + public static List create(final ConsentState state) { + final var text = new ArrayList(); + text.add(new Translation("faststats.consent.description", null, List.of( + new Link(new Literal("FastStats", null), MODS_URL), + new Link(new Literal(ABUSE_URL.toString(), null), ABUSE_URL), + new Link(new Literal(PRIVACY_URL.toString(), null), PRIVACY_URL)))); + text.add(new Literal("\n\n", null)); + text.add(new Translation("faststats.consent.installed_mods", null, List.of())); + + FastStatsRegistry.instance().registrations().forEach(registration -> { + text.add(new Literal("\n ", null)); + text.add(new Literal(registration.projectName(), Color.YELLOW)); + text.add(new Literal(":", null)); + if (registration.metrics()) { + feature(text, "faststats.consent.default_metrics", state.submitMetrics()); + } + if (registration.errorTracking()) { + feature(text, "faststats.consent.error_tracking", state.errorTracking()); + } + if (!registration.additionalMetrics().isEmpty()) { + feature(text, "faststats.consent.additional_metrics", state.additionalMetrics()); + registration.additionalMetrics().stream().sorted().forEach(metric -> { + text.add(new Literal("\n - ", null)); + text.add(new Literal(metric, state.additionalMetrics() ? Color.DARK_AQUA : Color.GRAY)); + }); + } + }); + + text.add(new Literal("\n\n", null)); + text.add(new Translation("faststats.consent.learn_more", null, List.of( + new Link(new Translation("faststats.consent.learn_more.link", null, List.of()), INFO_URL)))); + return text; + } + + private static void feature(final List text, final String key, final boolean enabled) { + text.add(new Literal("\n - ", null)); + text.add(enabled + ? new Translation(key, Color.AQUA, List.of()) + : new Translation(key + ".disabled", Color.GRAY, List.of())); + } + + /** + * The colors used by the consent text, named like Minecraft's {@code ChatFormatting}. + */ + public enum Color { + AQUA, DARK_AQUA, GRAY, YELLOW + } + + public sealed interface Segment permits Literal, Translation, Link { + } + + public record Literal(String text, @Nullable Color color) implements Segment { + } + + public record Translation(String key, @Nullable Color color, List args) implements Segment { + } + + public record Link(Segment text, URI uri) implements Segment { + } +} diff --git a/onboarding/src/main/java/dev/faststats/screen/onboarding/FirstRunOnboarding.java b/onboarding/src/main/java/dev/faststats/screen/onboarding/FirstRunOnboarding.java new file mode 100644 index 00000000..b7c7542c --- /dev/null +++ b/onboarding/src/main/java/dev/faststats/screen/onboarding/FirstRunOnboarding.java @@ -0,0 +1,19 @@ +package dev.faststats.screen.onboarding; + +import dev.faststats.config.SimpleConfig; +import dev.faststats.screen.registry.FastStatsRegistry; + +import java.util.concurrent.atomic.AtomicBoolean; + +public final class FirstRunOnboarding { + private static final AtomicBoolean OPENED = new AtomicBoolean(); + + private FirstRunOnboarding() { + } + + public static boolean shouldOpen() { + if (!SimpleConfig.enabledFlag()) return false; + if (!FastStatsRegistry.instance().consentPending()) return false; + return OPENED.compareAndSet(false, true); + } +} diff --git a/onboarding/src/main/java/dev/faststats/screen/registry/FastStatsRegistration.java b/onboarding/src/main/java/dev/faststats/screen/registry/FastStatsRegistration.java new file mode 100644 index 00000000..c7ebf2a7 --- /dev/null +++ b/onboarding/src/main/java/dev/faststats/screen/registry/FastStatsRegistration.java @@ -0,0 +1,11 @@ +package dev.faststats.screen.registry; + +import java.util.Set; + +public record FastStatsRegistration( + String projectName, + boolean metrics, + boolean errorTracking, + Set additionalMetrics +) { +} diff --git a/onboarding/src/main/java/dev/faststats/screen/registry/FastStatsRegistry.java b/onboarding/src/main/java/dev/faststats/screen/registry/FastStatsRegistry.java new file mode 100644 index 00000000..aa399801 --- /dev/null +++ b/onboarding/src/main/java/dev/faststats/screen/registry/FastStatsRegistry.java @@ -0,0 +1,75 @@ +package dev.faststats.screen.registry; + +import dev.faststats.SimpleContext; +import dev.faststats.SimpleMetrics; +import dev.faststats.config.SimpleConfig; +import dev.faststats.data.Metric; +import org.jetbrains.annotations.Contract; + +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public final class FastStatsRegistry { + private static final FastStatsRegistry INSTANCE = new FastStatsRegistry(); + + private final Map contexts = new IdentityHashMap<>(); + + private FastStatsRegistry() { + } + + @Contract(pure = true) + public static FastStatsRegistry instance() { + return INSTANCE; + } + + public synchronized void register(final SimpleContext context, final boolean consentRequired) { + final var additionalMetrics = context.metrics() + .filter(SimpleMetrics.class::isInstance) + .map(metrics -> ((SimpleMetrics) metrics).stream() + .map(Metric::getId) + .collect(Collectors.toUnmodifiableSet())) + .orElseGet(Set::of); + + contexts.put(context, new Entry(new FastStatsRegistration( + context.getProjectName(), + context.metrics().isPresent(), + context.errorTrackerService().isPresent(), + additionalMetrics + ), consentRequired)); + } + + public synchronized void unregister(final SimpleContext context) { + contexts.remove(context); + } + + public synchronized boolean isEmpty() { + return contexts.isEmpty(); + } + + public synchronized List registrations() { + return contexts.values().stream().map(Entry::registration).toList(); + } + + public synchronized List contexts() { + return List.copyOf(contexts.keySet()); + } + + public synchronized boolean consentPending() { + for (final var entry : contexts.entrySet()) { + if (!entry.getValue().consentRequired()) continue; + if (!((SimpleConfig) entry.getKey().getConfig()).consentScreenShown()) return true; + } + return false; + } + + public synchronized SimpleConfig anyConfig() throws IllegalStateException { + for (final var context : contexts.keySet()) return (SimpleConfig) context.getConfig(); + throw new IllegalStateException("Config not found!"); + } + + private record Entry(FastStatsRegistration registration, boolean consentRequired) { + } +} diff --git a/onboarding/src/main/java/module-info.java b/onboarding/src/main/java/module-info.java new file mode 100644 index 00000000..09ff7abf --- /dev/null +++ b/onboarding/src/main/java/module-info.java @@ -0,0 +1,14 @@ +import org.jspecify.annotations.NullMarked; + +@NullMarked +module dev.faststats.onboarding { + exports dev.faststats.screen.onboarding; + exports dev.faststats.screen.registry; + + requires dev.faststats.config; + + requires static org.jetbrains.annotations; + requires static org.jspecify; + + requires transitive dev.faststats; +} diff --git a/onboarding/src/main/resources/assets/faststats/lang/de_de.json b/onboarding/src/main/resources/assets/faststats/lang/de_de.json new file mode 100644 index 00000000..88aa72af --- /dev/null +++ b/onboarding/src/main/resources/assets/faststats/lang/de_de.json @@ -0,0 +1,23 @@ +{ + "faststats.consent.title": "FastStats-Metriken", + "faststats.consent.summary": "Anonyme Metriken und Fehlerberichte helfen Mod-Entwicklern, ihre Projekte zu verbessern.", + "faststats.consent.description": "%1$s sammelt anonyme Nutzungsstatistiken und Fehlerberichte.\nWenn du Metriken und Fehlerberichte aktiviert lässt, hilfst du Entwicklern, ihre Mods zu verbessern.\n\nFalls du vermutest, dass ein Entwickler personenbezogene Daten sammelt oder eine Opt-out-Option umgeht,\nmelde es bitte unter: %2$s\n\nDatenschutzerklärung: %3$s", + "faststats.consent.installed_mods": "> Installierte Mods, die FastStats verwenden:", + "faststats.consent.default_metrics": "Standardmetriken", + "faststats.consent.default_metrics.disabled": "Standardmetriken (deaktiviert)", + "faststats.consent.error_tracking": "Fehlerberichte", + "faststats.consent.error_tracking.disabled": "Fehlerberichte (deaktiviert)", + "faststats.consent.additional_metrics": "Zusätzliche Metriken:", + "faststats.consent.additional_metrics.disabled": "Zusätzliche Metriken (deaktiviert):", + "faststats.consent.learn_more": "Mehr erfahren: %s", + "faststats.consent.learn_more.link": "Welche Daten werden gesammelt?", + "faststats.consent.submit_metrics": "Metriken senden", + "faststats.consent.submit_additional_metrics": "Zusätzliche Metriken senden (vom Entwickler bereitgestellt)", + "faststats.consent.submit_errors": "Fehlerberichte senden (hilft Entwicklern, Fehler in ihren Mods zu beheben)", + "faststats.consent.select_all": "Alle auswählen", + "faststats.consent.unselect_all": "Alle abwählen", + "faststats.consent.link.info": "Info", + "faststats.consent.link.mods": "Mods", + "faststats.consent.link.privacy": "Datenschutz", + "faststats.consent.link.report": "Melden" +} diff --git a/onboarding/src/main/resources/assets/faststats/lang/en_us.json b/onboarding/src/main/resources/assets/faststats/lang/en_us.json new file mode 100644 index 00000000..247bdf32 --- /dev/null +++ b/onboarding/src/main/resources/assets/faststats/lang/en_us.json @@ -0,0 +1,23 @@ +{ + "faststats.consent.title": "FastStats Metrics", + "faststats.consent.summary": "Anonymous metrics and errors help mod developers improve their projects.", + "faststats.consent.description": "%1$s collects anonymous usage statistics and errors.\nKeeping Metrics and Error tracking enabled helps developers to improve their mods.\n\nIf you suspect a developer is collecting personal data or bypassing any opt-out option,\nplease report it at: %2$s\n\nPrivacy policy: %3$s", + "faststats.consent.installed_mods": "> Installed mods that use FastStats:", + "faststats.consent.default_metrics": "Default Metrics", + "faststats.consent.default_metrics.disabled": "Default Metrics (disabled)", + "faststats.consent.error_tracking": "Error Tracking", + "faststats.consent.error_tracking.disabled": "Error Tracking (disabled)", + "faststats.consent.additional_metrics": "Additional Metrics:", + "faststats.consent.additional_metrics.disabled": "Additional Metrics (disabled):", + "faststats.consent.learn_more": "Learn more: %s", + "faststats.consent.learn_more.link": "What data is collected?", + "faststats.consent.submit_metrics": "Submit Metrics", + "faststats.consent.submit_additional_metrics": "Submit Additional Metrics (provided by the developer)", + "faststats.consent.submit_errors": "Submit Errors (helps developers fix bugs in their mods)", + "faststats.consent.select_all": "Select All", + "faststats.consent.unselect_all": "Unselect All", + "faststats.consent.link.info": "Info", + "faststats.consent.link.mods": "Mods", + "faststats.consent.link.privacy": "Privacy", + "faststats.consent.link.report": "Report" +} diff --git a/onboarding/versions/1.16.1/src/main/java/dev/faststats/screen/minecraft/ConsentScreen.java b/onboarding/versions/1.16.1/src/main/java/dev/faststats/screen/minecraft/ConsentScreen.java new file mode 100644 index 00000000..fd794dbf --- /dev/null +++ b/onboarding/versions/1.16.1/src/main/java/dev/faststats/screen/minecraft/ConsentScreen.java @@ -0,0 +1,151 @@ +package dev.faststats.screen.minecraft; + +import com.mojang.blaze3d.vertex.PoseStack; +import dev.faststats.screen.onboarding.ConsentState; +import dev.faststats.screen.onboarding.ConsentText; +import dev.faststats.screen.registry.FastStatsRegistry; +import net.minecraft.ChatFormatting; +import net.minecraft.Util; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Checkbox; +import net.minecraft.client.gui.screens.ConfirmLinkScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.TextComponent; +import net.minecraft.network.chat.TranslatableComponent; +import org.jspecify.annotations.Nullable; + +import java.net.URI; +import java.util.List; +import java.util.function.Consumer; + +public final class ConsentScreen extends Screen { + private static final float DISABLED_ALPHA = 0.45F; + private static final int BUTTON_WIDTH = 100; + private static final int CONTENT_MAX_WIDTH = 600; + private static final int HEADER_AND_FOOTER_HEIGHT = 33; + private static final int MARGIN = 20; + private static final int SPACING = 8; + + private final @Nullable Screen parent; + private final boolean firstRun; + private final ConsentState state = new ConsentState(FastStatsRegistry.instance().anyConfig()); + private @Nullable TextArea text; + private double scroll; + + public ConsentScreen(final @Nullable Screen parent) { + this(parent, false); + } + + public ConsentScreen(final @Nullable Screen parent, final boolean firstRun) { + super(new TranslatableComponent(ConsentText.TITLE)); + this.parent = parent; + this.firstRun = firstRun; + } + + @Override + protected void init() { + final int width = Math.min(this.width - MARGIN * 2, CONTENT_MAX_WIDTH); + final int left = (this.width - width) / 2; + + final int buttonsLeft = (this.width - BUTTON_WIDTH * 3 - SPACING * 2) / 2; + final int buttonsY = height - (HEADER_AND_FOOTER_HEIGHT + 20) / 2; + addButton(editable(new Button(buttonsLeft, buttonsY, BUTTON_WIDTH, 20, new TranslatableComponent(ConsentText.UNSELECT_ALL), button -> selectAll(false)))); + addButton(editable(new Button(buttonsLeft + BUTTON_WIDTH + SPACING, buttonsY, BUTTON_WIDTH, 20, new TranslatableComponent(ConsentText.SELECT_ALL), button -> selectAll(true)))); + addButton(new Button(buttonsLeft + (BUTTON_WIDTH + SPACING) * 2, buttonsY, BUTTON_WIDTH, 20, new TranslatableComponent(ConsentText.DONE), button -> onClose())); + + final int checkboxesY = height - HEADER_AND_FOOTER_HEIGHT - SPACING - 20 * 3 - 4 * 2; + addButton(checkbox(ConsentText.SUBMIT_METRICS, left, checkboxesY, state.submitMetrics(), state.editable(), state::submitMetrics)); + addButton(checkbox(ConsentText.SUBMIT_ADDITIONAL_METRICS, left, checkboxesY + 24, state.additionalMetrics(), state.editable() && state.submitMetrics(), state::additionalMetrics)); + addButton(checkbox(ConsentText.SUBMIT_ERRORS, left, checkboxesY + 48, state.errorTracking(), state.editable(), state::errorTracking)); + + final int textY = HEADER_AND_FOOTER_HEIGHT + SPACING; + text = addButton(new TextArea(font, component(ConsentText.create(state)), left, textY, width, checkboxesY - SPACING - textY, this::clickLink)); + text.scrollPosition(scroll); + } + + @Override + public void render(final PoseStack pose, final int mouseX, final int mouseY, final float delta) { + renderBackground(pose); + drawCenteredString(pose, font, title, width / 2, (HEADER_AND_FOOTER_HEIGHT - font.lineHeight) / 2, 0xFFFFFF); + super.render(pose, mouseX, mouseY, delta); + } + + @Override + public boolean shouldCloseOnEsc() { + return !firstRun; + } + + @Override + public void onClose() { + state.apply(FastStatsRegistry.instance()); + minecraft.setScreen(parent); + } + + private void refresh() { + if (text != null) scroll = text.scrollPosition(); + init(minecraft, width, height); + } + + private Checkbox checkbox(final String key, final int x, final int y, final boolean selected, final boolean enabled, final Consumer onChange) { + final Component label = new TranslatableComponent(key); + final var checkbox = new Checkbox(x, y, 24 + font.width(label), 20, label, enabled && selected) { + @Override + public void onPress() { + super.onPress(); + onChange.accept(selected()); + refresh(); + } + }; + checkbox.active = enabled; + checkbox.setAlpha(enabled ? 1.0F : DISABLED_ALPHA); + return checkbox; + } + + private Button editable(final Button button) { + button.active = state.editable(); + return button; + } + + private void selectAll(final boolean selected) { + state.selectAll(selected); + refresh(); + } + + private boolean clickLink(final @Nullable Style style) { + final var link = style == null ? null : style.getInsertion(); + if (link == null) return false; + final var uri = URI.create(link); + minecraft.setScreen(new ConfirmLinkScreen(confirmed -> { + if (confirmed) Util.getPlatform().openUri(uri); + minecraft.setScreen(this); + }, link, true)); + return true; + } + + private static Component component(final List segments) { + final MutableComponent component = new TextComponent(""); + segments.forEach(segment -> component.append(component(segment))); + return component; + } + + private static MutableComponent component(final ConsentText.Segment segment) { + if (segment instanceof final ConsentText.Literal literal) { + return color(new TextComponent(literal.text()), literal.color()); + } else if (segment instanceof final ConsentText.Translation translation) { + final var args = translation.args().stream().map(ConsentScreen::component).toArray(); + return color(new TranslatableComponent(translation.key(), args), translation.color()); + } + // links are stored as the insertion and opened by clickLink + final var link = (ConsentText.Link) segment; + return component(link.text()) + .withStyle(ChatFormatting.AQUA, ChatFormatting.UNDERLINE) + .withStyle(style -> style.withInsertion(link.uri().toString())); + } + + private static MutableComponent color(final MutableComponent component, final ConsentText.@Nullable Color color) { + return color == null ? component : component.withStyle(ChatFormatting.valueOf(color.name())); + } +} diff --git a/onboarding/versions/1.16.1/src/main/java/dev/faststats/screen/minecraft/TextArea.java b/onboarding/versions/1.16.1/src/main/java/dev/faststats/screen/minecraft/TextArea.java new file mode 100644 index 00000000..ef98fb20 --- /dev/null +++ b/onboarding/versions/1.16.1/src/main/java/dev/faststats/screen/minecraft/TextArea.java @@ -0,0 +1,119 @@ +package dev.faststats.screen.minecraft; + +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.Style; +import org.jspecify.annotations.Nullable; +import org.lwjgl.opengl.GL11; + +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; + +/** + * A bordered, scrollable text area whose links are opened through the click handler. + *

+ * This version has no scrollable widget to build upon, so it is drawn by hand. + */ +final class TextArea extends AbstractWidget { + private static final int BORDER_COLOR = 0xFFA0A0A0; + private static final int PADDING = 4; + // drawn to the right of the widget + private static final int SCROLLBAR_WIDTH = 6; + private static final int SCROLLBAR_SPACING = 2; + + private final Font font; + private final List lines; + private final Predicate<@Nullable Style> onClick; + private double scroll; + + TextArea(final Font font, final Component text, final int x, final int y, final int width, final int maxHeight, + final Predicate<@Nullable Style> onClick) { + super(x, y, Math.max(1, width - SCROLLBAR_WIDTH - SCROLLBAR_SPACING), 0, text); + this.font = font; + this.lines = font.split(text, Math.max(1, this.width - PADDING * 2)); + this.onClick = onClick; + this.height = Math.max(1, Math.min(maxHeight, contentHeight())); + } + + // named differently from Minecraft's methods, which are remapped in production + double scrollPosition() { + return scroll; + } + + void scrollPosition(final double position) { + scroll = Math.max(0, Math.min(maxScroll(), position)); + } + + @Override + public void renderButton(final PoseStack pose, final int mouseX, final int mouseY, final float delta) { + fill(pose, x, y, x + width, y + height, BORDER_COLOR); + fill(pose, x + 1, y + 1, x + width - 1, y + height - 1, 0xFF000000); + + scissor(x + 1, y + 1, x + width - 1, y + height - 1); + for (int index = 0; index < lines.size(); index++) { + final int lineY = y + PADDING + index * font.lineHeight - (int) scroll; + if (lineY + font.lineHeight < y) continue; + if (lineY > y + height) break; + font.drawShadow(pose, lines.get(index), x + PADDING, lineY, 0xFFFFFF); + } + GL11.glDisable(GL11.GL_SCISSOR_TEST); + + if (maxScroll() <= 0) return; + final int left = x + width + SCROLLBAR_SPACING; + final int barHeight = Math.max(8, height * height / contentHeight()); + final int barY = y + (int) ((height - barHeight) * scroll / maxScroll()); + fill(pose, left, y, left + SCROLLBAR_WIDTH, y + height, 0xFF000000); + fill(pose, left, barY, left + SCROLLBAR_WIDTH, barY + barHeight, 0xFF808080); + fill(pose, left, barY, left + SCROLLBAR_WIDTH - 1, barY + barHeight - 1, 0xFFC0C0C0); + } + + @Override + public boolean mouseScrolled(final double mouseX, final double mouseY, final double amount) { + if (!isMouseOver(mouseX, mouseY)) return false; + scrollPosition(scroll - amount * font.lineHeight * 3); + return true; + } + + @Override + public boolean mouseClicked(final double mouseX, final double mouseY, final int button) { + if (button != 0 || !isMouseOver(mouseX, mouseY)) return false; + return onClick.test(styleAt(mouseX - x - PADDING, mouseY - y - PADDING + scroll)); + } + + private int contentHeight() { + return lines.size() * font.lineHeight + PADDING * 2; + } + + private double maxScroll() { + return Math.max(0, contentHeight() - height); + } + + private static void scissor(final int left, final int top, final int right, final int bottom) { + final var window = Minecraft.getInstance().getWindow(); + final double scale = window.getGuiScale(); + GL11.glEnable(GL11.GL_SCISSOR_TEST); + GL11.glScissor((int) (left * scale), (int) (window.getHeight() - bottom * scale), + (int) ((right - left) * scale), (int) ((bottom - top) * scale)); + } + + private @Nullable Style styleAt(final double x, final double y) { + if (x < 0 || y < 0) return null; + final int index = (int) (y / font.lineHeight); + if (index >= lines.size()) return null; + + final float[] offset = {0}; + return lines.get(index).visit((style, content) -> { + for (int position = 0; position < content.length(); position = content.offsetByCodePoints(position, 1)) { + final var character = content.substring(position, content.offsetByCodePoints(position, 1)); + offset[0] += font.width(FormattedText.of(character, style)); + if (x < offset[0]) return Optional.of(style); + } + return Optional.