From a67edee67fc338f3edbbdd668008e81ba3b3f8e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:50:02 +0000 Subject: [PATCH 01/22] feat!: support Minecraft 26.1 and 26.2 on Java 25 Compile against Paper API 26.2 and FastAsyncWorldEdit 2.16.1 with Gradle 9.8 and Java 25. Versions move into one catalog, shadow/plugin-yml/run-paper/hangar/spotless use their current coordinates, and build-logic, ServerLib, paperlib and EldoNexus publishing are dropped. Translations use MiniMessageTranslationStore because Adventure removed TranslationRegistry; extra language files in lang/ now fall back to the bundled English bundle. run- tasks start Paper with FastAsyncWorldEdit installed. BREAKING CHANGE: requires Paper 26.1.1 or newer and Java 25. Servers on 1.21 stay on 1.1.x. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp --- .gitignore | 1 - build-logic/build.gradle.kts | 7 - build-logic/src/main/kotlin/extensions.kt | 43 ---- .../main/kotlin/olf.build-logic.gradle.kts | 0 build.gradle.kts | 243 +++++++++--------- gradle.properties | 5 +- gradle/wrapper/gradle-wrapper.jar | Bin 43453 -> 47623 bytes gradle/wrapper/gradle-wrapper.properties | 4 +- gradlew | 19 +- gradlew.bat | 88 ++++--- settings.gradle.kts | 72 +++--- .../bettergopaint/BetterGoPaint.java | 59 ++--- .../PluginTranslationRegistry.java | 138 ---------- 13 files changed, 257 insertions(+), 422 deletions(-) delete mode 100644 build-logic/build.gradle.kts delete mode 100644 build-logic/src/main/kotlin/extensions.kt delete mode 100644 build-logic/src/main/kotlin/olf.build-logic.gradle.kts delete mode 100644 src/main/java/net/onelitefeather/bettergopaint/translations/PluginTranslationRegistry.java diff --git a/.gitignore b/.gitignore index d79f7abd..455dabfd 100644 --- a/.gitignore +++ b/.gitignore @@ -131,7 +131,6 @@ local.properties /target/ checkstyle.xml classes/ -docs/ build/ run-*/ diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts deleted file mode 100644 index cc02e636..00000000 --- a/build-logic/build.gradle.kts +++ /dev/null @@ -1,7 +0,0 @@ -plugins { - `kotlin-dsl` -} - -repositories { - gradlePluginPortal() -} diff --git a/build-logic/src/main/kotlin/extensions.kt b/build-logic/src/main/kotlin/extensions.kt deleted file mode 100644 index 0c696da7..00000000 --- a/build-logic/src/main/kotlin/extensions.kt +++ /dev/null @@ -1,43 +0,0 @@ -import org.gradle.api.Project -import org.gradle.api.publish.PublishingExtension -import org.gradle.api.publish.maven.MavenPublication -import org.gradle.kotlin.dsl.configure -import org.gradle.kotlin.dsl.get -import org.gradle.kotlin.dsl.named -import java.io.ByteArrayOutputStream - -fun Project.publishShadowJar() { - configurePublication { - artifact(tasks["shadowJar"]) - artifact(tasks["sourcesJar"]) - } -} - -private fun Project.configurePublication(configurer: MavenPublication.() -> Unit) { - extensions.configure { - publications.named("mavenJava") { - apply(configurer) - } - } -} - -fun Project.latestCommitHash(): String { - return runGitCommand(listOf("rev-parse", "--short", "HEAD")) -} - -fun Project.latestCommitMessage(): String { - return runGitCommand(listOf("log", "-1", "--pretty=%B")) -} - -fun Project.branchName(): String { - return runGitCommand(listOf("rev-parse", "--abbrev-ref", "HEAD")) -} - -fun Project.runGitCommand(args: List): String { - val byteOut = ByteArrayOutputStream() - exec { - commandLine = listOf("git") + args - standardOutput = byteOut - } - return byteOut.toString(Charsets.UTF_8.name()).trim() -} diff --git a/build-logic/src/main/kotlin/olf.build-logic.gradle.kts b/build-logic/src/main/kotlin/olf.build-logic.gradle.kts deleted file mode 100644 index e69de29b..00000000 diff --git a/build.gradle.kts b/build.gradle.kts index 4b6711cb..90bc07e2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,78 +1,78 @@ import io.papermc.hangarpublishplugin.model.Platforms -import kotlin.system.exitProcess import net.minecrell.pluginyml.bukkit.BukkitPluginDescription import net.minecrell.pluginyml.paper.PaperPluginDescription import xyz.jpenilla.runpaper.task.RunServer plugins { - id("java") + java + jacoco alias(libs.plugins.shadowJar) - alias(libs.plugins.publishdata) alias(libs.plugins.paper.run) alias(libs.plugins.paper.yml) alias(libs.plugins.hangar) alias(libs.plugins.modrinth) + alias(libs.plugins.cyclonedx) alias(libs.plugins.spotless) - id("olf.build-logic") - `maven-publish` } +group = "net.onelitefeather.bettergopaint" +version = "1.1.0" // x-release-please-version + if (!File("$rootDir/.git").exists()) { - logger.lifecycle(""" + logger.lifecycle( + """ ************************************************************************************** You need to fork and clone this repository! Don't download a .zip file. If you need assistance, consult the GitHub docs: https://docs.github.com/get-started/quickstart/fork-a-repo ************************************************************************************** """.trimIndent() - ).also { exitProcess(1) } + ).also { System.exit(1) } } -allprojects { - group = "net.onelitefeather.bettergopaint" - version = "1.1.0" -} -group = "net.onelitefeather.bettergopaint" - +// Every version listed here is offered on Hangar and Modrinth and gets a run- task. +// Paper 26.x runs on Java 25, which is also the bytecode level this plugin is compiled for, +// so older Minecraft lines cannot load the jar. Keep docs/reference/supported-versions.md in sync. val supportedMinecraftVersions = listOf( - "1.21" + "26.1.1", + "26.1.2", + "26.2" ) -repositories { - mavenCentral() - maven("https://papermc.io/repo/repository/maven-public/") - maven("https://maven.enginehub.org/repo/") +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(25)) + } } dependencies { - // Paper / Spigot compileOnly(libs.paper) - // Fawe / WorldEdit - implementation(platform(libs.fawe.bom)) - compileOnly(libs.fawe.bukkit) - // Utils - implementation(libs.serverlib) - implementation(libs.paperlib) - implementation(libs.semver) - // Stats + compileOnly(libs.fawe.core) + compileOnly(libs.fawe.bukkit) { isTransitive = false } + implementation(libs.bstats) - // Commands + implementation(libs.semver) implementation(libs.cloud.command.annotations) implementation(libs.cloud.command.extras) implementation(libs.cloud.command.paper) annotationProcessor(libs.cloud.command.annotations) -} -publishData { - useEldoNexusRepos(false) - publishTask("shadowJar") + testImplementation(libs.paper) + testImplementation(libs.fawe.core) + testImplementation(libs.fawe.bukkit) { isTransitive = false } + testImplementation(libs.junit.jupiter) + testImplementation(libs.mockito.core) + testImplementation(libs.mockito.junit.jupiter) + testImplementation(libs.assertj.core) + testRuntimeOnly(libs.junit.platform.launcher) } - paper { name = "BetterGoPaint" main = "net.onelitefeather.bettergopaint.BetterGoPaint" - authors = listOf("Arcaniax", "TheMeinerLP") - apiVersion = "1.20" + authors = listOf("Arcaniax", "TheMeinerLP", "OneLiteFeather") + apiVersion = "26.1" + website = "https://github.com/OneLiteFeatherNET/BetterGoPaint" + provides = listOf("goPaint") serverDependencies { register("FastAsyncWorldEdit") { @@ -81,138 +81,135 @@ paper { } } - website = "https://github.com/OneLiteFeatherNET/BetterGoPaint" - provides = listOf("goPaint") - permissions { - register("bettergopaint.command.admin.reload") { - default = BukkitPluginDescription.Permission.Default.OP - } - register("bettergopaint.notify.admin.update") { + register("bettergopaint.use") { + description = "Use the goPaint brush and the /gopaint command." default = BukkitPluginDescription.Permission.Default.OP } - register("bettergopaint.notify.disable.donation") { + register("bettergopaint.admin") { + description = "Use the administrative /gopaint subcommands such as reload." default = BukkitPluginDescription.Permission.Default.FALSE } - register("bettergopaint.use") { + register("bettergopaint.command.admin.reload") { + description = "Reload config.yml with /gopaint reload." default = BukkitPluginDescription.Permission.Default.OP } - register("bettergopaint.admin") { + register("bettergopaint.world.bypass") { + description = "Paint in worlds listed under disabled worlds in config.yml." default = BukkitPluginDescription.Permission.Default.FALSE } - register("bettergopaint.world.bypass") { + register("bettergopaint.notify.admin.update") { + description = "Receive a notice on join when a newer plugin version is available on Hangar." + default = BukkitPluginDescription.Permission.Default.OP + } + register("bettergopaint.notify.disable.donation") { + description = "Suppress the donation notice that is shown on join." default = BukkitPluginDescription.Permission.Default.FALSE } } } - spotless { java { licenseHeaderFile(rootProject.file("HEADER.txt")) - target("**/*.java") + target("src/**/*.java") } } -java { - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 -} - tasks { - jar { - archiveClassifier.set("unshaded") - } compileJava { - options.release.set(21) options.encoding = "UTF-8" + options.release.set(25) + } + compileTestJava { + options.encoding = "UTF-8" + options.release.set(25) + } + jar { + archiveClassifier.set("unshaded") } shadowJar { archiveClassifier.set("") - dependencies { - relocate("com.cryptomorin.xseries", "${rootProject.group}.xseries") - relocate("org.incendo.serverlib", "${rootProject.group}.serverlib") - relocate("org.bstats", "${rootProject.group}.metrics") - relocate("io.papermc.lib", "${rootProject.group}.paperlib") - } + relocate("org.bstats", "${project.group}.metrics") + relocate("com.github.zafarkhaja.semver", "${project.group}.semver") } build { dependsOn(shadowJar) } + test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } + reports { + junitXml.required.set(true) + html.required.set(true) + } + finalizedBy(jacocoTestReport) + } + jacocoTestReport { + dependsOn(test) + reports { + xml.required.set(true) + html.required.set(true) + } + } + named("modrinth") { + dependsOn(shadowJar) + } + named("publishAllPublicationsToHangar") { + dependsOn(shadowJar) + } supportedMinecraftVersions.forEach { serverVersion -> register("run-$serverVersion") { minecraftVersion(serverVersion) jvmArgs("-DPaper.IgnoreJavaVersion=true", "-Dcom.mojang.eula.agree=true") group = "run paper" runDirectory.set(file("run-$serverVersion")) - pluginJars(rootProject.tasks.shadowJar.map { it.archiveFile }.get()) - } - } -} - -val branch = rootProject.branchName() -val baseVersion = publishData.getVersion(false) -val isRelease = !baseVersion.contains('-') -val isMainBranch = branch == "master" -if (!isRelease || isMainBranch) { // Only publish releases from the main branch - val suffixedVersion = if (isRelease) baseVersion else baseVersion + "+" + System.getenv("GITHUB_RUN_NUMBER") - val changelogContent = if (isRelease) { - "See [GitHub](https://github.com/OneLiteFeatherNET/BetterGoPaint) for release notes." - } else { - val commitHash = rootProject.latestCommitHash() - "[$commitHash](https://github.com/OneLiteFeatherNET/BetterGoPaint/commit/$commitHash) ${rootProject.latestCommitMessage()}" - } - hangarPublish { - publications.register("BetterGoPaint") { - version.set(suffixedVersion) - channel.set(if (isRelease) "Release" else "Snapshot") - changelog.set(changelogContent) - apiKey.set(System.getenv("HANGAR_SECRET")) - id.set("BetterGoPaint") - platforms { - register(Platforms.PAPER) { - jar.set(tasks.shadowJar.flatMap { it.archiveFile }) - platformVersions.set(supportedMinecraftVersions) - } + pluginJars(shadowJar.flatMap { it.archiveFile }) + downloadPlugins { + modrinth("fastasyncworldedit", libs.versions.fawe.get()) } } } +} - modrinth { - token.set(System.getenv("MODRINTH_TOKEN")) - projectId.set("qf7sNg9A") - versionType.set(if (isRelease) "release" else "beta") - versionNumber.set(suffixedVersion) - versionName.set(suffixedVersion) - changelog.set(changelogContent) - uploadFile.set(tasks.shadowJar.flatMap { it.archiveFile }) - gameVersions.addAll(supportedMinecraftVersions) - loaders.add("paper") - loaders.add("bukkit") - loaders.add("folia") +val baseVersion = version as String +val baseChannel = with(baseVersion) { + when { + contains("SNAPSHOT", true) -> "Snapshot" + contains("ALPHA", true) -> "Alpha" + contains("BETA", true) -> "Beta" + else -> "Release" } } +val changelogContent = "See [GitHub](https://github.com/OneLiteFeatherNET/BetterGoPaint/releases/tag/v$baseVersion) for release notes." -publishing { - publications.create("maven") { - // Configure our maven publication - publishData.configurePublication(this) - } - - repositories { - // We add EldoNexus as our repository. The used url is defined by the publish data. - maven { - authentication { - credentials(PasswordCredentials::class) { - // Those credentials need to be set under "Settings -> Secrets -> Actions" in your repository - username = System.getenv("ELDO_USERNAME") - password = System.getenv("ELDO_PASSWORD") - } +hangarPublish { + publications.register("BetterGoPaint") { + version.set(baseVersion) + channel.set(baseChannel) + changelog.set(changelogContent) + apiKey.set(System.getenv("HANGAR_SECRET")) + id.set("BetterGoPaint") + platforms { + register(Platforms.PAPER) { + jar.set(tasks.shadowJar.flatMap { it.archiveFile }) + platformVersions.set(supportedMinecraftVersions) } - - name = "EldoNexus" - setUrl(publishData.getRepository()) } } } + +modrinth { + token.set(System.getenv("MODRINTH_TOKEN")) + projectId.set("qf7sNg9A") + versionType.set(baseChannel.lowercase()) + versionNumber.set(baseVersion) + versionName.set(baseVersion) + changelog.set(changelogContent) + uploadFile.set(tasks.shadowJar.flatMap { it.archiveFile }) + gameVersions.addAll(supportedMinecraftVersions) + loaders.add("paper") +} diff --git a/gradle.properties b/gradle.properties index 5f560d2e..04383f9c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,3 @@ -projectVersion=1.1.0-SNAPSHOT - org.gradle.jvmargs=-Xmx2048m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -org.gradle.daemon=true -org.gradle.configureondemand=true org.gradle.parallel=true +org.gradle.caching=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e6441136f3d4ba8a0da8d277868979cfbc8ad796..5097068a8d375f5bf0d15693fb5b3615c972a801 100644 GIT binary patch delta 39622 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
T;5dQi6j+8 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e7646dea..9b7cb4af 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index b740cf13..249efbb0 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,10 +15,12 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -27,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -112,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -170,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -203,15 +203,14 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 7101f8e4..03754c4c 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,16 +13,45 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -43,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -57,36 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% -:mainEnd -if "%OS%"=="Windows_NT" endlocal +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel -:omega +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/settings.gradle.kts b/settings.gradle.kts index 9f70ff0f..a7e396a9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,54 +1,62 @@ rootProject.name = "BetterGoPaint" -includeBuild("build-logic") -pluginManagement { +dependencyResolutionManagement { repositories { - maven("https://eldonexus.de/repository/maven-public/") - gradlePluginPortal() + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") + maven("https://maven.enginehub.org/repo/") } -} - -dependencyResolutionManagement { versionCatalogs { create("libs") { - version("publishdata", "1.4.0") version("modrinth", "2.+") - version("hangar", "0.1.2") - version("paper.yml", "0.6.0") - version("paper.run", "2.3.0") - version("shadowJar", "8.1.1") - version("intellectualsites", "1.51") - - version("paper", "1.21.3-R0.1-SNAPSHOT") - version("bstats", "3.0.2") + version("hangar", "0.1.4") + version("paper.yml", "0.9.0") + version("paper.run", "3.1.0") + version("shadowJar", "9.6.1") + version("cyclonedx", "3.4.1") + version("spotless", "8.10.4") + + // Compile against the newest stable Paper line. Paper 26.x requires Java 25. + version("paper", "26.2.build.+") + version("fawe", "2.16.1") + version("bstats", "3.2.1") + + version("cloudcommandPaper", "2.0.1") + version("cloudcommandAnnotations", "2.1.0") + version("cloudcommandExtras", "2.0.1") - version("cloudcommand", "2.0.0-SNAPSHOT") - - version("adventure", "4.17.0") version("semver", "0.10.2") + // Testing dependencies + version("junit", "6.1.3") + version("mockito", "5.24.0") + version("assertj", "3.27.7") + library("paper", "io.papermc.paper", "paper-api").versionRef("paper") - library("minimessage", "net.kyori", "adventure-text-minimessage").versionRef("adventure") + library("fawe.core", "com.fastasyncworldedit", "FastAsyncWorldEdit-Core").versionRef("fawe") + library("fawe.bukkit", "com.fastasyncworldedit", "FastAsyncWorldEdit-Bukkit").versionRef("fawe") library("bstats", "org.bstats", "bstats-bukkit").versionRef("bstats") - library("fawe.bom", "com.intellectualsites.bom", "bom-newest").versionRef("intellectualsites") - library("fawe.bukkit", "com.fastasyncworldedit", "FastAsyncWorldEdit-Bukkit").withoutVersion() - library("serverlib", "dev.notmyfault.serverlib", "ServerLib").withoutVersion() - library("paperlib", "io.papermc", "paperlib").withoutVersion() - - library("cloud.command.paper", "org.incendo", "cloud-paper").versionRef("cloudcommand") - library("cloud.command.annotations", "org.incendo", "cloud-annotations").versionRef("cloudcommand") - library("cloud.command.extras", "org.incendo", "cloud-minecraft-extras").versionRef("cloudcommand") + library("cloud.command.paper", "org.incendo", "cloud-paper").versionRef("cloudcommandPaper") + library("cloud.command.annotations", "org.incendo", "cloud-annotations").versionRef("cloudcommandAnnotations") + library("cloud.command.extras", "org.incendo", "cloud-minecraft-extras").versionRef("cloudcommandExtras") library("semver", "com.github.zafarkhaja", "java-semver").versionRef("semver") - plugin("publishdata","de.chojo.publishdata").versionRef("publishdata") + // Testing libraries + library("junit-jupiter", "org.junit.jupiter", "junit-jupiter").versionRef("junit") + library("junit-platform-launcher", "org.junit.platform", "junit-platform-launcher").withoutVersion() + library("mockito-core", "org.mockito", "mockito-core").versionRef("mockito") + library("mockito-junit-jupiter", "org.mockito", "mockito-junit-jupiter").versionRef("mockito") + library("assertj-core", "org.assertj", "assertj-core").versionRef("assertj") + plugin("modrinth", "com.modrinth.minotaur").versionRef("modrinth") plugin("hangar", "io.papermc.hangar-publish-plugin").versionRef("hangar") - plugin("paper.yml", "net.minecrell.plugin-yml.paper").versionRef("paper.yml") + plugin("paper.yml", "de.eldoria.plugin-yml.paper").versionRef("paper.yml") plugin("paper.run", "xyz.jpenilla.run-paper").versionRef("paper.run") - plugin("shadowJar", "com.github.johnrengelman.shadow").versionRef("shadowJar") - plugin("spotless", "com.diffplug.spotless").version("6.18.0") + plugin("shadowJar", "com.gradleup.shadow").versionRef("shadowJar") + plugin("cyclonedx", "org.cyclonedx.bom").versionRef("cyclonedx") + plugin("spotless", "com.diffplug.spotless").versionRef("spotless") } } } diff --git a/src/main/java/net/onelitefeather/bettergopaint/BetterGoPaint.java b/src/main/java/net/onelitefeather/bettergopaint/BetterGoPaint.java index 0a7f5df4..67562687 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/BetterGoPaint.java +++ b/src/main/java/net/onelitefeather/bettergopaint/BetterGoPaint.java @@ -22,9 +22,8 @@ import net.kyori.adventure.key.Key; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.translation.MiniMessageTranslationStore; import net.kyori.adventure.translation.GlobalTranslator; -import net.kyori.adventure.translation.TranslationRegistry; -import net.kyori.adventure.util.UTF8ResourceBundleControl; import net.onelitefeather.bettergopaint.brush.PlayerBrushManager; import net.onelitefeather.bettergopaint.command.GoPaintCommand; import net.onelitefeather.bettergopaint.command.ReloadCommand; @@ -33,7 +32,6 @@ import net.onelitefeather.bettergopaint.listeners.InventoryListener; import net.onelitefeather.bettergopaint.objects.other.Settings; import net.onelitefeather.bettergopaint.service.UpdateService; -import net.onelitefeather.bettergopaint.translations.PluginTranslationRegistry; import org.bstats.bukkit.Metrics; import org.bstats.charts.SimplePie; import org.bukkit.Bukkit; @@ -46,12 +44,12 @@ import org.incendo.cloud.bukkit.CloudBukkitCapabilities; import org.incendo.cloud.execution.ExecutionCoordinator; import org.incendo.cloud.paper.LegacyPaperCommandManager; -import org.incendo.serverlib.ServerLib; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.File; import java.io.IOException; +import java.io.UncheckedIOException; import java.net.URL; import java.net.URLClassLoader; import java.nio.file.Files; @@ -64,7 +62,7 @@ public class BetterGoPaint extends JavaPlugin implements Listener { - public static final String PAPER_DOCS = "https://jd.papermc.io/paper/1.20.6/org/bukkit/Material.html#enum-constant-summary"; + public static final String PAPER_DOCS = "https://jd.papermc.io/paper/org/bukkit/Material.html"; private final PlayerBrushManager brushManager = new PlayerBrushManager(); private final Metrics metrics = new Metrics(this, 18734); @@ -80,9 +78,6 @@ public void onLoad() { @Override public void onEnable() { - // Check if we are in a safe environment - ServerLib.checkUnsafeForks(); - // disable if goPaint and BetterGoPaint are installed simultaneously if (hasOriginalGoPaint()) { getComponentLogger().error("BetterGoPaint is a replacement for goPaint. Please use one instead of both"); @@ -93,27 +88,7 @@ public void onEnable() { reloadConfig(); - final TranslationRegistry translationRegistry = new PluginTranslationRegistry(TranslationRegistry.create(Key.key("bettergopaint", "translations"))); - translationRegistry.defaultLocale(Locale.US); - Path langFolder = getDataFolder().toPath().resolve("lang"); - var languages = new HashSet<>(Settings.settings().generic.LANGUAGES); - languages.add("en-US"); - if (Files.exists(langFolder)) { - try (var urlClassLoader = new URLClassLoader(new URL[]{langFolder.toUri().toURL()})) { - languages.stream().map(Locale::forLanguageTag).forEach(r -> { - var bundle = ResourceBundle.getBundle("bettergopaint", r, urlClassLoader, UTF8ResourceBundleControl.get()); - translationRegistry.registerAll(r, bundle, false); - }); - } catch (IOException e) { - throw new RuntimeException(e); - } - } else { - languages.stream().map(Locale::forLanguageTag).forEach(r -> { - var bundle = ResourceBundle.getBundle("bettergopaint", r, UTF8ResourceBundleControl.get()); - translationRegistry.registerAll(r, bundle, false); - }); - } - GlobalTranslator.translator().addSource(translationRegistry); + registerTranslations(); donationInformation(); @@ -152,6 +127,32 @@ public void reloadConfig() { } + private void registerTranslations() { + var translationStore = MiniMessageTranslationStore.create(Key.key("bettergopaint", "translations")); + translationStore.defaultLocale(Locale.US); + Path langFolder = getDataFolder().toPath().resolve("lang"); + var languages = new HashSet<>(Settings.settings().generic.LANGUAGES); + languages.add("en-US"); + if (Files.exists(langFolder)) { + try (var urlClassLoader = new URLClassLoader(new URL[]{langFolder.toUri().toURL()}, getClassLoader())) { + languages.stream().map(Locale::forLanguageTag).forEach(locale -> translationStore.registerAll( + locale, + ResourceBundle.getBundle("bettergopaint", locale, urlClassLoader), + false + )); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } else { + languages.stream().map(Locale::forLanguageTag).forEach(locale -> translationStore.registerAll( + locale, + ResourceBundle.getBundle("bettergopaint", locale), + false + )); + } + GlobalTranslator.translator().addSource(translationStore); + } + private void updateService() { this.updateService = new UpdateService(this); this.updateService.run(); diff --git a/src/main/java/net/onelitefeather/bettergopaint/translations/PluginTranslationRegistry.java b/src/main/java/net/onelitefeather/bettergopaint/translations/PluginTranslationRegistry.java deleted file mode 100644 index cd7038e5..00000000 --- a/src/main/java/net/onelitefeather/bettergopaint/translations/PluginTranslationRegistry.java +++ /dev/null @@ -1,138 +0,0 @@ -/* - * goPaint is designed to simplify painting inside of Minecraft. - * Copyright (C) Arcaniax-Development - * Copyright (C) Arcaniax team and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package net.onelitefeather.bettergopaint.translations; - -import net.kyori.adventure.key.Key; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.ComponentLike; -import net.kyori.adventure.text.TranslatableComponent; -import net.kyori.adventure.text.minimessage.Context; -import net.kyori.adventure.text.minimessage.MiniMessage; -import net.kyori.adventure.text.minimessage.ParsingException; -import net.kyori.adventure.text.minimessage.tag.Tag; -import net.kyori.adventure.text.minimessage.tag.resolver.ArgumentQueue; -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; -import net.kyori.adventure.translation.TranslationRegistry; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.text.MessageFormat; -import java.util.List; -import java.util.Locale; -import java.util.Objects; - -public final class PluginTranslationRegistry implements TranslationRegistry { - - private final TranslationRegistry backedRegistry; - - public PluginTranslationRegistry(TranslationRegistry backedRegistry) { - this.backedRegistry = backedRegistry; - } - - @Override - public boolean contains(@NotNull String key) { - return backedRegistry.contains(key); - } - - @Override - public @NotNull Key name() { - return backedRegistry.name(); - } - - @Override - public @Nullable MessageFormat translate(@NotNull String key, @NotNull Locale locale) { - return null; - } - - @Override - public @Nullable Component translate( - @NotNull TranslatableComponent component, - @NotNull Locale locale - ) { - final MessageFormat translationFormat = backedRegistry.translate(component.key(), locale); - - if (translationFormat == null) { - return null; - } - - final String miniMessageString = translationFormat.toPattern(); - - final Component resultingComponent; - - if (component.arguments().isEmpty()) { - resultingComponent = MiniMessage.miniMessage().deserialize(miniMessageString); - } else { - resultingComponent = MiniMessage.miniMessage().deserialize(miniMessageString, - new ArgumentTag(component.arguments())); - } - - if (component.children().isEmpty()) { - return resultingComponent; - } else { - return resultingComponent.children(component.children()); - } - } - - @Override - public void defaultLocale(@NotNull Locale locale) { - backedRegistry.defaultLocale(locale); - } - - @Override - public void register(@NotNull String key, @NotNull Locale locale, @NotNull MessageFormat format) { - backedRegistry.register(key, locale, format); - } - - @Override - public void unregister(@NotNull String key) { - backedRegistry.unregister(key); - } - - private static final class ArgumentTag implements TagResolver { - private static final String NAME = "argument"; - private static final String NAME_1 = "arg"; - - private final List argumentComponents; - - private ArgumentTag(final @NotNull List argumentComponents) { - this.argumentComponents = Objects.requireNonNull(argumentComponents, "argumentComponents"); - } - - @Override - public Tag resolve( - final @NotNull String name, - final @NotNull ArgumentQueue arguments, - final @NotNull Context ctx - ) throws ParsingException { - final int index = arguments.popOr("No argument number provided") - .asInt().orElseThrow(() -> ctx.newException("Invalid argument number", arguments)); - - if (index < 0 || index >= argumentComponents.size()) { - throw ctx.newException("Invalid argument number", arguments); - } - - return Tag.inserting(argumentComponents.get(index)); - } - - @Override - public boolean has(final @NotNull String name) { - return name.equals(NAME) || name.equals(NAME_1); - } - } -} From d98ce213d7c1b00747d5b447d393ce6a625eb40f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:50:02 +0000 Subject: [PATCH 02/22] test: add unit tests for brushes, curves and translations JUnit 6, AssertJ and Mockito with JaCoCo reports. Covers brush cycling and lookup, the Paint Brush curve and translation key completeness. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp --- .../brush/PlayerBrushManagerTest.java | 92 +++++++++++++++ .../translations/TranslationBundleTest.java | 106 ++++++++++++++++++ .../utils/curve/BezierSplineTest.java | 100 +++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 src/test/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManagerTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/translations/TranslationBundleTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineTest.java diff --git a/src/test/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManagerTest.java b/src/test/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManagerTest.java new file mode 100644 index 00000000..5f847967 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManagerTest.java @@ -0,0 +1,92 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.brush; + +import net.onelitefeather.bettergopaint.objects.brush.Brush; +import net.onelitefeather.bettergopaint.objects.brush.PaintBrush; +import net.onelitefeather.bettergopaint.objects.brush.SphereBrush; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlayerBrushManagerTest { + + private PlayerBrushManager manager; + private List brushes; + + @BeforeEach + void setUp() { + manager = new PlayerBrushManager(); + brushes = manager.getBrushes(); + } + + @Test + @DisplayName("a player without a brush starts on the first one") + void cycleForwardWithoutBrushStartsAtFirst() { + assertThat(manager.cycleForward(null)).isInstanceOf(SphereBrush.class); + assertThat(manager.cycleBack(null)).isInstanceOf(SphereBrush.class); + } + + @Test + @DisplayName("cycling forward walks the list and wraps to the first brush") + void cycleForwardWraps() { + assertThat(manager.cycleForward(brushes.getFirst())).isSameAs(brushes.get(1)); + assertThat(manager.cycleForward(brushes.getLast())).isSameAs(brushes.getFirst()); + } + + @Test + @DisplayName("cycling backwards walks the list and wraps to the last brush") + void cycleBackWraps() { + assertThat(manager.cycleBack(brushes.get(1))).isSameAs(brushes.getFirst()); + assertThat(manager.cycleBack(brushes.getFirst())).isSameAs(brushes.getLast()); + } + + @Test + @DisplayName("every brush can be found again by its name") + void everyBrushIsFoundByName() { + for (Brush brush : brushes) { + assertThat(manager.getBrushHandler(brush.getName())) + .as("lookup of %s", brush.getName()) + .containsSame(brush); + } + assertThat(manager.getBrushHandler("Paint Brush")).containsInstanceOf(PaintBrush.class); + } + + @Test + @DisplayName("an unknown name finds no brush") + void unknownNameFindsNothing() { + assertThat(manager.getBrushHandler("Teleport Brush")).isEmpty(); + } + + @Test + @DisplayName("the lore highlights only the selected brush") + void loreHighlightsSelectedBrush() { + Brush selected = brushes.get(2); + + String lore = manager.getBrushLore(selected); + + assertThat(lore.lines()).hasSize(brushes.size()); + assertThat(lore.lines().filter(line -> line.startsWith("§e"))) + .containsExactly("§e" + selected.getName()); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/translations/TranslationBundleTest.java b/src/test/java/net/onelitefeather/bettergopaint/translations/TranslationBundleTest.java new file mode 100644 index 00000000..f319e17a --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/translations/TranslationBundleTest.java @@ -0,0 +1,106 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.translations; + +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Guards the shipped language files: every key the code asks for has to exist, every language has + * to ship the same keys, and every message has to be valid MiniMessage. + */ +class TranslationBundleTest { + + private static final Path RESOURCES = Paths.get("src", "main", "resources"); + private static final Path SOURCES = Paths.get("src", "main", "java"); + private static final String BUNDLE = "bettergopaint"; + private static final Pattern TRANSLATABLE = Pattern.compile("translatable\\(\"(bettergopaint\\.[a-z0-9_.]+)\""); + + static List bundles() throws IOException { + List files = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(RESOURCES, BUNDLE + "*.properties")) { + stream.forEach(files::add); + } + Collections.sort(files); + return files; + } + + private static Properties read(Path file) throws IOException { + Properties properties = new Properties(); + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + properties.load(reader); + } + return properties; + } + + private static Set keysUsedInCode() throws IOException { + Set keys = new TreeSet<>(); + try (var files = Files.walk(SOURCES)) { + for (Path file : files.filter(path -> path.toString().endsWith(".java")).toList()) { + Matcher matcher = TRANSLATABLE.matcher(Files.readString(file)); + while (matcher.find()) { + keys.add(matcher.group(1)); + } + } + } + return keys; + } + + @ParameterizedTest(name = "{0}") + @MethodSource("bundles") + @DisplayName("every language ships every key the code uses") + void containsEveryKeyUsedInCode(Path bundle) throws IOException { + assertThat(read(bundle).stringPropertyNames()) + .as("keys of %s", bundle.getFileName()) + .containsAll(keysUsedInCode()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("bundles") + @DisplayName("every message is valid MiniMessage") + void messagesParse(Path bundle) throws IOException { + Properties properties = read(bundle); + for (String key : properties.stringPropertyNames()) { + assertThatCode(() -> MiniMessage.miniMessage().deserialize(properties.getProperty(key))) + .as("%s in %s", key, bundle.getFileName()) + .doesNotThrowAnyException(); + } + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineTest.java b/src/test/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineTest.java new file mode 100644 index 00000000..abb5d267 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineTest.java @@ -0,0 +1,100 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.utils.curve; + +import org.bukkit.Location; +import org.bukkit.World; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.LinkedList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +class BezierSplineTest { + + private final World world = Mockito.mock(World.class); + + private BezierSpline spline(Location... knots) { + return new BezierSpline(new LinkedList<>(List.of(knots))); + } + + @Test + @DisplayName("a spline through two knots is the straight line between them") + void twoKnotsFormAStraightLine() { + BezierSpline spline = spline(new Location(world, 0, 64, 0), new Location(world, 10, 64, 0)); + + assertThat(spline.getSegmentsCount()).isEqualTo(1); + assertThat(spline.getCurveLength()).isCloseTo(10, within(1.0)); + assertThat(spline.getPoint(0.5).getX()).isCloseTo(5, within(0.01)); + } + + @Test + @DisplayName("the curve starts on the first knot and ends on the last") + void curvePassesThroughEndKnots() { + BezierSpline spline = spline( + new Location(world, 0, 64, 0), + new Location(world, 5, 64, 8), + new Location(world, 12, 64, 3) + ); + + Location start = spline.getPoint(0); + Location end = spline.getPoint(spline.getSegmentsCount()); + + assertThat(start.getX()).isCloseTo(0, within(1e-9)); + assertThat(start.getZ()).isCloseTo(0, within(1e-9)); + assertThat(end.getX()).isCloseTo(12, within(1e-9)); + assertThat(end.getZ()).isCloseTo(3, within(1e-9)); + } + + @Test + @DisplayName("knots on one height keep the whole curve on that height") + void flatKnotsStayFlat() { + BezierSpline spline = spline( + new Location(world, 0, 70, 0), + new Location(world, 4, 70, 9), + new Location(world, 9, 70, -2) + ); + + for (double t = 0; t <= spline.getSegmentsCount(); t += 0.25) { + assertThat(spline.getPoint(t).getY()).as("y at t=%s", t).isEqualTo(70); + } + } + + @Test + @DisplayName("getT maps walked distance back onto the curve parameter") + void getTIsMonotonic() { + BezierSpline spline = spline( + new Location(world, 0, 64, 0), + new Location(world, 6, 64, 6), + new Location(world, 12, 64, 0) + ); + + double previous = -1; + for (double blocks = 0; blocks < spline.getCurveLength(); blocks += 1) { + double t = spline.getT(blocks); + assertThat(t).as("t after %s blocks", blocks).isGreaterThan(previous); + previous = t; + } + assertThat(spline.getT(spline.getCurveLength())).isEqualTo(spline.getSegmentsCount()); + } +} From 20244a336167e5540d5803b901d2180676c31fb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:50:02 +0000 Subject: [PATCH 03/22] ci: adopt release please, commit linting and security scans Mirror the AntiRedstoneClock-Remastered pipeline: pr-lint, multi-OS PR builds on Java 25, Release Please on main with a Trivy gate before Hangar/Modrinth publishing and an SBOM upload, weekly Trivy scan, shared Renovate presets and new issue forms. Snapshot publishing on every push and release-drafter are removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp --- .github/ISSUE_TEMPLATE/bug-report.yml | 98 ++++++++++ .github/ISSUE_TEMPLATE/bug_report.yml | 107 ---------- .github/ISSUE_TEMPLATE/feature-request.yml | 50 +++++ .github/ISSUE_TEMPLATE/feature_request.yml | 30 --- .github/PULL_REQUEST_TEMPLATE.md | 16 -- .github/pull_request_template.md | 53 +++++ .github/release-drafter.yml | 5 - .github/renovate.json | 11 -- .github/stale.yml | 18 -- .github/workflows/build-pr.yml | 94 ++++++++- .github/workflows/close_invalid_prs.yml | 4 +- .github/workflows/pr-lint.yml | 63 ++++++ .github/workflows/publish.yml | 27 --- .github/workflows/release-drafter.yml | 22 --- .github/workflows/release-please.yml | 216 +++++++++++++++++++++ .github/workflows/security.yml | 36 ++++ .release-please-manifest.json | 3 + CONTRIBUTING.md | 62 ++++++ commitlint.config.mjs | 18 ++ release-please-config.json | 30 +++ renovate.json | 10 +- 21 files changed, 722 insertions(+), 251 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-request.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/pull_request_template.md delete mode 100644 .github/release-drafter.yml delete mode 100644 .github/renovate.json delete mode 100644 .github/stale.yml create mode 100644 .github/workflows/pr-lint.yml delete mode 100644 .github/workflows/publish.yml delete mode 100644 .github/workflows/release-drafter.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .github/workflows/security.yml create mode 100644 .release-please-manifest.json create mode 100644 CONTRIBUTING.md create mode 100644 commitlint.config.mjs create mode 100644 release-please-config.json diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 00000000..7889abc0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,98 @@ +name: Bug Report +description: Report a brush that misbehaves, an error in the console or an incompatibility with another plugin. +labels: [ "status: needs triage", "type: bug" ] +body: + - type: checkboxes + id: acknowledgements + attributes: + label: Before you continue + description: We close reports that skip these. Please tick every box. + options: + - label: I am running the latest version of BetterGoPaint from [Hangar](https://hangar.papermc.io/OneLiteFeather/BetterGoPaint) or [Modrinth](https://modrinth.com/plugin/bettergopaint). + required: true + - label: I am running a Minecraft version that is listed in the [supported versions](https://github.com/OneLiteFeatherNET/BetterGoPaint/blob/main/docs/reference/supported-versions.md). + required: true + - label: I searched the open and closed issues and this is not a duplicate. + required: true + - label: I am pasting unmodified console output, not screenshots and not a summary. + required: true + + - type: dropdown + id: server-software + attributes: + label: Server software + description: | + BetterGoPaint supports Paper. Paper forks, Spigot, CraftBukkit, Folia and hybrid servers are not supported. + options: + - Paper + - A Paper fork (Purpur, Pufferfish, Leaves, ...) + - Something else + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected behavior + description: What you expected to see. + validations: + required: true + + - type: textarea + id: actual-behavior + attributes: + label: Actual behavior + description: What you actually saw. Screenshots or a short video of the brush result help a lot. + validations: + required: true + + - type: textarea + id: steps-to-reproduce + attributes: + label: Steps to reproduce + description: Which brush, which settings (size, chance, mask, surface mode) and which blocks you used. + validations: + required: true + + - type: input + id: log-link + attributes: + label: Full log (paste service) + description: Upload your full `logs/latest.log` to [mclo.gs](https://mclo.gs) and paste the link here. + placeholder: https://mclo.gs/xxxxxxx + validations: + required: false + + - type: textarea + id: paper-version + attributes: + label: Paper version + description: Run `/version` on your server and **paste** the full, unmodified output here. + render: text + validations: + required: true + + - type: textarea + id: plugin-version + attributes: + label: Plugin versions + description: Run `/version BetterGoPaint` and `/version FastAsyncWorldEdit` and **paste** both outputs here. + render: text + validations: + required: true + + - type: input + id: fawe-debugpaste + attributes: + label: FAWE debugpaste + description: Run `/fawe debugpaste` and paste the link here. + validations: + required: false + + - type: textarea + id: other-information + attributes: + label: Other + description: Anything else that helps us find the problem. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index 8b0009d7..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: Bug report -description: Create a report to help us improve -labels: 'Requires Testing' - -body: - - type: markdown - attributes: - value: | - Thanks for taking the time to fill out this bug report for goPaint! Fill out the following form to your best ability to help us fix the problem. - Only use this if you're absolutely sure that you found a bug and can reproduce it. For anything else, use: [our Discord server](https://discord.com/invite/jpRVrjd). - - - type: dropdown - attributes: - label: Server Implementation - description: Which server Implementation are you using? If your server implementation is not listed, it is not supported. Switch to a supported version first. - multiple: false - options: - - Paper - - Spigot - - Tuinity - validations: - required: true - - - type: dropdown - attributes: - label: Server Version - description: Which server version version you using? If your server version is not listed, it is not supported. Update to a supported version first. - multiple: false - options: - - '1.20' - - '1.19.4' - - '1.19.3' - - '1.19.2' - - '1.19.1' - - '1.19' - - '1.18.2' - - '1.18.1' - - '1.18' - - '1.17.2' - - '1.17.1' - - '1.16.5' - - '1.15.2' - - '1.14.4' - validations: - required: true - - - type: textarea - attributes: - label: Describe the bug - description: A clear and concise description of what the bug is. - validations: - required: true - - - type: textarea - attributes: - label: To Reproduce - description: Steps to reproduce this behaviour - placeholder: | - 1. Go to '...' - 2. Click on '...' - 3. Scroll down to '...' - 4. See error - validations: - required: true - - - type: textarea - attributes: - label: Expected behaviour - description: A clear and concise description of what you expected to happen. - validations: - required: true - - - type: textarea - attributes: - label: Screenshots / Videos - description: If applicable, add screenshots to help explain your problem. - - - type: input - attributes: - label: Error log (if applicable) - description: If you are reporting a console error, upload any relevant log excerpts to either https://paste.gg/ or https://gist.github.com, save and the paste the link in this box. - - - type: input - attributes: - label: Fawe Debugpaste (if applicable) - description: Run `/fawe debugpaste` in your console or ingame and provide the output link here. - - - type: input - attributes: - label: goPaint Version - description: What version of BetterGoPaint are you running? (`/version BetterGoPaint`) - placeholder: "For example: BetterGoPaint version 1.0.0-SNAPSHOT+fa92c8abf8a66c08621e66109a64407ec223bdae" - validations: - required: true - - - type: checkboxes - attributes: - label: Checklist - description: Make sure you have followed each of the steps outlined here. - options: - - label: I am using the newest build from https://github.com/TheMeinerLP/BetterGoPaint and the issue still persists. - required: true - - - type: textarea - attributes: - label: Anything else? - description: You can provide additional context below. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 00000000..961f1fce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,50 @@ +name: Feature Request +description: Suggest an idea for BetterGoPaint +labels: [ "status: needs triage", "type: feature" ] +body: + - type: markdown + id: intro + attributes: + value: | + Thank you for filling out a feature request for BetterGoPaint! Please be as detailed as possible so that we may consider and review the request easier. + We ask that you search all the issues to avoid a duplicate feature request. If one exists, please reply if you have anything to add. + Before requesting a new feature, please make sure you are using the latest version and that the feature you are requesting is not already in BetterGoPaint. + + - type: textarea + id: problem-context + attributes: + label: Is your feature request related to a problem? + description: Please give some context for this request. Why do you want it added? + validations: + required: true + + - type: textarea + id: solution-description + attributes: + label: Describe the solution you'd like. + description: A clear and concise description of what you want. + validations: + required: true + + - type: textarea + id: alternatives-considered + attributes: + label: Describe alternatives you've considered. + description: List any alternatives you might have tried to get the feature you want. + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Other + description: Add any other context or screenshots about the feature request below. + validations: + required: false + + - type: markdown + id: search-reminder + attributes: + value: | + Before submitting this feature request, please search our issue tracker to ensure your feature has not + already been requested. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 83df45d5..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Feature request -description: Suggest an idea for this project -labels: 'enhancement' - -body: - - type: markdown - attributes: - value: | - Thanks for taking the time to fill out this feature request for BetterGoPaint! Fill out the following form to your best - ability to help us understand your feature request and greately improve the change of it getting added. - For anything else than a feature request, use: [our Discord server](https://discord.onelitefeather.net). - - - type: textarea - attributes: - label: What feature do you want to see added? - description: A clear and concise description of your feature request. - validations: - required: true - - - type: textarea - attributes: - label: Are there any alternatives? - description: List any alternatives you might have tried - validations: - required: true - - - type: textarea - attributes: - label: Anything else? - description: You can provide additional context below. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 7ec366b6..00000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,16 +0,0 @@ -## Overview - - -**Fixes {Link to issue}** - -## Description - -## Checklist - -- [] I included all information required in the sections above -- [] I tested my changes and approved their functionality -- [] I ensured my changes do not break other parts of the code diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..624cc174 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,53 @@ + + +## Proposed changes + +Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. +If it fixes a bug or resolves a feature request, be sure to link to that issue. + +## Types of changes + +What types of changes does your code introduce to this project? +_Put an `x` in the boxes that apply_ + +- [ ] Bugfix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation Update (if none of the other choices apply) + +## Checklist + +_Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of +them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before +merging your code._ + +- [ ] I have read the [CONTRIBUTING.md](https://github.com/OneLiteFeatherNET/.github/blob/main/CONTRIBUTING.md) +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] I have added necessary documentation (if appropriate) + +## Further comments + +If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you +did and what alternatives you considered, etc... \ No newline at end of file diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml deleted file mode 100644 index 13813730..00000000 --- a/.github/release-drafter.yml +++ /dev/null @@ -1,5 +0,0 @@ -_extends: .github -template: | - ## What’s Changed - - $CHANGES \ No newline at end of file diff --git a/.github/renovate.json b/.github/renovate.json deleted file mode 100644 index 3298442b..00000000 --- a/.github/renovate.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:base", - ":semanticCommitsDisabled" - ], - "ignoreDeps": [], - "labels": ["Renovate"], - "rebaseWhen": "conflicted", - "schedule": ["on the first day of the month"] -} \ No newline at end of file diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index 547a7975..00000000 --- a/.github/stale.yml +++ /dev/null @@ -1,18 +0,0 @@ -daysUntilStale: 30 -daysUntilClose: 7 -only: issues -exemptLabels: - - "Bug" - - "Enhancement" - - "Approved" - - "Priority" - - "Under investigation" -staleLabel: "resolution: stale" -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. If the issue is still present and can be reproduced, please let the team know. - Thank you for your contributions. -closeComment: > - This issue has been automatically closed because it has not had activity in - a long time. If the issue still applies to the most recent supported - version, please reply to this issue and the team will reopen it. \ No newline at end of file diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index f06fd791..483d2052 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -1,21 +1,103 @@ name: Build PR on: [pull_request] +permissions: + contents: write + checks: write + issues: write + pull-requests: write jobs: build_pr: if: github.repository_owner == 'OneLiteFeatherNET' + name: Build Pull Request Branch runs-on: ${{ matrix.os }} + env: + ONELITEFEATHER_MAVEN_USERNAME: ${{ secrets.ONELITEFEATHER_MAVEN_USERNAME }} + ONELITEFEATHER_MAVEN_PASSWORD: ${{ secrets.ONELITEFEATHER_MAVEN_PASSWORD }} strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] steps: - name: Checkout Repository - uses: actions/checkout@v4 - - name: Validate Gradle Wrapper - uses: gradle/wrapper-validation-action@v1 + uses: actions/checkout@v7 - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: temurin - java-version: 21 + java-version: 25 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 - name: Build on ${{ matrix.os }} - run: ./gradlew clean build + run: ./gradlew clean build test + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() && matrix.os == 'ubuntu-latest' + with: + files: "**/build/test-results/test/TEST-*.xml" + check_name: "Test Results" + comment_title: "Unit Test Results" + - name: Upload Test Report + uses: actions/upload-artifact@v7 + if: always() && matrix.os == 'ubuntu-latest' + with: + name: test-report + path: build/reports/tests/test/ + - name: Upload Coverage Reports + uses: actions/upload-artifact@v7 + if: always() && matrix.os == 'ubuntu-latest' + with: + name: coverage-report + path: build/reports/jacoco/test/ + upload_jar_and_comment: + name: Upload JAR and comment PR + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + needs: build_pr + if: contains(github.event.pull_request.labels.*.name, 'publish-pr') + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + - name: Setup Java + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 25 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + - name: Build + run: ./gradlew clean build test + - name: Upload JAR for PR + uses: actions/upload-artifact@v7 + with: + name: pr-jar + path: | + build/libs/*.jar + !build/libs/*-unshaded.jar + - name: Get JAR Artifact Download URL + id: get-artifact-url + uses: actions/github-script@v9 + with: + script: | + const runId = context.runId; + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId + }); + const jarArtifact = artifacts.data.artifacts.find(a => a.name === 'pr-jar'); + if (!jarArtifact) throw new Error('pr-jar artifact not found'); + const url = jarArtifact.archive_download_url; + core.setOutput('jar_url', url); + - name: Create or Update PR Comment with direct JAR download link + uses: peter-evans/create-or-update-comment@v5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ github.event.pull_request.number }} + body: | + :package: A new JAR build is available! + [Download the JAR directly](${{ steps.get-artifact-url.outputs.jar_url }}) + + Note: The download link is valid for a limited time and requires GitHub authentication. + edit-mode: replace + comment-id: pr-jar-artifact-link diff --git a/.github/workflows/close_invalid_prs.yml b/.github/workflows/close_invalid_prs.yml index 03b5e823..432d368a 100644 --- a/.github/workflows/close_invalid_prs.yml +++ b/.github/workflows/close_invalid_prs.yml @@ -6,9 +6,9 @@ on: jobs: run: - if: ${{ github.repository != github.event.pull_request.head.repo.full_name && github.head_ref == 'master' }} + if: ${{ github.repository != github.event.pull_request.head.repo.full_name && github.head_ref == 'main' }} runs-on: ubuntu-latest steps: - uses: superbrothers/close-pull-request@v3 with: - comment: "Please do not open pull requests from the `develop` branch, create a new branch instead." \ No newline at end of file + comment: "Please do not open pull requests from the `main` branch, create a new branch instead." \ No newline at end of file diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml new file mode 100644 index 00000000..7c6e6a47 --- /dev/null +++ b/.github/workflows/pr-lint.yml @@ -0,0 +1,63 @@ +name: pr-lint + +# Enforce Conventional Commits on both the PR title (the squash-merge +# subject) and every commit on the branch. Release Please derives the +# version bump and the changelog from exactly these messages. +"on": + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + pr-title: + name: Conventional PR title + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + build + chore + ci + docs + feat + fix + perf + refactor + revert + style + test + requireScope: false + subjectPattern: ^(?![A-Z]).+$ + subjectPatternError: | + The subject "{subject}" must start with a lowercase letter. + + commits: + name: Conventional commits + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - name: Install commitlint + run: npm install --no-save @commitlint/cli @commitlint/config-conventional + - name: Lint commits in range + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: npx commitlint --from "$BASE_SHA" --to "$HEAD_SHA" --verbose diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index b6c05cc8..00000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Publish -on: - push: - branches: - - master - - develop -jobs: - build: - # Run on all label events (won't be duplicated) or all push events or on PR syncs not from the same repo - if: github.repository_owner == 'OneLiteFeatherNET' - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - name: Validate Gradle Wrapper - uses: gradle/wrapper-validation-action@v1 - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: temurin - cache: gradle - java-version: 21 - - name: Publish - env: - HANGAR_SECRET: ${{secrets.HANGAR_KEY}} - MODRINTH_TOKEN: ${{ secrets.MODRINTH_KEY }} - run: ./gradlew build modrinth publishAllPublicationsToHangar --stacktrace diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml deleted file mode 100644 index 7dcc4c8c..00000000 --- a/.github/workflows/release-drafter.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: draft release -on: - push: - branches: - - master - pull_request: - types: [opened, reopened, synchronize] - pull_request_target: - types: [opened, reopened, synchronize] -permissions: - contents: read -jobs: - update_release_draft: - permissions: - contents: write - pull-requests: write - if: ${{ github.event_name != 'pull_request' || github.repository != github.event.pull_request.head.repo.full_name }} - runs-on: ubuntu-latest - steps: - - uses: release-drafter/release-drafter@v5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 00000000..f1e3fb2c --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,216 @@ +name: Release Please + +# Two ways in: +# +# push to main Release Please collects commits into a release PR. +# Merging that PR tags the version and publishes it. +# +# workflow_dispatch Re-publish a release that was already tagged. Needed +# when publishing failed after the tag was created, which +# otherwise leaves a release stranded with no way back, +# for example when Hangar rejects a platform version. + +"on": + push: + branches: + - main + workflow_dispatch: + inputs: + tag: + description: "Release tag to publish, e.g. v1.2.0" + required: true + type: string + ref: + description: "Git ref to build from. Defaults to the tag itself. Point this at main when the tagged commit is what broke publishing." + required: false + type: string + +permissions: + contents: read + +jobs: + release-please: + name: Release Please + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write # to create the tag and the GitHub release + pull-requests: write # to open and update the release PR + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + version: ${{ steps.release.outputs.version }} + steps: + - name: Release Please + id: release + uses: googleapis/release-please-action@v5 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + # Builds, publishes nothing. Everything public waits behind security-gate. + build: + name: Build Release Artifacts + needs: release-please + # always() because release-please is skipped on workflow_dispatch, and a + # skipped dependency would otherwise skip this job too. + if: >- + always() && + (needs.release-please.outputs.release_created == 'true' || + github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.resolve.outputs.version }} + ref: ${{ steps.resolve.outputs.ref }} + env: + TAG_NAME: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || needs.release-please.outputs.tag_name }} + BUILD_REF: ${{ github.event_name == 'workflow_dispatch' && (inputs.ref || inputs.tag) || needs.release-please.outputs.tag_name }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && (inputs.ref || inputs.tag) || needs.release-please.outputs.tag_name }} + + - name: Resolve version + id: resolve + run: | + version="${TAG_NAME#v}" + declared="$(sed -n 's/^version = "\([^"]*\)".*/\1/p' build.gradle.kts)" + if [ "$version" != "$declared" ]; then + echo "::error::Tag $TAG_NAME expects version $version, but build.gradle.kts declares $declared. Refusing to publish mismatched artifacts." + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "ref=$BUILD_REF" >> "$GITHUB_OUTPUT" + + - name: Validate Gradle Wrapper + uses: gradle/actions/wrapper-validation@v6 + + - name: Setup Java + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 25 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Build + run: ./gradlew check shadowJar cyclonedxBom + + - name: Upload plugin JAR + uses: actions/upload-artifact@v7 + with: + name: plugin-jar + path: | + build/libs/BetterGoPaint-*.jar + !build/libs/*-unshaded.jar + if-no-files-found: error + retention-days: 14 + + - name: Upload SBOM artifact + uses: actions/upload-artifact@v7 + with: + name: sbom + path: build/reports/cyclonedx/bom.xml + if-no-files-found: error + retention-days: 14 + + - name: Upload build reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: publish-reports + path: build/reports/** + if-no-files-found: ignore + retention-days: 14 + + # The point of no return. Everything below this job is public, so a HIGH or + # CRITICAL finding in the jar we are about to ship stops the release here. + # Scans the built artifact, not the source tree: for a shaded jar that is the + # only target that sees what actually ships. + security-gate: + name: Security Gate + needs: build + permissions: + contents: read + security-events: write # SARIF upload to code scanning + uses: OneLiteFeatherNET/workflows/.github/workflows/security-scan.yml@v2.9.0 + with: + scan-type: rootfs + artifact-name: plugin-jar + severity: "CRITICAL,HIGH" + fail-on-findings: true + secrets: inherit + + publish: + name: Publish Release + needs: [release-please, build, security-gate] + # No always() here - a failed gate must stop the release, and always() + # would run this job anyway. + if: >- + !cancelled() && + needs.security-gate.result == 'success' && + (needs.release-please.outputs.release_created == 'true' || + github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + permissions: + contents: write # to upload assets to the GitHub release + env: + TAG_NAME: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || needs.release-please.outputs.tag_name }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ needs.build.outputs.ref }} + + - name: Download plugin JAR + uses: actions/download-artifact@v8 + with: + name: plugin-jar + path: build/libs + + # Deliberately before the platform uploads. Hangar and Modrinth reject + # versions for reasons outside this repository's control; when that + # happens the artifact should still be attached to the GitHub release + # rather than lost with the failed run. + - name: Upload JAR to GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "$TAG_NAME" build/libs/BetterGoPaint-*.jar --clobber + + - name: Setup Java + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 25 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Publish to Hangar and Modrinth + env: + HANGAR_SECRET: ${{ secrets.HANGAR_KEY }} + MODRINTH_TOKEN: ${{ secrets.MODRINTH_KEY }} + run: ./gradlew publishAllPublicationsToHangar modrinth + + # Separate job so Dependency-Track being down, or rejecting the API key, + # cannot take the release with it. With the SBOM upload inside the publish + # job, one rejected platform version would cost the JAR, both + # platform releases and the SBOM at once. + sbom: + name: Publish SBOM + needs: [build, publish] + uses: OneLiteFeatherNET/workflows/.github/workflows/sbom-publish.yml@v2.9.0 + with: + project-name: "BetterGoPaint" + project-version: ${{ needs.build.outputs.version }} + project-tags: "bukkit,paper,plugin" + parent-uuid: "682857a9-0cd2-4ffd-a2b3-098eeba5ab74" + # The CycloneDX Gradle plugin resolves the dependency graph better than + # an external scanner, so hand it the artifact the build produced. + artifact-name: "sbom" + sbom-path: "bom.xml" + secrets: inherit diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 00000000..bb0014f9 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,36 @@ +name: Security + +# Dependency and secret scanning via the shared Trivy workflow. Complements +# CodeQL rather than duplicating it: CodeQL analyses our own code, Trivy looks +# at what we pull in and at anything credential-shaped that slipped into the +# tree. +# +# No pull_request trigger on purpose. Fork pull requests get a read-only token, +# so the SARIF upload to code scanning would fail for exactly the contributions +# that need review most. Scanning main plus a weekly sweep also catches CVEs +# published against code that has not changed - which is the majority of them. + +"on": + push: + branches: + - main + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + trivy: + name: Trivy + permissions: + contents: read + security-events: write # to upload the SARIF report to code scanning + uses: OneLiteFeatherNET/workflows/.github/workflows/security-scan.yml@v2.9.0 + with: + scan-type: fs + severity: "CRITICAL,HIGH" + # Report-only for now. Flip once the backlog is at zero, otherwise the + # first unrelated CVE blocks main. + fail-on-findings: false diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 00000000..5fdd8830 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "1.1.0" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..808569bd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,62 @@ +# Contributing + +You want to help? Go for it. Look for an open issue, or open one first if you have a new idea — +communication is key, and it saves you from building something we cannot merge. + +Ask us anything on [Discord](https://discord.onelitefeather.net). + +## Working on the code + +- Java 25 is required to build. +- `./gradlew build` compiles, tests and runs the checks. +- `./gradlew run-26.2` starts a Paper test server with the plugin and FastAsyncWorldEdit + installed. There is one `run-` task per supported version. +- [docs/development/](docs/development/) covers the testing guide and how the documentation is + organised. +- Planned work is written down as [OpenSpec](https://github.com/Fission-AI/OpenSpec) changes in + [openspec/changes/](openspec/changes/). Larger changes start with a proposal there. + +Documentation for users lives in [docs/](docs/index.md) and is published by GitBook. See +[Writing documentation](docs/development/documentation.md) for how it is organised and which +pages have to be updated when you change a config key, a command or a permission. + +## Commit messages + +Every pull request title and every commit on the branch is checked against +[Conventional Commits](https://www.conventionalcommits.org/) by the `pr-lint` workflow, because +the version number and the changelog are derived from them. + +## Releases + +We use Semantic Versioning, automated with +[Release Please](https://github.com/googleapis/release-please). Pushing to `main` does not +release anything. Instead, Release Please opens and updates a release pull request that collects +the pending changelog. Merging that pull request is what bumps the version, writes +`CHANGELOG.md`, creates the tag and the GitHub release, and publishes to Hangar and Modrinth. + +Which part of the version increases follows from the commit types on the branch: + +| Commit | Effect on `MAJOR.MINOR.PATCH` | +|---|---| +| `feat!:` or a `BREAKING CHANGE:` footer | MAJOR — `1.2.3` → `2.0.0` | +| `feat:` | MINOR — `1.2.3` → `1.3.0` | +| `fix:` | PATCH — `1.2.3` → `1.2.4` | +| `docs:`, `style:`, `refactor:`, `test:`, `chore:` | no release | + +Dependency updates opened by Renovate go through exactly the same path. + +### Example + +1. Current version is 1.2.3. +2. `feat: add a terrace brush` is merged — Release Please opens a + release pull request for 1.3.0. +3. `fix: keep the gradient brush inside the mask` is merged — the same pull request is + updated and still targets 1.3.0. +4. `feat!: redesign configuration format` is merged — the pull request now targets 2.0.0. +5. A maintainer merges it. 2.0.0 is tagged, released and published. + +### References + +- [Semantic Versioning](https://semver.org/) +- [Conventional Commits](https://www.conventionalcommits.org/) +- [Release Please](https://github.com/googleapis/release-please) diff --git a/commitlint.config.mjs b/commitlint.config.mjs new file mode 100644 index 00000000..dc7c9910 --- /dev/null +++ b/commitlint.config.mjs @@ -0,0 +1,18 @@ +// Conventional Commits enforcement (commits + PR title). +// The types listed here must stay in sync with .github/workflows/pr-lint.yml +// and with the changelog-sections in release-please-config.json. +// https://www.conventionalcommits.org +export default { + extends: ['@commitlint/config-conventional'], + rules: { + // Renovate and release commits carry long URLs / tables in the body. + 'body-max-line-length': [0, 'always'], + 'footer-max-line-length': [0, 'always'], + 'header-max-length': [2, 'always', 100], + 'type-enum': [ + 2, + 'always', + ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test'], + ], + }, +}; diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..5a247e22 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "include-component-in-tag": false, + "include-v-in-tag": true, + "bootstrap-sha": "048f4e343770125162f5a696a77a1d3fa2d95a30", + "pull-request-header": "", + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "revert", "section": "Reverts" }, + { "type": "refactor", "section": "Code Refactoring" }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "style", "section": "Styles", "hidden": true }, + { "type": "chore", "section": "Miscellaneous Chores", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "ci", "section": "Continuous Integration", "hidden": true } + ], + "packages": { + ".": { + "package-name": "BetterGoPaint", + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { "type": "generic", "path": "build.gradle.kts" } + ] + } + } +} diff --git a/renovate.json b/renovate.json index 1e5efd3e..1bd0ca37 100644 --- a/renovate.json +++ b/renovate.json @@ -1,11 +1,7 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ - "config:base", - ":semanticCommitsDisabled" - ], - "labels": ["Renovate"], - "rebaseWhen": "conflicted", - "schedule": ["on the first day of the month"], - "automerge": true + "github>OneLiteFeatherNET/renovate:default(OneLiteFeatherNET/bettergopaint-core)", + "github>OneLiteFeatherNET/renovate:paper" + ] } From 786adb9eb5395bc29763cc15f63a573987fc8990 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:50:02 +0000 Subject: [PATCH 04/22] docs: restructure documentation along diataxis Tutorial, how-to guides, reference pages for brushes, commands, permissions, configuration and supported versions, background pages and developer guides, published through GitBook. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp --- .gitbook.yaml | 8 +++ README.md | 59 +++++++++---------- docs/SUMMARY.md | 27 +++++++++ docs/development/documentation.md | 15 +++++ docs/development/testing.md | 21 +++++++ .../how-a-brush-stroke-is-applied.md | 15 +++++ docs/explanation/scope-and-non-goals.md | 15 +++++ docs/how-to/change-the-brush-item.md | 11 ++++ docs/how-to/change-the-plugin-language.md | 19 ++++++ docs/how-to/disable-painting-in-a-world.md | 14 +++++ docs/how-to/share-a-brush-as-an-item.md | 11 ++++ docs/index.md | 26 ++++++++ docs/reference/brushes.md | 21 +++++++ docs/reference/commands.md | 13 ++++ docs/reference/configuration.md | 48 +++++++++++++++ docs/reference/permissions.md | 12 ++++ docs/reference/supported-versions.md | 12 ++++ docs/tutorial/paint-your-first-terrain.md | 54 +++++++++++++++++ 18 files changed, 369 insertions(+), 32 deletions(-) create mode 100644 .gitbook.yaml create mode 100644 docs/SUMMARY.md create mode 100644 docs/development/documentation.md create mode 100644 docs/development/testing.md create mode 100644 docs/explanation/how-a-brush-stroke-is-applied.md create mode 100644 docs/explanation/scope-and-non-goals.md create mode 100644 docs/how-to/change-the-brush-item.md create mode 100644 docs/how-to/change-the-plugin-language.md create mode 100644 docs/how-to/disable-painting-in-a-world.md create mode 100644 docs/how-to/share-a-brush-as-an-item.md create mode 100644 docs/index.md create mode 100644 docs/reference/brushes.md create mode 100644 docs/reference/commands.md create mode 100644 docs/reference/configuration.md create mode 100644 docs/reference/permissions.md create mode 100644 docs/reference/supported-versions.md create mode 100644 docs/tutorial/paint-your-first-terrain.md diff --git a/.gitbook.yaml b/.gitbook.yaml new file mode 100644 index 00000000..4a3f38d6 --- /dev/null +++ b/.gitbook.yaml @@ -0,0 +1,8 @@ +# GitBook Git Sync reads the documentation straight out of docs/. +# The navigation is docs/SUMMARY.md; a page that is not listed there is not part +# of the space. See docs/development/documentation.md. +root: ./docs/ + +structure: + readme: index.md + summary: SUMMARY.md diff --git a/README.md b/README.md index 46d8a23f..f440c932 100644 --- a/README.md +++ b/README.md @@ -4,41 +4,36 @@ --- -BetterGoPaint is a plugin that's designed to simplify painting inside of Minecraft. -This plugin is a [fork](https://github.com/Arcaniax-Development/goPaint_1.14) of original one, but with many improvements and -fixes. - - -## Major Changes -### Done -- Improved configuration file -- Better XMaterial Support -- Modrinth auto deployment -- Hangar auto deployment -- New CI Development builds -- New download link -- Better build gradle -- **Drop 1.13-1.19.4 support. Please use the original plugin!** - -### To-Do -- Fix performance impacts -- Memory leaks -- Better FAWE integration -- Reduce duplicated code -- Stability -- Replace default commands with cloud framework +BetterGoPaint paints terrain in Minecraft: hold a feather, pick a brush and a block palette, +right-click. Every stroke is a [FastAsyncWorldEdit](https://modrinth.com/plugin/fastasyncworldedit) +edit and can be undone with `//undo`. + +It is a maintained [fork](https://github.com/Arcaniax-Development/goPaint_1.14) of goPaint by Arcaniax. + +## Requirements + +| | | +|---|---| +| Server | Paper 26.1.1, 26.1.2, 26.2 ([details](docs/reference/supported-versions.md)) | +| Java | 25 | +| Required plugin | FastAsyncWorldEdit 2.16 or newer | + +Servers on Minecraft 1.21 keep using BetterGoPaint 1.1.x. + +## Documentation + +- [Paint your first terrain](docs/tutorial/paint-your-first-terrain.md) +- [Brushes](docs/reference/brushes.md), [commands](docs/reference/commands.md), + [permissions](docs/reference/permissions.md), [configuration](docs/reference/configuration.md) ## Links -* [Download](https://hangar.papermc.io/TheMeinerLP/BetterGoPaint) - * [Mirror](https://modrinth.com/plugin/bettergopaint) -* [Development Builds](https://hangar.papermc.io/TheMeinerLP/BetterGoPaint) - * [Mirror](https://modrinth.com/plugin/bettergopaint) +* [Download on Hangar](https://hangar.papermc.io/OneLiteFeather/BetterGoPaint) · + [Modrinth](https://modrinth.com/plugin/bettergopaint) * [Discord](https://discord.onelitefeather.net) -* [Issues](https://github.com/TheMeinerLP/BetterGoPaint/issues) +* [Issues](https://github.com/OneLiteFeatherNET/BetterGoPaint/issues) -# Building -Gradle is the recommended way to build the project. Use `./gradlew shadowJar` in the main project directory to build the project. +## Building -## Suggestions -Suggestions are welcome! We have a separate issue form for suggestions, that can be found [here](https://github.com/TheMeinerLP/BetterGoPaint/issues). +Java 25 is required. `./gradlew build` compiles, tests and builds `build/libs/BetterGoPaint-.jar`. +See [CONTRIBUTING.md](CONTRIBUTING.md) for commit conventions and the release process. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 00000000..b5fd2832 --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,27 @@ +# Table of contents + +* [Overview](index.md) + +## Getting started + +* [Paint your first terrain](tutorial/paint-your-first-terrain.md) + +## How-to guides + +* [Change the brush item](how-to/change-the-brush-item.md) +* [Disable painting in a world](how-to/disable-painting-in-a-world.md) +* [Share a brush as an item](how-to/share-a-brush-as-an-item.md) +* [Change the plugin language](how-to/change-the-plugin-language.md) + +## Reference + +* [Brushes](reference/brushes.md) +* [Commands](reference/commands.md) +* [Permissions](reference/permissions.md) +* [Configuration](reference/configuration.md) +* [Supported versions](reference/supported-versions.md) + +## Background + +* [How a brush stroke is applied](explanation/how-a-brush-stroke-is-applied.md) +* [Scope and non-goals](explanation/scope-and-non-goals.md) diff --git a/docs/development/documentation.md b/docs/development/documentation.md new file mode 100644 index 00000000..8441d71f --- /dev/null +++ b/docs/development/documentation.md @@ -0,0 +1,15 @@ +# Writing documentation + +The docs follow [Diátaxis](https://diataxis.fr/): `tutorial/` teaches, `how-to/` solves one task, +`reference/` lists facts, `explanation/` gives background. `SUMMARY.md` is the navigation; a page +that is not listed there is not published. + +Update these pages in the same pull request when you change: + +| Change | Page | +|---|---| +| A config key | `reference/configuration.md` | +| A command | `reference/commands.md` | +| A permission | `reference/permissions.md` (and `build.gradle.kts`) | +| `supportedMinecraftVersions` | `reference/supported-versions.md` | +| A brush | `reference/brushes.md` | diff --git a/docs/development/testing.md b/docs/development/testing.md new file mode 100644 index 00000000..3b9857b5 --- /dev/null +++ b/docs/development/testing.md @@ -0,0 +1,21 @@ +# Testing + +`./gradlew build` compiles, runs the unit tests, checks the license headers and builds the +shaded jar. `./gradlew test` runs only the tests; the JaCoCo report lands in +`build/reports/jacoco/test/html`. + +## What is tested + +- `PlayerBrushManagerTest` – brush cycling and lookup by name. +- `BezierSplineTest` – the curve the Paint Brush follows. +- `TranslationBundleTest` – every translation key used in the code exists in every bundle and + parses as MiniMessage. + +Tests use JUnit 6, AssertJ and Mockito. Bukkit types such as `World` are mocked; there is no +running server. Code that needs a server (brush painting, GUI, listeners) is covered by manual +testing with `./gradlew run-`, which starts Paper with FastAsyncWorldEdit preinstalled. + +## Adding tests + +Put tests next to the package they cover under `src/test/java`. `./gradlew spotlessApply` adds the +license header. diff --git a/docs/explanation/how-a-brush-stroke-is-applied.md b/docs/explanation/how-a-brush-stroke-is-applied.md new file mode 100644 index 00000000..1031adbb --- /dev/null +++ b/docs/explanation/how-a-brush-stroke-is-applied.md @@ -0,0 +1,15 @@ +# How a brush stroke is applied + +A right-click with the brush item goes through these steps: + +1. **Permission and world check.** The player needs `bettergopaint.use`. Worlds listed in + `generic.disabled-worlds` are skipped unless the player has `bettergopaint.world.bypass`. +2. **Target.** Clicking a block uses that block. Clicking into the air ray-traces up to 250 blocks. +3. **Settings.** The default brush item uses the player's own brush. Any other item with brush + lore uses the settings stored on the item (see + [Share a brush as an item](../how-to/share-a-brush-as-an-item.md)). +4. **Edit.** The brush computes the positions and places blocks inside one FastAsyncWorldEdit + edit session, queued through FAWE's player action queue. That is why every stroke shows up as + one entry in `//undo` and why large brushes do not freeze the main thread. + +Brush settings live in memory per player and are dropped when the player leaves the server. diff --git a/docs/explanation/scope-and-non-goals.md b/docs/explanation/scope-and-non-goals.md new file mode 100644 index 00000000..510462aa --- /dev/null +++ b/docs/explanation/scope-and-non-goals.md @@ -0,0 +1,15 @@ +# Scope and non-goals + +BetterGoPaint does one thing: interactive terrain painting with a held item. + +Out of scope: + +- **Servers other than Paper.** The plugin ships a `paper-plugin.yml` and uses Paper API. + Spigot, CraftBukkit and hybrid servers cannot load it. Folia is not supported because the + plugin relies on the global scheduler and FAWE's player queue. +- **Running without FastAsyncWorldEdit.** Every edit goes through FAWE; plain WorldEdit lacks the + APIs the brushes use. +- **Old Minecraft versions.** Only the versions in [Supported versions](../reference/supported-versions.md) + are built and tested. The original goPaint covers older versions. +- **Running next to goPaint.** BetterGoPaint replaces goPaint and disables itself if both are + installed. diff --git a/docs/how-to/change-the-brush-item.md b/docs/how-to/change-the-brush-item.md new file mode 100644 index 00000000..e1f05981 --- /dev/null +++ b/docs/how-to/change-the-brush-item.md @@ -0,0 +1,11 @@ +# Change the brush item + +By default the feather is the brush. To use another item: + +1. Open `plugins/BetterGoPaint/config.yml`. +2. Set `generic.default-brush` to the item you want, for example `BLAZE_ROD`. It must be an item, + not a block-only material such as `WATER`. +3. Run `/gopaint reload`. + +If the value is not an item, BetterGoPaint logs an error on start and disables itself. +See [Configuration](../reference/configuration.md) for all keys. diff --git a/docs/how-to/change-the-plugin-language.md b/docs/how-to/change-the-plugin-language.md new file mode 100644 index 00000000..f71f32a5 --- /dev/null +++ b/docs/how-to/change-the-plugin-language.md @@ -0,0 +1,19 @@ +# Change the plugin language + +Messages are sent in the language of the player's client when a translation exists, and in +English otherwise. + +To add a language: + +1. Create `plugins/BetterGoPaint/lang/`. +2. Put a file named `bettergopaint_.properties` into it, for example + `bettergopaint_de_DE.properties`. Use the keys of the bundled `bettergopaint.properties`. +3. Add the locale to `generic.languages` in `config.yml`, for example `de-DE`. +4. Restart the server. + +Messages use [MiniMessage](https://docs.advntr.dev/minimessage/format.html). ``, `` +and so on are replaced with values such as the prefix or a version number; keep them in your +translation. + +Translations are also collected on [Crowdin](https://crowdin.com); contributions there are +shipped with the next release. diff --git a/docs/how-to/disable-painting-in-a-world.md b/docs/how-to/disable-painting-in-a-world.md new file mode 100644 index 00000000..bddc93a6 --- /dev/null +++ b/docs/how-to/disable-painting-in-a-world.md @@ -0,0 +1,14 @@ +# Disable painting in a world + +1. Open `plugins/BetterGoPaint/config.yml`. +2. Add the world name to `generic.disabled-worlds`: + + ```yaml + generic: + disabled-worlds: + - world_nether + ``` + +3. Run `/gopaint reload`. + +Players with `bettergopaint.world.bypass` can still paint in those worlds. diff --git a/docs/how-to/share-a-brush-as-an-item.md b/docs/how-to/share-a-brush-as-an-item.md new file mode 100644 index 00000000..20501c4b --- /dev/null +++ b/docs/how-to/share-a-brush-as-an-item.md @@ -0,0 +1,11 @@ +# Share a brush as an item + +You can bake the current brush settings into an item and hand it to another builder. + +1. Hold any item that is not a block and not the brush item, for example a stick. +2. Open the brush menu and left-click the item onto the brush slot (the brush item in the second + row). +3. The item now carries the brush, its size and the palette in its lore. + +Right-clicking with that item paints with the stored settings, independent of the holder's own +brush. The holder still needs `bettergopaint.use`. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..57cb970a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,26 @@ +# BetterGoPaint + +BetterGoPaint paints terrain in Minecraft. You hold a feather, pick a brush and a palette of up to +ten blocks, and right-click the ground. Every stroke runs through +[FastAsyncWorldEdit](https://modrinth.com/plugin/fastasyncworldedit), so it can be undone with +`//undo` like any other edit. + +BetterGoPaint is a maintained fork of [goPaint](https://github.com/Arcaniax-Development/goPaint_1.14) +by Arcaniax. + +## Where to start + +- New to the plugin: [Paint your first terrain](tutorial/paint-your-first-terrain.md). +- Looking for a specific task: see the how-to guides in the navigation. +- Looking up a command, permission or config key: see the [reference](reference/commands.md). + +## Requirements + +- Paper on a version listed in [Supported versions](reference/supported-versions.md). +- FastAsyncWorldEdit 2.16 or newer. +- Java 25. + +## Getting help + +Ask on [Discord](https://discord.onelitefeather.net) or open an +[issue](https://github.com/OneLiteFeatherNET/BetterGoPaint/issues). diff --git a/docs/reference/brushes.md b/docs/reference/brushes.md new file mode 100644 index 00000000..ed9b7ef4 --- /dev/null +++ b/docs/reference/brushes.md @@ -0,0 +1,21 @@ +# Brushes + +Every brush places blocks from the palette. The brush decides which positions are painted. +Settings that a brush does not use are hidden in the menu. + +| Brush | Paints | Extra setting | +|---|---|---| +| Sphere Brush | Every block in a sphere around the clicked block. | — | +| Spray Brush | Random blocks in the sphere. | Chance | +| Splatter Brush | Random blocks, more likely close to the clicked block. | Falloff | +| Disc Brush | A flat disc on one axis through the clicked block. | Axis | +| Bucket Brush | Connected blocks of the same type as the clicked block. | — | +| Angle Brush | Only steep faces such as cliffs. | Angle distance, angle height difference | +| Overlay Brush | Blocks with air above them. | Thickness | +| Underlay Brush | Blocks without air above them. | Thickness | +| Fracture Brush | Cracks and fissures. | Fracture distance | +| Gradient Brush | A gradient that blends the palette blocks into each other. | Mixing, falloff | +| Paint Brush | Strokes along a curve through every point you click; sneak to finish the stroke. | Falloff | + +Settings shared by all brushes: size, mask (only replace the mask block) and surface mode +(`DIRECT`, `RELATIVE`, `DISABLED`). Defaults and limits are in [Configuration](configuration.md). diff --git a/docs/reference/commands.md b/docs/reference/commands.md new file mode 100644 index 00000000..6cd676ac --- /dev/null +++ b/docs/reference/commands.md @@ -0,0 +1,13 @@ +# Commands + +`/gopaint` has the alias `/gp`. Every subcommand needs `bettergopaint.use`. + +| Command | Does | Extra permission | +|---|---|---| +| `/gopaint` | Lists the subcommands. | — | +| `/gopaint size ` | Sets your brush size. Values above `generic.max-size` are capped. | — | +| `/gopaint toggle` | Enables or disables your brush. | — | +| `/gopaint info` | Shows the plugin credits. | — | +| `/gopaint reload` | Re-reads `config.yml`. | `bettergopaint.admin` | + +All commands are player-only. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md new file mode 100644 index 00000000..94d92f18 --- /dev/null +++ b/docs/reference/configuration.md @@ -0,0 +1,48 @@ +# Configuration + +`plugins/BetterGoPaint/config.yml` is written on first start and rewritten on every reload, so +new keys appear automatically. Materials use the names from the +[Paper Material list](https://jd.papermc.io/paper/org/bukkit/Material.html). + +## generic + +| Key | Default | Meaning | +|---|---|---| +| `languages` | `[]` | Extra locales to load from `lang/`, for example `de-DE`. English is always loaded. | +| `default-brush` | `FEATHER` | Item that acts as the brush. Must be an item. | +| `max-size` | `100` | Largest brush size a player can set. | +| `default-size` | `10` | Brush size of a new player. | +| `default-chance` | `50` | Starting chance of the Spray Brush, in percent. | +| `default-axis` | `Y` | Starting axis of the Disc Brush: `X`, `Y` or `Z`. | +| `default-falloff-strength` | `50` | Starting falloff. | +| `default-mixing-strength` | `50` | Starting mixing of the Gradient Brush. | +| `prefix` | `BetterGoPaint > ` | MiniMessage prefix of chat messages. | +| `disabled-worlds` | `[]` | Worlds in which painting is blocked. | +| `enabled-by-default` | `true` | Whether a new player's brush starts enabled. | +| `mask-enabled` | `true` | Whether the mask starts enabled. | +| `default-mask` | `SPONGE` | Starting mask block. | +| `surface-mode` | `DIRECT` | Starting surface mode: `DIRECT`, `RELATIVE` or `DISABLED`. | + +## thickness + +| Key | Default | Meaning | +|---|---|---| +| `default-thickness` | `1` | Starting thickness of the Overlay and Underlay Brush. | +| `max-thickness` | `5` | Largest thickness. | + +## angle + +| Key | Default | Meaning | +|---|---|---| +| `default-angle-distance` | `2` | Starting angle distance. | +| `max-angle-distance` | `5` | Largest angle distance. | +| `min-angle-height-difference` | `10.0` | Smallest angle height difference. | +| `default-angle-height-difference` | `40.0` | Starting angle height difference. | +| `max-angle-height-difference` | `85.0` | Largest angle height difference. | + +## fracture + +| Key | Default | Meaning | +|---|---|---| +| `default-fracture-distance` | `2` | Starting fracture distance. | +| `max-fracture-distance` | `5` | Largest fracture distance. | diff --git a/docs/reference/permissions.md b/docs/reference/permissions.md new file mode 100644 index 00000000..d67c5116 --- /dev/null +++ b/docs/reference/permissions.md @@ -0,0 +1,12 @@ +# Permissions + +| Permission | Default | Grants | +|---|---|---| +| `bettergopaint.use` | op | Painting with the brush and every `/gopaint` command. | +| `bettergopaint.admin` | false | `/gopaint reload` and the admin variant of the help text. | +| `bettergopaint.command.admin.reload` | op | Reload through the cloud command `/bgp reload`. | +| `bettergopaint.world.bypass` | false | Painting in worlds listed in `generic.disabled-worlds`. | +| `bettergopaint.notify.admin.update` | op | A message on join when a newer version is on Hangar. | +| `bettergopaint.notify.disable.donation` | false | Hides the donation message on join. | + +The list is generated into `paper-plugin.yml` from `build.gradle.kts`; keep both in sync. diff --git a/docs/reference/supported-versions.md b/docs/reference/supported-versions.md new file mode 100644 index 00000000..fd451ea8 --- /dev/null +++ b/docs/reference/supported-versions.md @@ -0,0 +1,12 @@ +# Supported versions + +| BetterGoPaint | Minecraft | Server | Java | FastAsyncWorldEdit | +|---|---|---|---|---| +| 2.x | 26.1.1, 26.1.2, 26.2 | Paper | 25 | 2.16 or newer | +| 1.1.x | 1.21 – 1.21.3 | Paper | 21 | — | + +The source of truth is `supportedMinecraftVersions` in `build.gradle.kts`; the versions listed +there are the ones offered on Hangar and Modrinth. + +Paper 26.x requires Java 25 and BetterGoPaint is compiled for it, so a jar built for 26.x cannot +load on 1.21.x. Servers that stay on 1.21 keep using BetterGoPaint 1.1.x. diff --git a/docs/tutorial/paint-your-first-terrain.md b/docs/tutorial/paint-your-first-terrain.md new file mode 100644 index 00000000..eb5f393a --- /dev/null +++ b/docs/tutorial/paint-your-first-terrain.md @@ -0,0 +1,54 @@ +# Paint your first terrain + +In this tutorial you paint a patch of grass with a mix of three blocks and then undo it. It takes +about five minutes. You need operator rights on a Paper server that has BetterGoPaint and +FastAsyncWorldEdit installed. + +## 1. Take the brush + +Give yourself a feather: + +``` +/give @s feather +``` + +The feather is the brush. Holding anything else does nothing. + +## 2. Open the brush menu + +Left-click with the feather in your hand. The **goPaint Menu** opens. The top row shows the +current brush, its size and its settings. The two rows at the bottom are your block palette. + +## 3. Fill the palette + +Take stone, andesite and gravel from your inventory and left-click each of them onto one of the +palette slots. Right-clicking a palette slot removes the block again. + +## 4. Pick the brush + +Left-click the brush icon until it shows **Spray Brush**. Right-click goes backwards; shift-click +shows every brush at once. + +## 5. Paint + +Close the menu, look at a grassy area and right-click. A circle of mixed stone, andesite and gravel +appears where you looked. Right-click a few more times while walking to paint a path. + +## 6. Undo + +Run: + +``` +//undo +``` + +Your last stroke disappears. Every stroke is one FastAsyncWorldEdit edit, so `//undo` and `//redo` +work stroke by stroke. + +## What you learned + +- The feather opens the menu with a left-click and paints with a right-click. +- The palette decides which blocks are placed; the brush decides where. +- Strokes are normal FastAsyncWorldEdit edits. + +Next, look at [Brushes](../reference/brushes.md) to see what the other brushes do. From 06b859f078e65a2523fae3c8041ecbac23eec645 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:50:02 +0000 Subject: [PATCH 05/22] docs: add openspec with the modernisation roadmap OpenSpec changes for the four delivered parts (platform upgrade, release pipeline, tests, documentation) and the planned follow-ups: cloud commands, brush menu hardening, non-blocking update check and dependency injection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp --- .claude/commands/opsx/apply.md | 202 ++++++++++ .claude/commands/opsx/archive.md | 264 +++++++++++++ .claude/commands/opsx/explore.md | 239 ++++++++++++ .claude/commands/opsx/propose.md | 170 +++++++++ .claude/commands/opsx/sync.md | 286 ++++++++++++++ .claude/commands/opsx/update.md | 105 ++++++ .claude/skills/openspec-apply-change/SKILL.md | 206 ++++++++++ .../skills/openspec-archive-change/SKILL.md | 221 +++++++++++ .claude/skills/openspec-explore/SKILL.md | 354 ++++++++++++++++++ .claude/skills/openspec-propose/SKILL.md | 174 +++++++++ .claude/skills/openspec-sync-specs/SKILL.md | 290 ++++++++++++++ .../skills/openspec-update-change/SKILL.md | 109 ++++++ .../add-test-foundation/.openspec.yaml | 2 + .../changes/add-test-foundation/design.md | 30 ++ .../changes/add-test-foundation/proposal.md | 33 ++ .../specs/build-verification/spec.md | 37 ++ openspec/changes/add-test-foundation/tasks.md | 18 + .../adopt-release-pipeline/.openspec.yaml | 2 + .../changes/adopt-release-pipeline/design.md | 44 +++ .../adopt-release-pipeline/proposal.md | 47 +++ .../specs/release-pipeline/spec.md | 59 +++ .../changes/adopt-release-pipeline/tasks.md | 30 ++ openspec/changes/archive/.gitkeep | 0 .../changes/harden-brush-menu/.openspec.yaml | 2 + openspec/changes/harden-brush-menu/design.md | 21 ++ .../changes/harden-brush-menu/proposal.md | 36 ++ .../specs/brush-menu/spec.md | 35 ++ .../specs/exported-brushes/spec.md | 32 ++ openspec/changes/harden-brush-menu/tasks.md | 17 + .../.openspec.yaml | 3 + .../introduce-dependency-injection/design.md | 26 ++ .../proposal.md | 34 ++ .../introduce-dependency-injection/tasks.md | 16 + .../migrate-commands-to-cloud/.openspec.yaml | 2 + .../migrate-commands-to-cloud/design.md | 24 ++ .../migrate-commands-to-cloud/proposal.md | 35 ++ .../specs/commands/spec.md | 42 +++ .../migrate-commands-to-cloud/tasks.md | 16 + .../non-blocking-update-check/.openspec.yaml | 2 + .../non-blocking-update-check/proposal.md | 31 ++ .../specs/update-notification/spec.md | 37 ++ .../non-blocking-update-check/tasks.md | 12 + .../restructure-documentation/.openspec.yaml | 2 + .../restructure-documentation/proposal.md | 33 ++ .../specs/user-documentation/spec.md | 31 ++ .../restructure-documentation/tasks.md | 21 ++ .../upgrade-to-minecraft-26/.openspec.yaml | 2 + .../changes/upgrade-to-minecraft-26/design.md | 60 +++ .../upgrade-to-minecraft-26/proposal.md | 48 +++ .../specs/platform-compatibility/spec.md | 56 +++ .../changes/upgrade-to-minecraft-26/tasks.md | 30 ++ openspec/config.yaml | 50 +++ openspec/specs/.gitkeep | 0 53 files changed, 3678 insertions(+) create mode 100644 .claude/commands/opsx/apply.md create mode 100644 .claude/commands/opsx/archive.md create mode 100644 .claude/commands/opsx/explore.md create mode 100644 .claude/commands/opsx/propose.md create mode 100644 .claude/commands/opsx/sync.md create mode 100644 .claude/commands/opsx/update.md create mode 100644 .claude/skills/openspec-apply-change/SKILL.md create mode 100644 .claude/skills/openspec-archive-change/SKILL.md create mode 100644 .claude/skills/openspec-explore/SKILL.md create mode 100644 .claude/skills/openspec-propose/SKILL.md create mode 100644 .claude/skills/openspec-sync-specs/SKILL.md create mode 100644 .claude/skills/openspec-update-change/SKILL.md create mode 100644 openspec/changes/add-test-foundation/.openspec.yaml create mode 100644 openspec/changes/add-test-foundation/design.md create mode 100644 openspec/changes/add-test-foundation/proposal.md create mode 100644 openspec/changes/add-test-foundation/specs/build-verification/spec.md create mode 100644 openspec/changes/add-test-foundation/tasks.md create mode 100644 openspec/changes/adopt-release-pipeline/.openspec.yaml create mode 100644 openspec/changes/adopt-release-pipeline/design.md create mode 100644 openspec/changes/adopt-release-pipeline/proposal.md create mode 100644 openspec/changes/adopt-release-pipeline/specs/release-pipeline/spec.md create mode 100644 openspec/changes/adopt-release-pipeline/tasks.md create mode 100644 openspec/changes/archive/.gitkeep create mode 100644 openspec/changes/harden-brush-menu/.openspec.yaml create mode 100644 openspec/changes/harden-brush-menu/design.md create mode 100644 openspec/changes/harden-brush-menu/proposal.md create mode 100644 openspec/changes/harden-brush-menu/specs/brush-menu/spec.md create mode 100644 openspec/changes/harden-brush-menu/specs/exported-brushes/spec.md create mode 100644 openspec/changes/harden-brush-menu/tasks.md create mode 100644 openspec/changes/introduce-dependency-injection/.openspec.yaml create mode 100644 openspec/changes/introduce-dependency-injection/design.md create mode 100644 openspec/changes/introduce-dependency-injection/proposal.md create mode 100644 openspec/changes/introduce-dependency-injection/tasks.md create mode 100644 openspec/changes/migrate-commands-to-cloud/.openspec.yaml create mode 100644 openspec/changes/migrate-commands-to-cloud/design.md create mode 100644 openspec/changes/migrate-commands-to-cloud/proposal.md create mode 100644 openspec/changes/migrate-commands-to-cloud/specs/commands/spec.md create mode 100644 openspec/changes/migrate-commands-to-cloud/tasks.md create mode 100644 openspec/changes/non-blocking-update-check/.openspec.yaml create mode 100644 openspec/changes/non-blocking-update-check/proposal.md create mode 100644 openspec/changes/non-blocking-update-check/specs/update-notification/spec.md create mode 100644 openspec/changes/non-blocking-update-check/tasks.md create mode 100644 openspec/changes/restructure-documentation/.openspec.yaml create mode 100644 openspec/changes/restructure-documentation/proposal.md create mode 100644 openspec/changes/restructure-documentation/specs/user-documentation/spec.md create mode 100644 openspec/changes/restructure-documentation/tasks.md create mode 100644 openspec/changes/upgrade-to-minecraft-26/.openspec.yaml create mode 100644 openspec/changes/upgrade-to-minecraft-26/design.md create mode 100644 openspec/changes/upgrade-to-minecraft-26/proposal.md create mode 100644 openspec/changes/upgrade-to-minecraft-26/specs/platform-compatibility/spec.md create mode 100644 openspec/changes/upgrade-to-minecraft-26/tasks.md create mode 100644 openspec/config.yaml create mode 100644 openspec/specs/.gitkeep diff --git a/.claude/commands/opsx/apply.md b/.claude/commands/opsx/apply.md new file mode 100644 index 00000000..2b718c94 --- /dev/null +++ b/.claude/commands/opsx/apply.md @@ -0,0 +1,202 @@ +--- +name: "OPSX: Apply" +description: "Implement tasks from an OpenSpec change (Experimental)" +allowed-tools: Bash(openspec:*) +category: "Workflow" +tags: ["workflow", "artifacts", "experimental"] +--- + +Implement tasks from an OpenSpec change. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + +**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and ask the user to select one + + Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) + - Progress (total, complete, remaining) + - Task list with status, source path, and source line + - Dynamic instruction based on current state + - Optional `context`: current required project instruction input from the selected root + - Optional `operationGuidance`: current advisory guidance for apply + - `missingArtifacts` (when present): required artifact ids with no output + + **Handle states:** + - If `state: "blocked"`: show the message and pause implementation. + - If `missingArtifacts` is non-empty: suggest completing the missing artifacts. Run `openspec status --change "" --json`, select the next `ready` artifact (not `skipped` or `blocked`), and use `openspec instructions "" --change "" --json` for its rules and template. Keep the selected `--store ` on both commands. + - Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked. + - If `state: "all_done"`: report that all tracked tasks are complete and suggest review or verification as appropriate before archiving + - Otherwise: proceed to implementation + + Treat `context` as a required prompt-level input. Read and consider it, and + apply relevant project facts, conventions, and constraints while implementing. + Treat `operationGuidance` as optional additive advice. Read and consider every + entry, and follow entries that are applicable and compatible with the built-in + workflow. + + Keep both fields separate from CLI-returned state, missing artifacts, tasks, + progress, `contextFiles`, and the built-in `instruction`. They are not + evidence of task completion, do not replace the built-in instruction, and do + not permit bypassing a blocked state. If context conflicts with the built-in + instruction, an explicit user choice, or a CLI-controlled value, report the + conflict and preserve the controlling value. If guidance is inapplicable or + conflicts with those controlling inputs, do not follow it and explain why. + These are prompt-level behavior contracts, not enforceable checks. + +4. **Read context files** + + Read every file path listed under `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + + Do not copy `context` or `operationGuidance` verbatim into implementation + files or planning artifacts unless the user separately asks for that content. + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Before editing, confirm the checkbox at the returned `sourcePath` and `line` still matches the task description; if it does not, rerun the apply instructions and use the refreshed location + - Mark the task complete at its returned `sourcePath` and `line`: `- [ ]` → `- [x]` + - Rerun the apply instructions and confirm that task is now done and progress changed + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - A task needs work beyond what the spec and tasks describe, or you are tempted to drop, narrow, defer, or accept exceptions to specified behavior to make it fit → surface the added scope and ask; do not absorb it silently + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: report that tracked tasks are complete and suggest review or verification as appropriate before archiving + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tracked tasks are complete. Review or verify the change as appropriate +before archiving. You can archive this change with `/opsx:archive`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.

+ * WorldEdit's own {@code BlockTypeMask} could do the same, but it sizes a lookup table from the block + * registry when it is created, which only exists on a running server. This mask compares the type + * directly, so it works against any extent and in unit tests. + */ +public final class SingleTypeMask extends AbstractExtentMask { + + private final BlockType type; + + /** + * Creates a mask for the given block type. It is not bound to an extent, because the extent of a + * stroke is only known once the stroke runs. Test it with {@link #test(Extent, BlockVector3)}. + * + * @param type the accepted block type + */ + public SingleTypeMask(@NotNull BlockType type) { + this(new NullExtent(), type); + } + + /** + * Creates a mask for the given block type that reads from the given extent when it is tested + * through {@link #test(BlockVector3)}. + * + * @param extent the extent to read from + * @param type the accepted block type + */ + public SingleTypeMask(@NotNull Extent extent, @NotNull BlockType type) { + super(extent); + this.type = type; + } + + /** + * Returns the accepted block type. + * + * @return the block type + */ + public @NotNull BlockType type() { + return type; + } + + @Override + public boolean test(@NotNull BlockVector3 position) { + return test(getExtent(), position); + } + + @Override + public boolean test(@NotNull Extent extent, @NotNull BlockVector3 position) { + return extent.getBlock(position).getBlockType().equals(type); + } + + @Override + public @NotNull Mask copy() { + return new SingleTypeMask(getExtent(), type); + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeContext.java b/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeContext.java new file mode 100644 index 00000000..d1af3e84 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeContext.java @@ -0,0 +1,128 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.brush; + +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.function.mask.AbstractExtentMask; +import com.sk89q.worldedit.function.mask.Mask; +import com.sk89q.worldedit.function.pattern.Pattern; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.math.Vector3; +import com.sk89q.worldedit.world.block.BlockType; +import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; +import org.bukkit.Axis; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Random; + +/** + * Everything a brush needs to compute a stroke, captured on the player's thread before the stroke is + * queued. The context only holds WorldEdit types and plain values, so a brush never has to reach + * back into Bukkit while the stroke runs. + * + * @param target the position the stroke is centred on + * @param points the points of a multi-click stroke in click order, the last one is the {@code target} + * @param eye the position of the player's eyes, used by the relative surface mode + * @param palette the pattern that picks the block to place + * @param blocks the palette blocks in the order they were configured, used by the gradient brush + * @param mask the BetterGoPaint mask, or null if the mask is disabled + * @param axis the axis of the disc brush + * @param surfaceMode the surface mode of the brush + * @param size the diameter of the brush + * @param chance the chance in percent of the spray brush + * @param falloffStrength the falloff strength in percent + * @param mixingStrength the mixing strength in percent of the gradient brush + * @param thickness the thickness of the overlay and underlay brush + * @param fractureDistance the distance of the fracture brush + * @param angleDistance the distance of the angle brush + * @param angleHeightDifference the height difference in degrees of the angle brush + * @param random the source of randomness of the stroke + */ +public record StrokeContext( + @NotNull BlockVector3 target, + @NotNull List points, + @NotNull Vector3 eye, + @NotNull Pattern palette, + @NotNull List blocks, + @Nullable Mask mask, + @NotNull Axis axis, + @NotNull SurfaceMode surfaceMode, + int size, + int chance, + int falloffStrength, + int mixingStrength, + int thickness, + int fractureDistance, + int angleDistance, + double angleHeightDifference, + @NotNull Random random +) { + + public StrokeContext { + points = List.copyOf(points); + blocks = List.copyOf(blocks); + } + + /** + * Creates a copy of this context for another target, palette and size. This is how the arguments of + * {@link com.sk89q.worldedit.command.tool.brush.Brush#build} reach the brush. + * + * @param target the new target + * @param palette the new palette + * @param size the new size + * @return the new context + */ + public @NotNull StrokeContext with(@NotNull BlockVector3 target, @NotNull Pattern palette, int size) { + List newPoints = this.target.equals(target) ? points : List.of(target); + return new StrokeContext(target, newPoints, eye, palette, blocks, mask, axis, surfaceMode, size, chance, + falloffStrength, mixingStrength, thickness, fractureDistance, angleDistance, angleHeightDifference, + random); + } + + /** + * Creates a copy of this context with the points of a multi-click stroke. + * + * @param points the points in click order + * @return the new context + */ + public @NotNull StrokeContext withPoints(@NotNull List points) { + return new StrokeContext(target, points, eye, palette, blocks, mask, axis, surfaceMode, size, chance, + falloffStrength, mixingStrength, thickness, fractureDistance, angleDistance, angleHeightDifference, + random); + } + + /** + * Checks if the BetterGoPaint mask lets the block at the position pass. + * + * @param extent the extent to read the block from + * @param position the position to check + * @return true if the mask is disabled or accepts the block + */ + public boolean passesMask(@NotNull Extent extent, @NotNull BlockVector3 position) { + if (mask == null) { + return true; + } + if (mask instanceof AbstractExtentMask extentMask) { + return extentMask.test(extent, position); + } + return mask.test(position); + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeContextFactory.java b/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeContextFactory.java new file mode 100644 index 00000000..31159806 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeContextFactory.java @@ -0,0 +1,115 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.brush; + +import com.fastasyncworldedit.core.math.random.SimpleRandom; +import com.sk89q.worldedit.function.mask.Mask; +import com.sk89q.worldedit.function.pattern.RandomPattern; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.math.Vector3; +import com.sk89q.worldedit.world.block.BlockType; +import org.bukkit.Location; +import org.bukkit.Material; +import org.jetbrains.annotations.NotNull; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.function.Function; + +/** + * Captures a {@link StrokeContext} on the player's thread. This is the one place where Bukkit values + * are turned into WorldEdit values. It only reads coordinates from the locations and never asks + * their world for anything. + */ +public final class StrokeContextFactory { + + /** + * Height of the player's eyes above the feet that the relative surface mode has always used. + */ + private static final double EYE_HEIGHT = 1.5; + + private StrokeContextFactory() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } + + /** + * Captures the context of a stroke. + * + * @param target the clicked block + * @param playerLocation the location of the player's feet + * @param settings the brush settings to snapshot + * @param blockTypes converts a material to its WorldEdit block type + * @param random the source of randomness of the stroke + * @return the context of the stroke + */ + public static @NotNull StrokeContext capture( + @NotNull Location target, + @NotNull Location playerLocation, + @NotNull BrushSettings settings, + @NotNull Function blockTypes, + @NotNull Random random + ) { + BlockVector3 position = BlockVector3.at(target.getBlockX(), target.getBlockY(), target.getBlockZ()); + Vector3 eye = Vector3.at(playerLocation.getX(), playerLocation.getY() + EYE_HEIGHT, playerLocation.getZ()); + + List blocks = settings.blocks().stream().map(blockTypes).toList(); + Mask mask = settings.maskEnabled() ? new SingleTypeMask(blockTypes.apply(settings.mask())) : null; + + return new StrokeContext( + position, + List.of(position), + eye, + palette(blocks, random), + blocks, + mask, + settings.axis(), + settings.surfaceMode(), + settings.size(), + settings.chance(), + settings.falloffStrength(), + settings.mixingStrength(), + settings.thickness(), + settings.fractureDistance(), + settings.angleDistance(), + settings.angleHeightDifference(), + random + ); + } + + /** + * Builds the palette pattern. Every entry of the block list has the same weight, so a block that is + * listed twice is twice as likely. + * + * @param blocks the palette blocks + * @param random the source of randomness + * @return the palette pattern + */ + static @NotNull RandomPattern palette(@NotNull List blocks, @NotNull Random random) { + SimpleRandom simpleRandom = (x, y, z) -> random.nextDouble(); + Map weights = new LinkedHashMap<>(); + for (BlockType block : blocks) { + weights.merge(block, 1, Integer::sum); + } + RandomPattern pattern = new RandomPattern(simpleRandom); + weights.forEach(pattern::add); + return pattern; + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeRunner.java b/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeRunner.java new file mode 100644 index 00000000..7eee4fa9 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeRunner.java @@ -0,0 +1,60 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.brush; + +import com.sk89q.worldedit.EditSession; +import com.sk89q.worldedit.LocalSession; +import com.sk89q.worldedit.command.tool.brush.Brush; +import com.sk89q.worldedit.extension.platform.Actor; +import org.jetbrains.annotations.NotNull; + +/** + * Runs a stroke inside an edit session of the player's WorldEdit session. The edit session carries the + * global mask and the limits of the player, and the stroke is remembered as one history entry. + */ +public final class StrokeRunner { + + private StrokeRunner() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } + + /** + * Runs the stroke. The actor and the session must be looked up on the player's thread, this method + * touches nothing from Bukkit and is meant to run in WorldEdit's action queue. + * + * @param actor the actor that paints + * @param localSession the WorldEdit session of the actor + * @param stroke the stroke to run + * @param context the context of the stroke + */ + public static void run( + @NotNull Actor actor, + @NotNull LocalSession localSession, + @NotNull Brush stroke, + @NotNull StrokeContext context + ) { + try (EditSession editSession = localSession.createEditSession(actor)) { + try { + stroke.build(editSession, context.target(), context.palette(), context.size()); + } finally { + localSession.remember(editSession); + } + } + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/brush/StrokeContextFactoryTest.java b/src/test/java/net/onelitefeather/bettergopaint/brush/StrokeContextFactoryTest.java new file mode 100644 index 00000000..131b2c4f --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/brush/StrokeContextFactoryTest.java @@ -0,0 +1,187 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.brush; + +import com.sk89q.worldedit.function.pattern.RandomPattern; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BlockType; +import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; +import net.onelitefeather.bettergopaint.support.TestBlocks; +import org.bukkit.Axis; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class StrokeContextFactoryTest { + + private final World world = mock(World.class); + private final BrushSettings settings = mock(BrushSettings.class); + private final Map converted = new HashMap<>(); + private final Function converter = + material -> converted.computeIfAbsent(material, key -> TestBlocks.solid(key.name())); + + private void stubSettings(boolean maskEnabled, Material... blocks) { + when(settings.blocks()).thenReturn(List.of(blocks)); + when(settings.maskEnabled()).thenReturn(maskEnabled); + when(settings.mask()).thenReturn(Material.GRASS_BLOCK); + when(settings.axis()).thenReturn(Axis.X); + when(settings.surfaceMode()).thenReturn(SurfaceMode.RELATIVE); + when(settings.size()).thenReturn(7); + when(settings.chance()).thenReturn(40); + when(settings.falloffStrength()).thenReturn(30); + when(settings.mixingStrength()).thenReturn(20); + when(settings.thickness()).thenReturn(3); + when(settings.fractureDistance()).thenReturn(4); + when(settings.angleDistance()).thenReturn(5); + when(settings.angleHeightDifference()).thenReturn(33.5); + } + + private StrokeContext capture(Random random) { + return StrokeContextFactory.capture( + new Location(world, 10, 64, -20), new Location(world, 1.5, 70, 2.5), settings, converter, random + ); + } + + @Test + @DisplayName("capturing a context never asks the world for anything") + void needsNoWorldAccess() { + stubSettings(true, Material.STONE, Material.ANDESITE); + + capture(new Random(1)); + + verifyNoInteractions(world); + } + + @Test + @DisplayName("the context holds the settings, the target and the eye position") + void snapshotsSettings() { + stubSettings(false, Material.STONE); + + StrokeContext context = capture(new Random(1)); + + assertThat(context.target()).isEqualTo(BlockVector3.at(10, 64, -20)); + assertThat(context.points()).containsExactly(BlockVector3.at(10, 64, -20)); + assertThat(context.eye().x()).isCloseTo(1.5, within(1e-9)); + assertThat(context.eye().y()).isCloseTo(71.5, within(1e-9)); + assertThat(context.eye().z()).isCloseTo(2.5, within(1e-9)); + assertThat(context.axis()).isEqualTo(Axis.X); + assertThat(context.surfaceMode()).isEqualTo(SurfaceMode.RELATIVE); + assertThat(context.size()).isEqualTo(7); + assertThat(context.chance()).isEqualTo(40); + assertThat(context.falloffStrength()).isEqualTo(30); + assertThat(context.mixingStrength()).isEqualTo(20); + assertThat(context.thickness()).isEqualTo(3); + assertThat(context.fractureDistance()).isEqualTo(4); + assertThat(context.angleDistance()).isEqualTo(5); + assertThat(context.angleHeightDifference()).isEqualTo(33.5); + } + + @Test + @DisplayName("materials are converted once and keep their order") + void convertsMaterials() { + stubSettings(false, Material.STONE, Material.ANDESITE, Material.STONE); + + StrokeContext context = capture(new Random(1)); + + assertThat(context.blocks()).containsExactly( + converted.get(Material.STONE), converted.get(Material.ANDESITE), converted.get(Material.STONE) + ); + assertThat(converted).hasSize(2); + } + + @Test + @DisplayName("a disabled mask is null") + void disabledMask() { + stubSettings(false, Material.STONE); + + assertThat(capture(new Random(1)).mask()).isNull(); + } + + @Test + @DisplayName("an enabled mask accepts the mask material") + void enabledMask() { + stubSettings(true, Material.STONE); + + StrokeContext context = capture(new Random(1)); + + assertThat(context.mask()).isInstanceOfSatisfying(SingleTypeMask.class, + mask -> assertThat(mask.type()).isSameAs(converted.get(Material.GRASS_BLOCK))); + } + + @Test + @DisplayName("the palette picks every listed block with the same weight") + void paletteWeightsEntriesEqually() { + BlockType stone = TestBlocks.solid("stone"); + BlockType andesite = TestBlocks.solid("andesite"); + RandomPattern palette = StrokeContextFactory.palette(List.of(stone, andesite), new Random(7)); + + Map counts = new HashMap<>(); + for (int i = 0; i < 4000; i++) { + counts.merge(palette.applyBlock(BlockVector3.at(i, 0, 0)).getBlockType(), 1, Integer::sum); + } + + assertThat(counts).containsOnlyKeys(stone, andesite); + assertThat(counts.get(stone)).isBetween(1800, 2200); + assertThat(counts.get(andesite)).isBetween(1800, 2200); + } + + @Test + @DisplayName("a block that is listed twice is picked twice as often") + void paletteKeepsDuplicates() { + BlockType stone = TestBlocks.solid("stone"); + BlockType andesite = TestBlocks.solid("andesite"); + RandomPattern palette = StrokeContextFactory.palette(List.of(stone, stone, andesite), new Random(7)); + + Map counts = new HashMap<>(); + for (int i = 0; i < 6000; i++) { + counts.merge(palette.applyBlock(BlockVector3.at(i, 0, 0)).getBlockType(), 1, Integer::sum); + } + + assertThat(counts.get(stone)).isBetween(3700, 4300); + assertThat(counts.get(andesite)).isBetween(1700, 2300); + } + + @Test + @DisplayName("the same seed gives the same palette picks") + void paletteIsSeeded() { + BlockType stone = TestBlocks.solid("stone"); + BlockType andesite = TestBlocks.solid("andesite"); + RandomPattern first = StrokeContextFactory.palette(List.of(stone, andesite), new Random(99)); + RandomPattern second = StrokeContextFactory.palette(List.of(stone, andesite), new Random(99)); + + for (int i = 0; i < 50; i++) { + BlockVector3 position = BlockVector3.at(i, 0, 0); + assertThat(first.applyBlock(position).getBlockType()).isSameAs(second.applyBlock(position).getBlockType()); + } + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/support/BrushAssertions.java b/src/test/java/net/onelitefeather/bettergopaint/support/BrushAssertions.java new file mode 100644 index 00000000..0b839414 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/support/BrushAssertions.java @@ -0,0 +1,61 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.support; + +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BlockType; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Helpers that describe the expected result of a brush without using the code under test. + */ +public final class BrushAssertions { + + private BrushAssertions() { + } + + /** + * Every position in the box around the middle whose distance to the middle is below half the size. + */ + public static Set sphere(BlockVector3 middle, int size) { + Set result = new LinkedHashSet<>(); + int reach = size / 2 + 1; + for (int x = -reach; x <= reach; x++) { + for (int y = -reach; y <= reach; y++) { + for (int z = -reach; z <= reach; z++) { + if (Math.sqrt(x * x + y * y + z * z) < size / 2d) { + result.add(middle.add(x, y, z)); + } + } + } + } + return result; + } + + /** + * The positions of the sphere that hold a block of the given type. + */ + public static Set sphereOf(MemoryExtent extent, BlockVector3 middle, int size, BlockType type) { + Set result = sphere(middle, size); + result.removeIf(position -> !extent.typeAt(position).equals(type)); + return result; + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/support/MemoryExtent.java b/src/test/java/net/onelitefeather/bettergopaint/support/MemoryExtent.java new file mode 100644 index 00000000..42fdce19 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/support/MemoryExtent.java @@ -0,0 +1,182 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.support; + +import com.fastasyncworldedit.core.nbt.FaweCompoundTag; +import com.sk89q.worldedit.WorldEditException; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.function.operation.Operation; +import com.sk89q.worldedit.math.BlockVector2; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.biome.BiomeType; +import com.sk89q.worldedit.world.block.BaseBlock; +import com.sk89q.worldedit.world.block.BlockState; +import com.sk89q.worldedit.world.block.BlockStateHolder; +import com.sk89q.worldedit.world.block.BlockType; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * A WorldEdit {@link Extent} that keeps its blocks in a map. Every position that was not set is air. + * Block types are stored, because the tests never look at block properties. + */ +public final class MemoryExtent implements Extent { + + private final Map blocks = new LinkedHashMap<>(); + private final Map writes = new LinkedHashMap<>(); + private final BlockType air; + private final int minY; + private final int maxY; + + public MemoryExtent(BlockType air, int minY, int maxY) { + this.air = air; + this.minY = minY; + this.maxY = maxY; + } + + /** + * Fills the box between the two corners, both inclusive, with the type. + */ + public MemoryExtent fill(BlockVector3 from, BlockVector3 to, BlockType type) { + for (int x = from.x(); x <= to.x(); x++) { + for (int y = from.y(); y <= to.y(); y++) { + for (int z = from.z(); z <= to.z(); z++) { + blocks.put(BlockVector3.at(x, y, z), type); + } + } + } + return this; + } + + public MemoryExtent set(int x, int y, int z, BlockType type) { + blocks.put(BlockVector3.at(x, y, z), type); + return this; + } + + public BlockType typeAt(BlockVector3 position) { + return blocks.getOrDefault(position, air); + } + + public BlockType typeAt(int x, int y, int z) { + return typeAt(BlockVector3.at(x, y, z)); + } + + /** + * All positions that hold the type, in insertion order. + */ + public Set positionsOf(BlockType type) { + Set result = new java.util.LinkedHashSet<>(); + blocks.forEach((position, value) -> { + if (value.equals(type)) { + result.add(position); + } + }); + return result; + } + + /** + * How often a position was written through {@link #setBlock}. + */ + public int writeCount(BlockVector3 position) { + return writes.getOrDefault(position, 0); + } + + /** + * The positions that were written through {@link #setBlock}. + */ + public Set written() { + return writes.keySet(); + } + + @Override + public BlockVector3 getMinimumPoint() { + return BlockVector3.at(-30000000, minY, -30000000); + } + + @Override + public BlockVector3 getMaximumPoint() { + return BlockVector3.at(30000000, maxY, 30000000); + } + + @Override + public BlockState getBlock(BlockVector3 position) { + return typeAt(position).getDefaultState(); + } + + @Override + public BlockState getBlock(int x, int y, int z) { + return typeAt(x, y, z).getDefaultState(); + } + + @Override + public BaseBlock getFullBlock(BlockVector3 position) { + throw new UnsupportedOperationException("not needed by the brushes"); + } + + @Override + public BiomeType getBiome(BlockVector3 position) { + return null; + } + + @Override + public BiomeType getBiomeType(int x, int y, int z) { + return null; + } + + @Override + public BiomeType getBiome(BlockVector2 position) { + return null; + } + + @Override + public > boolean setBlock(BlockVector3 position, T block) throws WorldEditException { + BlockType previous = typeAt(position); + BlockType type = block.getBlockType(); + writes.merge(position.toImmutable(), 1, Integer::sum); + blocks.put(position.toImmutable(), type); + return !previous.equals(type); + } + + @Override + public > boolean setBlock(int x, int y, int z, T block) throws WorldEditException { + return setBlock(BlockVector3.at(x, y, z), block); + } + + @Override + public boolean tile(int x, int y, int z, FaweCompoundTag tile) throws WorldEditException { + return false; + } + + @Override + public boolean setBiome(BlockVector3 position, BiomeType biome) { + return false; + } + + @Override + public boolean setBiome(int x, int y, int z, BiomeType biome) { + return false; + } + + @Override + public Operation commit() { + return null; + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/support/MemoryExtentTest.java b/src/test/java/net/onelitefeather/bettergopaint/support/MemoryExtentTest.java new file mode 100644 index 00000000..74861c3e --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/support/MemoryExtentTest.java @@ -0,0 +1,75 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.support; + +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BlockType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MemoryExtentTest { + + private final Terrain terrain = new Terrain(); + + @Test + @DisplayName("a block that was set can be read back") + void roundTripsABlock() { + MemoryExtent extent = terrain.empty(); + BlockVector3 position = BlockVector3.at(3, 70, -4); + + boolean changed = extent.setBlock(position, terrain.gold.getDefaultState()); + + assertThat(changed).isTrue(); + assertThat(extent.getBlock(position).getBlockType()).isSameAs(terrain.gold); + assertThat(extent.getBlock(3, 70, -4).getBlockType()).isSameAs(terrain.gold); + assertThat(extent.writeCount(position)).isEqualTo(1); + } + + @Test + @DisplayName("positions that were never set are air") + void unsetPositionsAreAir() { + MemoryExtent extent = terrain.empty(); + + BlockType type = extent.getBlock(BlockVector3.at(1, 2, 3)).getBlockType(); + + assertThat(type).isSameAs(terrain.air); + assertThat(type.getMaterial().isAir()).isTrue(); + } + + @Test + @DisplayName("setting the same type again reports no change") + void sameTypeIsNoChange() { + MemoryExtent extent = terrain.empty().set(0, 64, 0, terrain.stone); + + boolean changed = extent.setBlock(BlockVector3.at(0, 64, 0), terrain.stone.getDefaultState()); + + assertThat(changed).isFalse(); + } + + @Test + @DisplayName("the build limits are the ones given") + void reportsBuildLimits() { + MemoryExtent extent = terrain.empty(); + + assertThat(extent.getMinY()).isEqualTo(Terrain.MIN_Y); + assertThat(extent.getMaxY()).isEqualTo(Terrain.MAX_Y); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/support/StrokeBuilder.java b/src/test/java/net/onelitefeather/bettergopaint/support/StrokeBuilder.java new file mode 100644 index 00000000..1b84a7e6 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/support/StrokeBuilder.java @@ -0,0 +1,161 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.support; + +import com.fastasyncworldedit.core.math.random.SimpleRandom; +import com.sk89q.worldedit.function.mask.Mask; +import com.sk89q.worldedit.function.pattern.RandomPattern; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.math.Vector3; +import com.sk89q.worldedit.world.block.BlockType; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; +import org.bukkit.Axis; + +import java.util.List; +import java.util.Random; + +/** + * Builds {@link StrokeContext}s for tests. Every value has a plain default, the random source is seeded. + */ +public final class StrokeBuilder { + + private BlockVector3 target = Terrain.surface(); + private List points; + private Vector3 eye; + private List blocks = List.of(); + private Mask mask; + private Axis axis = Axis.Y; + private SurfaceMode surfaceMode = SurfaceMode.DISABLED; + private int size = 6; + private int chance = 50; + private int falloffStrength = 0; + private int mixingStrength = 0; + private int thickness = 1; + private int fractureDistance = 2; + private int angleDistance = 1; + private double angleHeightDifference = 45; + private long seed = 1; + + public static StrokeBuilder stroke() { + return new StrokeBuilder(); + } + + public StrokeBuilder target(BlockVector3 target) { + this.target = target; + return this; + } + + public StrokeBuilder points(BlockVector3... points) { + this.points = List.of(points); + return this; + } + + public StrokeBuilder eye(Vector3 eye) { + this.eye = eye; + return this; + } + + public StrokeBuilder blocks(BlockType... blocks) { + this.blocks = List.of(blocks); + return this; + } + + public StrokeBuilder mask(Mask mask) { + this.mask = mask; + return this; + } + + public StrokeBuilder axis(Axis axis) { + this.axis = axis; + return this; + } + + public StrokeBuilder surfaceMode(SurfaceMode surfaceMode) { + this.surfaceMode = surfaceMode; + return this; + } + + public StrokeBuilder size(int size) { + this.size = size; + return this; + } + + public StrokeBuilder chance(int chance) { + this.chance = chance; + return this; + } + + public StrokeBuilder falloff(int falloffStrength) { + this.falloffStrength = falloffStrength; + return this; + } + + public StrokeBuilder mixing(int mixingStrength) { + this.mixingStrength = mixingStrength; + return this; + } + + public StrokeBuilder thickness(int thickness) { + this.thickness = thickness; + return this; + } + + public StrokeBuilder fractureDistance(int fractureDistance) { + this.fractureDistance = fractureDistance; + return this; + } + + public StrokeBuilder angle(int distance, double heightDifference) { + this.angleDistance = distance; + this.angleHeightDifference = heightDifference; + return this; + } + + public StrokeBuilder seed(long seed) { + this.seed = seed; + return this; + } + + public StrokeContext build() { + Random random = new Random(seed); + SimpleRandom simpleRandom = (x, y, z) -> random.nextDouble(); + RandomPattern palette = new RandomPattern(simpleRandom); + blocks.forEach(block -> palette.add(TestBlocks.pattern(block), 1)); + return new StrokeContext( + target, + points == null ? List.of(target) : points, + eye == null ? Vector3.at(target.x(), target.y() + 1.5, target.z()) : eye, + palette, + blocks, + mask, + axis, + surfaceMode, + size, + chance, + falloffStrength, + mixingStrength, + thickness, + fractureDistance, + angleDistance, + angleHeightDifference, + random + ); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/support/Terrain.java b/src/test/java/net/onelitefeather/bettergopaint/support/Terrain.java new file mode 100644 index 00000000..206d68a6 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/support/Terrain.java @@ -0,0 +1,85 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.support; + +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BlockType; + +/** + * Builds the small worlds the brush tests paint on. All of them are flat or have a feature at the origin. + */ +public final class Terrain { + + public static final int MIN_Y = 0; + public static final int MAX_Y = 255; + /** + * Height of the top layer of {@link #flat}. + */ + public static final int SURFACE = 63; + private static final int RADIUS = 12; + + public final BlockType air = TestBlocks.air(); + public final BlockType stone = TestBlocks.solid("stone"); + public final BlockType grass = TestBlocks.solid("grass"); + public final BlockType dirt = TestBlocks.solid("dirt"); + public final BlockType andesite = TestBlocks.solid("andesite"); + public final BlockType gold = TestBlocks.solid("gold"); + + public MemoryExtent empty() { + return new MemoryExtent(air, MIN_Y, MAX_Y); + } + + /** + * A world of stone up to and including {@link #SURFACE}. + */ + public MemoryExtent flat() { + return empty().fill(BlockVector3.at(-RADIUS, MIN_Y, -RADIUS), BlockVector3.at(RADIUS, SURFACE, RADIUS), stone); + } + + /** + * A world of stone with a top layer of the given type at {@link #SURFACE}. + */ + public MemoryExtent flatWithTop(BlockType top) { + return flat().fill(BlockVector3.at(-RADIUS, SURFACE, -RADIUS), BlockVector3.at(RADIUS, SURFACE, RADIUS), top); + } + + /** + * Flat ground that is four blocks higher for every x greater than zero. + */ + public MemoryExtent step() { + return flat().fill(BlockVector3.at(1, SURFACE + 1, -RADIUS), BlockVector3.at(RADIUS, SURFACE + 4, RADIUS), stone); + } + + /** + * Flat ground with a one block wide trench along z at x = 0. The trench is three blocks deep. + */ + public MemoryExtent trench() { + MemoryExtent extent = flat(); + for (int y = SURFACE - 2; y <= SURFACE; y++) { + for (int z = -RADIUS; z <= RADIUS; z++) { + extent.set(0, y, z, air); + } + } + return extent; + } + + public static BlockVector3 surface() { + return BlockVector3.at(0, SURFACE, 0); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/support/TestBlocks.java b/src/test/java/net/onelitefeather/bettergopaint/support/TestBlocks.java new file mode 100644 index 00000000..01032fe2 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/support/TestBlocks.java @@ -0,0 +1,87 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.support; + +import com.sk89q.worldedit.function.pattern.Pattern; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BaseBlock; +import com.sk89q.worldedit.world.block.BlockState; +import com.sk89q.worldedit.world.block.BlockType; +import com.sk89q.worldedit.world.registry.BlockMaterial; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Block types for tests without a running server. + *

+ * WorldEdit builds {@link BlockType}s from the block registry of the platform, which needs a Minecraft + * server, so {@code BlockTypes.STONE} cannot be initialised in a unit test. The brushes only ask a type + * for its default state and whether its material is air or solid, so a mock that answers those is enough. + * Mocks compare by identity, which makes two types equal exactly when they are the same instance. + */ +public final class TestBlocks { + + private TestBlocks() { + } + + /** + * Creates a block type. + * + * @param id the name used in assertion messages + * @param air whether the material is air + * @param solid whether the material is solid + */ + public static BlockType type(String id, boolean air, boolean solid) { + BlockType type = mock(BlockType.class, id); + BlockState state = mock(BlockState.class, id + " (state)"); + BlockMaterial material = mock(BlockMaterial.class, id + " (material)"); + when(material.isAir()).thenReturn(air); + when(material.isSolid()).thenReturn(solid); + when(type.getMaterial()).thenReturn(material); + when(type.getDefaultState()).thenReturn(state); + when(state.getBlockType()).thenReturn(type); + BaseBlock block = block(type); + when(type.applyBlock(any(BlockVector3.class))).thenReturn(block); + return type; + } + + public static BlockType air() { + return type("air", true, false); + } + + public static BlockType solid(String id) { + return type(id, false, true); + } + + /** + * Creates a pattern that always answers with the type. + */ + public static Pattern pattern(BlockType type) { + BaseBlock block = block(type); + return position -> block; + } + + private static BaseBlock block(BlockType type) { + BaseBlock block = mock(BaseBlock.class); + when(block.getBlockType()).thenReturn(type); + return block; + } +} From 93bc4fb6e130fe9221acafa3285087150fd5a245 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:15:06 +0000 Subject: [PATCH 14/22] refactor: read and write brush strokes through worldedit extents only Brushes now compute their blocks from a StrokeContext and an Extent. The geometry helpers (Sphere, Height, Surface, ConnectedBlocks) and the Bezier curve use BlockVector3 and Vector3, so no Bukkit world, block or location call remains in objects/ and utils/. InteractListener captures the context on the player's thread and queues a WorldEdit Brush that runs in the player's edit session with its global mask and one history entry. Brushes read all positions before they write, which keeps the result the same as when the Bukkit world was read. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp --- .../changes/native-worldedit-brushes/tasks.md | 12 +- .../listeners/InteractListener.java | 37 ++- .../objects/brush/AngleBrush.java | 30 +-- .../bettergopaint/objects/brush/Brush.java | 184 +++++++++------ .../objects/brush/BucketBrush.java | 26 +- .../objects/brush/DiscBrush.java | 24 +- .../objects/brush/FractureBrush.java | 46 ++-- .../objects/brush/GradientBrush.java | 58 ++--- .../objects/brush/OverlayBrush.java | 32 +-- .../objects/brush/PaintBrush.java | 121 +++++----- .../objects/brush/SphereBrush.java | 24 +- .../objects/brush/SplatterBrush.java | 43 ++-- .../objects/brush/SprayBrush.java | 26 +- .../objects/brush/UnderlayBrush.java | 32 +-- .../objects/other/SurfaceMode.java | 11 +- .../bettergopaint/utils/Blocks.java | 88 +++++++ .../bettergopaint/utils/ConnectedBlocks.java | 67 +++--- .../bettergopaint/utils/Height.java | 80 ++++--- .../bettergopaint/utils/Sphere.java | 80 ++++--- .../bettergopaint/utils/Surface.java | 80 ++++--- .../utils/curve/BezierSpline.java | 172 ++++++-------- .../utils/curve/BezierSplineSegment.java | 99 ++++---- .../brush/NoBukkitWorldAccessTest.java | 74 ++++++ .../objects/brush/ShapeBrushesTest.java | 191 +++++++++++++++ .../objects/brush/StrokeRulesTest.java | 200 ++++++++++++++++ .../objects/brush/TerrainBrushesTest.java | 222 ++++++++++++++++++ .../utils/ConnectedBlocksTest.java | 81 +++++++ .../bettergopaint/utils/HeightTest.java | 98 ++++++++ .../bettergopaint/utils/SphereTest.java | 127 ++++++++++ .../bettergopaint/utils/SurfaceTest.java | 72 ++++++ .../utils/curve/BezierSplineTest.java | 44 ++-- 31 files changed, 1814 insertions(+), 667 deletions(-) create mode 100644 src/main/java/net/onelitefeather/bettergopaint/utils/Blocks.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/objects/brush/NoBukkitWorldAccessTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/objects/brush/ShapeBrushesTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/objects/brush/StrokeRulesTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/objects/brush/TerrainBrushesTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocksTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/utils/HeightTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/utils/SphereTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/utils/SurfaceTest.java diff --git a/openspec/changes/native-worldedit-brushes/tasks.md b/openspec/changes/native-worldedit-brushes/tasks.md index 2a07792c..9f0ab720 100644 --- a/openspec/changes/native-worldedit-brushes/tasks.md +++ b/openspec/changes/native-worldedit-brushes/tasks.md @@ -2,19 +2,19 @@ ## 1. Foundation -- [ ] 1.1 Add `StrokeContext` (captured target, player eye position, settings snapshot, palette `Pattern`, mask, `Random`) and build it on the player's thread in `InteractListener`; verify with a unit test that building it needs no world access +- [x] 1.1 Add `StrokeContext` (captured target, player eye position, settings snapshot, palette `Pattern`, mask, `Random`) and build it on the player's thread in `InteractListener`; verify with a unit test that building it needs no world access (`StrokeContextFactoryTest` verifies the mocked `World` is never touched; the listener calls `StrokeContextFactory.capture` before `runAction`) - [x] 1.2 Add an in-memory test `Extent` and verify it round-trips `setBlock`/`getBlock` (`MemoryExtent`, `MemoryExtentTest`; block types are Mockito mocks because WorldEdit's registry cannot initialise offline, see `TestBlocks`) ## 2. Geometry on extents -- [ ] 2.1 Port `Sphere`, `Height`, `Surface`, `ConnectedBlocks` to `Extent` + `BlockVector3`; verify each with unit tests against the in-memory extent +- [x] 2.1 Port `Sphere`, `Height`, `Surface`, `ConnectedBlocks` to `Extent` + `BlockVector3`; verify each with unit tests against the in-memory extent (`SphereTest`, `HeightTest`, `SurfaceTest`, `ConnectedBlocksTest`) ## 3. Brushes -- [ ] 3.1 Make the brush base implement WorldEdit's `Brush` and port Sphere, Spray, Splatter, Disc and Bucket brushes; verify each with a deterministic test (fixed seed, expected positions) -- [ ] 3.2 Port Angle, Overlay, Underlay, Fracture, Gradient and Paint brushes; verify each with a deterministic test -- [ ] 3.3 Remove every Bukkit world/block call from brush code; verify `grep -rn "getBlock()\|getBlockAt\|org.bukkit.block.Block" src/main/java/net/onelitefeather/bettergopaint/objects src/main/java/net/onelitefeather/bettergopaint/utils` returns nothing -- [ ] 3.4 Run strokes through the session's `EditSession` so the global mask and history apply; verify with a test that a mask on the session blocks changes +- [x] 3.1 Make the brush base implement WorldEdit's `Brush` and port Sphere, Spray, Splatter, Disc and Bucket brushes; verify each with a deterministic test (fixed seed, expected positions) (`ShapeBrushesTest`; the base exposes WorldEdit's `Brush` through `Brush#bind`, see design.md) +- [x] 3.2 Port Angle, Overlay, Underlay, Fracture, Gradient and Paint brushes; verify each with a deterministic test (`TerrainBrushesTest`, `StrokeRulesTest#everyBrushIsDeterministic`) +- [x] 3.3 Remove every Bukkit world/block call from brush code; verify `grep -rn "getBlock()\|getBlockAt\|org.bukkit.block.Block" src/main/java/net/onelitefeather/bettergopaint/objects src/main/java/net/onelitefeather/bettergopaint/utils` returns nothing (grep is empty; `NoBukkitWorldAccessTest` repeats it in the build) +- [x] 3.4 Run strokes through the session's `EditSession` so the global mask and history apply; verify with a test that a mask on the session blocks changes (`StrokeRunner` uses `LocalSession#createEditSession` and `remember`; `StrokeRulesTest#sessionMaskBlocksChanges` tests the masking wrapper against the in-memory extent, a real `EditSession` needs a server, so history and `//gmask` still need the manual check) ## 4. Docs diff --git a/src/main/java/net/onelitefeather/bettergopaint/listeners/InteractListener.java b/src/main/java/net/onelitefeather/bettergopaint/listeners/InteractListener.java index 727ba1f0..68f7296b 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/listeners/InteractListener.java +++ b/src/main/java/net/onelitefeather/bettergopaint/listeners/InteractListener.java @@ -18,13 +18,20 @@ */ package net.onelitefeather.bettergopaint.listeners; +import com.sk89q.worldedit.LocalSession; +import com.sk89q.worldedit.WorldEdit; import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.bukkit.BukkitPlayer; import net.kyori.adventure.text.Component; import jakarta.inject.Inject; import net.onelitefeather.bettergopaint.brush.BrushSettings; import net.onelitefeather.bettergopaint.brush.ExportedBrushes; import net.onelitefeather.bettergopaint.brush.PlayerBrush; import net.onelitefeather.bettergopaint.brush.PlayerBrushManager; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.brush.StrokeContextFactory; +import net.onelitefeather.bettergopaint.brush.StrokeRunner; +import net.onelitefeather.bettergopaint.objects.brush.Brush; import net.onelitefeather.bettergopaint.settings.SettingsHolder; import net.onelitefeather.bettergopaint.utils.Constants; import net.onelitefeather.bettergopaint.utils.Messages; @@ -41,6 +48,7 @@ import org.jetbrains.annotations.NotNull; import java.util.Optional; +import java.util.Random; public final class InteractListener implements Listener { @@ -116,12 +124,35 @@ public void onClick(PlayerInteractEvent event) { } if (brushSettings.enabled()) { - BukkitAdapter.adapt(player).runAction( - () -> brushSettings.brush().paint(location, player, brushSettings), false, true - ); + paint(player, location, brushSettings); } else { player.sendMessage(Component.translatable("bettergopaint.brush.disabled", Messages.prefix(settings.get()))); } } + /** + * Captures everything the stroke needs on the player's thread and queues the stroke. The queued + * work only touches WorldEdit types. + */ + private void paint(@NotNull Player player, @NotNull Location target, @NotNull BrushSettings brushSettings) { + BukkitPlayer actor = BukkitAdapter.adapt(player); + LocalSession localSession = WorldEdit.getInstance().getSessionManager().get(actor); + Brush brush = brushSettings.brush(); + + StrokeContext captured = StrokeContextFactory.capture( + target, + player.getLocation(), + brushSettings, + BukkitAdapter::asBlockType, + new Random(brushSettings.random().nextLong()) + ); + StrokeContext context = brush.prepare(player.getUniqueId(), player.isSneaking(), player, captured); + if (context == null) { + return; + } + + com.sk89q.worldedit.command.tool.brush.Brush stroke = brush.bind(context); + actor.runAction(() -> StrokeRunner.run(actor, localSession, stroke, context), false, true); + } + } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/AngleBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/AngleBrush.java index 378a4ba6..82265d63 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/AngleBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/AngleBrush.java @@ -18,16 +18,13 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.Height; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; - public class AngleBrush extends Brush { private static final String NAME = "Angle Brush"; @@ -39,19 +36,12 @@ public AngleBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false); - blocks.filter(block -> passesDefaultChecks(brushSettings, player, block)) - .filter(block -> Height.getAverageHeightDiffAngle(block.getLocation(), 1) >= 0.1 - && Height.getAverageHeightDiffAngle(block.getLocation(), brushSettings.angleDistance()) - >= Math.tan(Math.toRadians(brushSettings.angleHeightDifference()))) - .forEach(block -> setBlock(session, block, brushSettings.randomBlock())); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + placeAll(extent, context, Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .filter(position -> passesDefaultChecks(extent, context, position)) + .filter(position -> Height.getAverageHeightDiffAngle(extent, position, 1) >= 0.1 + && Height.getAverageHeightDiffAngle(extent, position, context.angleDistance()) + >= Math.tan(Math.toRadians(context.angleHeightDifference()))) + .toList()); } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/Brush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/Brush.java index 70001ccc..91801ff9 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/Brush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/Brush.java @@ -19,22 +19,29 @@ package net.onelitefeather.bettergopaint.objects.brush; import com.sk89q.worldedit.EditSession; -import com.sk89q.worldedit.LocalSession; import com.sk89q.worldedit.MaxChangedBlocksException; -import com.sk89q.worldedit.WorldEdit; -import com.sk89q.worldedit.bukkit.BukkitAdapter; -import com.sk89q.worldedit.bukkit.BukkitPlayer; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.extent.MaskingExtent; +import com.sk89q.worldedit.function.mask.Mask; +import com.sk89q.worldedit.function.pattern.Pattern; import com.sk89q.worldedit.math.BlockVector3; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.world.block.BlockType; +import net.kyori.adventure.audience.Audience; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.Surface; -import org.bukkit.Location; -import org.bukkit.Material; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; -import java.util.function.Consumer; +import java.util.List; +import java.util.UUID; +/** + * A BetterGoPaint brush. A brush instance only describes the brush. The state of one stroke lives in a + * {@link StrokeContext}, and the brush reads and writes blocks only through the {@link Extent} it is given. + *

+ * {@link #bind(StrokeContext)} turns a brush and a context into WorldEdit's own + * {@link com.sk89q.worldedit.command.tool.brush.Brush}. + */ public abstract class Brush { private final String name; @@ -75,89 +82,134 @@ protected Brush(@NotNull String name, @NotNull String description, @NotNull Stri } /** - * Performs a painting action using the provided location, player, and brush settings. + * Called on the player's thread for every click before a stroke is queued. Most brushes paint on + * every click and return the context unchanged. A brush that needs several clicks collects them here. * - * @param location The location the painting action is performed. - * @param player The player who is performing the paint action. - * @param brushSettings The brush settings to be applied while painting. + * @param playerId the unique id of the player + * @param sneaking whether the player is sneaking + * @param feedback where messages for the player go + * @param context the context captured for this click + * @return the context of the stroke to queue, or null if no stroke should run yet */ - public abstract void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ); + public @Nullable StrokeContext prepare( + @NotNull UUID playerId, + boolean sneaking, + @NotNull Audience feedback, + @NotNull StrokeContext context + ) { + return context; + } /** - * Sets the material of a block in an EditSession. + * Computes the stroke and writes it to the extent. Every read happens before the first block is + * written, so the result does not depend on the order of the writes. * - * @param session The EditSession to perform the block update in. - * @param block The block to update. - * @param material The material to set the block to. - * @throws MaxChangedBlocksException If the maximum number of changed blocks is exceeded. + * @param extent the extent to read from and write to + * @param context the context of the stroke */ - protected void setBlock( - @NotNull EditSession session, - @NotNull Block block, - @NotNull Material material - ) throws MaxChangedBlocksException { - BlockVector3 vector = BlockVector3.at(block.getX(), block.getY(), block.getZ()); - if (session.getMask() == null || session.getMask().test(vector)) { - session.setBlock(vector, BukkitAdapter.asBlockType(material)); - } + public abstract void apply(@NotNull Extent extent, @NotNull StrokeContext context); + + /** + * Binds this brush to the context of one stroke. + * + * @param context the context of the stroke + * @return the stroke as a WorldEdit brush + */ + public final @NotNull com.sk89q.worldedit.command.tool.brush.Brush bind(@NotNull StrokeContext context) { + return new Stroke(this, context); + } + + /** + * Wraps the extent so that no block the mask rejects is written. A stroke runs against the edit session + * of the player, which already carries the global mask. This makes the rule hold for any extent. + * + * @param extent the extent to write to + * @param mask the mask of the player's session, or null + * @return the extent the brush should use + */ + static @NotNull Extent masked(@NotNull Extent extent, @Nullable Mask mask) { + return mask == null ? extent : new MaskingExtent(extent, mask); + } + + /** + * Writes a block of the palette to the position. + * + * @param extent the extent to write to + * @param context the context of the stroke + * @param position the position to change + */ + protected void place(@NotNull Extent extent, @NotNull StrokeContext context, @NotNull BlockVector3 position) { + extent.setBlock(position, context.palette().applyBlock(position)); } /** - * Performs an edit using WorldEdit's EditSession. - * This method wraps the edit session in a try-with-resources block to ensure proper cleanup of resources. + * Writes a block of the palette to every position. * - * @param player The player performing the edit. - * @param edit A Consumer functional interface that defines the actions to be taken within the edit session. + * @param extent the extent to write to + * @param context the context of the stroke + * @param positions the positions to change */ - protected void performEdit(@NotNull Player player, @NotNull Consumer edit) { - BukkitPlayer wrapped = BukkitAdapter.adapt(player); - LocalSession localSession = WorldEdit.getInstance().getSessionManager().get(wrapped); - try (EditSession editsession = localSession.createEditSession(wrapped)) { - try { - edit.accept(editsession); - } finally { - localSession.remember(editsession); - } + protected void placeAll(@NotNull Extent extent, @NotNull StrokeContext context, @NotNull List positions) { + for (BlockVector3 position : positions) { + place(extent, context, position); } } /** - * Checks if a given block passes the default checks defined by the brush settings. + * Writes the default state of a block type to the position. * - * @param brushSettings The brush settings to be checked against. - * @param player The player using the brush. - * @param block The block being checked. - * @return true if the block passes all the default checks, false otherwise. + * @param extent the extent to write to + * @param type the block type to place + * @param position the position to change */ - protected boolean passesDefaultChecks(@NotNull BrushSettings brushSettings, @NotNull Player player, @NotNull Block block) { - return passesMaskCheck(brushSettings, block) && passesSurfaceCheck(brushSettings, player, block); + protected void place(@NotNull Extent extent, @NotNull BlockType type, @NotNull BlockVector3 position) { + extent.setBlock(position, type.getDefaultState()); } /** - * Checks if a given block passes the surface check defined by the brush settings. + * Checks if a position passes the default checks defined by the stroke context. * - * @param brushSettings The brush settings to be checked against. - * @param player The player using the brush. - * @param block The block being checked. - * @return true if the block passes the surface check, false otherwise. + * @param extent the extent to read from + * @param context the context of the stroke + * @param position the position being checked + * @return true if the position passes all the default checks, false otherwise */ - protected boolean passesSurfaceCheck(@NotNull BrushSettings brushSettings, @NotNull Player player, @NotNull Block block) { - return Surface.isOnSurface(block, brushSettings.surfaceMode(), player.getLocation()); + protected boolean passesDefaultChecks(@NotNull Extent extent, @NotNull StrokeContext context, + @NotNull BlockVector3 position) { + return context.passesMask(extent, position) && passesSurfaceCheck(extent, context, position); } /** - * Checks if a given block passes the mask check defined by the brush settings. + * Checks if a position passes the surface check defined by the stroke context. * - * @param brushSettings The brush settings to be checked against. - * @param block The block being checked. - * @return true if the block passes the mask check, false otherwise. + * @param extent the extent to read from + * @param context the context of the stroke + * @param position the position being checked + * @return true if the position passes the surface check, false otherwise */ - protected boolean passesMaskCheck(@NotNull BrushSettings brushSettings, @NotNull Block block) { - return !brushSettings.maskEnabled() || block.getType().equals(brushSettings.mask()); + protected boolean passesSurfaceCheck(@NotNull Extent extent, @NotNull StrokeContext context, + @NotNull BlockVector3 position) { + return Surface.isOnSurface(extent, position, context.surfaceMode(), context.eye()); + } + + /** + * The stroke of a brush as WorldEdit sees it. + */ + private static final class Stroke implements com.sk89q.worldedit.command.tool.brush.Brush { + + private final Brush brush; + private final StrokeContext context; + + private Stroke(@NotNull Brush brush, @NotNull StrokeContext context) { + this.brush = brush; + this.context = context; + } + + @Override + public void build(EditSession session, BlockVector3 position, Pattern pattern, double size) + throws MaxChangedBlocksException { + brush.apply(masked(session, session.getMask()), context.with(position, pattern, (int) Math.round(size))); + } } } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/BucketBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/BucketBrush.java index 7be9e819..628842a9 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/BucketBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/BucketBrush.java @@ -18,16 +18,14 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.ConnectedBlocks; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; import java.util.List; -import java.util.stream.Stream; public class BucketBrush extends Brush { @@ -40,17 +38,11 @@ public BucketBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - List blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false).toList(); - Stream connectedBlocks = ConnectedBlocks.getConnectedBlocks(location, blocks); - connectedBlocks.filter(block -> passesDefaultChecks(brushSettings, player, block)) - .forEach(block -> setBlock(session, block, brushSettings.randomBlock())); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + List blocks = Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .toList(); + placeAll(extent, context, ConnectedBlocks.getConnectedBlocks(extent, context.target(), blocks) + .filter(position -> passesDefaultChecks(extent, context, position)) + .toList()); } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/DiscBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/DiscBrush.java index 138b265e..e6fc2a2b 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/DiscBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/DiscBrush.java @@ -18,15 +18,12 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; - public class DiscBrush extends Brush { private static final String NAME = "Disc Brush"; @@ -38,16 +35,9 @@ public DiscBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), brushSettings.axis(), false); - blocks.filter(block -> passesDefaultChecks(brushSettings, player, block)) - .forEach(block -> setBlock(session, block, brushSettings.randomBlock())); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + placeAll(extent, context, Sphere.getBlocksInRadius(extent, context.target(), context.size(), context.axis(), false) + .filter(position -> passesDefaultChecks(extent, context, position)) + .toList()); } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/FractureBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/FractureBrush.java index 6819958c..25846139 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/FractureBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/FractureBrush.java @@ -18,16 +18,13 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.Height; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; - public class FractureBrush extends Brush { private static final String NAME = "Fracture Brush"; @@ -39,26 +36,21 @@ public FractureBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false); - blocks.filter(block -> passesMaskCheck(brushSettings, block)) - .filter(block -> Height.getAverageHeightDiffFracture( - block.getLocation(), - Height.getNearestNonEmptyBlock(block.getLocation()), - 1 - ) >= 0.1) - .filter(block -> Height.getAverageHeightDiffFracture( - block.getLocation(), - Height.getNearestNonEmptyBlock(block.getLocation()), - brushSettings.fractureDistance() - ) >= 0.1) - .forEach(block -> setBlock(session, block, brushSettings.randomBlock())); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + placeAll(extent, context, Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .filter(position -> context.passesMask(extent, position)) + .filter(position -> Height.getAverageHeightDiffFracture( + extent, + position, + Height.getNearestNonEmptyBlock(extent, position), + 1 + ) >= 0.1) + .filter(position -> Height.getAverageHeightDiffFracture( + extent, + position, + Height.getNearestNonEmptyBlock(extent, position), + context.fractureDistance() + ) >= 0.1) + .toList()); } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/GradientBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/GradientBrush.java index f9851a7c..d10fe78f 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/GradientBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/GradientBrush.java @@ -18,14 +18,15 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BlockType; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; +import java.util.ArrayList; +import java.util.List; public class GradientBrush extends Brush { @@ -38,31 +39,30 @@ public GradientBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - double y = location.getBlockY() - (brushSettings.size() / 2d); - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false); - blocks.filter(block -> passesDefaultChecks(brushSettings, player, block)).filter(block -> { - double rate = (block.getLocation().distance(location) - (brushSettings.size() / 2d) - * ((100d - brushSettings.falloffStrength()) / 100d)) - / ((brushSettings.size() / 2d) - (brushSettings.size() / 2d) - * ((100d - brushSettings.falloffStrength()) / 100d)); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + double y = context.target().y() - (context.size() / 2d); + List blocks = context.blocks(); + List positions = Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .filter(position -> passesDefaultChecks(extent, context, position)) + .filter(position -> { + double rate = (position.distance(context.target()) - (context.size() / 2d) + * ((100d - context.falloffStrength()) / 100d)) + / ((context.size() / 2d) - (context.size() / 2d) + * ((100d - context.falloffStrength()) / 100d)); - return brushSettings.random().nextDouble() > rate; - }).forEach(block -> { - int random = (int) (((block.getLocation().getBlockY() - y) / brushSettings.size() - * brushSettings.blocks().size()) + (brushSettings.random().nextDouble() * 2 - 1) - * (brushSettings.mixingStrength() / 100d)); + return context.random().nextDouble() > rate; + }) + .toList(); + List types = new ArrayList<>(positions.size()); + for (BlockVector3 position : positions) { + int random = (int) (((position.y() - y) / context.size() + * blocks.size()) + (context.random().nextDouble() * 2 - 1) + * (context.mixingStrength() / 100d)); - int index = Math.clamp(random, 0, brushSettings.blocks().size() - 1); - - setBlock(session, block, brushSettings.blocks().get(index)); - }); - }); + types.add(blocks.get(Math.clamp(random, 0, blocks.size() - 1))); + } + for (int i = 0; i < positions.size(); i++) { + place(extent, types.get(i), positions.get(i)); + } } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/OverlayBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/OverlayBrush.java index c944675a..d55d989d 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/OverlayBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/OverlayBrush.java @@ -18,16 +18,13 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.utils.Blocks; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; - public class OverlayBrush extends Brush { private static final String NAME = "Overlay Brush"; @@ -39,26 +36,19 @@ public OverlayBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false); - blocks.filter(block -> passesMaskCheck(brushSettings, block)) - .filter(block -> isOverlay(block, brushSettings.thickness())) - .forEach(block -> setBlock(session, block, brushSettings.randomBlock())); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + placeAll(extent, context, Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .filter(position -> context.passesMask(extent, position)) + .filter(position -> isOverlay(extent, position, context.thickness())) + .toList()); } - private boolean isOverlay(Block block, int thickness) { + private boolean isOverlay(Extent extent, BlockVector3 position, int thickness) { for (int i = 1; i <= thickness; i++) { - if (block.isSolid() && !block.getRelative(BlockFace.UP, i).isSolid()) { + if (Blocks.isSolid(extent, position) && !Blocks.isSolid(extent, position.add(0, i, 0))) { return true; } } return false; } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrush.java index c1b0df02..fa510c87 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrush.java @@ -18,24 +18,27 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.math.Vector3; +import net.kyori.adventure.audience.Audience; import net.kyori.adventure.text.Component; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.settings.SettingsHolder; -import net.onelitefeather.bettergopaint.utils.Messages; +import net.onelitefeather.bettergopaint.utils.Blocks; import net.onelitefeather.bettergopaint.utils.Height; +import net.onelitefeather.bettergopaint.utils.Messages; import net.onelitefeather.bettergopaint.utils.Sphere; import net.onelitefeather.bettergopaint.utils.curve.BezierSpline; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; -import java.util.HashMap; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.UUID; -import java.util.stream.Stream; +import java.util.concurrent.ConcurrentHashMap; public class PaintBrush extends Brush { @@ -43,6 +46,8 @@ public class PaintBrush extends Brush { private static final String DESCRIPTION = "Paints strokes\n§8hold shift to end"; private static final String HEAD = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvODBiM2E5ZGZhYmVmYmRkOTQ5YjIxN2JiZDRmYTlhNDg2YmQwYzNmMGNhYjBkMGI5ZGZhMjRjMzMyZGQzZTM0MiJ9fX0="; + private static final Map> selectedPoints = new ConcurrentHashMap<>(); + private final SettingsHolder settings; public PaintBrush(@NotNull SettingsHolder settings) { @@ -50,68 +55,70 @@ public PaintBrush(@NotNull SettingsHolder settings) { this.settings = settings; } - private static final HashMap> selectedPoints = new HashMap<>(); - @Override - public void paint( - @NotNull Location target, - @NotNull Player player, - @NotNull BrushSettings brushSettings + public @Nullable StrokeContext prepare( + @NotNull UUID playerId, + boolean sneaking, + @NotNull Audience feedback, + @NotNull StrokeContext context ) { - List locations = selectedPoints.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()); - locations.add(target); + List points = selectedPoints.computeIfAbsent(playerId, ignored -> new ArrayList<>()); + points.add(context.target()); - if (!player.isSneaking()) { - player.sendMessage(Component.translatable( + if (!sneaking) { + feedback.sendMessage(Component.translatable( "bettergopaint.brush.paint.point", Messages.prefix(settings.get()), - Component.text(locations.size()) + Component.text(points.size()) )); - return; + return null; } - selectedPoints.remove(player.getUniqueId()); - - performEdit(player, session -> { - Location first = locations.getFirst(); - Stream blocks = Sphere.getBlocksInRadius(first, brushSettings.size(), null, false); - blocks.forEach(block -> { - if (Height.getAverageHeightDiffAngle(block.getLocation(), 1) >= 0.1 - && Height.getAverageHeightDiffAngle(block.getLocation(), brushSettings.angleDistance()) - >= Math.tan(Math.toRadians(brushSettings.angleHeightDifference()))) { - return; - } - - double rate = (block.getLocation().distance(first) - (brushSettings.size() / 2.0) - * ((100.0 - brushSettings.falloffStrength()) / 100.0)) / ((brushSettings.size() / 2.0) - - (brushSettings.size() / 2.0) * ((100.0 - brushSettings.falloffStrength()) / 100.0)); - - if (brushSettings.random().nextDouble() <= rate) { - return; - } + selectedPoints.remove(playerId); + return context.withPoints(points); + } - LinkedList newCurve = new LinkedList<>(); - newCurve.add(block.getLocation()); - for (Location location : locations) { - newCurve.add(block.getLocation().clone().add( - location.getX() - first.getX(), - location.getY() - first.getY(), - location.getZ() - first.getZ() - )); + @Override + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + List points = context.points(); + BlockVector3 first = points.getFirst(); + List positions = new ArrayList<>(); + + Sphere.getBlocksInRadius(extent, first, context.size(), null, false).forEach(block -> { + if (Height.getAverageHeightDiffAngle(extent, block, 1) >= 0.1 + && Height.getAverageHeightDiffAngle(extent, block, context.angleDistance()) + >= Math.tan(Math.toRadians(context.angleHeightDifference()))) { + return; + } + + double rate = (block.distance(first) - (context.size() / 2.0) + * ((100.0 - context.falloffStrength()) / 100.0)) / ((context.size() / 2.0) + - (context.size() / 2.0) * ((100.0 - context.falloffStrength()) / 100.0)); + + if (context.random().nextDouble() <= rate) { + return; + } + + LinkedList newCurve = new LinkedList<>(); + newCurve.add(block.toVector3()); + for (BlockVector3 point : points) { + newCurve.add(block.add(point.subtract(first)).toVector3()); + } + BezierSpline spline = new BezierSpline(newCurve); + double maxCount = (spline.getCurveLength() * 2.5) + 1; + for (int y = 0; y <= maxCount; y++) { + Vector3 curvePoint = spline.getPoint((y / maxCount) * (points.size() - 1)); + BlockVector3 point = BlockVector3.at(curvePoint.x(), curvePoint.y(), curvePoint.z()); + + if (Blocks.isAir(extent, point) || !passesDefaultChecks(extent, context, point)) { + continue; } - BezierSpline spline = new BezierSpline(newCurve); - double maxCount = (spline.getCurveLength() * 2.5) + 1; - for (int y = 0; y <= maxCount; y++) { - Block point = spline.getPoint((y / maxCount) * (locations.size() - 1)).getBlock(); - if (point.isEmpty() || !passesDefaultChecks(brushSettings, player, point)) { - continue; - } - - setBlock(session, point, brushSettings.randomBlock()); - } - }); + positions.add(point); + } }); + + placeAll(extent, context, positions); } } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SphereBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SphereBrush.java index e23d4e1e..324f2e3c 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SphereBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SphereBrush.java @@ -18,15 +18,12 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; - public class SphereBrush extends Brush { private static final String NAME = "Sphere Brush"; @@ -38,16 +35,9 @@ public SphereBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false); - blocks.filter(block -> passesDefaultChecks(brushSettings, player, block)) - .forEach(block -> setBlock(session, block, brushSettings.randomBlock())); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + placeAll(extent, context, Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .filter(position -> passesDefaultChecks(extent, context, position)) + .toList()); } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SplatterBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SplatterBrush.java index 8803a488..d7a7017a 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SplatterBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SplatterBrush.java @@ -18,15 +18,12 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; - public class SplatterBrush extends Brush { private static final String NAME = "Splatter Brush"; @@ -38,27 +35,17 @@ public SplatterBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false); - blocks.filter(block -> passesDefaultChecks(brushSettings, player, block)) - .forEach(block -> { - double rate = (block.getLocation().distance(location) - ((double) brushSettings.size() / 2.0) - * ((100.0 - (double) brushSettings.falloffStrength()) / 100.0)) - / (((double) brushSettings.size() / 2.0) - ((double) brushSettings.size() / 2.0) - * ((100.0 - (double) brushSettings.falloffStrength()) / 100.0)); - - if (brushSettings.random().nextDouble() <= rate) { - return; - } - - setBlock(session, block, brushSettings.randomBlock()); - }); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + placeAll(extent, context, Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .filter(position -> passesDefaultChecks(extent, context, position)) + .filter(position -> { + double rate = (position.distance(context.target()) - ((double) context.size() / 2.0) + * ((100.0 - (double) context.falloffStrength()) / 100.0)) + / (((double) context.size() / 2.0) - ((double) context.size() / 2.0) + * ((100.0 - (double) context.falloffStrength()) / 100.0)); + + return !(context.random().nextDouble() <= rate); + }) + .toList()); } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SprayBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SprayBrush.java index e9fb5c3c..ccedd02c 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SprayBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SprayBrush.java @@ -18,15 +18,12 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; - public class SprayBrush extends Brush { private static final String NAME = "Spray Brush"; @@ -38,17 +35,10 @@ public SprayBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false); - blocks.filter(block -> passesDefaultChecks(brushSettings, player, block)) - .filter(block -> brushSettings.random().nextInt(100) < brushSettings.chance()) - .forEach(block -> setBlock(session, block, brushSettings.randomBlock())); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + placeAll(extent, context, Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .filter(position -> passesDefaultChecks(extent, context, position)) + .filter(position -> context.random().nextInt(100) < context.chance()) + .toList()); } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/UnderlayBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/UnderlayBrush.java index ecf24c07..e2325c43 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/UnderlayBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/UnderlayBrush.java @@ -18,16 +18,13 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.utils.Blocks; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; - public class UnderlayBrush extends Brush { private static final String NAME = "Underlay Brush"; @@ -39,26 +36,19 @@ public UnderlayBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false); - blocks.filter(block -> passesMaskCheck(brushSettings, block)) - .filter(block -> isUnderlay(block, brushSettings.thickness())) - .forEach(block -> setBlock(session, block, brushSettings.randomBlock())); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + placeAll(extent, context, Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .filter(position -> context.passesMask(extent, position)) + .filter(position -> isUnderlay(extent, position, context.thickness())) + .toList()); } - private boolean isUnderlay(Block block, int thickness) { + private boolean isUnderlay(Extent extent, BlockVector3 position, int thickness) { for (int i = 1; i <= thickness; i++) { - if (!block.isSolid() || !block.getRelative(BlockFace.UP, i).isSolid()) { + if (!Blocks.isSolid(extent, position) || !Blocks.isSolid(extent, position.add(0, i, 0))) { return false; } } return true; } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/other/SurfaceMode.java b/src/main/java/net/onelitefeather/bettergopaint/objects/other/SurfaceMode.java index 8d0e3749..778bec26 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/other/SurfaceMode.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/other/SurfaceMode.java @@ -19,8 +19,9 @@ package net.onelitefeather.bettergopaint.objects.other; import net.onelitefeather.bettergopaint.utils.Surface; -import org.bukkit.Location; -import org.bukkit.block.Block; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.math.Vector3; import org.jetbrains.annotations.NotNull; import java.util.Optional; @@ -29,19 +30,19 @@ public enum SurfaceMode { /** * This enumeration represents a more intuitive check. * - * @see Surface#isDirectlyOnSurface(Block) + * @see Surface#isDirectlyOnSurface(Extent, BlockVector3) */ DIRECT("Direct"), /** * This enumeration represents that surface mode is disabled. * - * @see Surface#isOnSurface(Block, SurfaceMode, Location) + * @see Surface#isOnSurface(Extent, BlockVector3, SurfaceMode, Vector3) */ DISABLED("Disabled"), /** * This enumeration represents the original surface mode check. * - * @see Surface#isRelativelyOnSurface(Block, Location) + * @see Surface#isRelativelyOnSurface(Extent, BlockVector3, Vector3) */ RELATIVE("Relative"); diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/Blocks.java b/src/main/java/net/onelitefeather/bettergopaint/utils/Blocks.java new file mode 100644 index 00000000..45d06f3e --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/Blocks.java @@ -0,0 +1,88 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.utils; + +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BlockState; +import org.jetbrains.annotations.NotNull; + +/** + * Block queries on a WorldEdit {@link Extent}. Positions outside the build limits of the extent read + * as air, like they did when the Bukkit world was asked. + */ +public final class Blocks { + + private Blocks() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } + + /** + * Checks if the given height is inside the build limits of the extent. + * + * @param extent the extent to check + * @param y the height to check + * @return true if the height can be read from the extent + */ + public static boolean isInBounds(@NotNull Extent extent, int y) { + return y >= extent.getMinY() && y <= extent.getMaxY(); + } + + /** + * Checks if the block at the given position is air. Positions outside the build limits are air. + * + * @param extent the extent to read from + * @param position the position to check + * @return true if the block is air + */ + public static boolean isAir(@NotNull Extent extent, @NotNull BlockVector3 position) { + return isAir(extent, position.x(), position.y(), position.z()); + } + + /** + * Checks if the block at the given coordinates is air. Positions outside the build limits are air. + * + * @param extent the extent to read from + * @param x the x coordinate + * @param y the y coordinate + * @param z the z coordinate + * @return true if the block is air + */ + public static boolean isAir(@NotNull Extent extent, int x, int y, int z) { + if (!isInBounds(extent, y)) { + return true; + } + BlockState state = extent.getBlock(x, y, z); + return state.getBlockType().getMaterial().isAir(); + } + + /** + * Checks if the block at the given position is solid. Positions outside the build limits are not solid. + * + * @param extent the extent to read from + * @param position the position to check + * @return true if the block is solid + */ + public static boolean isSolid(@NotNull Extent extent, @NotNull BlockVector3 position) { + if (!isInBounds(extent, position.y())) { + return false; + } + return extent.getBlock(position).getBlockType().getMaterial().isSolid(); + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocks.java b/src/main/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocks.java index 8927a23a..39224258 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocks.java +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocks.java @@ -18,14 +18,14 @@ */ package net.onelitefeather.bettergopaint.utils; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BlockType; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; -import java.util.Arrays; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Queue; @@ -34,13 +34,13 @@ public final class ConnectedBlocks { - private static final BlockFace[] faces = new BlockFace[]{ - BlockFace.NORTH, - BlockFace.EAST, - BlockFace.SOUTH, - BlockFace.WEST, - BlockFace.UP, - BlockFace.DOWN, + private static final BlockVector3[] FACES = new BlockVector3[]{ + BlockVector3.at(0, 0, -1), + BlockVector3.at(1, 0, 0), + BlockVector3.at(0, 0, 1), + BlockVector3.at(-1, 0, 0), + BlockVector3.at(0, 1, 0), + BlockVector3.at(0, -1, 0), }; private ConnectedBlocks() { @@ -48,34 +48,37 @@ private ConnectedBlocks() { } /** - * Returns a stream of connected blocks starting from a given location, based on a list of blocks. + * Returns a stream of connected positions starting from a given position, based on a list of positions. * Only blocks of the same type as the start block are considered. * - * @param loc the starting location - * @param blocks the list of blocks to check for connectivity - * @return a stream of connected blocks + * @param extent the extent to read from + * @param start the starting position + * @param blocks the list of positions to check for connectivity + * @return a stream of connected positions */ - @Contract(value = "_, _ -> new", pure = true) - public static @NotNull Stream getConnectedBlocks(@NotNull Location loc, @NotNull List blocks) { + @Contract(value = "_, _, _ -> new", pure = true) + public static @NotNull Stream getConnectedBlocks(@NotNull Extent extent, @NotNull BlockVector3 start, + @NotNull List blocks) { if (blocks.isEmpty()) return Stream.empty(); - Block startBlock = loc.getBlock(); - Set connected = new HashSet<>(); - Queue toCheck = new LinkedList<>(); + BlockType startType = extent.getBlock(start).getBlockType(); + Set candidates = new HashSet<>(blocks); + Set connected = new LinkedHashSet<>(); + Queue toCheck = new LinkedList<>(); - toCheck.add(startBlock); - connected.add(startBlock); + toCheck.add(start); + connected.add(start); while (!toCheck.isEmpty() && connected.size() < blocks.size()) { - Block current = toCheck.poll(); - List neighbors = Arrays.stream(faces) - .map(current::getRelative) - .filter(relative -> relative.getType().equals(startBlock.getType()) - && !connected.contains(relative) - && blocks.contains(relative)) - .toList(); - - connected.addAll(neighbors); - toCheck.addAll(neighbors); + BlockVector3 current = toCheck.poll(); + for (BlockVector3 face : FACES) { + BlockVector3 relative = current.add(face); + if (candidates.contains(relative) + && !connected.contains(relative) + && extent.getBlock(relative).getBlockType().equals(startType)) { + connected.add(relative); + toCheck.add(relative); + } + } } return connected.stream(); diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/Height.java b/src/main/java/net/onelitefeather/bettergopaint/utils/Height.java index 49b64b00..8a5c4c05 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/utils/Height.java +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/Height.java @@ -18,8 +18,8 @@ */ package net.onelitefeather.bettergopaint.utils; -import org.bukkit.Location; -import org.bukkit.block.Block; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; import org.jetbrains.annotations.NotNull; public class Height { @@ -29,82 +29,88 @@ private Height() { } /** - * Gets the height of the nearest non-empty block at a given location. + * Gets the height of the nearest non-empty block at a given position. * - * @param location the location to check - * @return the height of the nearest non-empty block at the location + * @param extent the extent to read from + * @param position the position to check + * @return the height of the nearest non-empty block at the position */ - public static int getNearestNonEmptyBlock(@NotNull Location location) { - if (location.getBlock().getType().isEmpty()) { - for (int y = location.getBlockY(); y >= location.getWorld().getMinHeight(); y--) { - Block block = location.getWorld().getBlockAt(location.getBlockX(), y, location.getBlockZ()); - if (!block.isEmpty()) { + public static int getNearestNonEmptyBlock(@NotNull Extent extent, @NotNull BlockVector3 position) { + int minHeight = extent.getMinY(); + int maxHeight = extent.getMaxY() + 1; + int x = position.x(); + int z = position.z(); + if (Blocks.isAir(extent, position)) { + for (int y = position.y(); y >= minHeight; y--) { + if (!Blocks.isAir(extent, x, y, z)) { return y + 1; } } - return location.getWorld().getMinHeight(); + return minHeight; } else { - for (int y = location.getBlockY(); y <= location.getWorld().getMaxHeight(); y++) { - Block block = location.getWorld().getBlockAt(location.getBlockX(), y, location.getBlockZ()); - if (block.isEmpty()) { + for (int y = position.y(); y <= maxHeight; y++) { + if (Blocks.isAir(extent, x, y, z)) { return y; } } - return location.getWorld().getMaxHeight(); + return maxHeight; } } /** - * Calculates the average height difference of the surrounding blocks from a given location, within a specified distance. + * Calculates the average height difference of the surrounding blocks from a given position, within a specified distance. * - * @param location the location to calculate the average height difference from + * @param extent the extent to read from + * @param position the position to calculate the average height difference from * @param height the reference height of the nearest non-empty block * @param distance the distance at which to calculate the average height difference * @return the average height difference of the surrounding blocks within the specified distance */ - public static double getAverageHeightDiffFracture(@NotNull Location location, int height, int distance) { + public static double getAverageHeightDiffFracture(@NotNull Extent extent, @NotNull BlockVector3 position, int height, + int distance) { double totalHeight = 0; - totalHeight += Math.abs(getNearestNonEmptyBlock(location.clone().add(distance, 0, -distance))) - height; - totalHeight += Math.abs(getNearestNonEmptyBlock(location.clone().add(distance, 0, distance))) - height; - totalHeight += Math.abs(getNearestNonEmptyBlock(location.clone().add(-distance, 0, distance))) - height; - totalHeight += Math.abs(getNearestNonEmptyBlock(location.clone().add(-distance, 0, -distance))) - height; - totalHeight += Math.abs(getNearestNonEmptyBlock(location.clone().add(0, 0, -distance))) - height; - totalHeight += Math.abs(getNearestNonEmptyBlock(location.clone().add(0, 0, distance))) - height; - totalHeight += Math.abs(getNearestNonEmptyBlock(location.clone().add(-distance, 0, 0))) - height; - totalHeight += Math.abs(getNearestNonEmptyBlock(location.clone().add(distance, 0, 0))) - height; + totalHeight += Math.abs(getNearestNonEmptyBlock(extent, position.add(distance, 0, -distance))) - height; + totalHeight += Math.abs(getNearestNonEmptyBlock(extent, position.add(distance, 0, distance))) - height; + totalHeight += Math.abs(getNearestNonEmptyBlock(extent, position.add(-distance, 0, distance))) - height; + totalHeight += Math.abs(getNearestNonEmptyBlock(extent, position.add(-distance, 0, -distance))) - height; + totalHeight += Math.abs(getNearestNonEmptyBlock(extent, position.add(0, 0, -distance))) - height; + totalHeight += Math.abs(getNearestNonEmptyBlock(extent, position.add(0, 0, distance))) - height; + totalHeight += Math.abs(getNearestNonEmptyBlock(extent, position.add(-distance, 0, 0))) - height; + totalHeight += Math.abs(getNearestNonEmptyBlock(extent, position.add(distance, 0, 0))) - height; return (totalHeight / 8d) / distance; } /** - * Calculates the average height difference angle of the surrounding blocks from a given location within a specified distance. + * Calculates the average height difference angle of the surrounding blocks from a given position within a specified distance. * - * @param location the location to calculate the average height difference angle from + * @param extent the extent to read from + * @param position the position to calculate the average height difference angle from * @param distance the distance at which to calculate the average height difference angle * @return the average height difference angle of the surrounding blocks within the specified distance */ - public static double getAverageHeightDiffAngle(@NotNull Location location, int distance) { + public static double getAverageHeightDiffAngle(@NotNull Extent extent, @NotNull BlockVector3 position, int distance) { double maxHeightDiff = 0; double maxHeightDiff2 = 0; - double diff = Math.abs(getNearestNonEmptyBlock(location.clone().add(distance, 0, -distance)) - - getNearestNonEmptyBlock(location.clone().add(-distance, 0, distance))); + double diff = Math.abs(getNearestNonEmptyBlock(extent, position.add(distance, 0, -distance)) + - getNearestNonEmptyBlock(extent, position.add(-distance, 0, distance))); if (diff >= maxHeightDiff) { maxHeightDiff = diff; maxHeightDiff2 = maxHeightDiff; } - diff = Math.abs(getNearestNonEmptyBlock(location.clone().add(distance, 0, distance)) - - getNearestNonEmptyBlock(location.clone().add(-distance, 0, -distance))); + diff = Math.abs(getNearestNonEmptyBlock(extent, position.add(distance, 0, distance)) + - getNearestNonEmptyBlock(extent, position.add(-distance, 0, -distance))); if (diff > maxHeightDiff) { maxHeightDiff = diff; maxHeightDiff2 = maxHeightDiff; } - diff = Math.abs(getNearestNonEmptyBlock(location.clone().add(distance, 0, 0)) - - getNearestNonEmptyBlock(location.clone().add(-distance, 0, 0))); + diff = Math.abs(getNearestNonEmptyBlock(extent, position.add(distance, 0, 0)) + - getNearestNonEmptyBlock(extent, position.add(-distance, 0, 0))); if (diff > maxHeightDiff) { maxHeightDiff = diff; maxHeightDiff2 = maxHeightDiff; } - diff = Math.abs(getNearestNonEmptyBlock(location.clone().add(0, 0, -distance)) - - getNearestNonEmptyBlock(location.clone().add(0, 0, distance))); + diff = Math.abs(getNearestNonEmptyBlock(extent, position.add(0, 0, -distance)) + - getNearestNonEmptyBlock(extent, position.add(0, 0, distance))); if (diff > maxHeightDiff) { maxHeightDiff = diff; maxHeightDiff2 = maxHeightDiff; diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/Sphere.java b/src/main/java/net/onelitefeather/bettergopaint/utils/Sphere.java index 12d5798b..3a347ecb 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/utils/Sphere.java +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/Sphere.java @@ -18,9 +18,9 @@ */ package net.onelitefeather.bettergopaint.utils; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; import org.bukkit.Axis; -import org.bukkit.Location; -import org.bukkit.block.Block; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -36,70 +36,72 @@ private Sphere() { } /** - * Returns a stream of blocks within a specified radius from a given middle point. + * Returns a stream of positions within a specified radius from a given middle point. Positions + * outside the build limits of the extent are never returned. * + * @param extent The extent the blocks are read from. * @param middlePoint The middle point from which to calculate the radius. * @param radius The radius value. * @param axis The axis along which to calculate the radius (optional). * @param air Whether air blocks should be included. - * @return A stream of blocks within the specified radius. + * @return A stream of positions within the specified radius. */ - @Contract(value = "_, _, _, _ -> new", pure = true) - public static @NotNull Stream getBlocksInRadius(@NotNull Location middlePoint, int radius, @Nullable Axis axis, - boolean air) { - List blocks = new ArrayList<>(); - Location loc1 = middlePoint.clone().add(-radius / 2d, -radius / 2d, -radius / 2d).getBlock().getLocation(); - Location loc2 = middlePoint.clone().add(radius / 2d, radius / 2d, radius / 2d).getBlock().getLocation(); + @Contract(value = "_, _, _, _, _ -> new", pure = true) + public static @NotNull Stream getBlocksInRadius(@NotNull Extent extent, @NotNull BlockVector3 middlePoint, + int radius, @Nullable Axis axis, boolean air) { + List blocks = new ArrayList<>(); + int minX = floor(middlePoint.x() + -radius / 2d); + int minY = floor(middlePoint.y() + -radius / 2d); + int minZ = floor(middlePoint.z() + -radius / 2d); + int maxX = floor(middlePoint.x() + radius / 2d); + int maxY = floor(middlePoint.y() + radius / 2d); + int maxZ = floor(middlePoint.z() + radius / 2d); switch (axis) { case Y: - for (int x = loc1.getBlockX(); x <= loc2.getBlockX(); x++) { - for (int z = loc1.getBlockZ(); z <= loc2.getBlockZ(); z++) { - Location location = new Location(loc1.getWorld(), x, middlePoint.getBlockY(), z); - if (passesDefaultChecks(location, middlePoint, radius)) { - blocks.add(location.getBlock()); - } + for (int x = minX; x <= maxX; x++) { + for (int z = minZ; z <= maxZ; z++) { + add(blocks, BlockVector3.at(x, middlePoint.y(), z), middlePoint, radius); } } break; case X: - for (int y = loc1.getBlockY(); y <= loc2.getBlockY(); y++) { - for (int z = loc1.getBlockZ(); z <= loc2.getBlockZ(); z++) { - Location location = new Location(loc1.getWorld(), middlePoint.getBlockX(), y, z); - if (passesDefaultChecks(location, middlePoint, radius)) { - blocks.add(location.getBlock()); - } + for (int y = minY; y <= maxY; y++) { + for (int z = minZ; z <= maxZ; z++) { + add(blocks, BlockVector3.at(middlePoint.x(), y, z), middlePoint, radius); } } break; case Z: - for (int x = loc1.getBlockX(); x <= loc2.getBlockX(); x++) { - for (int y = loc1.getBlockY(); y <= loc2.getBlockY(); y++) { - Location location = new Location(loc1.getWorld(), x, y, middlePoint.getBlockZ()); - if (passesDefaultChecks(location, middlePoint, radius)) { - blocks.add(location.getBlock()); - } + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + add(blocks, BlockVector3.at(x, y, middlePoint.z()), middlePoint, radius); } } break; case null: - for (int x = loc1.getBlockX(); x <= loc2.getBlockX(); x++) { - for (int y = loc1.getBlockY(); y <= loc2.getBlockY(); y++) { - for (int z = loc1.getBlockZ(); z <= loc2.getBlockZ(); z++) { - Location location = new Location(loc1.getWorld(), x, y, z); - if (passesDefaultChecks(location, middlePoint, radius)) { - blocks.add(location.getBlock()); - } + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + for (int z = minZ; z <= maxZ; z++) { + add(blocks, BlockVector3.at(x, y, z), middlePoint, radius); } } } break; } - if (air) return blocks.stream(); - return blocks.stream().filter(block -> !block.isEmpty()); + return blocks.stream() + .filter(position -> Blocks.isInBounds(extent, position.y())) + .filter(position -> air || !Blocks.isAir(extent, position)); } - private static boolean passesDefaultChecks(@NotNull Location location, @NotNull Location middlePoint, int radius) { - return location.distance(middlePoint) < radius / 2d; + private static int floor(double value) { + return (int) Math.floor(value); + } + + private static void add(@NotNull List blocks, @NotNull BlockVector3 position, + @NotNull BlockVector3 middlePoint, int radius) { + if (position.distance(middlePoint) < radius / 2d) { + blocks.add(position); + } } } diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/Surface.java b/src/main/java/net/onelitefeather/bettergopaint/utils/Surface.java index 0ca21aa2..547d4b4e 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/utils/Surface.java +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/Surface.java @@ -18,10 +18,10 @@ */ package net.onelitefeather.bettergopaint.utils; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.math.Vector3; import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; import org.jetbrains.annotations.NotNull; public final class Surface { @@ -33,50 +33,56 @@ private Surface() { /** * Checks if the given block is on the surface. * - * @param block the block to check + * @param extent the extent to read from + * @param position the position of the block to check * @return true if the block is on the surface, false otherwise */ - public static boolean isDirectlyOnSurface(@NotNull Block block) { - return block.isSolid() && !block.getRelative(BlockFace.UP).isSolid(); + public static boolean isDirectlyOnSurface(@NotNull Extent extent, @NotNull BlockVector3 position) { + return Blocks.isSolid(extent, position) && !Blocks.isSolid(extent, position.add(0, 1, 0)); } /** - * Checks if a block is on the surface, taking into account the player's location. + * Checks if a block is on the surface, taking into account the player's position. * - * @param block the block to check if the player is near - * @param playerLoc the player's location - * @return true if the block is on the surface from the player's location, false otherwise + * @param extent the extent to read from + * @param position the position of the block to check + * @param eye the position of the player's eyes + * @return true if the block is on the surface from the player's position, false otherwise */ - public static boolean isRelativelyOnSurface(@NotNull Block block, @NotNull Location playerLoc) { - Location location = block.getLocation(); + public static boolean isRelativelyOnSurface(@NotNull Extent extent, @NotNull BlockVector3 position, + @NotNull Vector3 eye) { + double distanceX = eye.x() - position.x(); + double distanceY = eye.y() - position.y(); + double distanceZ = eye.z() - position.z(); - playerLoc.add(0, 1.5, 0); - double distanceX = playerLoc.getX() - block.getX(); - double distanceY = playerLoc.getY() - block.getY(); - double distanceZ = playerLoc.getZ() - block.getZ(); + double x = position.x(); + double y = position.y(); + double z = position.z(); if (distanceX > 1) { - location.add(1, 0, 0); + x += 1; } else if (distanceX > 0) { - location.add(0.5, 0, 0); + x += 0.5; } if (distanceY > 1) { - location.add(0, 1, 0); + y += 1; } else if (distanceY > 0) { - location.add(0, 0.5, 0); + y += 0.5; } if (distanceZ > 1) { - location.add(0, 0, 1); + z += 1; } else if (distanceZ > 0) { - location.add(0, 0, 0.5); + z += 0.5; } - double distance = location.distance(playerLoc); - for (int x = 1; x < distance; x++) { - double moveX = distanceX * (x / distance); - double moveY = distanceY * (x / distance); - double moveZ = distanceZ * (x / distance); - Location checkLoc = location.add(moveX, moveY, moveZ); - if (!checkLoc.getBlock().isEmpty()) { + double dx = x - eye.x(); + double dy = y - eye.y(); + double dz = z - eye.z(); + double distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + for (int step = 1; step < distance; step++) { + x += distanceX * (step / distance); + y += distanceY * (step / distance); + z += distanceZ * (step / distance); + if (!Blocks.isAir(extent, (int) Math.floor(x), (int) Math.floor(y), (int) Math.floor(z))) { return false; } } @@ -84,17 +90,19 @@ public static boolean isRelativelyOnSurface(@NotNull Block block, @NotNull Locat } /** - * Checks if a given block is on the surface based on the specified surface mode and location. + * Checks if a given block is on the surface based on the specified surface mode and eye position. * - * @param block the block to check + * @param extent the extent to read from + * @param position the position of the block to check * @param surfaceMode the surface mode to use for the check - * @param location the location to use for the check - * @return true if the block is on the surface based on the surface mode and location, false otherwise + * @param eye the position of the player's eyes + * @return true if the block is on the surface based on the surface mode and position, false otherwise */ - public static boolean isOnSurface(@NotNull Block block, @NotNull SurfaceMode surfaceMode, @NotNull Location location) { + public static boolean isOnSurface(@NotNull Extent extent, @NotNull BlockVector3 position, + @NotNull SurfaceMode surfaceMode, @NotNull Vector3 eye) { return switch (surfaceMode) { - case RELATIVE -> isRelativelyOnSurface(block, location); - case DIRECT -> isDirectlyOnSurface(block); + case RELATIVE -> isRelativelyOnSurface(extent, position, eye); + case DIRECT -> isDirectlyOnSurface(extent, position); case DISABLED -> true; }; } diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/curve/BezierSpline.java b/src/main/java/net/onelitefeather/bettergopaint/utils/curve/BezierSpline.java index ef763476..6c8d2812 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/utils/curve/BezierSpline.java +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/curve/BezierSpline.java @@ -18,34 +18,32 @@ */ package net.onelitefeather.bettergopaint.utils.curve; -import org.bukkit.Location; -import org.bukkit.World; -import org.bukkit.util.Vector; +import com.fastasyncworldedit.core.math.MutableVector3; +import com.sk89q.worldedit.math.Vector3; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import java.util.LinkedList; public class BezierSpline { - private final LinkedList knotsList; - private Location[] knots; + private final LinkedList knotsList; + private MutableVector3[] knots; private BezierSplineSegment[] segments; private double length = 0; - private Location anchorPoint; + private Vector3 anchorPoint; public BezierSpline() { knotsList = new LinkedList<>(); segments = new BezierSplineSegment[0]; } - public BezierSpline(@NotNull LinkedList knotsList) { + public BezierSpline(@NotNull LinkedList knotsList) { this.knotsList = knotsList; recalculate(); } private void recalculate() { - knots = knotsList.toArray(new Location[0]); + knots = knotsList.stream().map(MutableVector3::new).toArray(MutableVector3[]::new); segments = new BezierSplineSegment[knots.length - 1]; for (int i = 0; i < knots.length - 1; i++) { segments[i] = new BezierSplineSegment(knots[i], knots[i + 1]); @@ -54,8 +52,8 @@ private void recalculate() { calculateLength(); } - public void addKnot(@NotNull Location location) { - knotsList.add(location); + public void addKnot(@NotNull Vector3 knot) { + knotsList.add(knot); recalculate(); } @@ -116,7 +114,7 @@ public double getT(double blocks) { return i + segments[i].getT(blocks - current); } - public @NotNull Location getPoint(double point) { + public @NotNull Vector3 getPoint(double point) { if (point >= segments.length) { return getPoint(segments.length - 1, 1); } else { @@ -124,7 +122,7 @@ public double getT(double blocks) { } } - public @NotNull Location getPoint(int n, double f) { + public @NotNull Vector3 getPoint(int n, double f) { assert (n < segments.length); assert (0 <= f && f <= 1); BezierSplineSegment segment = segments[n]; @@ -161,45 +159,43 @@ public void calculateControlPoints() { } Double xflat, yflat, zflat; - xflat = knots[0].getX(); - yflat = knots[0].getY(); - zflat = knots[0].getZ(); - for (Location l : knots) { - if (l.getBlockX() != xflat) { + xflat = knots[0].x(); + yflat = knots[0].y(); + zflat = knots[0].z(); + for (MutableVector3 l : knots) { + if (floor(l.x()) != xflat) { xflat = null; break; } } - for (Location l : knots) { - if (l.getBlockY() != yflat) { + for (MutableVector3 l : knots) { + if (floor(l.y()) != yflat) { yflat = null; break; } } - for (Location l : knots) { - if (l.getBlockZ() != zflat) { + for (MutableVector3 l : knots) { + if (floor(l.z()) != zflat) { zflat = null; break; } } if (segments.length == 1) { - Location midpoint = new Location(segments[0].getP0().getWorld(), 0, 0, 0); - midpoint - .setX((segments[0].getP0().getX() + segments[0].getP3().getX()) / 2); - midpoint - .setY((segments[0].getP0().getY() + segments[0].getP3().getY()) / 2); - midpoint - .setZ((segments[0].getP0().getZ() + segments[0].getP3().getZ()) / 2); + MutableVector3 midpoint = new MutableVector3( + (segments[0].getP0().x() + segments[0].getP3().x()) / 2, + (segments[0].getP0().y() + segments[0].getP3().y()) / 2, + (segments[0].getP0().z() + segments[0].getP3().z()) / 2 + ); segments[0].setP1(midpoint); - segments[0].setP2(midpoint.clone()); + segments[0].setP2(new MutableVector3(midpoint)); } else { segments[0].setA(0); segments[0].setB(2); segments[0].setC(1); - segments[0].getR().setX(knots[0].getX() + 2 * knots[1].getX()); - segments[0].getR().setY(knots[0].getY() + 2 * knots[1].getY()); - segments[0].getR().setZ(knots[0].getZ() + 2 * knots[1].getZ()); + segments[0].getR().mutX(knots[0].x() + 2 * knots[1].x()); + segments[0].getR().mutY(knots[0].y() + 2 * knots[1].y()); + segments[0].getR().mutZ(knots[0].z() + 2 * knots[1].z()); int n = knots.length - 1; int i; float m; @@ -208,64 +204,64 @@ public void calculateControlPoints() { segments[i].setA(1); segments[i].setB(4); segments[i].setC(1); - segments[i].getR().setX(4 * knots[i].getX() + 2 * knots[i + 1].getX()); - segments[i].getR().setY(4 * knots[i].getY() + 2 * knots[i + 1].getY()); - segments[i].getR().setZ(4 * knots[i].getZ() + 2 * knots[i + 1].getZ()); + segments[i].getR().mutX(4 * knots[i].x() + 2 * knots[i + 1].x()); + segments[i].getR().mutY(4 * knots[i].y() + 2 * knots[i + 1].y()); + segments[i].getR().mutZ(4 * knots[i].z() + 2 * knots[i + 1].z()); } segments[n - 1].setA(2); segments[n - 1].setB(7); segments[n - 1].setC(0); - segments[n - 1].getR().setX(8 * knots[n - 1].getX() + knots[n].getX()); - segments[n - 1].getR().setY(8 * knots[n - 1].getY() + knots[n].getY()); - segments[n - 1].getR().setZ(8 * knots[n - 1].getZ() + knots[n].getZ()); + segments[n - 1].getR().mutX(8 * knots[n - 1].x() + knots[n].x()); + segments[n - 1].getR().mutY(8 * knots[n - 1].y() + knots[n].y()); + segments[n - 1].getR().mutZ(8 * knots[n - 1].z() + knots[n].z()); for (i = 1; i < n; i++) { m = segments[i].getA() / segments[i - 1].getB(); segments[i].setB(segments[i].getB() - m * segments[i - 1].getC()); - segments[i].getR().setX( - segments[i].getR().getX() - m * segments[i - 1].getR().getX()); - segments[i].getR().setY( - segments[i].getR().getY() - m * segments[i - 1].getR().getY()); - segments[i].getR().setZ( - segments[i].getR().getZ() - m * segments[i - 1].getR().getZ()); + segments[i].getR().mutX( + segments[i].getR().x() - m * segments[i - 1].getR().x()); + segments[i].getR().mutY( + segments[i].getR().y() - m * segments[i - 1].getR().y()); + segments[i].getR().mutZ( + segments[i].getR().z() - m * segments[i - 1].getR().z()); } - segments[n - 1].getP1().setX( - segments[n - 1].getR().getX() / segments[n - 1].getB()); - segments[n - 1].getP1().setY( - segments[n - 1].getR().getY() / segments[n - 1].getB()); - segments[n - 1].getP1().setZ( - segments[n - 1].getR().getZ() / segments[n - 1].getB()); + segments[n - 1].getP1().mutX( + segments[n - 1].getR().x() / segments[n - 1].getB()); + segments[n - 1].getP1().mutY( + segments[n - 1].getR().y() / segments[n - 1].getB()); + segments[n - 1].getP1().mutZ( + segments[n - 1].getR().z() / segments[n - 1].getB()); for (i = n - 2; i >= 0; i--) { - segments[i].getP1().setX( - (segments[i].getR().getX() - segments[i].getC() - * segments[i + 1].getP1().getX()) + segments[i].getP1().mutX( + (segments[i].getR().x() - segments[i].getC() + * segments[i + 1].getP1().x()) / segments[i].getB()); - segments[i].getP1().setY( - (segments[i].getR().getY() - segments[i].getC() - * segments[i + 1].getP1().getY()) + segments[i].getP1().mutY( + (segments[i].getR().y() - segments[i].getC() + * segments[i + 1].getP1().y()) / segments[i].getB()); - segments[i].getP1().setZ( - (segments[i].getR().getZ() - segments[i].getC() - * segments[i + 1].getP1().getZ()) + segments[i].getP1().mutZ( + (segments[i].getR().z() - segments[i].getC() + * segments[i + 1].getP1().z()) / segments[i].getB()); } for (i = 0; i < n - 1; i++) { - segments[i].getP2().setX( - 2 * knots[i + 1].getX() - segments[i + 1].getP1().getX()); - segments[i].getP2().setY( - 2 * knots[i + 1].getY() - segments[i + 1].getP1().getY()); - segments[i].getP2().setZ( - 2 * knots[i + 1].getZ() - segments[i + 1].getP1().getZ()); + segments[i].getP2().mutX( + 2 * knots[i + 1].x() - segments[i + 1].getP1().x()); + segments[i].getP2().mutY( + 2 * knots[i + 1].y() - segments[i + 1].getP1().y()); + segments[i].getP2().mutZ( + 2 * knots[i + 1].z() - segments[i + 1].getP1().z()); } - segments[n - 1].getP2().setX( - 0.5 * (knots[n].getX() + segments[n - 1].getP1().getX())); - segments[n - 1].getP2().setY( - 0.5 * (knots[n].getY() + segments[n - 1].getP1().getY())); - segments[n - 1].getP2().setZ( - 0.5 * (knots[n].getZ() + segments[n - 1].getP1().getZ())); + segments[n - 1].getP2().mutX( + 0.5 * (knots[n].x() + segments[n - 1].getP1().x())); + segments[n - 1].getP2().mutY( + 0.5 * (knots[n].y() + segments[n - 1].getP1().y())); + segments[n - 1].getP2().mutZ( + 0.5 * (knots[n].z() + segments[n - 1].getP1().z())); } if (xflat != null) { @@ -285,41 +281,23 @@ public void calculateControlPoints() { } } - public void shift(@NotNull Vector vector) { - for (Location location : knotsList) { - location.add(vector); - } + public void shift(@NotNull Vector3 vector) { + knotsList.replaceAll(knot -> knot.add(vector)); recalculate(); } public void scale(double d) { - for (Location l : knotsList) { - l.subtract(anchorPoint); - l.multiply(d); - l.add(anchorPoint); - } + knotsList.replaceAll(knot -> knot.subtract(anchorPoint).multiply(d).add(anchorPoint)); recalculate(); } - public void scale(@NotNull Vector vector) { - for (Location l : knotsList) { - l.subtract(anchorPoint); - l.setX(l.getX() * vector.getX()); - l.setY(l.getY() * vector.getY()); - l.setZ(l.getZ() * vector.getZ()); - l.add(anchorPoint); - } + public void scale(@NotNull Vector3 vector) { + knotsList.replaceAll(knot -> knot.subtract(anchorPoint).multiply(vector).add(anchorPoint)); recalculate(); } - public @Nullable World getWorld() { - if (knots == null) { - return null; - } - if (knots.length == 0) { - return null; - } - return knots[0].getWorld(); + private static int floor(double value) { + return (int) Math.floor(value); } @Override diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineSegment.java b/src/main/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineSegment.java index 752f83bc..dd0e7326 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineSegment.java +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineSegment.java @@ -18,27 +18,27 @@ */ package net.onelitefeather.bettergopaint.utils.curve; -import org.bukkit.Location; +import com.fastasyncworldedit.core.math.MutableVector3; +import com.sk89q.worldedit.math.Vector3; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import java.util.Objects; public class BezierSplineSegment { private final double[] lengths = new double[20]; - private Location p0, p1, p2, p3; + private MutableVector3 p0, p1, p2, p3; private float a, b, c; private Double xFlat, yFlat, zFlat; - private Location r; + private MutableVector3 r; private double curveLength; - public BezierSplineSegment(@NotNull Location p0, @NotNull Location p3) { + public BezierSplineSegment(@NotNull MutableVector3 p0, @NotNull MutableVector3 p3) { this.p0 = p0; this.p3 = p3; - p1 = new Location(p0.getWorld(), 0, 0, 0); - p2 = new Location(p0.getWorld(), 0, 0, 0); - r = new Location(p0.getWorld(), 0, 0, 0); + p1 = new MutableVector3(0, 0, 0); + p2 = new MutableVector3(0, 0, 0); + r = new MutableVector3(0, 0, 0); } public double getCurveLength() { @@ -46,34 +46,34 @@ public double getCurveLength() { } public void setX(double xflat2) { - p0.setX(xflat2); - p1.setX(xflat2); - p2.setX(xflat2); - p3.setX(xflat2); + p0.mutX(xflat2); + p1.mutX(xflat2); + p2.mutX(xflat2); + p3.mutX(xflat2); xFlat = xflat2; } public void setY(double yflat2) { - p0.setY(yflat2); - p1.setY(yflat2); - p2.setY(yflat2); - p3.setY(yflat2); + p0.mutY(yflat2); + p1.mutY(yflat2); + p2.mutY(yflat2); + p3.mutY(yflat2); yFlat = yflat2; } public void setZ(double zflat2) { - p0.setZ(zflat2); - p1.setZ(zflat2); - p2.setZ(zflat2); - p3.setZ(zflat2); + p0.mutZ(zflat2); + p1.mutZ(zflat2); + p2.mutZ(zflat2); + p3.mutZ(zflat2); zFlat = zflat2; } public void calculateCurveLength() { - Location current = p0.clone(); + Vector3 current = Vector3.at(p0.x(), p0.y(), p0.z()); double step = 0.05; lengths[0] = 0; - Location temp; + Vector3 temp; for (int i = 1; i < 20; i++) { temp = getPoint(i * step); lengths[i] = lengths[i - 1] + temp.distance(current); @@ -85,21 +85,21 @@ public void calculateCurveLength() { public double getdXdT(double t) { assert (t >= 0); assert (t <= 1); - return 3 * (1 - t) * (1 - t) * (p1.getX() - p0.getX()) + 6 * (1 - t) * t - * (p2.getX() - p1.getX()) + 3 * t * t * (p3.getX() - p2.getX()); + return 3 * (1 - t) * (1 - t) * (p1.x() - p0.x()) + 6 * (1 - t) * t + * (p2.x() - p1.x()) + 3 * t * t * (p3.x() - p2.x()); } public double getdYdT(double t) { assert (t <= 1); - return 3 * (1 - t) * (1 - t) * (p1.getY() - p0.getY()) + 6 * (1 - t) * t - * (p2.getY() - p1.getY()) + 3 * t * t * (p3.getY() - p2.getY()); + return 3 * (1 - t) * (1 - t) * (p1.y() - p0.y()) + 6 * (1 - t) * t + * (p2.y() - p1.y()) + 3 * t * t * (p3.y() - p2.y()); } public double getdZdT(double t) { assert (t >= 0); assert (t <= 1); - return 3 * (1 - t) * (1 - t) * (p1.getZ() - p0.getZ()) + 6 * (1 - t) * t - * (p2.getZ() - p1.getZ()) + 3 * t * t * (p3.getZ() - p2.getZ()); + return 3 * (1 - t) * (1 - t) * (p1.z() - p0.z()) + 6 * (1 - t) * t + * (p2.z() - p1.z()) + 3 * t * t * (p3.z() - p2.z()); } public double getdTdS(double t) { @@ -149,53 +149,52 @@ public double getT(double d) { return (i + (d - lengths[i - 1]) / (lengths[i] - lengths[i - 1])) / 20; } - public Location getPoint(double f) { - Location result = new Location(p0.getWorld(), 0, 0, 0); - result.setX(Objects.requireNonNullElseGet(xFlat, () -> (Math.pow(1 - f, 3) * p0.getX()) - + (3 * Math.pow(1 - f, 2) * f * p1.getX()) - + (3 * (1 - f) * f * f * p2.getX()) + (Math.pow(f, 3) * p3.getX()))); - result.setY(Objects.requireNonNullElseGet(yFlat, () -> (Math.pow(1 - f, 3) * p0.getY()) - + (3 * Math.pow(1 - f, 2) * f * p1.getY()) - + (3 * (1 - f) * f * f * p2.getY()) + (Math.pow(f, 3) * p3.getY()))); - result.setZ(Objects.requireNonNullElseGet(zFlat, () -> (Math.pow(1 - f, 3) * p0.getZ()) - + (3 * Math.pow(1 - f, 2) * f * p1.getZ()) - + (3 * (1 - f) * f * f * p2.getZ()) + (Math.pow(f, 3) * p3.getZ()))); - return result; + public @NotNull Vector3 getPoint(double f) { + double x = Objects.requireNonNullElseGet(xFlat, () -> (Math.pow(1 - f, 3) * p0.x()) + + (3 * Math.pow(1 - f, 2) * f * p1.x()) + + (3 * (1 - f) * f * f * p2.x()) + (Math.pow(f, 3) * p3.x())); + double y = Objects.requireNonNullElseGet(yFlat, () -> (Math.pow(1 - f, 3) * p0.y()) + + (3 * Math.pow(1 - f, 2) * f * p1.y()) + + (3 * (1 - f) * f * f * p2.y()) + (Math.pow(f, 3) * p3.y())); + double z = Objects.requireNonNullElseGet(zFlat, () -> (Math.pow(1 - f, 3) * p0.z()) + + (3 * Math.pow(1 - f, 2) * f * p1.z()) + + (3 * (1 - f) * f * f * p2.z()) + (Math.pow(f, 3) * p3.z())); + return Vector3.at(x, y, z); } public double getLinearLength() { return p0.distance(p3); } - public @NotNull Location getP0() { + public @NotNull MutableVector3 getP0() { return p0; } - public void setP0(@NotNull Location p0) { + public void setP0(@NotNull MutableVector3 p0) { this.p0 = p0; } - public @NotNull Location getP1() { + public @NotNull MutableVector3 getP1() { return p1; } - public void setP1(@NotNull Location p1) { + public void setP1(@NotNull MutableVector3 p1) { this.p1 = p1; } - public @NotNull Location getP2() { + public @NotNull MutableVector3 getP2() { return p2; } - public void setP2(@NotNull Location p2) { + public void setP2(@NotNull MutableVector3 p2) { this.p2 = p2; } - public @NotNull Location getP3() { + public @NotNull MutableVector3 getP3() { return p3; } - public void setP3(@NotNull Location p3) { + public void setP3(@NotNull MutableVector3 p3) { this.p3 = p3; } @@ -223,11 +222,11 @@ public void setC(float c) { this.c = c; } - public @NotNull Location getR() { + public @NotNull MutableVector3 getR() { return r; } - public void setR(@NotNull Location r) { + public void setR(@NotNull MutableVector3 r) { this.r = r; } diff --git a/src/test/java/net/onelitefeather/bettergopaint/objects/brush/NoBukkitWorldAccessTest.java b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/NoBukkitWorldAccessTest.java new file mode 100644 index 00000000..37339c60 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/NoBukkitWorldAccessTest.java @@ -0,0 +1,74 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.objects.brush; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Guards the rule that a stroke is computed from WorldEdit types only. The brushes and the geometry + * helpers must not call Bukkit world, block or location methods, because FAWE runs them on a worker thread. + */ +class NoBukkitWorldAccessTest { + + private static final List ROOTS = List.of( + Path.of("src/main/java/net/onelitefeather/bettergopaint/objects"), + Path.of("src/main/java/net/onelitefeather/bettergopaint/utils/Sphere.java"), + Path.of("src/main/java/net/onelitefeather/bettergopaint/utils/Height.java"), + Path.of("src/main/java/net/onelitefeather/bettergopaint/utils/Surface.java"), + Path.of("src/main/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocks.java"), + Path.of("src/main/java/net/onelitefeather/bettergopaint/utils/Blocks.java"), + Path.of("src/main/java/net/onelitefeather/bettergopaint/utils/curve") + ); + + private static final Pattern FORBIDDEN = Pattern.compile( + "getBlock\\(\\)|getBlockAt|org\\.bukkit\\.block\\.|org\\.bukkit\\.Location|org\\.bukkit\\.World|" + + "org\\.bukkit\\.entity\\.|org\\.bukkit\\.Bukkit|\\.getWorld\\(\\)" + ); + + @Test + @DisplayName("brush and geometry code does not use Bukkit worlds, blocks, locations or players") + void brushCodeIsFreeOfBukkitWorldAccess() throws IOException { + List offences = new java.util.ArrayList<>(); + for (Path root : ROOTS) { + assertThat(root).exists(); + try (Stream files = Files.walk(root)) { + for (Path file : files.filter(path -> path.toString().endsWith(".java")).toList()) { + List lines = Files.readAllLines(file); + for (int i = 0; i < lines.size(); i++) { + if (FORBIDDEN.matcher(lines.get(i)).find()) { + offences.add(file + ":" + (i + 1) + " " + lines.get(i).strip()); + } + } + } + } + } + + assertThat(offences).isEmpty(); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/objects/brush/ShapeBrushesTest.java b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/ShapeBrushesTest.java new file mode 100644 index 00000000..11080bbe --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/ShapeBrushesTest.java @@ -0,0 +1,191 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.objects.brush; + +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import net.onelitefeather.bettergopaint.support.MemoryExtent; +import net.onelitefeather.bettergopaint.support.Terrain; +import org.bukkit.Axis; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static net.onelitefeather.bettergopaint.support.BrushAssertions.sphereOf; +import static net.onelitefeather.bettergopaint.support.StrokeBuilder.stroke; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The brushes that shape their result from a sphere around the target. + */ +class ShapeBrushesTest { + + private final Terrain terrain = new Terrain(); + private final BlockVector3 target = Terrain.surface(); + + private Set painted(MemoryExtent extent) { + return extent.positionsOf(terrain.gold); + } + + @Test + @DisplayName("sphere brush paints every solid block of the sphere and nothing else") + void sphereBrush() { + MemoryExtent extent = terrain.flat(); + StrokeContext context = stroke().blocks(terrain.gold).size(6).build(); + + new SphereBrush().apply(extent, context); + + assertThat(painted(extent)).isEqualTo(sphereOf(extent, target, 6, terrain.gold)); + assertThat(painted(extent)).isNotEmpty().allMatch(position -> position.y() <= Terrain.SURFACE); + assertThat(extent.written()).isEqualTo(painted(extent)); + } + + @Test + @DisplayName("sphere brush skips air") + void sphereBrushSkipsAir() { + MemoryExtent extent = terrain.empty(); + + new SphereBrush().apply(extent, stroke().blocks(terrain.gold).build()); + + assertThat(extent.written()).isEmpty(); + } + + @Test + @DisplayName("spray brush paints a share of the sphere that follows the chance") + void sprayBrush() { + Set sphere = sphereOf(terrain.flat(), target, 8, terrain.stone); + + MemoryExtent none = terrain.flat(); + new SprayBrush().apply(none, stroke().blocks(terrain.gold).size(8).chance(0).build()); + MemoryExtent all = terrain.flat(); + new SprayBrush().apply(all, stroke().blocks(terrain.gold).size(8).chance(100).build()); + MemoryExtent half = terrain.flat(); + new SprayBrush().apply(half, stroke().blocks(terrain.gold).size(8).chance(50).build()); + + assertThat(painted(none)).isEmpty(); + assertThat(painted(all)).isEqualTo(sphere); + assertThat(painted(half)).isNotEmpty().isSubsetOf(sphere).hasSizeBetween(sphere.size() / 4, sphere.size() * 3 / 4); + } + + @Test + @DisplayName("spray brush with the same seed paints the same blocks") + void sprayBrushIsDeterministic() { + MemoryExtent first = terrain.flat(); + MemoryExtent second = terrain.flat(); + MemoryExtent other = terrain.flat(); + + new SprayBrush().apply(first, stroke().blocks(terrain.gold).size(8).seed(5).build()); + new SprayBrush().apply(second, stroke().blocks(terrain.gold).size(8).seed(5).build()); + new SprayBrush().apply(other, stroke().blocks(terrain.gold).size(8).seed(6).build()); + + assertThat(painted(first)).isEqualTo(painted(second)); + assertThat(painted(first)).isNotEqualTo(painted(other)); + } + + @Test + @DisplayName("splatter brush without falloff paints the whole sphere") + void splatterWithoutFalloff() { + MemoryExtent extent = terrain.flat(); + + new SplatterBrush().apply(extent, stroke().blocks(terrain.gold).size(8).falloff(0).build()); + + assertThat(painted(extent)).isEqualTo(sphereOf(extent, target, 8, terrain.gold)).isNotEmpty(); + } + + @Test + @DisplayName("splatter brush with full falloff paints more blocks near the target") + void splatterWithFalloff() { + MemoryExtent extent = terrain.flat(); + Set sphere = sphereOf(extent, target, 12, terrain.stone); + + new SplatterBrush().apply(extent, stroke().blocks(terrain.gold).size(12).falloff(100).build()); + + Set painted = painted(extent); + assertThat(painted).isNotEmpty().isSubsetOf(sphere).hasSizeLessThan(sphere.size()); + double paintedDistance = painted.stream().mapToDouble(position -> position.distance(target)).average().orElseThrow(); + double sphereDistance = sphere.stream().mapToDouble(position -> position.distance(target)).average().orElseThrow(); + assertThat(paintedDistance).isLessThan(sphereDistance); + } + + @Test + @DisplayName("disc brush paints one layer of the sphere on the axis") + void discBrush() { + for (Axis axis : Axis.values()) { + MemoryExtent extent = terrain.flat(); + StrokeContext context = stroke().blocks(terrain.gold).size(8).axis(axis).build(); + + new DiscBrush().apply(extent, context); + + Set painted = painted(extent); + assertThat(painted).as("disc on %s", axis).isNotEmpty() + .allMatch(position -> switch (axis) { + case X -> position.x() == target.x(); + case Y -> position.y() == target.y(); + case Z -> position.z() == target.z(); + }); + assertThat(painted).isSubsetOf(sphereOf(extent, target, 8, terrain.gold)); + } + } + + @Test + @DisplayName("disc brush on the y axis covers the full circle of the surface layer") + void discBrushCircle() { + MemoryExtent extent = terrain.flat(); + + new DiscBrush().apply(extent, stroke().blocks(terrain.gold).size(8).axis(Axis.Y).build()); + + assertThat(painted(extent)).contains(target, target.add(3, 0, 0), target.add(0, 0, -3), target.add(-2, 0, 2)) + .doesNotContain(target.add(4, 0, 0), target.add(3, 0, 3)); + } + + @Test + @DisplayName("bucket brush paints connected blocks of the target type only") + void bucketBrush() { + MemoryExtent extent = terrain.flat() + .set(0, Terrain.SURFACE, 0, terrain.dirt) + .set(1, Terrain.SURFACE, 0, terrain.dirt) + .set(3, Terrain.SURFACE, 0, terrain.dirt); + + new BucketBrush().apply(extent, stroke().blocks(terrain.gold).size(10).build()); + + assertThat(painted(extent)).containsExactlyInAnyOrder(target, target.add(1, 0, 0)); + assertThat(extent.typeAt(3, Terrain.SURFACE, 0)).isSameAs(terrain.dirt); + } + + @Test + @DisplayName("bucket brush on plain ground fills the connected part of the sphere") + void bucketBrushOnGround() { + MemoryExtent extent = terrain.flat(); + + new BucketBrush().apply(extent, stroke().blocks(terrain.gold).size(6).build()); + + assertThat(painted(extent)).isEqualTo(sphereOf(extent, target, 6, terrain.gold)).isNotEmpty(); + } + + @Test + @DisplayName("the brushes keep their names") + void names() { + assertThat(new SphereBrush().getName()).isEqualTo("Sphere Brush"); + assertThat(new BucketBrush().getName()).isEqualTo("Bucket Brush"); + assertThat(new PaintBrush(SettingsHolder.of(net.onelitefeather.bettergopaint.settings.Settings.defaults())).getName()) + .isEqualTo("Paint Brush"); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/objects/brush/StrokeRulesTest.java b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/StrokeRulesTest.java new file mode 100644 index 00000000..6b3b454a --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/StrokeRulesTest.java @@ -0,0 +1,200 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.objects.brush; + +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.function.mask.Mask; +import net.onelitefeather.bettergopaint.brush.SingleTypeMask; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.support.MemoryExtent; +import net.onelitefeather.bettergopaint.support.Terrain; +import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static net.onelitefeather.bettergopaint.support.BrushAssertions.sphereOf; +import static net.onelitefeather.bettergopaint.support.StrokeBuilder.stroke; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The rules every stroke follows, independent of the brush: palette, mask, the session mask and threads. + */ +class StrokeRulesTest { + + private final Terrain terrain = new Terrain(); + private final BlockVector3 target = Terrain.surface(); + + @Test + @DisplayName("a stroke only places blocks of the palette") + void placesOnlyPaletteBlocks() { + MemoryExtent extent = terrain.flat(); + + new SphereBrush().apply(extent, stroke().blocks(terrain.gold, terrain.andesite).size(10).build()); + + Set placed = extent.written(); + assertThat(placed).isNotEmpty(); + assertThat(placed).allMatch(position -> extent.typeAt(position).equals(terrain.gold) + || extent.typeAt(position).equals(terrain.andesite)); + } + + @Test + @DisplayName("every block of the palette is chosen about equally often") + void paletteIsEvenlyWeighted() { + MemoryExtent extent = terrain.flat(); + + new SphereBrush().apply(extent, stroke().blocks(terrain.gold, terrain.andesite).size(16).seed(3).build()); + + int gold = extent.positionsOf(terrain.gold).size(); + int andesite = extent.positionsOf(terrain.andesite).size(); + assertThat(gold).isGreaterThan(100); + assertThat(andesite).isGreaterThan(100); + assertThat((double) gold / (gold + andesite)).isBetween(0.4, 0.6); + } + + private MemoryExtent grassAndDirt() { + MemoryExtent extent = terrain.flat() + .fill(BlockVector3.at(-12, Terrain.SURFACE, -12), BlockVector3.at(-2, Terrain.SURFACE, 12), terrain.grass) + .fill(BlockVector3.at(-1, Terrain.SURFACE, -12), BlockVector3.at(12, Terrain.SURFACE, 12), terrain.dirt); + return extent.set(0, Terrain.SURFACE, 0, terrain.grass); + } + + @Test + @DisplayName("with the mask enabled only blocks of the mask type change") + void maskLimitsTheStroke() { + MemoryExtent extent = grassAndDirt(); + Set grass = sphereOf(grassAndDirt(), target, 10, terrain.grass); + Mask mask = new SingleTypeMask(terrain.grass); + StrokeContext context = stroke().blocks(terrain.gold, terrain.andesite).size(10).mask(mask).build(); + + new SphereBrush().apply(extent, context); + + assertThat(grass).contains(target).hasSizeGreaterThan(1); + assertThat(extent.written()).isEqualTo(grass); + assertThat(extent.typeAt(1, Terrain.SURFACE, 0)).isSameAs(terrain.dirt); + assertThat(extent.typeAt(0, Terrain.SURFACE - 1, 0)).isSameAs(terrain.stone); + } + + @Test + @DisplayName("overlay, underlay and fracture brushes respect the mask as well") + void maskAppliesToEveryBrush() { + Mask nothing = new SingleTypeMask(terrain.grass); + + for (Brush brush : new Brush[]{new OverlayBrush(), new UnderlayBrush(), new FractureBrush(), new AngleBrush(), + new SprayBrush(), new SplatterBrush(), new DiscBrush(), new BucketBrush(), new GradientBrush()}) { + MemoryExtent extent = terrain.step(); + brush.apply(extent, stroke().blocks(terrain.gold).size(8).mask(nothing).chance(100).build()); + assertThat(extent.written()).as(brush.getName()).isEmpty(); + } + } + + @Test + @DisplayName("blocks the mask of the session rejects stay untouched") + void sessionMaskBlocksChanges() { + MemoryExtent extent = terrain.flatWithTop(terrain.grass); + Mask sessionMask = new SingleTypeMask(extent, terrain.grass); + + new SphereBrush().apply(Brush.masked(extent, sessionMask), stroke().blocks(terrain.gold).size(8).build()); + + assertThat(extent.positionsOf(terrain.gold)).isNotEmpty().allMatch(position -> position.y() == Terrain.SURFACE); + assertThat(extent.typeAt(0, Terrain.SURFACE - 1, 0)).isSameAs(terrain.stone); + assertThat(extent.typeAt(0, Terrain.SURFACE, 0)).isSameAs(terrain.gold); + } + + @Test + @DisplayName("without a session mask every block of the stroke changes") + void noSessionMask() { + MemoryExtent extent = terrain.flatWithTop(terrain.grass); + + new SphereBrush().apply(Brush.masked(extent, null), stroke().blocks(terrain.gold).size(8).build()); + + assertThat(extent.typeAt(0, Terrain.SURFACE - 1, 0)).isSameAs(terrain.gold); + } + + @Test + @DisplayName("the surface mode decides which blocks of the sphere are painted") + void surfaceModeApplies() { + MemoryExtent extent = terrain.flat(); + + new SphereBrush().apply(extent, stroke().blocks(terrain.gold).size(8).surfaceMode(SurfaceMode.DIRECT).build()); + + assertThat(extent.written()).isNotEmpty().allMatch(position -> position.y() == Terrain.SURFACE); + } + + @Test + @DisplayName("a stroke reads before it writes, so earlier writes cannot change which blocks are chosen") + void readsBeforeWrites() { + MemoryExtent extent = terrain.flat(); + + // Underlay picks blocks with a solid block above. The palette is air, so every write makes the + // block above the next layer non-solid. The layers are visited from the bottom up: a brush that + // read after writing would stop after the first layer it removed. + new UnderlayBrush().apply(extent, stroke().blocks(terrain.air).size(8).thickness(1).build()); + + Set expected = sphereOf(terrain.flat(), target, 8, terrain.stone).stream() + .filter(position -> position.y() < Terrain.SURFACE) + .collect(java.util.stream.Collectors.toSet()); + assertThat(expected).isNotEmpty(); + assertThat(extent.written()).isEqualTo(expected); + } + + @Test + @DisplayName("the same seed changes the same blocks no matter which thread runs the stroke") + void sameResultOnAnyThread() throws Exception { + MemoryExtent local = terrain.flat(); + MemoryExtent remote = terrain.flat(); + Brush brush = new SprayBrush(); + + brush.apply(local, stroke().blocks(terrain.gold, terrain.andesite).size(10).chance(60).seed(42).build()); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + executor.submit(() -> brush.apply(remote, + stroke().blocks(terrain.gold, terrain.andesite).size(10).chance(60).seed(42).build())).get(30, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + } + + assertThat(remote.positionsOf(terrain.gold)).isNotEmpty().isEqualTo(local.positionsOf(terrain.gold)); + assertThat(remote.positionsOf(terrain.andesite)).isNotEmpty().isEqualTo(local.positionsOf(terrain.andesite)); + } + + @Test + @DisplayName("every brush paints deterministically for a fixed seed") + void everyBrushIsDeterministic() { + for (Brush brush : new Brush[]{new SphereBrush(), new SprayBrush(), new SplatterBrush(), new DiscBrush(), + new BucketBrush(), new AngleBrush(), new OverlayBrush(), new UnderlayBrush(), new FractureBrush(), + new GradientBrush()}) { + MemoryExtent first = terrain.step(); + MemoryExtent second = terrain.step(); + brush.apply(first, stroke().blocks(terrain.gold, terrain.andesite, terrain.dirt).size(10).falloff(50).mixing(40) + .chance(50).seed(11).angle(1, 30).build()); + brush.apply(second, stroke().blocks(terrain.gold, terrain.andesite, terrain.dirt).size(10).falloff(50).mixing(40) + .chance(50).seed(11).angle(1, 30).build()); + + assertThat(second.written()).as(brush.getName()).isEqualTo(first.written()); + for (var type : new com.sk89q.worldedit.world.block.BlockType[]{terrain.gold, terrain.andesite, terrain.dirt}) { + assertThat(second.positionsOf(type)).as(brush.getName()).isEqualTo(first.positionsOf(type)); + } + } + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/objects/brush/TerrainBrushesTest.java b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/TerrainBrushesTest.java new file mode 100644 index 00000000..8f35a513 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/TerrainBrushesTest.java @@ -0,0 +1,222 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.objects.brush; + +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.settings.Settings; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import net.onelitefeather.bettergopaint.support.MemoryExtent; +import net.onelitefeather.bettergopaint.support.Terrain; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.UUID; + +import static net.onelitefeather.bettergopaint.support.BrushAssertions.sphereOf; +import static net.onelitefeather.bettergopaint.support.StrokeBuilder.stroke; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The brushes that look at the shape of the terrain around the target. + */ +class TerrainBrushesTest { + + private final Terrain terrain = new Terrain(); + private final BlockVector3 target = Terrain.surface(); + + private Set painted(MemoryExtent extent) { + return extent.positionsOf(terrain.gold); + } + + @Test + @DisplayName("angle brush leaves flat ground alone") + void angleBrushOnFlatGround() { + MemoryExtent extent = terrain.flat(); + + new AngleBrush().apply(extent, stroke().blocks(terrain.gold).size(8).build()); + + assertThat(extent.written()).isEmpty(); + } + + @Test + @DisplayName("angle brush paints only the blocks on the edge of a cliff") + void angleBrushOnACliff() { + MemoryExtent extent = terrain.step(); + + new AngleBrush().apply(extent, stroke().blocks(terrain.gold).size(8).angle(1, 45).build()); + + assertThat(painted(extent)).isNotEmpty() + .allMatch(position -> position.x() == 0 || position.x() == 1) + .isSubsetOf(sphereOf(extent, target, 8, terrain.gold)); + } + + @Test + @DisplayName("angle brush ignores a cliff that is flatter than the wanted angle") + void angleBrushNeedsASteepEnoughCliff() { + MemoryExtent extent = terrain.step(); + + new AngleBrush().apply(extent, stroke().blocks(terrain.gold).size(8).angle(1, 80).build()); + + assertThat(extent.written()).isEmpty(); + } + + @Test + @DisplayName("overlay brush paints the blocks that have air above them") + void overlayBrush() { + MemoryExtent extent = terrain.flat(); + + new OverlayBrush().apply(extent, stroke().blocks(terrain.gold).size(8).thickness(1).build()); + + assertThat(painted(extent)).isNotEmpty().allMatch(position -> position.y() == Terrain.SURFACE) + .isEqualTo(sphereOf(extent, target, 8, terrain.gold)); + } + + @Test + @DisplayName("overlay brush with a larger thickness reaches that many layers down") + void overlayBrushThickness() { + MemoryExtent extent = terrain.flat(); + + new OverlayBrush().apply(extent, stroke().blocks(terrain.gold).size(8).thickness(2).build()); + + assertThat(painted(extent)).isNotEmpty() + .allMatch(position -> position.y() >= Terrain.SURFACE - 1) + .anyMatch(position -> position.y() == Terrain.SURFACE - 1) + .anyMatch(position -> position.y() == Terrain.SURFACE); + } + + @Test + @DisplayName("underlay brush paints the blocks that are covered") + void underlayBrush() { + MemoryExtent extent = terrain.flat(); + + new UnderlayBrush().apply(extent, stroke().blocks(terrain.gold).size(8).thickness(1).build()); + + assertThat(painted(extent)).isNotEmpty() + .allMatch(position -> position.y() <= Terrain.SURFACE - 1) + .anyMatch(position -> position.y() == Terrain.SURFACE - 1); + assertThat(extent.typeAt(0, Terrain.SURFACE, 0)).isSameAs(terrain.stone); + } + + @Test + @DisplayName("underlay brush with a larger thickness needs that many covering layers") + void underlayBrushThickness() { + MemoryExtent extent = terrain.flat(); + + new UnderlayBrush().apply(extent, stroke().blocks(terrain.gold).size(8).thickness(2).build()); + + assertThat(painted(extent)).isNotEmpty().allMatch(position -> position.y() <= Terrain.SURFACE - 2); + } + + @Test + @DisplayName("fracture brush leaves flat ground alone") + void fractureBrushOnFlatGround() { + MemoryExtent extent = terrain.flat(); + + new FractureBrush().apply(extent, stroke().blocks(terrain.gold).size(8).build()); + + assertThat(extent.written()).isEmpty(); + } + + @Test + @DisplayName("fracture brush paints the blocks inside a crack") + void fractureBrushInATrench() { + MemoryExtent extent = terrain.trench(); + + new FractureBrush().apply(extent, stroke().blocks(terrain.gold).size(8).target(BlockVector3.at(0, Terrain.SURFACE - 3, 0)) + .fractureDistance(2).build()); + + assertThat(painted(extent)).isNotEmpty() + .allMatch(position -> position.x() == 0) + .contains(BlockVector3.at(0, Terrain.SURFACE - 3, 0)); + assertThat(extent.typeAt(1, Terrain.SURFACE - 3, 0)).isSameAs(terrain.stone); + } + + @Test + @DisplayName("gradient brush lays the palette out from the bottom of the sphere to the top") + void gradientBrush() { + MemoryExtent extent = terrain.flat(); + StrokeContext context = stroke().blocks(terrain.dirt, terrain.andesite, terrain.gold).size(6).mixing(0).falloff(0) + .build(); + + new GradientBrush().apply(extent, context); + + // The sphere of size 6 around y = 63 reaches down to y = 61 (y = 60 is exactly 3 blocks away). + // The gradient starts at y = 60, so the block index is (y - 60) / 6 * 3 rounded down. + assertThat(extent.typeAt(0, 60, 0)).isSameAs(terrain.stone); + assertThat(extent.typeAt(0, 61, 0)).isSameAs(terrain.dirt); + assertThat(extent.typeAt(0, 62, 0)).isSameAs(terrain.andesite); + assertThat(extent.typeAt(0, 63, 0)).isSameAs(terrain.andesite); + assertThat(extent.written()).hasSize(sphereOf(terrain.flat(), target, 6, terrain.stone).size()); + } + + @Test + @DisplayName("gradient brush with a single block paints that block") + void gradientBrushSingleBlock() { + MemoryExtent extent = terrain.flat(); + + new GradientBrush().apply(extent, stroke().blocks(terrain.gold).size(6).mixing(100).falloff(0).build()); + + assertThat(painted(extent)).isEqualTo(sphereOf(terrain.flat(), target, 6, terrain.stone)); + } + + @Test + @DisplayName("paint brush with a single point paints the sphere around it") + void paintBrushSinglePoint() { + MemoryExtent extent = terrain.flat(); + + new PaintBrush(SettingsHolder.of(Settings.defaults())) + .apply(extent, stroke().blocks(terrain.gold).size(6).build()); + + assertThat(painted(extent)).isEqualTo(sphereOf(terrain.flat(), target, 6, terrain.stone)); + } + + @Test + @DisplayName("paint brush with several points follows the curve") + void paintBrushCurve() { + MemoryExtent single = terrain.flat(); + MemoryExtent curve = terrain.flat(); + BlockVector3 second = target.add(6, 0, 0); + PaintBrush brush = new PaintBrush(SettingsHolder.of(Settings.defaults())); + + brush.apply(single, stroke().blocks(terrain.gold).size(4).build()); + brush.apply(curve, stroke().blocks(terrain.gold).size(4).points(target, second).target(second).build()); + + assertThat(painted(curve)).containsAll(painted(single)).hasSizeGreaterThan(painted(single).size()); + assertThat(painted(curve)).allMatch(position -> position.y() <= Terrain.SURFACE); + } + + @Test + @DisplayName("paint brush only collects points until the player sneaks") + void paintBrushCollectsPoints() { + PaintBrush brush = new PaintBrush(SettingsHolder.of(Settings.defaults())); + UUID player = UUID.randomUUID(); + net.kyori.adventure.audience.Audience feedback = net.kyori.adventure.audience.Audience.empty(); + StrokeContext first = stroke().blocks(terrain.gold).build(); + StrokeContext second = stroke().blocks(terrain.gold).target(target.add(4, 0, 0)).build(); + + assertThat(brush.prepare(player, false, feedback, first)).isNull(); + StrokeContext result = brush.prepare(player, true, feedback, second); + + assertThat(result).isNotNull(); + assertThat(result.points()).containsExactly(target, target.add(4, 0, 0)); + assertThat(brush.prepare(player, true, feedback, first).points()).containsExactly(target); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocksTest.java b/src/test/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocksTest.java new file mode 100644 index 00000000..94167bd1 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocksTest.java @@ -0,0 +1,81 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.utils; + +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.support.MemoryExtent; +import net.onelitefeather.bettergopaint.support.Terrain; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConnectedBlocksTest { + + private final Terrain terrain = new Terrain(); + private final BlockVector3 start = Terrain.surface(); + + private MemoryExtent islands() { + return terrain.flat() + .set(0, Terrain.SURFACE, 0, terrain.dirt) + .set(1, Terrain.SURFACE, 0, terrain.dirt) + .set(3, Terrain.SURFACE, 0, terrain.dirt); + } + + @Test + @DisplayName("only blocks of the start type that touch each other are returned") + void followsTheStartType() { + MemoryExtent extent = islands(); + List candidates = Sphere.getBlocksInRadius(extent, start, 8, null, false).toList(); + + List connected = ConnectedBlocks.getConnectedBlocks(extent, start, candidates).toList(); + + assertThat(connected).containsExactlyInAnyOrder(start, BlockVector3.at(1, Terrain.SURFACE, 0)); + } + + @Test + @DisplayName("blocks outside the candidate list are not reached") + void staysInsideTheCandidates() { + MemoryExtent extent = islands(); + List candidates = List.of(start); + + List connected = ConnectedBlocks.getConnectedBlocks(extent, start, candidates).toList(); + + assertThat(connected).containsExactly(start); + } + + @Test + @DisplayName("a flood of stone spreads through the whole candidate list") + void floodsAllCandidates() { + MemoryExtent extent = terrain.flat(); + List candidates = Sphere.getBlocksInRadius(extent, start, 6, null, false).toList(); + + List connected = ConnectedBlocks.getConnectedBlocks(extent, start, candidates).toList(); + + assertThat(connected).containsExactlyInAnyOrderElementsOf(candidates); + } + + @Test + @DisplayName("no candidates means no result") + void emptyCandidates() { + assertThat(ConnectedBlocks.getConnectedBlocks(islands(), start, List.of())).isEmpty(); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/utils/HeightTest.java b/src/test/java/net/onelitefeather/bettergopaint/utils/HeightTest.java new file mode 100644 index 00000000..62cfc968 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/utils/HeightTest.java @@ -0,0 +1,98 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.utils; + +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.support.MemoryExtent; +import net.onelitefeather.bettergopaint.support.Terrain; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +class HeightTest { + + private final Terrain terrain = new Terrain(); + + @Test + @DisplayName("from inside the ground the nearest non-empty height is the first air above") + void searchesUpFromSolid() { + MemoryExtent extent = terrain.flat(); + + assertThat(Height.getNearestNonEmptyBlock(extent, BlockVector3.at(0, 40, 0))).isEqualTo(Terrain.SURFACE + 1); + assertThat(Height.getNearestNonEmptyBlock(extent, BlockVector3.at(0, Terrain.SURFACE, 0))) + .isEqualTo(Terrain.SURFACE + 1); + } + + @Test + @DisplayName("from the air the nearest non-empty height is the block above the ground") + void searchesDownFromAir() { + MemoryExtent extent = terrain.flat(); + + assertThat(Height.getNearestNonEmptyBlock(extent, BlockVector3.at(0, 90, 0))).isEqualTo(Terrain.SURFACE + 1); + } + + @Test + @DisplayName("an empty column ends on the minimum height") + void emptyColumn() { + assertThat(Height.getNearestNonEmptyBlock(terrain.empty(), BlockVector3.at(0, 90, 0))).isEqualTo(Terrain.MIN_Y); + } + + @Test + @DisplayName("a column that is solid up to the build limit ends above the maximum height") + void solidColumn() { + MemoryExtent extent = terrain.empty() + .fill(BlockVector3.at(0, Terrain.MIN_Y, 0), BlockVector3.at(0, Terrain.MAX_Y, 0), terrain.stone); + + assertThat(Height.getNearestNonEmptyBlock(extent, BlockVector3.at(0, 100, 0))).isEqualTo(Terrain.MAX_Y + 1); + } + + @Test + @DisplayName("flat ground has no height difference") + void flatGroundHasNoAngle() { + MemoryExtent extent = terrain.flat(); + + assertThat(Height.getAverageHeightDiffAngle(extent, Terrain.surface(), 1)).isZero(); + assertThat(Height.getAverageHeightDiffAngle(extent, Terrain.surface(), 3)).isZero(); + assertThat(Height.getAverageHeightDiffFracture(extent, Terrain.surface(), Terrain.SURFACE + 1, 2)).isZero(); + } + + @Test + @DisplayName("a four block step has a height difference angle of 2 on its edge") + void stepAngle() { + MemoryExtent extent = terrain.step(); + + assertThat(Height.getAverageHeightDiffAngle(extent, Terrain.surface(), 1)).isCloseTo(2.0, within(1e-9)); + assertThat(Height.getAverageHeightDiffAngle(extent, BlockVector3.at(-5, Terrain.SURFACE, 0), 1)).isZero(); + } + + @Test + @DisplayName("the bottom of a trench is lower than the ground around it") + void trenchFracture() { + MemoryExtent extent = terrain.trench(); + BlockVector3 bottom = BlockVector3.at(0, Terrain.SURFACE - 3, 0); + int height = Height.getNearestNonEmptyBlock(extent, bottom); + + assertThat(height).isEqualTo(Terrain.SURFACE - 2); + assertThat(Height.getAverageHeightDiffFracture(extent, bottom, height, 1)).isCloseTo(2.25, within(1e-9)); + assertThat(Height.getAverageHeightDiffFracture(extent, BlockVector3.at(5, Terrain.SURFACE, 0), Terrain.SURFACE + 1, 1)) + .isZero(); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/utils/SphereTest.java b/src/test/java/net/onelitefeather/bettergopaint/utils/SphereTest.java new file mode 100644 index 00000000..6af5d393 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/utils/SphereTest.java @@ -0,0 +1,127 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.utils; + +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.support.MemoryExtent; +import net.onelitefeather.bettergopaint.support.Terrain; +import org.bukkit.Axis; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +class SphereTest { + + private final Terrain terrain = new Terrain(); + private final BlockVector3 middle = Terrain.surface(); + + private Set reference(MemoryExtent extent, int radius, boolean air) { + Set result = new HashSet<>(); + for (int x = -radius; x <= radius; x++) { + for (int y = -radius; y <= radius; y++) { + for (int z = -radius; z <= radius; z++) { + BlockVector3 position = middle.add(x, y, z); + boolean inside = Math.sqrt(x * x + y * y + z * z) < radius / 2d; + if (inside && (air || !extent.typeAt(position).equals(terrain.air))) { + result.add(position); + } + } + } + } + return result; + } + + @Test + @DisplayName("a sphere of size 6 holds every solid block closer than 3 blocks") + void solidBlocksInsideTheRadius() { + MemoryExtent extent = terrain.flat(); + + Set blocks = Sphere.getBlocksInRadius(extent, middle, 6, null, false).collect(Collectors.toSet()); + + assertThat(blocks).isEqualTo(reference(extent, 6, false)).isNotEmpty(); + assertThat(blocks).allMatch(position -> position.y() <= Terrain.SURFACE); + } + + @Test + @DisplayName("air blocks are only part of the sphere when asked for") + void airIsOptional() { + MemoryExtent extent = terrain.flat(); + + Set withAir = Sphere.getBlocksInRadius(extent, middle, 6, null, true).collect(Collectors.toSet()); + + assertThat(withAir).isEqualTo(reference(extent, 6, true)); + assertThat(withAir).anyMatch(position -> position.y() > Terrain.SURFACE); + } + + @Test + @DisplayName("a disc on the y axis stays on the height of the middle") + void yAxisDisc() { + Set blocks = Sphere.getBlocksInRadius(terrain.flat(), middle, 8, Axis.Y, false) + .collect(Collectors.toSet()); + + assertThat(blocks).isNotEmpty().allMatch(position -> position.y() == middle.y()); + assertThat(blocks).contains(middle, middle.add(3, 0, 0), middle.add(0, 0, -3)).doesNotContain(middle.add(4, 0, 0)); + } + + @Test + @DisplayName("a disc on the x axis stays on the x of the middle") + void xAxisDisc() { + Set blocks = Sphere.getBlocksInRadius(terrain.flat(), middle, 8, Axis.X, false) + .collect(Collectors.toSet()); + + assertThat(blocks).isNotEmpty().allMatch(position -> position.x() == middle.x()); + assertThat(blocks).contains(middle.add(0, -3, 0), middle.add(0, 0, 3)); + } + + @Test + @DisplayName("a disc on the z axis stays on the z of the middle") + void zAxisDisc() { + Set blocks = Sphere.getBlocksInRadius(terrain.flat(), middle, 8, Axis.Z, false) + .collect(Collectors.toSet()); + + assertThat(blocks).isNotEmpty().allMatch(position -> position.z() == middle.z()); + assertThat(blocks).contains(middle.add(3, 0, 0), middle.add(0, -3, 0)); + } + + @Test + @DisplayName("positions outside the build limits are never returned") + void respectsBuildLimits() { + MemoryExtent extent = terrain.empty(); + BlockVector3 bottom = BlockVector3.at(0, Terrain.MIN_Y, 0); + + Set blocks = Sphere.getBlocksInRadius(extent, bottom, 8, null, true).collect(Collectors.toSet()); + + assertThat(blocks).isNotEmpty().allMatch(position -> position.y() >= Terrain.MIN_Y); + } + + @Test + @DisplayName("the sphere reads from the extent it is given and does not change it") + void doesNotWrite() { + MemoryExtent extent = terrain.flat(); + + Sphere.getBlocksInRadius(extent, middle, 6, null, false).toList(); + + assertThat(extent.written()).isEmpty(); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/utils/SurfaceTest.java b/src/test/java/net/onelitefeather/bettergopaint/utils/SurfaceTest.java new file mode 100644 index 00000000..4c16ec36 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/utils/SurfaceTest.java @@ -0,0 +1,72 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.utils; + +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.math.Vector3; +import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; +import net.onelitefeather.bettergopaint.support.MemoryExtent; +import net.onelitefeather.bettergopaint.support.Terrain; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SurfaceTest { + + private final Terrain terrain = new Terrain(); + private final MemoryExtent extent = terrain.flat(); + private final Vector3 eye = Vector3.at(0.5, Terrain.SURFACE + 3.5, 0.5); + + @Test + @DisplayName("direct mode accepts a solid block with a free block above") + void directTopBlock() { + assertThat(Surface.isDirectlyOnSurface(extent, BlockVector3.at(2, Terrain.SURFACE, 0))).isTrue(); + } + + @Test + @DisplayName("direct mode rejects a buried block and air") + void directRejects() { + assertThat(Surface.isDirectlyOnSurface(extent, BlockVector3.at(2, Terrain.SURFACE - 1, 0))).isFalse(); + assertThat(Surface.isDirectlyOnSurface(extent, BlockVector3.at(2, Terrain.SURFACE + 1, 0))).isFalse(); + } + + @Test + @DisplayName("relative mode accepts a block the eye can see") + void relativeVisible() { + assertThat(Surface.isRelativelyOnSurface(extent, BlockVector3.at(2, Terrain.SURFACE, 0), eye)).isTrue(); + } + + @Test + @DisplayName("relative mode rejects a block hidden below the ground") + void relativeHidden() { + assertThat(Surface.isRelativelyOnSurface(extent, BlockVector3.at(2, Terrain.SURFACE - 3, 0), eye)).isFalse(); + } + + @Test + @DisplayName("the surface mode picks the check, and disabled accepts everything") + void modeSelectsCheck() { + BlockVector3 buried = BlockVector3.at(2, Terrain.SURFACE - 3, 0); + + assertThat(Surface.isOnSurface(extent, buried, SurfaceMode.DISABLED, eye)).isTrue(); + assertThat(Surface.isOnSurface(extent, buried, SurfaceMode.DIRECT, eye)).isFalse(); + assertThat(Surface.isOnSurface(extent, buried, SurfaceMode.RELATIVE, eye)).isFalse(); + assertThat(Surface.isOnSurface(extent, BlockVector3.at(2, Terrain.SURFACE, 0), SurfaceMode.DIRECT, eye)).isTrue(); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineTest.java b/src/test/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineTest.java index abb5d267..7de86b3b 100644 --- a/src/test/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineTest.java +++ b/src/test/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineTest.java @@ -18,11 +18,9 @@ */ package net.onelitefeather.bettergopaint.utils.curve; -import org.bukkit.Location; -import org.bukkit.World; +import com.sk89q.worldedit.math.Vector3; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import java.util.LinkedList; import java.util.List; @@ -32,51 +30,49 @@ class BezierSplineTest { - private final World world = Mockito.mock(World.class); - - private BezierSpline spline(Location... knots) { + private BezierSpline spline(Vector3... knots) { return new BezierSpline(new LinkedList<>(List.of(knots))); } @Test @DisplayName("a spline through two knots is the straight line between them") void twoKnotsFormAStraightLine() { - BezierSpline spline = spline(new Location(world, 0, 64, 0), new Location(world, 10, 64, 0)); + BezierSpline spline = spline(Vector3.at(0, 64, 0), Vector3.at(10, 64, 0)); assertThat(spline.getSegmentsCount()).isEqualTo(1); assertThat(spline.getCurveLength()).isCloseTo(10, within(1.0)); - assertThat(spline.getPoint(0.5).getX()).isCloseTo(5, within(0.01)); + assertThat(spline.getPoint(0.5).x()).isCloseTo(5, within(0.01)); } @Test @DisplayName("the curve starts on the first knot and ends on the last") void curvePassesThroughEndKnots() { BezierSpline spline = spline( - new Location(world, 0, 64, 0), - new Location(world, 5, 64, 8), - new Location(world, 12, 64, 3) + Vector3.at(0, 64, 0), + Vector3.at(5, 64, 8), + Vector3.at(12, 64, 3) ); - Location start = spline.getPoint(0); - Location end = spline.getPoint(spline.getSegmentsCount()); + Vector3 start = spline.getPoint(0); + Vector3 end = spline.getPoint(spline.getSegmentsCount()); - assertThat(start.getX()).isCloseTo(0, within(1e-9)); - assertThat(start.getZ()).isCloseTo(0, within(1e-9)); - assertThat(end.getX()).isCloseTo(12, within(1e-9)); - assertThat(end.getZ()).isCloseTo(3, within(1e-9)); + assertThat(start.x()).isCloseTo(0, within(1e-9)); + assertThat(start.z()).isCloseTo(0, within(1e-9)); + assertThat(end.x()).isCloseTo(12, within(1e-9)); + assertThat(end.z()).isCloseTo(3, within(1e-9)); } @Test @DisplayName("knots on one height keep the whole curve on that height") void flatKnotsStayFlat() { BezierSpline spline = spline( - new Location(world, 0, 70, 0), - new Location(world, 4, 70, 9), - new Location(world, 9, 70, -2) + Vector3.at(0, 70, 0), + Vector3.at(4, 70, 9), + Vector3.at(9, 70, -2) ); for (double t = 0; t <= spline.getSegmentsCount(); t += 0.25) { - assertThat(spline.getPoint(t).getY()).as("y at t=%s", t).isEqualTo(70); + assertThat(spline.getPoint(t).y()).as("y at t=%s", t).isEqualTo(70); } } @@ -84,9 +80,9 @@ void flatKnotsStayFlat() { @DisplayName("getT maps walked distance back onto the curve parameter") void getTIsMonotonic() { BezierSpline spline = spline( - new Location(world, 0, 64, 0), - new Location(world, 6, 64, 6), - new Location(world, 12, 64, 0) + Vector3.at(0, 64, 0), + Vector3.at(6, 64, 6), + Vector3.at(12, 64, 0) ); double previous = -1; From 0ff104799993a4be0b447c608b01947534513845 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:15:31 +0000 Subject: [PATCH 15/22] docs: describe the native worldedit stroke and record design deviations Explain the stroke context, the player's WorldEdit session and //gmask in the stroke explanation, document how brushes are tested without a server and note where the implementation differs from the design decisions. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp --- docs/development/testing.md | 26 ++++++++++- .../how-a-brush-stroke-is-applied.md | 25 +++++++++-- docs/reference/brushes.md | 3 +- .../native-worldedit-brushes/design.md | 45 +++++++++++++++++++ .../changes/native-worldedit-brushes/tasks.md | 2 +- 5 files changed, 94 insertions(+), 7 deletions(-) diff --git a/docs/development/testing.md b/docs/development/testing.md index a7f0ed5c..96dcc4db 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -8,6 +8,14 @@ shaded jar. `./gradlew test` runs only the tests; the JaCoCo report lands in - `PlayerBrushManagerTest` – brush cycling and lookup by name. - `BezierSplineTest` – the curve the Paint Brush follows. +- `StrokeContextFactoryTest` – the stroke context is captured without asking the world for + anything, and the palette weights every entry equally. +- `SphereTest`, `HeightTest`, `SurfaceTest`, `ConnectedBlocksTest` – the geometry helpers against an + in-memory extent. +- `ShapeBrushesTest`, `TerrainBrushesTest`, `StrokeRulesTest` – every brush with a fixed seed and + expected positions, plus palette, mask, the global mask of the session and thread independence. +- `NoBukkitWorldAccessTest` – the brush and geometry sources contain no Bukkit world, block, + location or player calls. - `TranslationBundleTest` – every translation key used in the code exists in every bundle and parses as MiniMessage. - `ExportedBrushCodecTest` – brushes stored on items, including 1.x lore and edited values. @@ -19,8 +27,22 @@ shaded jar. `./gradlew test` runs only the tests; the JaCoCo report lands in - `DIValidationTest` – the Guice module graph. Tests use JUnit 6, AssertJ and Mockito. Bukkit types such as `World` are mocked; there is no -running server. Code that needs a server (brush painting, GUI, listeners) is covered by manual -testing with `./gradlew run-`, which starts Paper with FastAsyncWorldEdit preinstalled. +running server. Code that needs a server (the GUI, listeners, running a stroke in a real edit +session) is covered by manual testing with `./gradlew run-`, which starts Paper with +FastAsyncWorldEdit preinstalled. + +## Testing brushes without a server + +Brushes work on a WorldEdit `Extent`, so the tests paint on `MemoryExtent` +(`src/test/java/.../support`), an extent backed by a map. WorldEdit's block registry only exists +on a running server, so `BlockTypes.STONE` cannot be initialised in a unit test. `TestBlocks` +creates `BlockType` mocks that answer the default state and whether the material is air or solid, +which is all the brushes ask. `Terrain` builds the small worlds, `StrokeBuilder` builds a stroke +context with a seeded random generator. `BlockVector3`, `Vector3`, `RandomPattern` and `Mask` +work offline as they are. + +A real `EditSession` needs a platform as well, so the tests call `Brush#apply(Extent, +StrokeContext)` and wrap the extent with the session mask the way the stroke does. ## Adding tests diff --git a/docs/explanation/how-a-brush-stroke-is-applied.md b/docs/explanation/how-a-brush-stroke-is-applied.md index 1031adbb..126ba409 100644 --- a/docs/explanation/how-a-brush-stroke-is-applied.md +++ b/docs/explanation/how-a-brush-stroke-is-applied.md @@ -8,8 +8,27 @@ A right-click with the brush item goes through these steps: 3. **Settings.** The default brush item uses the player's own brush. Any other item with brush lore uses the settings stored on the item (see [Share a brush as an item](../how-to/share-a-brush-as-an-item.md)). -4. **Edit.** The brush computes the positions and places blocks inside one FastAsyncWorldEdit - edit session, queued through FAWE's player action queue. That is why every stroke shows up as - one entry in `//undo` and why large brushes do not freeze the main thread. +4. **Stroke context.** Still on the player's thread, the plugin captures everything the brush + needs into an immutable stroke context: the target position, the position of the player's eyes + (for the relative surface mode), a snapshot of the brush settings, the palette as a WorldEdit + random pattern, the mask as a WorldEdit mask and a random generator seeded for this stroke. + Materials are converted to WorldEdit block types here, once per stroke. The Paint Brush also + collects its clicked points at this step and queues nothing until you sneak. +5. **Edit.** The stroke runs in the action queue of FastAsyncWorldEdit, inside an edit session of + the player's own WorldEdit session. The brush reads and writes blocks only through that edit + session and never asks Bukkit for a world, block or location, so it is safe off the main + thread. A brush reads everything it needs first and writes afterwards, so a block it has + already changed cannot influence which block it picks next. + +Because the stroke uses the player's WorldEdit session: + +- **Undo.** The whole stroke is one history entry, so one `//undo` takes back everything a click + painted. +- **Global mask.** A mask set with `//gmask` applies to brush strokes. With `//gmask grass_block` + a sphere over grass and stone only changes the grass. +- **Limits.** The block change limit and the other FastAsyncWorldEdit limits of the player apply. + +The BetterGoPaint mask from the brush menu is a separate, additional filter: with it enabled a +stroke only changes blocks of the mask type. A block has to pass both masks. Brush settings live in memory per player and are dropped when the player leaves the server. diff --git a/docs/reference/brushes.md b/docs/reference/brushes.md index ed9b7ef4..bc8c33a4 100644 --- a/docs/reference/brushes.md +++ b/docs/reference/brushes.md @@ -18,4 +18,5 @@ Settings that a brush does not use are hidden in the menu. | Paint Brush | Strokes along a curve through every point you click; sneak to finish the stroke. | Falloff | Settings shared by all brushes: size, mask (only replace the mask block) and surface mode -(`DIRECT`, `RELATIVE`, `DISABLED`). Defaults and limits are in [Configuration](configuration.md). +(`DIRECT`, `RELATIVE`, `DISABLED`). Strokes also respect the global mask of the builder's +WorldEdit session (`//gmask`). Defaults and limits are in [Configuration](configuration.md). diff --git a/openspec/changes/native-worldedit-brushes/design.md b/openspec/changes/native-worldedit-brushes/design.md index 62f0454b..1717f07b 100644 --- a/openspec/changes/native-worldedit-brushes/design.md +++ b/openspec/changes/native-worldedit-brushes/design.md @@ -44,6 +44,51 @@ makes "same seed, same result" testable. A small test `Extent` backed by a `Map` implements `getBlock`/`setBlock`. If FAWE's interfaces make that impractical, tests use `BlockArrayClipboard` where it works offline. +### Implementation notes (deviations from the decisions above) + +- **`Brush#bind` instead of an inheriting base.** Brush objects are shared singletons (the settings + of every player point at the same `SphereBrush`), so they cannot hold the context of a stroke and + also be WorldEdit's `Brush`. The base class keeps the brush metadata and gets + `apply(Extent, StrokeContext)`; `Brush#bind(StrokeContext)` returns a private + `com.sk89q.worldedit.command.tool.brush.Brush` whose `build(EditSession, BlockVector3, Pattern, + double)` hands position, pattern and size to the brush through `StrokeContext#with`. A later + `/brush` binding only has to produce a `StrokeContext`. +- **`apply` takes an `Extent`, not an `EditSession`.** An `EditSession` cannot be created without a + platform, and an `Extent` is all a brush needs. `build` wraps the session in a `MaskingExtent` + with the session mask, so no block the global mask excludes is written, whatever extent the + brush gets. +- **Own `SingleTypeMask` instead of `BlockTypeMask`.** `BlockTypeMask` sizes a lookup table from the + block registry when it is constructed, which fails without a server. `SingleTypeMask` extends + `AbstractExtentMask` and compares the block type directly. It starts without a real extent + (`NullExtent`), because the extent of the stroke is not known on the player's thread. The stroke + tests it with `test(Extent, BlockVector3)`. +- **Palette weights follow the list.** Palette entries are aggregated before they go into the + `RandomPattern`, so a material that is listed twice keeps twice the weight, as with the old + `randomBlock()`. The `RandomPattern` draws from the stroke's `Random` through a `SimpleRandom`. +- **Read first, write second.** FAWE's edit session shows earlier writes of the same stroke to later + reads, while the old code read the untouched Bukkit world. Every brush therefore collects its + positions first and writes afterwards, which keeps old and new results the same. The Gradient + brush draws its random numbers in the collect step and writes afterwards. +- **`BezierSpline` uses `Vector3`.** The public API takes and returns WorldEdit `Vector3`. Inside, + FAWE's `MutableVector3` replaces the mutable `Location`s of the control point maths, which is + pure arithmetic and unchanged. The unused `getWorld()` was removed because a curve has no world. +- **Solid and air checks.** Bukkit's `Block#isEmpty` becomes `BlockMaterial#isAir()` and + `Block#isSolid` becomes `BlockMaterial#isSolid()`. Positions outside the build limits read as + air and are never returned by `Sphere`. +- **Multi-click state stays on the player's thread.** `Brush#prepare` runs in the listener before + the stroke is queued. The Paint Brush collects its points there and returns the context with all + points, or null while the player is still clicking. +- **The eye position keeps the old 1.5 block offset** above the player's feet for the relative + surface mode, so existing builds look the same. + +### Test doubles +WorldEdit's `BlockTypes` needs the block registry of a running platform, so no real `BlockType` or +`BlockState` exists in a unit test. The tests use `MemoryExtent` (an `Extent` over a map of block +types) and Mockito mocks of `BlockType` that answer the default state and whether the material is +air or solid. `BlockVector3`, `Vector3`, `RandomPattern` and `Mask` work offline. A real +`EditSession` cannot be built either, so the tests call `Brush#apply` and check the session-mask +wrapper directly. History and `//gmask` on a real session stay in the manual check. + ## Risks / Trade-offs - [Subtle result differences per brush] → port brush by brush with a test each, and compare against diff --git a/openspec/changes/native-worldedit-brushes/tasks.md b/openspec/changes/native-worldedit-brushes/tasks.md index 9f0ab720..95281bb6 100644 --- a/openspec/changes/native-worldedit-brushes/tasks.md +++ b/openspec/changes/native-worldedit-brushes/tasks.md @@ -18,7 +18,7 @@ ## 4. Docs -- [ ] 4.1 Update docs/explanation/how-a-brush-stroke-is-applied.md (stroke context, WorldEdit session, `//gmask`) +- [x] 4.1 Update docs/explanation/how-a-brush-stroke-is-applied.md (stroke context, WorldEdit session, `//gmask`) (also docs/development/testing.md and docs/reference/brushes.md) ## Manual follow-up (needs a Minecraft client) From 4d0ea60b3875fe756f42b443f2efcb7d84d3a3e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:20:15 +0000 Subject: [PATCH 16/22] fix: schedule update check on paper's async scheduler The legacy BukkitScheduler is not available on Folia. Run the update check with Bukkit.getAsyncScheduler().runAtFixedRate and cancel the returned ScheduledTask on shutdown. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp --- .../changes/folia-ready-scheduling/tasks.md | 2 +- .../bettergopaint/service/UpdateService.java | 21 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/openspec/changes/folia-ready-scheduling/tasks.md b/openspec/changes/folia-ready-scheduling/tasks.md index 7458ecfd..c961cc1d 100644 --- a/openspec/changes/folia-ready-scheduling/tasks.md +++ b/openspec/changes/folia-ready-scheduling/tasks.md @@ -2,7 +2,7 @@ ## 1. Scheduling -- [ ] 1.1 Move the update check to `Bukkit.getAsyncScheduler().runAtFixedRate`; verify `grep -rn "getScheduler()" src/main/java` finds no `BukkitScheduler` use and `UpdateServiceTest` passes +- [x] 1.1 Move the update check to `Bukkit.getAsyncScheduler().runAtFixedRate`; verify `grep -rn "getScheduler()" src/main/java` finds no `BukkitScheduler` use and `UpdateServiceTest` passes ## 2. Shared state diff --git a/src/main/java/net/onelitefeather/bettergopaint/service/UpdateService.java b/src/main/java/net/onelitefeather/bettergopaint/service/UpdateService.java index ea938ad7..5976637c 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/service/UpdateService.java +++ b/src/main/java/net/onelitefeather/bettergopaint/service/UpdateService.java @@ -28,13 +28,14 @@ import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; -import org.bukkit.scheduler.BukkitTask; +import io.papermc.paper.threadedregions.scheduler.ScheduledTask; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Optional; +import java.util.concurrent.TimeUnit; import java.util.function.Supplier; /** @@ -44,7 +45,7 @@ public final class UpdateService { private static final Logger LOGGER = LoggerFactory.getLogger(UpdateService.class); - private static final long CHECK_INTERVAL_TICKS = 20L * 60 * 60 * 3; + private static final long CHECK_INTERVAL_HOURS = 3L; private static final String DOWNLOAD_URL = "https://hangar.papermc.io/OneLiteFeather/BetterGoPaint/versions/%s"; private final Plugin plugin; @@ -54,7 +55,7 @@ public final class UpdateService { private final Supplier> latestReleaseSource; private volatile @Nullable Version remoteVersion; - private @Nullable BukkitTask task; + private @Nullable ScheduledTask task; @Inject public UpdateService(@NotNull Plugin plugin, @NotNull ComponentLogger console, @NotNull SettingsHolder settings) { @@ -76,14 +77,21 @@ public UpdateService(@NotNull Plugin plugin, @NotNull ComponentLogger console, @ } /** - * Schedules the periodic check, starting immediately but asynchronously. Does nothing when - * {@code generic.check-for-updates} is disabled. + * Schedules the periodic check on Paper's async scheduler, starting immediately. The check runs off the + * server thread, which also keeps it valid on Folia. Does nothing when {@code generic.check-for-updates} + * is disabled. */ public void start() { if (!settings.get().generic().checkForUpdates()) { return; } - this.task = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::check, 0, CHECK_INTERVAL_TICKS); + this.task = Bukkit.getAsyncScheduler().runAtFixedRate( + plugin, + scheduled -> check(), + 0, + CHECK_INTERVAL_HOURS, + TimeUnit.HOURS + ); } /** @@ -135,6 +143,7 @@ public void notifyPlayer(@NotNull Player player) { public void shutdown() { if (task != null) { task.cancel(); + task = null; } if (latestReleaseSource instanceof AutoCloseable closeable) { try { From da2dc094ce6805d2a6944ab0e25d02b9bf4dc9e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:20:18 +0000 Subject: [PATCH 17/22] refactor: make per-player brush state thread-safe PlayerBrushManager keeps its per-player brushes in a ConcurrentHashMap and takes a brush factory so the concurrency behaviour can be tested without a server. The Paint Brush stroke points move from a static map into the brush instance and are dropped with the player's brush state on quit. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp --- .../changes/folia-ready-scheduling/tasks.md | 4 +- .../brush/PlayerBrushManager.java | 31 +++++++-- .../objects/brush/PaintBrush.java | 15 +++- .../brush/PlayerBrushManagerTest.java | 55 +++++++++++++++ .../objects/brush/PaintBrushTest.java | 68 +++++++++++++++++++ 5 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 src/test/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrushTest.java diff --git a/openspec/changes/folia-ready-scheduling/tasks.md b/openspec/changes/folia-ready-scheduling/tasks.md index c961cc1d..2f685b54 100644 --- a/openspec/changes/folia-ready-scheduling/tasks.md +++ b/openspec/changes/folia-ready-scheduling/tasks.md @@ -6,8 +6,8 @@ ## 2. Shared state -- [ ] 2.1 Make `PlayerBrushManager` use a `ConcurrentHashMap`; verify with a unit test that concurrent `getBrush` calls for one UUID return the same instance -- [ ] 2.2 Move Paint Brush points from the static map into per-player state that is cleared on quit; verify with a unit test +- [x] 2.1 Make `PlayerBrushManager` use a `ConcurrentHashMap`; verify with a unit test that concurrent `getBrush` calls for one UUID return the same instance +- [x] 2.2 Move Paint Brush points from the static map into per-player state that is cleared on quit; verify with a unit test ## 3. Folia switch diff --git a/src/main/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManager.java b/src/main/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManager.java index fbbbc3b5..2fd180b6 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManager.java +++ b/src/main/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManager.java @@ -37,10 +37,13 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.HashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; /** * The PlayerBrushManager class manages the brush selection for each player. @@ -48,15 +51,30 @@ @Singleton public class PlayerBrushManager { - private final HashMap playerBrushes; + /** + * Written from the region thread of each player and read from commands and the FAWE worker, so it must be + * safe for concurrent use. + */ + private final Map playerBrushes; + private final BiFunction brushFactory; private final List brushes; + private final PaintBrush paintBrush; private final SettingsHolder settings; @Inject public PlayerBrushManager(@NotNull SettingsHolder settings) { + this(settings, (manager, locale) -> new PlayerBrush(manager, settings, locale)); + } + + PlayerBrushManager( + @NotNull SettingsHolder settings, + @NotNull BiFunction brushFactory + ) { this.settings = settings; - this.playerBrushes = new HashMap<>(); + this.brushFactory = brushFactory; + this.playerBrushes = new ConcurrentHashMap<>(); + this.paintBrush = new PaintBrush(settings); this.brushes = List.of( new SphereBrush(), new SprayBrush(), @@ -68,7 +86,7 @@ public PlayerBrushManager(@NotNull SettingsHolder settings) { new UnderlayBrush(), new FractureBrush(), new GradientBrush(), - new PaintBrush(settings) + paintBrush ); } @@ -79,7 +97,7 @@ public PlayerBrushManager(@NotNull SettingsHolder settings) { * @return The brush for the specified player. */ public @NotNull PlayerBrush getBrush(@NotNull Player player) { - return playerBrushes.computeIfAbsent(player.getUniqueId(), ignored -> new PlayerBrush(this, settings, player.locale())); + return playerBrushes.computeIfAbsent(player.getUniqueId(), ignored -> brushFactory.apply(this, player.locale())); } /** @@ -116,12 +134,13 @@ public PlayerBrushManager(@NotNull SettingsHolder settings) { } /** - * Removes the player from the {@link #playerBrushes} map. + * Removes the player's brush settings and any unfinished Paint Brush stroke. * * @param player The player who should be removed. */ public void removeBrush(@NotNull Player player) { playerBrushes.remove(player.getUniqueId()); + paintBrush.forget(player.getUniqueId()); } /** diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrush.java index fa510c87..aaa427fb 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrush.java @@ -46,7 +46,11 @@ public class PaintBrush extends Brush { private static final String DESCRIPTION = "Paints strokes\n§8hold shift to end"; private static final String HEAD = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvODBiM2E5ZGZhYmVmYmRkOTQ5YjIxN2JiZDRmYTlhNDg2YmQwYzNmMGNhYjBkMGI5ZGZhMjRjMzMyZGQzZTM0MiJ9fX0="; - private static final Map> selectedPoints = new ConcurrentHashMap<>(); + /** + * Points of the stroke each player is currently drawing. Owned by this brush instance, so it is dropped with + * the player's brush state through {@link #forget(UUID)}. + */ + private final Map> selectedPoints = new ConcurrentHashMap<>(); private final SettingsHolder settings; @@ -55,6 +59,15 @@ public PaintBrush(@NotNull SettingsHolder settings) { this.settings = settings; } + /** + * Drops the unfinished stroke of a player, for example when the player quits. + * + * @param playerId the player whose stroke points should be discarded + */ + public void forget(@NotNull UUID playerId) { + selectedPoints.remove(playerId); + } + @Override public @Nullable StrokeContext prepare( @NotNull UUID playerId, diff --git a/src/test/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManagerTest.java b/src/test/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManagerTest.java index 62f8e466..afef5d45 100644 --- a/src/test/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManagerTest.java +++ b/src/test/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManagerTest.java @@ -23,13 +23,29 @@ import net.onelitefeather.bettergopaint.objects.brush.SphereBrush; import net.onelitefeather.bettergopaint.settings.Settings; import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import org.bukkit.entity.Player; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class PlayerBrushManagerTest { @@ -88,4 +104,43 @@ void exactNameLookup() { } assertThat(manager.getBrushByName(" ♦ Sphere Brush ♦ ")).isEmpty(); } + + @Test + @DisplayName("concurrent lookups for one player share a single brush") + void concurrentLookupsShareOneBrush() throws Exception { + AtomicInteger created = new AtomicInteger(); + PlayerBrushManager concurrent = new PlayerBrushManager( + SettingsHolder.of(Settings.defaults()), + (ignored, locale) -> { + created.incrementAndGet(); + return mock(PlayerBrush.class); + } + ); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.locale()).thenReturn(Locale.ENGLISH); + + int threads = 16; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + try { + List> results = new ArrayList<>(); + for (int i = 0; i < threads; i++) { + results.add(pool.submit((Callable) () -> { + start.await(); + return concurrent.getBrush(player); + })); + } + start.countDown(); + + Set distinct = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Future result : results) { + distinct.add(result.get(10, TimeUnit.SECONDS)); + } + assertThat(distinct).hasSize(1); + assertThat(created).hasValue(1); + } finally { + pool.shutdownNow(); + } + } } diff --git a/src/test/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrushTest.java b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrushTest.java new file mode 100644 index 00000000..187f41ce --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrushTest.java @@ -0,0 +1,68 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.objects.brush; + +import com.sk89q.worldedit.math.BlockVector3; +import net.kyori.adventure.audience.Audience; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.settings.Settings; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static net.onelitefeather.bettergopaint.support.StrokeBuilder.stroke; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class PaintBrushTest { + + private final PaintBrush brush = new PaintBrush(SettingsHolder.of(Settings.defaults())); + private final Audience feedback = mock(Audience.class); + private final UUID player = UUID.randomUUID(); + private final UUID other = UUID.randomUUID(); + + @Test + @DisplayName("the points of a stroke are collected until the player sneaks to end it") + void pointsAccumulateUntilSneaking() { + assertThat(brush.prepare(player, false, feedback, stroke().target(BlockVector3.at(1, 64, 1)).build())).isNull(); + assertThat(brush.prepare(player, false, feedback, stroke().target(BlockVector3.at(2, 64, 2)).build())).isNull(); + + StrokeContext finished = brush.prepare(player, true, feedback, stroke().target(BlockVector3.at(3, 64, 3)).build()); + + assertThat(finished).isNotNull(); + assertThat(finished.points()).hasSize(3); + } + + @Test + @DisplayName("forgetting a player drops only that player's unfinished stroke") + void forgetDropsOnlyThatPlayer() { + brush.prepare(player, false, feedback, stroke().target(BlockVector3.at(1, 64, 1)).build()); + brush.prepare(other, false, feedback, stroke().target(BlockVector3.at(5, 64, 5)).build()); + + brush.forget(player); + + StrokeContext ownStroke = brush.prepare(player, true, feedback, stroke().target(BlockVector3.at(9, 64, 9)).build()); + assertThat(ownStroke.points()).containsExactly(BlockVector3.at(9, 64, 9)); + + StrokeContext otherStroke = brush.prepare(other, true, feedback, stroke().target(BlockVector3.at(6, 64, 6)).build()); + assertThat(otherStroke.points()).hasSize(2); + } +} From 2edc7c35078b8f360754b792f247832e6b075f1b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:21:36 +0000 Subject: [PATCH 18/22] build: drive foliaSupported from a gradle property paper-plugin.yml declares folia-supported only when -PfoliaSupported=true is passed. Without it the flag stays false, so release builds cannot declare Folia support by accident. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp --- build.gradle.kts | 2 ++ openspec/changes/folia-ready-scheduling/tasks.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 7d5a3900..d2aaa223 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -79,6 +79,8 @@ paper { apiVersion = "26.1" website = "https://github.com/OneLiteFeatherNET/BetterGoPaint" provides = listOf("goPaint") + // Folia is only declared with -PfoliaSupported=true (local testing). Release builds never set it. + foliaSupported = providers.gradleProperty("foliaSupported").map(String::toBoolean).orElse(false).get() serverDependencies { register("FastAsyncWorldEdit") { diff --git a/openspec/changes/folia-ready-scheduling/tasks.md b/openspec/changes/folia-ready-scheduling/tasks.md index 2f685b54..ebcd4988 100644 --- a/openspec/changes/folia-ready-scheduling/tasks.md +++ b/openspec/changes/folia-ready-scheduling/tasks.md @@ -11,7 +11,7 @@ ## 3. Folia switch -- [ ] 3.1 Drive `foliaSupported` from the `foliaSupported` Gradle property (default false); verify the generated `paper-plugin.yml` with and without `-PfoliaSupported=true` +- [x] 3.1 Drive `foliaSupported` from the `foliaSupported` Gradle property (default false); verify the generated `paper-plugin.yml` with and without `-PfoliaSupported=true` - [ ] 3.2 Add `run-folia-` tasks that install FAWE from `-PfoliaFaweUrl`; verify the task fails with a clear message when the property is missing - [ ] 3.3 Document the Folia status in docs/explanation/scope-and-non-goals.md and docs/reference/supported-versions.md, and how to test on Folia in docs/development/testing.md From 51075cdecea2acecd14559ac1612b5941a104439 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:23:48 +0000 Subject: [PATCH 19/22] build: add run-folia tasks with a configurable fawe url run-folia- starts a local Folia test server with run-paper's Folia downloads. It installs FastAsyncWorldEdit from -PfoliaFaweUrl. Without that property the task fails when it runs, with a message that names the property, so normal builds are unaffected. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp --- build.gradle.kts | 33 +++++++++++++++++++ .../changes/folia-ready-scheduling/tasks.md | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index d2aaa223..666d3f55 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,10 @@ import io.papermc.hangarpublishplugin.model.Platforms import net.minecrell.pluginyml.bukkit.BukkitPluginDescription import net.minecrell.pluginyml.paper.PaperPluginDescription +import org.gradle.api.GradleException import xyz.jpenilla.runpaper.task.RunServer +import xyz.jpenilla.runtask.pluginsapi.PluginDownloadService +import xyz.jpenilla.runtask.service.DownloadsAPIService plugins { java @@ -201,6 +204,36 @@ tasks { } } } + // Folia test servers for local testing only. Stable FastAsyncWorldEdit does not support Folia yet, so a + // Folia-capable FAWE build has to be passed in with -PfoliaFaweUrl=. + supportedMinecraftVersions.forEach { serverVersion -> + register("run-folia-$serverVersion") { + minecraftVersion(serverVersion) + jvmArgs("-DPaper.IgnoreJavaVersion=true", "-Dcom.mojang.eula.agree=true") + group = "run folia" + description = "Starts a local Folia $serverVersion test server. Needs -PfoliaFaweUrl=." + runDirectory.set(file("run-folia-$serverVersion")) + pluginJars(shadowJar.flatMap { it.archiveFile }) + downloadsApiService.convention(DownloadsAPIService.folia(project)) + pluginDownloadService.convention(PluginDownloadService.paper(project)) + + val faweUrl = providers.gradleProperty("foliaFaweUrl").orNull + if (faweUrl != null) { + downloadPlugins { + url(faweUrl) + } + } + doFirst { + if (faweUrl == null) { + throw GradleException( + "run-folia-$serverVersion needs a Folia-capable FastAsyncWorldEdit jar. Stable FAWE does not " + + "support Folia yet. Pass -PfoliaFaweUrl=, for example " + + "./gradlew run-folia-$serverVersion -PfoliaSupported=true -PfoliaFaweUrl=." + ) + } + } + } + } } val baseVersion = version as String diff --git a/openspec/changes/folia-ready-scheduling/tasks.md b/openspec/changes/folia-ready-scheduling/tasks.md index ebcd4988..9168af69 100644 --- a/openspec/changes/folia-ready-scheduling/tasks.md +++ b/openspec/changes/folia-ready-scheduling/tasks.md @@ -12,7 +12,7 @@ ## 3. Folia switch - [x] 3.1 Drive `foliaSupported` from the `foliaSupported` Gradle property (default false); verify the generated `paper-plugin.yml` with and without `-PfoliaSupported=true` -- [ ] 3.2 Add `run-folia-` tasks that install FAWE from `-PfoliaFaweUrl`; verify the task fails with a clear message when the property is missing +- [x] 3.2 Add `run-folia-` tasks that install FAWE from `-PfoliaFaweUrl`; verify the task fails with a clear message when the property is missing - [ ] 3.3 Document the Folia status in docs/explanation/scope-and-non-goals.md and docs/reference/supported-versions.md, and how to test on Folia in docs/development/testing.md ## Manual follow-up From a261f264b152a16d91aadb4757c50408916078eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:24:26 +0000 Subject: [PATCH 20/22] docs: state folia status and how to test on folia Scope and non-goals now says BetterGoPaint is prepared for Folia but does not declare support until a stable FastAsyncWorldEdit release supports it. Supported versions links to that page, and the testing guide describes run-folia- for local testing only. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp --- docs/development/testing.md | 14 +++++++++++++- docs/explanation/scope-and-non-goals.md | 7 +++++-- docs/reference/supported-versions.md | 2 ++ openspec/changes/folia-ready-scheduling/tasks.md | 2 +- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/development/testing.md b/docs/development/testing.md index 96dcc4db..f12dad8c 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -6,7 +6,8 @@ shaded jar. `./gradlew test` runs only the tests; the JaCoCo report lands in ## What is tested -- `PlayerBrushManagerTest` – brush cycling and lookup by name. +- `PlayerBrushManagerTest` – brush cycling, lookup by name and concurrent lookups sharing one brush. +- `PaintBrushTest` – collecting the points of a stroke and dropping them when a player quits. - `BezierSplineTest` – the curve the Paint Brush follows. - `StrokeContextFactoryTest` – the stroke context is captured without asking the world for anything, and the palette weights every entry equally. @@ -31,6 +32,17 @@ running server. Code that needs a server (the GUI, listeners, running a stroke i session) is covered by manual testing with `./gradlew run-`, which starts Paper with FastAsyncWorldEdit preinstalled. +## Testing on Folia + +`./gradlew run-folia-26.2 -PfoliaSupported=true -PfoliaFaweUrl=` starts a local Folia 26.2 +server. `-PfoliaSupported=true` makes the built plugin declare Folia support, and `-PfoliaFaweUrl` +installs FastAsyncWorldEdit from the given URL. Use a Folia-capable FAWE build there. Stable FAWE +does not support Folia, so the task fails without `-PfoliaFaweUrl`. The same tasks exist for every +version in `supportedMinecraftVersions`, as `run-folia-`. + +This is for local testing only. Do not publish a jar built with `-PfoliaSupported=true`, and do not +describe Folia as supported until a stable FastAsyncWorldEdit release supports it. + ## Testing brushes without a server Brushes work on a WorldEdit `Extent`, so the tests paint on `MemoryExtent` diff --git a/docs/explanation/scope-and-non-goals.md b/docs/explanation/scope-and-non-goals.md index 510462aa..c69cf312 100644 --- a/docs/explanation/scope-and-non-goals.md +++ b/docs/explanation/scope-and-non-goals.md @@ -5,8 +5,11 @@ BetterGoPaint does one thing: interactive terrain painting with a held item. Out of scope: - **Servers other than Paper.** The plugin ships a `paper-plugin.yml` and uses Paper API. - Spigot, CraftBukkit and hybrid servers cannot load it. Folia is not supported because the - plugin relies on the global scheduler and FAWE's player queue. + Spigot, CraftBukkit and hybrid servers cannot load it. +- **Folia (not declared yet).** BetterGoPaint is prepared for Folia: it schedules work through + Paper's region-aware schedulers, and brush strokes only touch the world through WorldEdit. It + does not declare Folia support, because stable FastAsyncWorldEdit does not support Folia yet. + Support will be declared once a FAWE release does. - **Running without FastAsyncWorldEdit.** Every edit goes through FAWE; plain WorldEdit lacks the APIs the brushes use. - **Old Minecraft versions.** Only the versions in [Supported versions](../reference/supported-versions.md) diff --git a/docs/reference/supported-versions.md b/docs/reference/supported-versions.md index fd451ea8..130b9935 100644 --- a/docs/reference/supported-versions.md +++ b/docs/reference/supported-versions.md @@ -10,3 +10,5 @@ there are the ones offered on Hangar and Modrinth. Paper 26.x requires Java 25 and BetterGoPaint is compiled for it, so a jar built for 26.x cannot load on 1.21.x. Servers that stay on 1.21 keep using BetterGoPaint 1.1.x. + +Folia is not supported (yet). See [Scope and non-goals](../explanation/scope-and-non-goals.md). diff --git a/openspec/changes/folia-ready-scheduling/tasks.md b/openspec/changes/folia-ready-scheduling/tasks.md index 9168af69..240eec82 100644 --- a/openspec/changes/folia-ready-scheduling/tasks.md +++ b/openspec/changes/folia-ready-scheduling/tasks.md @@ -13,7 +13,7 @@ - [x] 3.1 Drive `foliaSupported` from the `foliaSupported` Gradle property (default false); verify the generated `paper-plugin.yml` with and without `-PfoliaSupported=true` - [x] 3.2 Add `run-folia-` tasks that install FAWE from `-PfoliaFaweUrl`; verify the task fails with a clear message when the property is missing -- [ ] 3.3 Document the Folia status in docs/explanation/scope-and-non-goals.md and docs/reference/supported-versions.md, and how to test on Folia in docs/development/testing.md +- [x] 3.3 Document the Folia status in docs/explanation/scope-and-non-goals.md and docs/reference/supported-versions.md, and how to test on Folia in docs/development/testing.md ## Manual follow-up From 251543489ec0c6249a71a84072e4c618d9309130 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:25:41 +0000 Subject: [PATCH 21/22] build: refuse run-folia without the folia flag Without -PfoliaSupported=true the test server starts but Folia skips BetterGoPaint, which looks like a broken setup. The task now fails early and names both required properties. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp --- build.gradle.kts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 666d3f55..81c2d921 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -218,17 +218,25 @@ tasks { pluginDownloadService.convention(PluginDownloadService.paper(project)) val faweUrl = providers.gradleProperty("foliaFaweUrl").orNull + val foliaDeclared = providers.gradleProperty("foliaSupported").map(String::toBoolean).orElse(false).get() if (faweUrl != null) { downloadPlugins { url(faweUrl) } } doFirst { + val example = "./gradlew run-folia-$serverVersion -PfoliaSupported=true -PfoliaFaweUrl=" if (faweUrl == null) { throw GradleException( "run-folia-$serverVersion needs a Folia-capable FastAsyncWorldEdit jar. Stable FAWE does not " + - "support Folia yet. Pass -PfoliaFaweUrl=, for example " + - "./gradlew run-folia-$serverVersion -PfoliaSupported=true -PfoliaFaweUrl=." + "support Folia yet. Pass -PfoliaFaweUrl=, for example $example." + ) + } + if (!foliaDeclared) { + // Without the flag Folia refuses to load the plugin, which looks like a broken test server. + throw GradleException( + "run-folia-$serverVersion needs -PfoliaSupported=true, otherwise Folia skips BetterGoPaint. " + + "Run $example." ) } } From de922fa4f6c4c4799529133ea6bcd2d057a4a655 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:25:44 +0000 Subject: [PATCH 22/22] docs: archive native worldedit brushes and folia readiness Both changes are implemented; their specs move to openspec/specs/. In-game checks with a client and a Folia test with a FAWE fork remain listed as manual follow-ups. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/server-threading/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/brush-painting/spec.md | 0 .../tasks.md | 0 openspec/specs/brush-painting/spec.md | 45 +++++++++++++++++++ openspec/specs/server-threading/spec.md | 34 ++++++++++++++ 12 files changed, 79 insertions(+) rename openspec/changes/{folia-ready-scheduling => archive/2026-10-09-folia-ready-scheduling}/.openspec.yaml (100%) rename openspec/changes/{folia-ready-scheduling => archive/2026-10-09-folia-ready-scheduling}/design.md (100%) rename openspec/changes/{folia-ready-scheduling => archive/2026-10-09-folia-ready-scheduling}/proposal.md (100%) rename openspec/changes/{folia-ready-scheduling => archive/2026-10-09-folia-ready-scheduling}/specs/server-threading/spec.md (100%) rename openspec/changes/{folia-ready-scheduling => archive/2026-10-09-folia-ready-scheduling}/tasks.md (100%) rename openspec/changes/{native-worldedit-brushes => archive/2026-10-09-native-worldedit-brushes}/.openspec.yaml (100%) rename openspec/changes/{native-worldedit-brushes => archive/2026-10-09-native-worldedit-brushes}/design.md (100%) rename openspec/changes/{native-worldedit-brushes => archive/2026-10-09-native-worldedit-brushes}/proposal.md (100%) rename openspec/changes/{native-worldedit-brushes => archive/2026-10-09-native-worldedit-brushes}/specs/brush-painting/spec.md (100%) rename openspec/changes/{native-worldedit-brushes => archive/2026-10-09-native-worldedit-brushes}/tasks.md (100%) create mode 100644 openspec/specs/brush-painting/spec.md create mode 100644 openspec/specs/server-threading/spec.md diff --git a/openspec/changes/folia-ready-scheduling/.openspec.yaml b/openspec/changes/archive/2026-10-09-folia-ready-scheduling/.openspec.yaml similarity index 100% rename from openspec/changes/folia-ready-scheduling/.openspec.yaml rename to openspec/changes/archive/2026-10-09-folia-ready-scheduling/.openspec.yaml diff --git a/openspec/changes/folia-ready-scheduling/design.md b/openspec/changes/archive/2026-10-09-folia-ready-scheduling/design.md similarity index 100% rename from openspec/changes/folia-ready-scheduling/design.md rename to openspec/changes/archive/2026-10-09-folia-ready-scheduling/design.md diff --git a/openspec/changes/folia-ready-scheduling/proposal.md b/openspec/changes/archive/2026-10-09-folia-ready-scheduling/proposal.md similarity index 100% rename from openspec/changes/folia-ready-scheduling/proposal.md rename to openspec/changes/archive/2026-10-09-folia-ready-scheduling/proposal.md diff --git a/openspec/changes/folia-ready-scheduling/specs/server-threading/spec.md b/openspec/changes/archive/2026-10-09-folia-ready-scheduling/specs/server-threading/spec.md similarity index 100% rename from openspec/changes/folia-ready-scheduling/specs/server-threading/spec.md rename to openspec/changes/archive/2026-10-09-folia-ready-scheduling/specs/server-threading/spec.md diff --git a/openspec/changes/folia-ready-scheduling/tasks.md b/openspec/changes/archive/2026-10-09-folia-ready-scheduling/tasks.md similarity index 100% rename from openspec/changes/folia-ready-scheduling/tasks.md rename to openspec/changes/archive/2026-10-09-folia-ready-scheduling/tasks.md diff --git a/openspec/changes/native-worldedit-brushes/.openspec.yaml b/openspec/changes/archive/2026-10-09-native-worldedit-brushes/.openspec.yaml similarity index 100% rename from openspec/changes/native-worldedit-brushes/.openspec.yaml rename to openspec/changes/archive/2026-10-09-native-worldedit-brushes/.openspec.yaml diff --git a/openspec/changes/native-worldedit-brushes/design.md b/openspec/changes/archive/2026-10-09-native-worldedit-brushes/design.md similarity index 100% rename from openspec/changes/native-worldedit-brushes/design.md rename to openspec/changes/archive/2026-10-09-native-worldedit-brushes/design.md diff --git a/openspec/changes/native-worldedit-brushes/proposal.md b/openspec/changes/archive/2026-10-09-native-worldedit-brushes/proposal.md similarity index 100% rename from openspec/changes/native-worldedit-brushes/proposal.md rename to openspec/changes/archive/2026-10-09-native-worldedit-brushes/proposal.md diff --git a/openspec/changes/native-worldedit-brushes/specs/brush-painting/spec.md b/openspec/changes/archive/2026-10-09-native-worldedit-brushes/specs/brush-painting/spec.md similarity index 100% rename from openspec/changes/native-worldedit-brushes/specs/brush-painting/spec.md rename to openspec/changes/archive/2026-10-09-native-worldedit-brushes/specs/brush-painting/spec.md diff --git a/openspec/changes/native-worldedit-brushes/tasks.md b/openspec/changes/archive/2026-10-09-native-worldedit-brushes/tasks.md similarity index 100% rename from openspec/changes/native-worldedit-brushes/tasks.md rename to openspec/changes/archive/2026-10-09-native-worldedit-brushes/tasks.md diff --git a/openspec/specs/brush-painting/spec.md b/openspec/specs/brush-painting/spec.md new file mode 100644 index 00000000..5edb8bbb --- /dev/null +++ b/openspec/specs/brush-painting/spec.md @@ -0,0 +1,45 @@ +# brush-painting Specification + +## Purpose +Defines how a brush stroke reads and modifies the world. Every stroke goes through the builder's +WorldEdit session, so it is thread-safe, undoable and limited like any other WorldEdit edit. + +## Requirements + +### Requirement: World access only through WorldEdit +A brush stroke SHALL read and write blocks only through the WorldEdit edit session of the stroke, +and SHALL NOT call Bukkit world, block or location methods while the stroke is computed. + +#### Scenario: Stroke computed in FAWE's queue +- **WHEN** a builder right-clicks with a brush and FAWE runs the stroke on a worker thread +- **THEN** no Bukkit world or block method is called on that thread + +### Requirement: One stroke, one undo step +Each brush stroke SHALL be recorded in the builder's WorldEdit history as exactly one entry. + +#### Scenario: Undo a stroke +- **WHEN** a builder paints one stroke and runs `//undo` +- **THEN** every block changed by that stroke is restored and nothing else + +### Requirement: WorldEdit global mask is respected +A brush stroke SHALL NOT change blocks that the builder's WorldEdit global mask excludes. + +#### Scenario: Global mask set +- **WHEN** a builder ran `//gmask grass_block` and paints with a sphere brush over grass and stone +- **THEN** only grass blocks are changed + +### Requirement: Palette and mask behave as before +A stroke SHALL place only blocks from the palette, chosen with equal weight, and with the +BetterGoPaint mask enabled it SHALL change only blocks of the mask type. + +#### Scenario: Mask enabled +- **WHEN** the palette is stone and andesite, the mask is grass block and a sphere brush hits grass and dirt +- **THEN** only grass blocks become stone or andesite + +### Requirement: Brush results are independent of the thread +For the same world, settings and random seed, a brush SHALL change the same blocks regardless of +which thread computes the stroke. + +#### Scenario: Deterministic test +- **WHEN** a brush runs twice against identical in-memory extents with the same seed +- **THEN** both runs change exactly the same positions to the same blocks diff --git a/openspec/specs/server-threading/spec.md b/openspec/specs/server-threading/spec.md new file mode 100644 index 00000000..72f42e17 --- /dev/null +++ b/openspec/specs/server-threading/spec.md @@ -0,0 +1,34 @@ +# server-threading Specification + +## Purpose +Defines how BetterGoPaint schedules work and shares state across threads, so that it runs on Paper +today and on Folia as soon as its required dependencies do. + +## Requirements + +### Requirement: Region-aware scheduling only +The plugin SHALL schedule work only through Paper's async, global region and entity schedulers, and +SHALL NOT use the legacy `BukkitScheduler`. + +#### Scenario: Update check on Folia +- **WHEN** the plugin starts on a Folia server with update checks enabled +- **THEN** the update check is scheduled without an `UnsupportedOperationException` + +### Requirement: Thread-safe per-player state +Per-player brush state SHALL be safe to read and write from different threads at the same time. + +#### Scenario: Two players paint in different regions +- **WHEN** two builders in different Folia regions paint and change brush settings at the same time +- **THEN** each keeps their own settings and no state is lost or corrupted + +### Requirement: Folia declared only with a Folia-capable FAWE +The published plugin SHALL NOT declare Folia support while no stable FastAsyncWorldEdit release +supports Folia. Declaring it SHALL require changing only one build property. + +#### Scenario: Default build +- **WHEN** the plugin is built without extra properties +- **THEN** the generated `paper-plugin.yml` does not declare `folia-supported: true` + +#### Scenario: Opt-in test build +- **WHEN** the plugin is built with `-PfoliaSupported=true` +- **THEN** the generated `paper-plugin.yml` declares `folia-supported: true`