commit 6b07d3bf87529b8e8e191923a5f395db223f8de8 Author: Dylan Garvis Date: Fri Sep 18 08:06:50 2026 -0400 feat(potato): add native poisonous potato throws and releases diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..c702946 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout public source without a credential + env: + SOURCE_SHA: ${{ github.sha }} + GIT_TERMINAL_PROMPT: '0' + run: | + test "${#SOURCE_SHA}" -eq 40 + printf '%s' "$SOURCE_SHA" | grep -Eq '^[a-f0-9]{40}$' + git init . + git remote add origin https://git.garvis.dev/dmg/purpur-potato.git + git config --local credential.helper '' + git config --local http.extraHeader '' + git config --local http.followRedirects false + git fetch --force --tags origin '+refs/heads/*:refs/remotes/origin/*' + git fetch origin "$SOURCE_SHA" + git checkout --detach "$SOURCE_SHA" + - name: Set up Java + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + distribution: temurin + java-version: 25 + cache: gradle + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: npm + - name: Validate conventional commits + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.sha }} + run: | + npx -y -p @commitlint/cli -p @commitlint/config-conventional \ + commitlint --from "$BASE_SHA" --to "$HEAD_SHA" --extends @commitlint/config-conventional + - name: Build and test + run: ./gradlew clean check jar + - name: Upload development artifact + uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3 + with: + name: purpur-potato-${{ github.sha }} + path: build/libs/purpur-potato-*.jar + if-no-files-found: error diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..726c473 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,122 @@ +name: Release + +on: + push: + branches: [main] + +concurrency: + group: potato-release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout public source without a credential + env: + SOURCE_SHA: ${{ github.sha }} + GIT_TERMINAL_PROMPT: '0' + run: | + test "${#SOURCE_SHA}" -eq 40 + printf '%s' "$SOURCE_SHA" | grep -Eq '^[a-f0-9]{40}$' + git init . + git remote add origin https://git.garvis.dev/dmg/purpur-potato.git + git config --local credential.helper '' + git config --local http.extraHeader '' + git config --local http.followRedirects false + git fetch --force --tags origin '+refs/heads/*:refs/remotes/origin/*' + git fetch origin "$SOURCE_SHA" + git checkout -B main "$SOURCE_SHA" + - name: Set up Java + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + distribution: temurin + java-version: 25 + cache: gradle + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: npm + - name: Build and test before release + run: ./gradlew clean check jar + - name: Capture previous tag + id: previous + run: | + previous=$(git describe --tags --abbrev=0 2>/dev/null || true) + printf 'tag=%s\n' "$previous" >> "$GITHUB_OUTPUT" + - name: Compute and push semantic release tag + env: + RELEASE_CREDENTIAL: ${{ secrets.RELEASE_TOKEN }} + REPOSITORY: ${{ github.repository }} + LC_ALL: C + run: | + set +x + test "$REPOSITORY" = dmg/purpur-potato + test -n "$RELEASE_CREDENTIAL" || { printf '%s\n' 'Missing RELEASE_TOKEN.' >&2; exit 1; } + # Prevent semantic-release from embedding any recognized token in a Git URL/argv. + unset GIT_CREDENTIALS GH_TOKEN GITHUB_TOKEN GL_TOKEN GITLAB_TOKEN + unset BB_TOKEN BITBUCKET_TOKEN BB_TOKEN_BASIC_AUTH BITBUCKET_TOKEN_BASIC_AUTH + unset DEBUG NODE_DEBUG GIT_TRACE GIT_TRACE_CURL GIT_CURL_VERBOSE GIT_TRACE_PACKET + unset GIT_TRACE_SETUP GIT_TRACE2 GIT_TRACE2_EVENT GIT_TRACE2_PERF + # semantic-release forces GIT_ASKPASS=echo. Use a scoped, memory-only helper instead. + export GIT_TERMINAL_PROMPT=0 + git config --local credential.helper '' + git config --local --add credential.helper "$GITHUB_WORKSPACE/scripts/git-credential.sh" + git config --local credential.useHttpPath true + git config --local http.followRedirects false + npx -y \ + -p semantic-release@24.2.9 \ + -p @semantic-release/commit-analyzer@13.0.1 \ + -p @semantic-release/release-notes-generator@14.1.0 \ + semantic-release --branches main \ + --repository-url https://git.garvis.dev/dmg/purpur-potato.git \ + --plugins @semantic-release/commit-analyzer,@semantic-release/release-notes-generator + - name: Resolve newly created exact-source release + id: release + env: + PREVIOUS_TAG: ${{ steps.previous.outputs.tag }} + EXPECTED_COMMIT: ${{ github.sha }} + run: | + current=$(git describe --tags --exact-match HEAD 2>/dev/null || true) + if [ -n "$current" ] && [ "$current" != "$PREVIOUS_TAG" ]; then + printf '%s' "$current" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' + test "$(git rev-parse "${current}^{commit}")" = "$EXPECTED_COMMIT" + printf 'created=true\nversion=%s\n' "${current#v}" >> "$GITHUB_OUTPUT" + else + printf 'created=false\n' >> "$GITHUB_OUTPUT" + fi + - name: Verify versioned plugin and release tooling + if: steps.release.outputs.created == 'true' + env: + VERSION: ${{ steps.release.outputs.version }} + run: ./gradlew clean check jar -PreleaseVersion="$VERSION" + - name: Upload release workflow artifact + if: steps.release.outputs.created == 'true' + uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3 + with: + name: purpur-potato-${{ steps.release.outputs.version }} + path: build/libs/purpur-potato-${{ steps.release.outputs.version }}.jar + if-no-files-found: error + - name: Publish verified JAR through a draft release + if: steps.release.outputs.created == 'true' + env: + RELEASE_CREDENTIAL: ${{ secrets.RELEASE_TOKEN }} + RELEASE_COMMIT: ${{ github.sha }} + VERSION: ${{ steps.release.outputs.version }} + PREVIOUS_TAG: ${{ steps.previous.outputs.tag }} + run: | + set +x + unset GIT_CREDENTIALS GH_TOKEN GITHUB_TOKEN GL_TOKEN GITLAB_TOKEN + unset BB_TOKEN BITBUCKET_TOKEN BB_TOKEN_BASIC_AUTH BITBUCKET_TOKEN_BASIC_AUTH + unset DEBUG NODE_DEBUG GIT_TRACE GIT_TRACE_CURL GIT_CURL_VERBOSE GIT_TRACE_PACKET + unset GIT_TRACE_SETUP GIT_TRACE2 GIT_TRACE2_EVENT GIT_TRACE2_PERF + if [ -n "$PREVIOUS_TAG" ]; then + RELEASE_NOTES=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD") + else + RELEASE_NOTES=$(git log --pretty='format:- %s (%h)' HEAD) + fi + export RELEASE_NOTES + node build/release-tools/publish-release.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09e6083 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.gradle/ +build/ +node_modules/ +*.log +.env +.env.* +*.pem +*.key +*token* +*secret*.local +.idea/ +*.iml +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7953a45 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# Purpur Potato + +Canonical knowledge and stories live in the sibling `somc-okf/` checkout, not this public source repository. Read `../AGENTS.md` when available and the wiki's `index.md`, `processes/index.md`, `processes/development.md`, `runbooks/development-cycle.md`, `projects/purpur-potato/index.md`, `engineering.md`, and relevant `user-stories/purpur-potato/` documents before work. + +Standalone checkout entrypoint: https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md . If unavailable, request access; do not create a substitute knowledge bundle or copy private wiki content here. + +Obtain explicit story-change approval before implementation and operational approval before infrastructure, tooling or secret changes. Use vertical-slice TDD, retain native combat/protection decisions and verify `./gradlew clean check jar`, including mandatory non-listening native tests. Java 25, Gradle 9.6.1, Purpur API `26.2.build.2618-stable`; do not silently upgrade. Keep blocking I/O off the tick thread. + +Publishing credentials and release-triggering pushes require separate readiness verification and authorization. Never put secret bytes in argv, URLs, logs or Git. No deployment/GitOps push follows implicitly from source work. Preserve unrelated changes and use conventional commits. diff --git a/README.md b/README.md new file mode 100644 index 0000000..20f8df3 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Purpur Potato + +A Java 25 plugin for Purpur 26.2 build 2618. Right-click a poisonous potato to throw it with native snowball flight and a potato visual. Each successful launch costs one potato (including Creative), has a 20-tick cooldown, and deals one health point of base damage without poison. Native damage modifiers, PvP rules and protection cancellations remain authoritative. Projectiles expire after 100 ticks; misses are not refunded. + +`/potato [help]` explains the mechanic to players and console. `purpurpotato.help` defaults to true. No recipe, unlock, persistent data or runtime credential is required. + +## Build + +```sh +./gradlew clean check jar +./gradlew clean check jar -PreleaseVersion=1.2.3 +``` + +Gradle 9.6.1 selects Java 25. Full checks include Java tests, mandatory non-listening native tests against the checksum-pinned Purpur runtime, and TypeScript release-tool tests (`npm ci` requires Node 20+). Native preparation runs Paperclip **patch-only**: it does not start a server, listen on a port or access production. Build downloads require network access. Generated runtime files stay under ignored `build/`. + +Outputs: `build/libs/purpur-potato-.jar`. Server and test dependencies are not bundled. Do not deploy snapshot workflow artifacts. + +## Release boundary + +CI checks pushes and pull requests; main releases verify snapshot and versioned builds before publishing a draft's exact JAR. Git uses a repository-scoped, memory-only credential helper; uploads use in-process HTTP authentication and reject redirects. Never pass tokens in command arguments or remote URLs, print environment variables, or enable credential tracing. + +`RELEASE_TOKEN` requires separately approved protected-file/stdin provisioning **before the first main push**. `scripts/secret-input.ts` only validates metadata and constructs an input plan; it never provisions or reads secret bytes. Recheck permissions and obtain approval before executing any upload. Local tests are not proof of CI or publication. Source publication is not deployment approval. + +## Canonical documentation + +The shared [SoMC wiki](https://git.garvis.dev/dmg/somc-okf) contains the canonical [project guidance](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-potato/index.md), stories and operational processes. Access may require authorization. No private wiki content is mirrored here. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..ae1ed97 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,113 @@ +import java.net.URI +import java.security.MessageDigest + +plugins { + java +} + +group = "games.dmg" +version = providers.gradleProperty("releaseVersion") + .orElse("0.1.0-SNAPSHOT") + .get() + +repositories { + mavenCentral() + maven("https://repo.purpurmc.org/snapshots/") +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} + +tasks.withType().configureEach { + options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror")) +} + +dependencies { + compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable") + + testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable") + testImplementation(platform("org.junit:junit-bom:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.mockito:mockito-core:5.18.0") + testImplementation("org.yaml:snakeyaml:2.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() + dependsOn(tasks.jar) + systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath) + systemProperty("distribution.version", project.version.toString()) +} + +// Verify native projectile adapters against the declared runtime, without starting a server. +val nativeRuntime = layout.buildDirectory.dir("native-runtime") +val downloadNativeRuntime = tasks.register("downloadNativeRuntime") { + val launcher = nativeRuntime.map { it.file("purpur-26.2-2618.jar") } + outputs.file(launcher) + doLast { + val file = launcher.get().asFile + file.parentFile.mkdirs() + val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download").toURL().openConnection() + connection.connectTimeout = 30_000 + connection.readTimeout = 120_000 + val bytes = connection.getInputStream().use { it.readBytes() } + val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") { + "Purpur 2618 checksum mismatch" + } + file.writeBytes(bytes) + } +} +val prepareNativeRuntime = tasks.register("prepareNativeRuntime") { + dependsOn(downloadNativeRuntime) + javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) } + classpath = files(nativeRuntime.map { it.file("purpur-26.2-2618.jar") }) + mainClass = "io.papermc.paperclip.Main" + jvmArgs("-Dpaperclip.patchonly=true") + workingDir(nativeRuntime) + outputs.dir(nativeRuntime.map { it.dir("versions") }) + outputs.dir(nativeRuntime.map { it.dir("libraries") }) +} +val nativeTest = sourceSets.create("nativeTest") +dependencies { + add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4")) + add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter") + add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0") + add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2") + add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2") + add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher") +} +val nativeRuntimeJars = files(fileTree(nativeRuntime) { + include("versions/**/*.jar", "libraries/**/*.jar") +}).builtBy(prepareNativeRuntime) +nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars +nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars +val nativeCompatibilityTest = tasks.register("nativeCompatibilityTest") { + description = "Tests native projectile and damage behavior without a listening server" + testClassesDirs = nativeTest.output.classesDirs + classpath = nativeTest.runtimeClasspath + useJUnitPlatform() + maxHeapSize = "1G" + workingDir(nativeRuntime) +} +val installReleaseTools = tasks.register("installReleaseTools") { + commandLine("npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund") +} +val releaseToolCheck = tasks.register("releaseToolCheck") { + dependsOn(installReleaseTools) + commandLine("npm", "test") +} +tasks.check { dependsOn(nativeCompatibilityTest, releaseToolCheck) } + +val pluginVersion = version + +tasks.processResources { + inputs.property("pluginVersion", pluginVersion) + filesMatching("plugin.yml") { + expand("version" to pluginVersion) + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a351597 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..203529c --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# 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. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# 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/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# 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 -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 + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * 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" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..7e60b72 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@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 gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +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 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +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 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@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 + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..02a7224 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,50 @@ +{ + "name": "purpur-potato-release-tools", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "purpur-potato-release-tools", + "version": "0.0.0", + "devDependencies": { + "@types/node": "20.19.9", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "20.19.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", + "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..987205a --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "purpur-potato-release-tools", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "scripts": { + "test": "rm -rf build/release-tools && tsc && node --test build/release-tools/*.test.js" + }, + "devDependencies": { + "@types/node": "20.19.9", + "typescript": "5.9.3" + } +} diff --git a/scripts/git-credential.sh b/scripts/git-credential.sh new file mode 100755 index 0000000..fa21ebd --- /dev/null +++ b/scripts/git-credential.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Git consumes this protocol over a pipe; never run interactively with a real credential. +# semantic-release forces GIT_ASKPASS=echo, but still honors credential helpers. +set +x +[ "${1-}" = get ] || exit 0 +protocol= host= path= username= +while IFS= read -r line && [ -n "$line" ]; do + case "$line" in + protocol=*) protocol=${line#protocol=} ;; + host=*) host=${line#host=} ;; + path=*) path=${line#path=} ;; + username=*) username=${line#username=} ;; + esac +done +[ "$protocol" = https ] || exit 1 +[ "$host" = git.garvis.dev ] || exit 1 +[ "$path" = dmg/purpur-potato.git ] || exit 1 +[ -z "$username" ] || [ "$username" = dmg ] || exit 1 +[ -n "${RELEASE_CREDENTIAL-}" ] || exit 1 +LC_ALL=C +export LC_ALL +case "$RELEASE_CREDENTIAL" in *[![:graph:]]*) exit 1 ;; esac +printf '%s\n' 'username=dmg' +printf 'password=%s\n\n' "$RELEASE_CREDENTIAL" diff --git a/scripts/git-credential.test.ts b/scripts/git-credential.test.ts new file mode 100644 index 0000000..d6d2f33 --- /dev/null +++ b/scripts/git-credential.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { test } from "node:test"; + +const fixture = "synthetic-git-protocol-fixture"; +const helper = resolve("scripts/git-credential.sh"); +const request = "protocol=https\nhost=git.garvis.dev\npath=dmg/purpur-potato.git\n\n"; +const environment = { + PATH: process.env.PATH, RELEASE_CREDENTIAL: fixture, LC_ALL: "C", + GIT_ASKPASS: "echo", GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", +}; + +test("real Git obtains the credential even when semantic-release forces GIT_ASKPASS=echo", () => { + const child = spawnSync("git", [ + "-c", "credential.helper=", "-c", `credential.helper=${helper}`, + "-c", "credential.useHttpPath=true", "credential", "fill", + ], { encoding: "utf8", input: request, env: environment }); + assert.equal(child.status, 0); + assert.ok(child.stdout.split("\n").includes("username=dmg")); + assert.ok(child.stdout.split("\n").includes(`password=${fixture}`)); + assert.equal(child.stderr, ""); +}); + +for (const input of [ + request.replace("https", "http"), request.replace("git.garvis.dev", "evil.invalid"), + request.replace("purpur-potato.git", "another-project.git"), + request.replace("\n\n", "\nusername=other\n\n"), +]) { + test(`refuses mismatched credential scope: ${JSON.stringify(input)}`, () => { + const child = spawnSync("/bin/sh", [helper, "get"], { encoding: "utf8", input, env: environment }); + assert.notEqual(child.status, 0); + assert.equal(child.stdout, ""); + assert.equal(child.stderr, ""); + }); +} + +for (const action of ["store", "erase"]) { + test(`ignores ${action} without emitting or persisting credentials`, () => { + const child = spawnSync("/bin/sh", [helper, action], { + encoding: "utf8", input: request, env: environment, + }); + assert.equal(child.status, 0); + assert.equal(child.stdout, ""); + assert.equal(child.stderr, ""); + }); +} + +for (const value of ["", "bad\npassword=injected", "trailing-space "]) { + test(`rejects absent or malformed credential input of length ${value.length}`, () => { + const child = spawnSync("/bin/sh", [helper, "get"], { + encoding: "utf8", input: request, env: { ...environment, RELEASE_CREDENTIAL: value }, + }); + assert.notEqual(child.status, 0); + assert.equal(child.stdout, ""); + assert.equal(child.stderr, ""); + }); +} diff --git a/scripts/publish-release.test.ts b/scripts/publish-release.test.ts new file mode 100644 index 0000000..0f4ef9c --- /dev/null +++ b/scripts/publish-release.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { test } from "node:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const fixture = "synthetic-cli-fixture"; +const entrypoint = resolve("build/release-tools/publish-release.js"); + +function runPublication(mode: "success" | "transport" | "missing") { + const dir = mkdtempSync(join(tmpdir(), "potato-cli-")); + try { + mkdirSync(join(dir, "build/libs"), { recursive: true }); + if (mode !== "missing") writeFileSync(join(dir, "build/libs/purpur-potato-1.2.3.jar"), Buffer.from([80, 75, 3, 4])); + // A process-local fake at the HTTP boundary: these tests must never contact Gitea. + const preload = join(dir, "http-fixture.mjs"); + writeFileSync(preload, ` + import assert from 'node:assert/strict'; + let calls = 0; + globalThis.fetch = async (url, init) => { + calls++; + assert.equal(new Headers(init.headers).get('authorization'), 'token ' + process.env.RELEASE_CREDENTIAL); + assert.equal(init.redirect, 'error'); + assert.ok(!url.includes(process.env.RELEASE_CREDENTIAL)); + if (process.env.FIXTURE_MODE === 'transport') throw new Error(process.env.RELEASE_CREDENTIAL); + if (calls === 1) { + const body = JSON.parse(init.body); + assert.equal(body.target_commitish, 'a'.repeat(40)); + assert.equal(body.body, 'Fixture notes'); + assert.equal(body.draft, true); + } else if (calls === 2) { + assert.deepEqual(new Uint8Array(init.body), new Uint8Array([80, 75, 3, 4])); + assert.ok(url.endsWith('/42/assets?name=purpur-potato-1.2.3.jar')); + } else { + assert.equal(calls, 3); + assert.equal(init.method, 'PATCH'); + } + return Response.json({id:42, draft: calls !== 3, tag_name:'v1.2.3', name:'purpur-potato-1.2.3.jar', size:4}); + }; + if (process.env.FIXTURE_MODE === 'success') process.on('exit', () => assert.equal(calls, 3)); + `); + return spawnSync(process.execPath, ["--import", preload, entrypoint], { + cwd: dir, encoding: "utf8", env: { + VERSION: "1.2.3", RELEASE_COMMIT: "a".repeat(40), RELEASE_CREDENTIAL: fixture, + RELEASE_NOTES: "Fixture notes", FIXTURE_MODE: mode, + }, + }); + } finally { rmSync(dir, { recursive: true, force: true }); } +} + +test("CLI reads only the exact versioned JAR and reports completed publication", () => { + const child = runPublication("success"); + assert.equal(child.status, 0, child.stderr); + assert.equal(child.stdout, "Published purpur-potato v1.2.3.\n"); + assert.equal(child.stderr, ""); +}); + +for (const mode of ["transport", "missing"] as const) { + test(`CLI sanitizes ${mode} failures and never claims publication`, () => { + const child = runPublication(mode); + assert.equal(child.status, 1); + assert.equal(child.stdout, ""); + assert.equal(child.stderr, "Release publication failed; inspect the release state before retrying.\n"); + }); +} + +for (const environment of [ + { VERSION: "1.2.3", RELEASE_COMMIT: "a".repeat(40) }, + { VERSION: "../../outside", RELEASE_CREDENTIAL: "synthetic-cli-fixture", RELEASE_COMMIT: "a".repeat(40) }, +]) { + test(`CLI fails closed on invalid input without echoing environment: ${Object.keys(environment)}`, () => { + const child = spawnSync(process.execPath, ["build/release-tools/publish-release.js"], { + encoding: "utf8", env: environment, + }); + assert.equal(child.status, 1); + assert.equal(child.stdout, ""); + assert.equal(child.stderr, "Release publication failed; inspect the release state before retrying.\n"); + }); +} diff --git a/scripts/publish-release.ts b/scripts/publish-release.ts new file mode 100644 index 0000000..15d8850 --- /dev/null +++ b/scripts/publish-release.ts @@ -0,0 +1,21 @@ +import { readFile } from "node:fs/promises"; +import { publishRelease } from "./release-client.js"; + +try { + const version = process.env.VERSION ?? ""; + const credential = process.env.RELEASE_CREDENTIAL ?? ""; + const commit = process.env.RELEASE_COMMIT ?? ""; + // Check path components and required inputs before opening an artifact or contacting Gitea. + if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version) + || !credential || !/^[a-f0-9]{40}$/.test(commit)) throw new Error("Invalid input"); + const jar = await readFile(`build/libs/purpur-potato-${version}.jar`); + await publishRelease({ + credential, version, commit, jar, + notes: process.env.RELEASE_NOTES ?? "See the source history for changes.", + }, fetch); + console.log(`Published purpur-potato v${version}.`); +} catch { + // Never echo credentials, upstream error bodies, transport diagnostics or the environment. + console.error("Release publication failed; inspect the release state before retrying."); + process.exitCode = 1; +} diff --git a/scripts/release-client.test.ts b/scripts/release-client.test.ts new file mode 100644 index 0000000..7967baf --- /dev/null +++ b/scripts/release-client.test.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { publishRelease, type ReleaseInput } from "./release-client.js"; + +const input: ReleaseInput = { + credential: "synthetic-test-value-not-a-real-credential", + version: "1.2.3", + commit: "a".repeat(40), + notes: "Verified changes", + jar: new Uint8Array([0x50, 0x4b, 0x03, 0x04]), +}; + +function success(init: RequestInit): Response { + return Response.json({ + id: 42, draft: init.method !== "PATCH", tag_name: "v1.2.3", + name: "purpur-potato-1.2.3.jar", size: input.jar.length, + }); +} + +test("creates a draft on the fixed repository and exact source commit, without following redirects", async () => { + const calls: { url: string; init: RequestInit }[] = []; + const id = await publishRelease(input, async (url, init) => { + calls.push({ url, init }); + return success(init); + }); + assert.ok(calls.length >= 1); + const first = calls[0]!; + assert.equal(first.url, "https://git.garvis.dev/api/v1/repos/dmg/purpur-potato/releases"); + assert.equal(first.init.method, "POST"); + assert.equal(first.init.redirect, "error"); + assert.equal(new Headers(first.init.headers).get("authorization"), `token ${input.credential}`); + assert.deepEqual(JSON.parse(String(first.init.body)), { + tag_name: "v1.2.3", target_commitish: input.commit, name: "v1.2.3", + body: input.notes, draft: true, prerelease: false, + }); + assert.equal(id, 42); +}); + +test("uploads the exact versioned bytes before publishing the draft", async () => { + const calls: { url: string; init: RequestInit }[] = []; + await publishRelease(input, async (url, init) => { + calls.push({ url, init }); + return success(init); + }); + assert.equal(calls.length, 3); + assert.equal(calls[1]!.url, + "https://git.garvis.dev/api/v1/repos/dmg/purpur-potato/releases/42/assets?name=purpur-potato-1.2.3.jar"); + assert.equal(calls[1]!.init.method, "POST"); + assert.deepEqual(new Uint8Array(await new Response(calls[1]!.init.body).arrayBuffer()), input.jar); + assert.equal(new Headers(calls[1]!.init.headers).get("content-type"), "application/octet-stream"); + assert.equal(calls[2]!.init.method, "PATCH"); + assert.equal(calls[2]!.url, "https://git.garvis.dev/api/v1/repos/dmg/purpur-potato/releases/42"); + assert.deepEqual(JSON.parse(String(calls[2]!.init.body)), { draft: false }); + for (const call of calls) { + assert.equal(call.init.redirect, "error"); + assert.equal(new Headers(call.init.headers).get("authorization"), `token ${input.credential}`); + assert.ok(!call.url.includes(input.credential)); + } +}); + +for (const failureStep of [1, 2, 3]) { + for (const status of [302, 401, 409, 500]) { + test(`stops at step ${failureStep} on HTTP ${status} without leaking API error bodies`, async () => { + let calls = 0; + await assert.rejects(publishRelease(input, async (_url, init) => { + calls++; + if (calls === failureStep) return new Response(input.credential, { status }); + return success(init); + }), (error: unknown) => { + assert.ok(error instanceof Error); + assert.ok(error.message.includes(String(status))); + assert.ok(!error.message.includes(input.credential)); + return true; + }); + assert.equal(calls, failureStep, "Do not retry, overwrite an asset or publish after a failed step"); + }); + } +} + +test("sanitizes transport errors and does not follow a redirect with the credential", async () => { + await assert.rejects(publishRelease(input, async () => { + throw new Error(`diagnostic containing ${input.credential}`); + }), { message: "Release API request failed." }); +}); + +for (const invalid of [ + { credential: "" }, { credential: "header\r\ninjection" }, { version: "../../latest" }, + { version: "1.0.0-SNAPSHOT" }, { commit: "main" }, { jar: new Uint8Array() }, +]) { + test(`rejects invalid ${Object.keys(invalid)[0]} before any network request`, async () => { + let calls = 0; + await assert.rejects(publishRelease({ ...input, ...invalid }, async () => { + calls++; + return Response.json({ id: 42 }); + }), { message: "Invalid release input." }); + assert.equal(calls, 0); + }); +} + +for (const body of ["not JSON", "null", JSON.stringify({ id: -1 }), JSON.stringify({ id: "../other" }), + JSON.stringify({ id: 42, draft: false, tag_name: "v1.2.3" })]) { + test(`rejects malformed or already-public draft response: ${body}`, async () => { + let calls = 0; + await assert.rejects(publishRelease(input, async () => { + calls++; + return new Response(body); + }), { message: "Invalid draft release response." }); + assert.equal(calls, 1); + }); +} + +for (const [failureStep, body] of [ + [2, {}], [2, { name: "other.jar", size: 4 }], + [2, { name: "purpur-potato-1.2.3.jar", size: 0 }], + [3, { id: 42, draft: true, tag_name: "v1.2.3" }], + [3, { id: 77, draft: false, tag_name: "v1.2.3" }], + [3, { id: 42, draft: false, tag_name: "v8.8.8" }], +] as const) { + test(`requires a matching asset and completed publication: ${JSON.stringify(body)}`, async () => { + let calls = 0; + await assert.rejects(publishRelease(input, async (_url, init) => { + calls++; + return calls === failureStep ? Response.json(body) : success(init); + }), { message: failureStep === 2 ? "Invalid release asset response." : "Invalid published release response." }); + assert.equal(calls, failureStep); + }); +} diff --git a/scripts/release-client.ts b/scripts/release-client.ts new file mode 100644 index 0000000..f913254 --- /dev/null +++ b/scripts/release-client.ts @@ -0,0 +1,70 @@ +export interface ReleaseInput { + credential: string; + version: string; + commit: string; + notes: string; + jar: Uint8Array; +} +export type Request = (url: string, init: RequestInit) => Promise; +const releases = "https://git.garvis.dev/api/v1/repos/dmg/purpur-potato/releases"; + +export async function publishRelease(input: ReleaseInput, request: Request): Promise { + if (!/^[\x21-\x7e]+$/.test(input.credential) + || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(input.version) + || !/^[a-f0-9]{40}$/.test(input.commit) || input.jar.length === 0) { + throw new Error("Invalid release input."); + } + const send: Request = async (url, init) => { + let response: Response; + try { + response = await request(url, { ...init, signal: AbortSignal.timeout(60_000) }); + } catch { + throw new Error("Release API request failed."); + } + if (!response.ok) throw new Error(`Release API request failed (HTTP ${response.status}).`); + return response; + }; + const response = await send(releases, { + method: "POST", redirect: "error", + headers: { authorization: `token ${input.credential}`, "content-type": "application/json" }, + body: JSON.stringify({ + tag_name: `v${input.version}`, target_commitish: input.commit, name: `v${input.version}`, + body: input.notes, draft: true, prerelease: false, + }), + }); + const release = await readObject(response, "Invalid draft release response."); + if (typeof release.id !== "number" || !Number.isSafeInteger(release.id) || release.id <= 0 + || release.draft !== true || release.tag_name !== `v${input.version}`) { + throw new Error("Invalid draft release response."); + } + const assetResponse = await send(`${releases}/${release.id}/assets?name=purpur-potato-${input.version}.jar`, { + method: "POST", redirect: "error", + headers: { authorization: `token ${input.credential}`, "content-type": "application/octet-stream" }, + body: new Uint8Array(input.jar).buffer, + }); + const asset = await readObject(assetResponse, "Invalid release asset response."); + if (asset.name !== `purpur-potato-${input.version}.jar` || asset.size !== input.jar.length) { + throw new Error("Invalid release asset response."); + } + const publishedResponse = await send(`${releases}/${release.id}`, { + method: "PATCH", redirect: "error", + headers: { authorization: `token ${input.credential}`, "content-type": "application/json" }, + body: JSON.stringify({ draft: false }), + }); + const published = await readObject(publishedResponse, "Invalid published release response."); + if (published.id !== release.id || published.draft !== false || published.tag_name !== `v${input.version}`) { + throw new Error("Invalid published release response."); + } + return release.id; +} + +async function readObject(response: Response, message: string): Promise> { + let value: unknown; + try { + value = await response.json(); + } catch { + throw new Error(message); + } + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(message); + return value as Record; +} diff --git a/scripts/secret-input.test.ts b/scripts/secret-input.test.ts new file mode 100644 index 0000000..111037d --- /dev/null +++ b/scripts/secret-input.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { closeSync, chmodSync, mkdtempSync, openSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; +import { planSecretInput } from "./secret-input.js"; + +const fixture = "synthetic-provisioning-value-not-a-real-secret"; +function protectedInput(body: (dir: string, file: string) => void) { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "potato-secret-contract-"))); + const file = join(dir, "release-token"); + try { + chmodSync(dir, 0o700); + writeFileSync(file, fixture, { mode: 0o600 }); + body(dir, file); + } finally { rmSync(dir, { recursive: true, force: true }); } +} + +for (const invalid of ["public-file", "public-directory", "symlink", "relative", "repository", "missing", "directory", "empty", "login"] as const) { + test(`protected provisioning plan refuses ${invalid} input without reading or echoing it`, () => protectedInput((dir, file) => { + let path = file; + let root = resolve("."); + let login = "fixture-login"; + if (invalid === "public-file") chmodSync(file, 0o644); + if (invalid === "public-directory") chmodSync(dir, 0o755); + if (invalid === "symlink") { path = join(dir, "link"); symlinkSync(file, path); } + if (invalid === "relative") path = "relative-token"; + if (invalid === "repository") root = dir; + if (invalid === "missing") path = join(dir, "missing"); + if (invalid === "directory") path = dir; + if (invalid === "empty") writeFileSync(file, ""); + if (invalid === "login") login = "--debug"; + assert.throws(() => planSecretInput(login, path, "file", root), { + message: "Invalid protected secret input; no provisioning was performed.", + }); + })); +} + +for (const mode of ["file", "stdin"] as const) { + test(`protected ${mode} plan follows tea contract without credential argv, logs or actual provisioning`, () => protectedInput((dir, file) => { + const plan = planSecretInput("fixture-login", file, mode, resolve(".")); + assert.equal(plan.command, "tea"); + assert.deepEqual(plan.args, ["actions", "secrets", "create", "--login", "fixture-login", "--repo", "dmg/purpur-potato", + ...(mode === "file" ? ["--file", file] : ["--stdin"]), "RELEASE_TOKEN"]); + assert.equal(plan.stdinFile, mode === "stdin" ? file : undefined); + assert.ok(!JSON.stringify(plan).includes(fixture)); + // Execute only an isolated local fake, NEVER the installed tea command. + const fake = join(dir, "fake-tea.cjs"); + writeFileSync(fake, ` + const fs = require('node:fs'); + const args = process.argv.slice(2); + const bytes = args.includes('--stdin') ? fs.readFileSync(0, 'utf8') : fs.readFileSync(args[args.indexOf('--file') + 1], 'utf8'); + if (bytes !== ${JSON.stringify(fixture)} || args.join(' ').includes(bytes)) process.exit(1); + process.stdout.write('Accepted protected input for RELEASE_TOKEN.\\n'); + `, { mode: 0o600 }); + const fd = plan.stdinFile ? openSync(plan.stdinFile, "r") : undefined; + try { + const child = spawnSync(process.execPath, [fake, ...plan.args], { + env: {}, encoding: "utf8", stdio: [fd ?? "ignore", "pipe", "pipe"], + }); + assert.equal(child.status, 0, child.stderr); + assert.equal(child.stdout, "Accepted protected input for RELEASE_TOKEN.\n"); + assert.equal(child.stderr, ""); + } finally { if (fd !== undefined) closeSync(fd); } + })); +} diff --git a/scripts/secret-input.ts b/scripts/secret-input.ts new file mode 100644 index 0000000..8541fdf --- /dev/null +++ b/scripts/secret-input.ts @@ -0,0 +1,35 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; + +export interface SecretInputPlan { + command: "tea"; + args: string[]; + stdinFile?: string; +} + +// Plan only: never reads secret bytes, spawns tea, or provisions a secret. +// This is an inspectable contract, not upload authorization or a race-free open. +// An operator must recheck metadata and approve the actual upload separately. +export function planSecretInput(login: string, file: string, mode: "file" | "stdin", repositoryRoot: string): SecretInputPlan { + try { + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(login) || !isAbsolute(file) + || (mode !== "file" && mode !== "stdin")) throw new Error(); + const canonical = realpathSync(file); + const local = relative(realpathSync(repositoryRoot), canonical); + if (canonical !== resolve(file) || !(local === ".." || local.startsWith(`..${sep}`) || isAbsolute(local))) throw new Error(); + const metadata = lstatSync(file); + const directory = lstatSync(dirname(file)); + const owner = process.getuid?.(); + if (owner === undefined || !metadata.isFile() || metadata.isSymbolicLink() || metadata.size === 0 + || metadata.uid !== owner || (metadata.mode & 0o777) !== 0o600 + || !directory.isDirectory() || directory.uid !== owner || (directory.mode & 0o777) !== 0o700) throw new Error(); + } catch { + throw new Error("Invalid protected secret input; no provisioning was performed."); + } + return { + command: "tea", + args: ["actions", "secrets", "create", "--login", login, "--repo", "dmg/purpur-potato", + ...(mode === "file" ? ["--file", file] : ["--stdin"]), "RELEASE_TOKEN"], + ...(mode === "stdin" ? { stdinFile: file } : {}), + }; +} diff --git a/scripts/workflow.test.ts b/scripts/workflow.test.ts new file mode 100644 index 0000000..a44bfc9 --- /dev/null +++ b/scripts/workflow.test.ts @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; + +const root = resolve("."); +const sha = "a".repeat(40); +const fixture = "synthetic-workflow-credential"; +function workflow(name: string): string { return readFileSync(join(root, `.gitea/workflows/${name}.yml`), "utf8"); } +function step(name: string, title: string): string { + const value = workflow(name).split(" - name: ").find(block => block.startsWith(title + "\n")); + assert.ok(value, `Missing workflow step: ${title}`); + return value; +} +function script(name: string, title: string): string { + const block = step(name, title); + const run = block.split(" run: ")[1]; + assert.ok(run, `Missing run body: ${title}`); + return run.startsWith("|\n") ? run.slice(2).split("\n").map(line => line.replace(/^ /, "")).join("\n") : run.trim(); +} +function sandbox(body: (dir: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), "potato-workflow-")); + try { body(dir); } finally { rmSync(dir, { recursive: true, force: true }); } +} +function fake(dir: string, command: string, source: string): void { + mkdirSync(join(dir, "bin"), { recursive: true }); + writeFileSync(join(dir, "bin", command), `#!${process.execPath}\n${source}`, { mode: 0o700 }); +} +function shell(dir: string, source: string, env: NodeJS.ProcessEnv = {}) { + return spawnSync("/bin/sh", ["-eu", "-c", source], { cwd: dir, encoding: "utf8", env: { + PATH: `${join(dir, "bin")}:${process.env.PATH}`, HOME: dir, GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", GITHUB_WORKSPACE: root, GITHUB_OUTPUT: join(dir, "output"), ...env, + } }); +} + +test("CI runs the full check and commitlint with inert SHA arguments and SHA-named artifacts", () => { + sandbox(dir => { + writeFileSync(join(dir, "build-args"), ""); + writeFileSync(join(dir, "gradlew"), '#!/bin/sh\nprintf "%s\\n" "$@" > build-args\n', { mode: 0o700 }); + assert.equal(shell(dir, script("ci", "Build and test")).status, 0); + assert.equal(readFileSync(join(dir, "build-args"), "utf8"), "clean\ncheck\njar\n"); + fake(dir, "npx", `require('node:fs').writeFileSync('lint-args', JSON.stringify(process.argv.slice(2)));`); + const child = shell(dir, script("ci", "Validate conventional commits"), { BASE_SHA: "b".repeat(40), HEAD_SHA: sha }); + assert.equal(child.status, 0, child.stderr); + const args = JSON.parse(readFileSync(join(dir, "lint-args"), "utf8")); + assert.deepEqual(args.slice(-6), ["--from", "b".repeat(40), "--to", sha, "--extends", "@commitlint/config-conventional"]); + assert.match(step("ci", "Validate conventional commits"), /if: github.event_name == 'pull_request'/); + assert.match(step("ci", "Upload development artifact"), /name: purpur-potato-\$\{\{ github.sha \}\}/); + assert.match(step("ci", "Upload development artifact"), /path: build\/libs\/purpur-potato-\*\.jar/); + assert.match(step("ci", "Upload development artifact"), /if-no-files-found: error/); + assert.match(workflow("ci"), /java-version: 25/); + assert.match(workflow("ci"), /node-version: 20/); + }); +}); + +test("release performs full snapshot and versioned checks and gates versioned artifacts on a new release", () => sandbox(dir => { + writeFileSync(join(dir, "build-args"), ""); + writeFileSync(join(dir, "gradlew"), '#!/bin/sh\nprintf "%s\\n" "$@" > build-args\n', { mode: 0o700 }); + assert.equal(shell(dir, script("release", "Build and test before release")).status, 0); + assert.equal(readFileSync(join(dir, "build-args"), "utf8"), "clean\ncheck\njar\n"); + assert.equal(shell(dir, script("release", "Verify versioned plugin and release tooling"), { VERSION: "1.2.3" }).status, 0); + assert.equal(readFileSync(join(dir, "build-args"), "utf8"), "clean\ncheck\njar\n-PreleaseVersion=1.2.3\n"); + for (const title of ["Verify versioned plugin and release tooling", "Upload release workflow artifact"]) { + assert.match(step("release", title), /if: steps.release.outputs.created == 'true'/); + } + const release = workflow("release"); + assert.ok(release.indexOf("Build and test before release") < release.indexOf("Compute and push")); + assert.ok(release.indexOf("Capture previous tag") < release.indexOf("Compute and push")); + assert.ok(release.indexOf("Verify versioned") < release.indexOf("Upload release workflow artifact")); + assert.match(release, /branches: \[main\]/); + assert.match(release, /group: potato-release\n cancel-in-progress: false/); + assert.match(step("release", "Upload release workflow artifact"), /path: build\/libs\/purpur-potato-\$\{\{ steps.release.outputs.version \}\}\.jar/); +})); + +for (const scenario of ["new", "none", "unchanged", "wrong-source", "invalid-tag"] as const) { + test(`release resolution proves exact source and does not claim no-release publication: ${scenario}`, () => sandbox(dir => { + const init = shell(dir, "git init -q; git -c user.name=Fixture -c user.email=fixture@example.invalid commit --allow-empty -qm 'feat: fixture'"); + assert.equal(init.status, 0, init.stderr); + if (scenario === "unchanged") assert.equal(shell(dir, "git tag v1.2.3").status, 0); + writeFileSync(join(dir, "output"), ""); + const previous = shell(dir, script("release", "Capture previous tag")); + assert.equal(previous.status, 0, previous.stderr); + const previousOutput = readFileSync(join(dir, "output"), "utf8"); + assert.equal(previousOutput, scenario === "unchanged" ? "tag=v1.2.3\n" : "tag=\n"); + writeFileSync(join(dir, "output"), ""); + if (scenario === "new" || scenario === "wrong-source") assert.equal(shell(dir, "git tag v1.2.3").status, 0); + if (scenario === "invalid-tag") assert.equal(shell(dir, "git tag v01.2.3-unsupported").status, 0); + const head = shell(dir, "git rev-parse HEAD").stdout.trim(); + const child = shell(dir, script("release", "Resolve newly created exact-source release"), { + PREVIOUS_TAG: scenario === "unchanged" ? "v1.2.3" : "", EXPECTED_COMMIT: scenario === "wrong-source" ? sha : head, + }); + const output = readFileSync(join(dir, "output"), "utf8"); + if (scenario === "wrong-source" || scenario === "invalid-tag") { + assert.notEqual(child.status, 0); + assert.ok(!output.includes("created=true")); + } else { + assert.equal(child.status, 0, child.stderr); + assert.equal(output, scenario === "new" ? "created=true\nversion=1.2.3\n" : "created=false\n"); + } + })); +} + +const forbiddenEnv = [ + "GIT_CREDENTIALS", "GH_TOKEN", "GITHUB_TOKEN", "GL_TOKEN", "GITLAB_TOKEN", "BB_TOKEN", "BITBUCKET_TOKEN", + "BB_TOKEN_BASIC_AUTH", "BITBUCKET_TOKEN_BASIC_AUTH", "DEBUG", "NODE_DEBUG", "GIT_TRACE", "GIT_TRACE_CURL", + "GIT_CURL_VERBOSE", "GIT_TRACE_PACKET", "GIT_TRACE_SETUP", "GIT_TRACE2", "GIT_TRACE2_EVENT", "GIT_TRACE2_PERF", +]; + +test("publication invokes only the in-process publisher with no secret argv or tracing", () => sandbox(dir => { + assert.equal(shell(dir, "git init -q; git -c user.name=Fixture -c user.email=fixture@example.invalid commit --allow-empty -qm 'feat: fixture'").status, 0); + writeFileSync(join(dir, "publish-command"), "null"); + fake(dir, "node", ` + const assert = require('node:assert/strict'); + for (const key of ${JSON.stringify(forbiddenEnv)}) assert.equal(process.env[key], undefined, key); + assert.equal(process.env.RELEASE_CREDENTIAL, ${JSON.stringify(fixture)}); + assert.match(process.env.RELEASE_NOTES, /feat: fixture/); + require('node:fs').writeFileSync('publish-command', JSON.stringify(process.argv.slice(2))); + `); + const child = shell(dir, script("release", "Publish verified JAR through a draft release"), { + RELEASE_CREDENTIAL: fixture, PREVIOUS_TAG: "", + ...Object.fromEntries(forbiddenEnv.map(key => [key, key.includes("TOKEN") ? fixture : "1"])), + }); + assert.equal(child.status, 0, child.stderr); + assert.deepEqual(JSON.parse(readFileSync(join(dir, "publish-command"), "utf8")), ["build/release-tools/publish-release.js"]); + assert.ok(![child.stdout, child.stderr].join("").includes(fixture)); + assert.match(step("release", "Publish verified JAR through a draft release"), /if: steps.release.outputs.created == 'true'/); + assert.ok(workflow("release").indexOf("Verify versioned") < workflow("release").indexOf("Publish verified")); + assert.doesNotMatch(workflow("release"), /curl|secrets\.RELEASE_TOKEN[^\n]*(?:curl|git|node)/); +})); + +test("semantic tagging keeps the dummy credential only in helper environment, never argv, URLs or diagnostics", () => { + sandbox(dir => { + assert.equal(shell(dir, "git init -q").status, 0); + writeFileSync(join(dir, "tag-command"), "null"); + fake(dir, "npx", ` + const assert = require('node:assert/strict'); + for (const key of ${JSON.stringify(forbiddenEnv)}) assert.equal(process.env[key], undefined, key); + assert.equal(process.env.RELEASE_CREDENTIAL, ${JSON.stringify(fixture)}); + assert.equal(process.env.GIT_TERMINAL_PROMPT, '0'); + require('node:fs').writeFileSync('tag-command', JSON.stringify(process.argv.slice(2))); + `); + const child = shell(dir, script("release", "Compute and push semantic release tag"), { + RELEASE_CREDENTIAL: fixture, REPOSITORY: "dmg/purpur-potato", + ...Object.fromEntries(forbiddenEnv.map(key => [key, key.includes("TOKEN") ? fixture : "1"])), + }); + assert.equal(child.status, 0, child.stderr); + const args = JSON.parse(readFileSync(join(dir, "tag-command"), "utf8")); + assert.ok(Array.isArray(args), "Must actually invoke semantic-release"); + assert.ok(args.includes("semantic-release@24.2.9")); + assert.ok(args.includes("@semantic-release/commit-analyzer@13.0.1")); + assert.ok(args.includes("@semantic-release/release-notes-generator@14.1.0")); + assert.equal(args[args.indexOf("--repository-url") + 1], "https://git.garvis.dev/dmg/purpur-potato.git"); + const config = shell(dir, "git config --local --list"); + assert.match(config.stdout, /credential.usehttppath=true/); + assert.match(config.stdout, /http.followredirects=false/); + assert.ok(config.stdout.includes(`credential.helper=${root}/scripts/git-credential.sh`)); + assert.ok(![JSON.stringify(args), config.stdout, child.stdout, child.stderr].join("").includes(fixture)); + }); +}); + +for (const env of [{ RELEASE_CREDENTIAL: "", REPOSITORY: "dmg/purpur-potato" }, + { RELEASE_CREDENTIAL: fixture, REPOSITORY: "evil/purpur-potato" }]) { + test(`tagging refuses missing credential or wrong repository: ${env.REPOSITORY}`, () => sandbox(dir => { + fake(dir, "npx", "process.exit(90);"); + const child = shell(dir, script("release", "Compute and push semantic release tag"), env); + assert.notEqual(child.status, 0); + assert.notEqual(child.status, 90); + assert.ok(!child.stderr.includes(fixture)); + })); +} + +for (const name of ["ci", "release"]) { + test(`${name} rejects multiline source SHAs before invoking Git`, () => sandbox(dir => { + writeFileSync(join(dir, "calls"), ""); + fake(dir, "git", `require('node:fs').appendFileSync('calls', 'called');`); + const child = shell(dir, script(name, "Checkout public source without a credential"), { SOURCE_SHA: `${sha}\nmalformed` }); + assert.notEqual(child.status, 0); + assert.equal(readFileSync(join(dir, "calls"), "utf8"), ""); + })); +} + +for (const name of ["ci", "release"]) test(`${name} anonymously fetches full history and exact source without credentials or redirects`, () => { + sandbox(dir => { + writeFileSync(join(dir, "calls"), ""); + fake(dir, "git", `require('node:fs').appendFileSync('calls', JSON.stringify(process.argv.slice(2))+'\\n');`); + const child = shell(dir, script(name, "Checkout public source without a credential"), { SOURCE_SHA: sha }); + assert.equal(child.status, 0, child.stderr); + const calls = readFileSync(join(dir, "calls"), "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line)); + assert.ok(calls.some(args => JSON.stringify(args) === JSON.stringify(["remote", "add", "origin", "https://git.garvis.dev/dmg/purpur-potato.git"]))); + for (const [key, value] of [["credential.helper", ""], ["http.extraHeader", ""], ["http.followRedirects", "false"]]) { + assert.ok(calls.some(args => JSON.stringify(args) === JSON.stringify(["config", "--local", key, value]))); + } + assert.ok(calls.some(args => JSON.stringify(args) === JSON.stringify(["fetch", "--force", "--tags", "origin", "+refs/heads/*:refs/remotes/origin/*"]))); + assert.deepEqual(calls.at(-1), name === "ci" ? ["checkout", "--detach", sha] : ["checkout", "-B", "main", sha]); + assert.doesNotMatch(step(name, "Checkout public source without a credential"), /secrets\.|token:/); + }); +}); diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..d6850dc --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +rootProject.name = "purpur-potato" diff --git a/src/main/java/games/dmg/purpurpotato/PotatoCommand.java b/src/main/java/games/dmg/purpurpotato/PotatoCommand.java new file mode 100644 index 0000000..1a18cc5 --- /dev/null +++ b/src/main/java/games/dmg/purpurpotato/PotatoCommand.java @@ -0,0 +1,26 @@ +package games.dmg.purpurpotato; + +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; + +public final class PotatoCommand implements CommandExecutor, org.bukkit.command.TabCompleter { + @Override public java.util.List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + return sender.hasPermission("purpurpotato.help") && args.length == 1 + && "help".startsWith(args[0].toLowerCase(java.util.Locale.ROOT)) + ? java.util.List.of("help") : java.util.List.of(); + } + @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!sender.hasPermission("purpurpotato.help")) { + sender.sendMessage("You do not have permission to use /potato."); + return true; + } + if (args.length > 1 || (args.length == 1 && !args[0].equalsIgnoreCase("help"))) return false; + sender.sendMessage("Right-click a poisonous potato to throw it with snowball flight."); + sender.sendMessage("Cost: one potato per successful throw, including Creative; misses are not refunded."); + sender.sendMessage("Deals half a heart (1 health point) of base damage, with no poison."); + sender.sendMessage("Cooldown: one second (20 ticks). Normal PvP rules and protections apply."); + sender.sendMessage("No crafting recipe or unlock is required."); + return true; + } +} diff --git a/src/main/java/games/dmg/purpurpotato/PotatoListener.java b/src/main/java/games/dmg/purpurpotato/PotatoListener.java new file mode 100644 index 0000000..626726c --- /dev/null +++ b/src/main/java/games/dmg/purpurpotato/PotatoListener.java @@ -0,0 +1,147 @@ +package games.dmg.purpurpotato; + +import org.bukkit.Material; +import org.bukkit.entity.Snowball; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.inventory.ItemStack; +import org.bukkit.event.player.PlayerInteractEvent; + +/** Server-thread interaction and projectile boundary. */ +public final class PotatoListener implements Listener { + private final ThrowGate gate = new ThrowGate(); + private final java.util.Set launching = new java.util.HashSet<>(); + private long tick; + private boolean stopped; + + public void tick() { + gate.prune(++tick); + retire(shot -> tick >= shot.expiresAt); + } + + private void retire(java.util.function.Predicate predicate) { + for (Shot shot : java.util.List.copyOf(projectiles.values())) { + if (predicate.test(shot) && projectiles.remove(shot.ball.getUniqueId(), shot)) shot.ball.remove(); + } + } + + private static final class Shot { + final Snowball ball; + long expiresAt; + boolean damaged; + Shot(Snowball ball, long expiresAt) { this.ball = ball; this.expiresAt = expiresAt; } + } + private final java.util.Map projectiles = new java.util.HashMap<>(); + + @EventHandler(priority = EventPriority.MONITOR) + public void onHit(org.bukkit.event.entity.ProjectileHitEvent event) { + Shot shot = projectiles.get(event.getEntity().getUniqueId()); + if (shot == null) return; + // Paper fires this BEFORE native damage. Keep attribution until native removal, + // with a one-tick fallback for cancelled/deflected impacts that do not discard. + shot.expiresAt = Math.min(shot.expiresAt, tick + 1); + if (event.isCancelled()) shot.damaged = true; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onRemove(org.bukkit.event.entity.EntityRemoveEvent event) { + projectiles.remove(event.getEntity().getUniqueId()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onChunkUnload(org.bukkit.event.world.ChunkUnloadEvent event) { + var chunk = event.getChunk(); + retire(shot -> { + var location = shot.ball.getLocation(); + return location.getWorld() == event.getWorld() + && (location.getBlockX() >> 4) == chunk.getX() + && (location.getBlockZ() >> 4) == chunk.getZ(); + }); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onEntitiesUnload(org.bukkit.event.world.EntitiesUnloadEvent event) { + var ids = new java.util.HashSet(); + for (var entity : event.getEntities()) ids.add(entity.getUniqueId()); + retire(shot -> ids.contains(shot.ball.getUniqueId())); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onWorldUnload(org.bukkit.event.world.WorldUnloadEvent event) { + if (!event.isCancelled()) retire(shot -> shot.ball.getWorld() == event.getWorld()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(org.bukkit.event.player.PlayerQuitEvent event) { + gate.forget(event.getPlayer().getUniqueId()); + } + + public void shutdown() { + stopped = true; + retire(shot -> true); + gate.clear(); + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onDamage(org.bukkit.event.entity.EntityDamageByEntityEvent event) { + Shot shot = projectiles.get(event.getDamager().getUniqueId()); + if (shot == null || !(event.getEntity() instanceof org.bukkit.entity.LivingEntity)) return; + if (shot.damaged) { + event.setCancelled(true); + return; + } + shot.damaged = true; + if (!event.isCancelled()) event.setDamage(1.0); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onInteract(PlayerInteractEvent event) { + if (stopped) return; + if (event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK) return; + if (event.getItem() == null || event.getItem().getType() != Material.POISONOUS_POTATO) return; + // Air interaction is predictively cancelled by vanilla for block use only. + // Explicit item denial (or protected block use) must still win. + if (event.useItemInHand() == Event.Result.DENY + || (event.getAction() == Action.RIGHT_CLICK_BLOCK && event.useInteractedBlock() == Event.Result.DENY) + || event.getPlayer().getGameMode() == org.bukkit.GameMode.SPECTATOR) return; + event.setUseItemInHand(Event.Result.DENY); + var playerId = event.getPlayer().getUniqueId(); + if (launching.contains(playerId) || !gate.begin(playerId, tick)) return; + launching.add(playerId); + try { + launch(event); + } finally { + launching.remove(playerId); + } + } + + private void launch(PlayerInteractEvent event) { + var inventory = event.getPlayer().getInventory(); + var before = inventory.getItem(event.getHand()).clone(); + if (before.getType() != Material.POISONOUS_POTATO || before.getAmount() < 1) return; + Snowball ball; + try { + ball = event.getPlayer().launchProjectile(Snowball.class, null, + projectile -> { + projectile.setItem(new ItemStack(Material.POISONOUS_POTATO)); + projectile.setPersistent(false); + }); + } catch (IllegalArgumentException | IllegalStateException failure) { + return; + } + // CraftLivingEntity returns the object even when addFreshEntity/launch is cancelled. + if (ball == null || !ball.isValid()) return; + if (stopped || !before.equals(inventory.getItem(event.getHand()))) { + ball.remove(); + return; + } + var remaining = before.clone(); + remaining.setAmount(remaining.getAmount() - 1); + inventory.setItem(event.getHand(), remaining); + gate.launched(event.getPlayer().getUniqueId(), tick); + projectiles.put(ball.getUniqueId(), new Shot(ball, tick + 100)); + } +} diff --git a/src/main/java/games/dmg/purpurpotato/PurpurPotato.java b/src/main/java/games/dmg/purpurpotato/PurpurPotato.java new file mode 100644 index 0000000..f71dc1c --- /dev/null +++ b/src/main/java/games/dmg/purpurpotato/PurpurPotato.java @@ -0,0 +1,30 @@ +package games.dmg.purpurpotato; + +import org.bukkit.plugin.java.JavaPlugin; + +public final class PurpurPotato extends JavaPlugin { + private PotatoListener listener; + private org.bukkit.scheduler.BukkitTask task; + + @Override public void onEnable() { + var command = java.util.Objects.requireNonNull(getCommand("potato"), "Missing potato command"); + var help = new PotatoCommand(); + command.setExecutor(help); + command.setTabCompleter(help); + listener = new PotatoListener(); + getServer().getPluginManager().registerEvents(listener, this); + task = getServer().getScheduler().runTaskTimer(this, listener::tick, 1L, 1L); + } + + @Override public void onDisable() { + if (task != null) { + task.cancel(); + task = null; + } + if (listener != null) { + listener.shutdown(); + org.bukkit.event.HandlerList.unregisterAll(listener); + listener = null; + } + } +} diff --git a/src/main/java/games/dmg/purpurpotato/ThrowGate.java b/src/main/java/games/dmg/purpurpotato/ThrowGate.java new file mode 100644 index 0000000..4811c51 --- /dev/null +++ b/src/main/java/games/dmg/purpurpotato/ThrowGate.java @@ -0,0 +1,21 @@ +package games.dmg.purpurpotato; + +import java.util.UUID; +import java.util.HashMap; +import java.util.Map; + +/** Tick-based admission; a failed launch only suppresses duplicate events in its own tick. */ +final class ThrowGate { + private final Map readyAt = new HashMap<>(); + + boolean begin(UUID player, long tick) { + if (tick < readyAt.getOrDefault(player, Long.MIN_VALUE)) return false; + readyAt.put(player, tick + 1); + return true; + } + void launched(UUID player, long tick) { readyAt.put(player, tick + 20); } + void prune(long tick) { readyAt.values().removeIf(deadline -> tick >= deadline); } + void forget(UUID player) { readyAt.remove(player); } + void clear() { readyAt.clear(); } + int size() { return readyAt.size(); } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..69d76b4 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,14 @@ +name: PurpurPotato +version: '${version}' +main: games.dmg.purpurpotato.PurpurPotato +api-version: '26.2' +description: Throw consumable poisonous potatoes with native snowball flight. +commands: + potato: + description: Learn how to throw poisonous potatoes. + usage: /potato [help] + permission: purpurpotato.help +permissions: + purpurpotato.help: + description: View poisonous potato help. + default: true diff --git a/src/nativeTest/java/games/dmg/purpurpotato/NativeCombatTest.java b/src/nativeTest/java/games/dmg/purpurpotato/NativeCombatTest.java new file mode 100644 index 0000000..2bb1d81 --- /dev/null +++ b/src/nativeTest/java/games/dmg/purpurpotato/NativeCombatTest.java @@ -0,0 +1,187 @@ +package games.dmg.purpurpotato; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.papermc.paper.plugin.manager.NativeEvents; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.damagesource.DamageSources; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.projectile.throwableitemprojectile.Snowball; +import net.minecraft.world.phys.EntityHitResult; +import net.minecraft.world.phys.Vec3; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.craftbukkit.CraftRegistry; +import org.bukkit.craftbukkit.entity.CraftLivingEntity; +import org.bukkit.craftbukkit.entity.CraftPlayer; +import org.bukkit.craftbukkit.entity.CraftSnowball; +import org.bukkit.craftbukkit.inventory.CraftItemFactory; +import org.bukkit.craftbukkit.util.CraftMagicNumbers; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class NativeCombatTest { + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + // Constructor is not called by Mockito. Only the protected native collision entrypoint is exposed. + static class CollisionBall extends Snowball { + CollisionBall(ServerLevel world) { super(net.minecraft.world.entity.EntityTypes.SNOWBALL, world); } + void collide(EntityHitResult hit) { super.preHitTargetOrDeflectSelf(hit); } + } + + @ParameterizedTest @ValueSource(strings = {"base", "mob", "player", "cancelled", "pvp-denied", "hit-cancelled", "invulnerable"}) + @SuppressWarnings("try") + void nativeZeroDamageSnowballActuallyEmitsAttributedModifiableCancellableDamage(String mode) throws Exception { + boolean cancel = mode.equals("cancelled"); + var server = mock(org.bukkit.craftbukkit.CraftServer.class); + when(server.isPrimaryThread()).thenReturn(true); + var manager = mock(PluginManager.class); + when(server.getPluginManager()).thenReturn(manager); + var events = new NativeEvents(server); + doAnswer(call -> { events.call(call.getArgument(0)); return null; }).when(manager).callEvent(any()); + var plugin = mock(Plugin.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("PotatoTest")); + var listener = new PotatoListener(); + var observed = new ArrayList(); + Listener protection = new Listener() { + @EventHandler(priority = EventPriority.HIGH) + public void hit(org.bukkit.event.entity.ProjectileHitEvent event) { + if (mode.equals("hit-cancelled")) event.setCancelled(true); + } + @EventHandler(priority = EventPriority.HIGH) + public void damage(EntityDamageByEntityEvent event) { + assertEquals(1, event.getDamage()); + if (!mode.equals("base")) event.setDamage(2); // downstream modifiers remain authoritative + event.setCancelled(cancel); + } + @EventHandler(priority = EventPriority.MONITOR) + public void monitor(EntityDamageByEntityEvent event) { observed.add(event); } + }; + try (var platform = mockStatic(Bukkit.class)) { + platform.when(Bukkit::getPluginManager).thenReturn(manager); + platform.when(Bukkit::isPrimaryThread).thenReturn(true); + platform.when(Bukkit::getItemFactory).thenReturn(CraftItemFactory.instance()); + platform.when(Bukkit::getUnsafe).thenReturn(CraftMagicNumbers.INSTANCE); + events.register(listener, plugin); + events.register(protection, plugin); + var world = mock(ServerLevel.class); + when(world.getCraftServer()).thenReturn(server); + when(world.getRandom()).thenReturn(net.minecraft.util.RandomSource.create(1)); + when(world.getServer()).thenReturn(mock(net.minecraft.server.MinecraftServer.class, RETURNS_DEEP_STUBS)); + var config = mock(org.purpurmc.purpur.PurpurWorldConfig.class); + config.snowballDamage = -1; // vanilla non-Blaze snowball base = zero + var configField = net.minecraft.world.level.Level.class.getField("purpurConfig"); + configField.setAccessible(true); + configField.set(world, config); + var nativePlayer = mock(ServerPlayer.class); + var player = mock(CraftPlayer.class); + when(nativePlayer.getBukkitEntity()).thenReturn(player); + when(player.getScoreboard()).thenReturn(mock(org.bukkit.craftbukkit.scoreboard.CraftScoreboard.class)); + when(nativePlayer.level()).thenReturn(world); + when(nativePlayer.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class)); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + var inventory = mock(PlayerInventory.class); + when(player.getInventory()).thenReturn(inventory); + when(inventory.getItem(EquipmentSlot.HAND)).thenReturn(new ItemStack(Material.POISONOUS_POTATO, 3)); + var rawBall = mock(CollisionBall.class, call -> switch (call.getMethod().getName()) { + case "preHitTargetOrDeflectSelf", "hitTargetOrDeflectSelf", "onHit", "onHitEntity" -> call.callRealMethod(); + default -> RETURNS_DEFAULTS.answer(call); + }); + var ball = mock(CraftSnowball.class); + when(ball.getUniqueId()).thenReturn(UUID.randomUUID()); + when(ball.isValid()).thenReturn(true); + when(rawBall.getBukkitEntity()).thenReturn(ball); + when(rawBall.getOwner()).thenReturn(nativePlayer); + when(rawBall.level()).thenReturn(world); + when(rawBall.damageSources()).thenReturn(new DamageSources(new RegistryAccess.ImmutableRegistryAccess( + List.of(CraftRegistry.getMinecraftRegistry(Registries.DAMAGE_TYPE))))); + doCallRealMethod().when(rawBall).collide(any()); + when(player.launchProjectile(eq(org.bukkit.entity.Snowball.class), isNull(), any())).thenAnswer(call -> { + Consumer setup = call.getArgument(2); + setup.accept(ball); + return ball; + }); + Class targetType = mode.equals("mob") ? LivingEntity.class : ServerPlayer.class; + var target = mock(targetType, call -> switch (call.getMethod().getName()) { + case "getDamageAfterArmorAbsorb", "getDamageAfterMagicAbsorb", "actuallyHurt" -> call.callRealMethod(); + default -> RETURNS_DEFAULTS.answer(call); + }); + when(target.getArmorValue()).thenReturn(mode.equals("base") ? 0 : 10); + when(target.isInvulnerableTo(eq(world), any())).thenReturn(mode.equals("invulnerable")); + CraftLivingEntity victim = mode.equals("mob") ? mock(CraftLivingEntity.class) : mock(CraftPlayer.class); + when(target.getBukkitEntity()).thenReturn(victim); + if (victim instanceof CraftPlayer craftVictim) { + when(craftVictim.getScoreboard()).thenReturn(mock(org.bukkit.craftbukkit.scoreboard.CraftScoreboard.class)); + } + when(target.deflection(any())).thenReturn(net.minecraft.world.entity.projectile.ProjectileDeflection.NONE); + if (target instanceof ServerPlayer nativeVictim) { + when(world.isPvpAllowed()).thenReturn(!mode.equals("pvp-denied")); + when(nativeVictim.canHarmPlayer(nativePlayer)).thenCallRealMethod(); + var abilities = net.minecraft.world.entity.player.Player.class.getDeclaredField("abilities"); + abilities.setAccessible(true); + abilities.set(nativeVictim, new net.minecraft.world.entity.player.Abilities()); + when(nativeVictim.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class)); + } + when(target.level()).thenReturn(world); + var levelField = net.minecraft.world.entity.Entity.class.getDeclaredField("level"); + levelField.setAccessible(true); + levelField.set(target, world); + var health = new java.util.concurrent.atomic.AtomicReference<>(20f); + when(target.getHealth()).thenAnswer(call -> health.get()); + doAnswer(call -> { health.set(call.getArgument(0)); return null; }).when(target).setHealth(anyFloat()); + when(target.getCombatTracker()).thenReturn(mock(net.minecraft.world.damagesource.CombatTracker.class)); + when(target.getItemBySlot(any())).thenReturn(net.minecraft.world.item.ItemStack.EMPTY); + when(target.getUseItem()).thenReturn(net.minecraft.world.item.ItemStack.EMPTY); + doCallRealMethod().when(target).hurt(any(), anyFloat()); + when(target.hurtServer(eq(world), any(), anyFloat())).thenCallRealMethod(); + events.call(new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, + inventory.getItem(EquipmentSlot.HAND), null, null, EquipmentSlot.HAND)); + rawBall.collide(new EntityHitResult(target, Vec3.ZERO)); + if (mode.equals("pvp-denied") || mode.equals("hit-cancelled") || mode.equals("invulnerable")) { + assertTrue(observed.isEmpty(), "Native protection must stop before damage dispatch"); + assertEquals(20f, health.get()); + verify(victim, never()).damage(anyDouble(), any(org.bukkit.entity.Entity.class)); + return; + } + assertEquals(1, observed.size(), "Native zero-damage Snowball must produce a real damage event"); + var event = observed.getFirst(); + assertEquals(EntityDamageEvent.DamageCause.PROJECTILE, event.getCause()); + assertSame(ball, event.getDamager()); + assertSame(ball, event.getDamageSource().getDirectEntity()); + assertSame(player, event.getDamageSource().getCausingEntity(), "Leaf's first attribution path"); + assertEquals(cancel, event.isCancelled()); + assertEquals(mode.equals("base") ? 1 : 2, event.getDamage()); + if (mode.equals("base")) assertEquals(1, event.getFinalDamage()); + else assertTrue(event.getFinalDamage() > 0 && event.getFinalDamage() < 2, + "Native armor recalculation must survive changing the zero base"); + assertEquals(cancel, target.lastDamageCancelled); + assertEquals(cancel ? 20 : 20 - event.getFinalDamage(), health.get(), 1e-5); + verify(victim, never()).addPotionEffect(any()); + } finally { + HandlerList.unregisterAll(plugin); + } + } +} diff --git a/src/nativeTest/java/games/dmg/purpurpotato/NativeLaunchTest.java b/src/nativeTest/java/games/dmg/purpurpotato/NativeLaunchTest.java new file mode 100644 index 0000000..e347548 --- /dev/null +++ b/src/nativeTest/java/games/dmg/purpurpotato/NativeLaunchTest.java @@ -0,0 +1,144 @@ +package games.dmg.purpurpotato; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.UUID; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.projectile.throwableitemprojectile.Snowball; +import net.minecraft.world.phys.Vec3; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.craftbukkit.CraftServer; +import org.bukkit.craftbukkit.CraftWorld; +import org.bukkit.craftbukkit.entity.CraftPlayer; +import org.bukkit.craftbukkit.entity.CraftSnowball; +import org.bukkit.craftbukkit.inventory.CraftItemFactory; +import org.bukkit.craftbukkit.util.CraftMagicNumbers; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class NativeLaunchTest { + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + @ParameterizedTest @ValueSource(booleans = {false, true}) @SuppressWarnings("try") + void realCraftLaunchBuildsANativeOwnedSnowballAndHonorsNativeLaunchCancellation(boolean cancelled) throws Exception { + var server = mock(CraftServer.class); + when(server.isPrimaryThread()).thenReturn(true); + var events = new io.papermc.paper.plugin.manager.NativeEvents(server); + var manager = mock(org.bukkit.plugin.PluginManager.class); + when(server.getPluginManager()).thenReturn(manager); + doAnswer(call -> { events.call(call.getArgument(0)); return null; }).when(manager).callEvent(any()); + var plugin = mock(org.bukkit.plugin.Plugin.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getPluginMeta()).thenReturn(new org.bukkit.plugin.PluginDescriptionFile("PotatoTest", "1", "Test")); + when(server.getLogger()).thenReturn(java.util.logging.Logger.getLogger("PotatoTest")); + var launches = new java.util.concurrent.atomic.AtomicInteger(); + events.register(new org.bukkit.event.Listener() { + @org.bukkit.event.EventHandler + public void launch(org.bukkit.event.entity.ProjectileLaunchEvent event) { + launches.incrementAndGet(); + assertEquals(Material.POISONOUS_POTATO, ((org.bukkit.entity.Snowball) event.getEntity()).getItem().getType()); + event.setCancelled(cancelled); + } + }, plugin); + var world = mock(ServerLevel.class); + var lookup = mock(ca.spottedleaf.moonrise.patches.chunk_system.level.entity.EntityLookup.class); + when(lookup.canRemoveEntity(any())).thenReturn(true); + when(world.moonrise$getEntityLookup()).thenReturn(lookup); + var configField = net.minecraft.world.level.Level.class.getField("purpurConfig"); + configField.setAccessible(true); + configField.set(world, mock(org.purpurmc.purpur.PurpurWorldConfig.class)); + var spigotField = net.minecraft.world.level.Level.class.getField("spigotConfig"); + spigotField.setAccessible(true); + spigotField.set(world, mock(org.spigotmc.SpigotWorldConfig.class)); + var paper = mock(io.papermc.paper.configuration.WorldConfiguration.class); + paper.entities = mock(io.papermc.paper.configuration.WorldConfiguration.Entities.class); + paper.entities.spawning = mock(io.papermc.paper.configuration.WorldConfiguration.Entities.Spawning.class); + paper.entities.spawning.despawnTime = new it.unimi.dsi.fastutil.objects.Reference2ObjectOpenHashMap<>(); + paper.misc = mock(io.papermc.paper.configuration.WorldConfiguration.Misc.class); + when(world.paperConfig()).thenReturn(paper); + var craftWorld = mock(CraftWorld.class); + when(craftWorld.getHandle()).thenReturn(world); + when(world.getWorld()).thenReturn(craftWorld); + when(world.getCraftServer()).thenReturn(server); + var nativePlayer = mock(ServerPlayer.class); + var player = mock(CraftPlayer.class); + when(player.getHandle()).thenReturn(nativePlayer); + when(player.getWorld()).thenReturn(craftWorld); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(nativePlayer.getUUID()).thenAnswer(call -> player.getUniqueId()); + when(nativePlayer.getBukkitEntity()).thenReturn(player); + when(nativePlayer.level()).thenReturn(world); + when(nativePlayer.getDeltaMovement()).thenReturn(Vec3.ZERO); + when(nativePlayer.getKnownMovement()).thenReturn(Vec3.ZERO); + when(nativePlayer.getEyeY()).thenReturn(65.62); + var inventory = mock(PlayerInventory.class); + when(player.getInventory()).thenReturn(inventory); + when(inventory.getItem(EquipmentSlot.HAND)).thenReturn(new ItemStack(Material.POISONOUS_POTATO, 3)); + var spawned = new ArrayList(); + when(world.addFreshEntity(any(Entity.class))).thenAnswer(call -> { + Snowball raw = call.getArgument(0); + spawned.add(raw); + boolean added = org.bukkit.craftbukkit.event.CraftEventFactory.doEntityAddEventCalling(world, raw, + org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason.CUSTOM); + raw.valid = added; + return added; + }); + when(player.launchProjectile(eq(org.bukkit.entity.Snowball.class), isNull(), any())).thenCallRealMethod(); + try (var platform = mockStatic(Bukkit.class)) { + platform.when(Bukkit::getServer).thenReturn(server); + platform.when(Bukkit::getPluginManager).thenReturn(manager); + platform.when(Bukkit::isPrimaryThread).thenReturn(true); + platform.when(Bukkit::getItemFactory).thenReturn(CraftItemFactory.instance()); + platform.when(Bukkit::getUnsafe).thenReturn(CraftMagicNumbers.INSTANCE); + var listener = new PotatoListener(); + events.register(listener, plugin); + events.call(new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, + inventory.getItem(EquipmentSlot.HAND), null, null, EquipmentSlot.HAND)); + assertEquals(1, spawned.size()); + assertEquals(1, launches.get()); + var raw = spawned.getFirst(); + var ball = (CraftSnowball) raw.getBukkitEntity(); + assertSame(player, ball.getShooter()); + assertSame(nativePlayer, raw.getOwner()); + assertEquals(Material.POISONOUS_POTATO, ball.getItem().getType()); + assertEquals(1, ball.getItem().getAmount()); + assertTrue(ball.hasGravity()); + assertFalse(ball.isPersistent(), "Temporary ammunition must not survive saving/reloading"); + assertEquals(1.5, raw.getDeltaMovement().length(), 0.1); + assertTrue(raw.getDeltaMovement().z > 1.4); + assertEquals(65.52, raw.getY(), 0.001); + if (cancelled) { + assertFalse(ball.isValid()); + assertTrue(raw.isRemoved()); + verify(inventory, never()).setItem(any(EquipmentSlot.class), any()); + } else { + var before = raw.getDeltaMovement(); + var gravity = Entity.class.getDeclaredMethod("applyGravity"); + gravity.setAccessible(true); + gravity.invoke(raw); + assertEquals(before.y - 0.03, raw.getDeltaMovement().y, 1e-6); + verify(inventory).setItem(eq(EquipmentSlot.HAND), argThat(item -> item.getAmount() == 2)); + for (int i = 0; i < 99; i++) listener.tick(); + assertFalse(raw.isRemoved()); + listener.tick(); + assertTrue(raw.isRemoved(), "Native CraftSnowball removal at the exact deadline"); + assertFalse(raw.isAlive()); + verify(inventory, times(1)).setItem(any(EquipmentSlot.class), any()); + verify(world, times(1)).addFreshEntity(any(Entity.class)); + } + } finally { + org.bukkit.event.HandlerList.unregisterAll(plugin); + } + } +} diff --git a/src/nativeTest/java/games/dmg/purpurpotato/NativeRuntime.java b/src/nativeTest/java/games/dmg/purpurpotato/NativeRuntime.java new file mode 100644 index 0000000..6d4e6c2 --- /dev/null +++ b/src/nativeTest/java/games/dmg/purpurpotato/NativeRuntime.java @@ -0,0 +1,63 @@ +package games.dmg.purpurpotato; + +import java.util.List; +import java.util.stream.Stream; +import net.minecraft.SharedConstants; +import net.minecraft.commands.Commands; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.LayeredRegistryAccess; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.RegistryDataLoader; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.RegistryLayer; +import net.minecraft.server.ReloadableServerResources; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.repository.ServerPacksSource; +import net.minecraft.server.packs.resources.MultiPackResourceManager; +import net.minecraft.server.permissions.LevelBasedPermissionSet; +import net.minecraft.tags.TagLoader; +import net.minecraft.util.Util; +import net.minecraft.world.flag.FeatureFlags; +import net.minecraft.world.level.DataPackConfig; +import net.minecraft.world.level.WorldDataConfiguration; +import org.bukkit.craftbukkit.CraftRegistry; + +/** Loads vanilla registries/tags/components using the same path as the server's own tests. */ +final class NativeRuntime { + private static boolean initialized; + + private NativeRuntime() {} + + static synchronized void bootstrap() throws Exception { + // Paper registries are process-global and reject loading a second time. + if (initialized) return; + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + var flags = FeatureFlags.VANILLA_SET; + var packs = ServerPacksSource.createVanillaTrustedRepository(); + MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig( + FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(), List.of()), flags), true, false); + try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) { + LayeredRegistryAccess layers = RegistryLayer.createRegistryAccess(); + List> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC)); + List> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags); + RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups, + RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join(); + layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen); + RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources, + Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(), + RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join(); + layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions); + Class.forName(org.bukkit.Registry.class.getName()); + var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags, + Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS, + Util.backgroundExecutor(), Runnable::run).join(); + datapack.updateComponentsAndStaticRegistryTags(); + CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze()); + } + initialized = true; + } +} diff --git a/src/nativeTest/java/games/dmg/purpurpotato/PluginLifecycleTest.java b/src/nativeTest/java/games/dmg/purpurpotato/PluginLifecycleTest.java new file mode 100644 index 0000000..72b1a26 --- /dev/null +++ b/src/nativeTest/java/games/dmg/purpurpotato/PluginLifecycleTest.java @@ -0,0 +1,109 @@ +package games.dmg.purpurpotato; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.papermc.paper.plugin.manager.NativeEvents; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.command.PluginCommand; +import org.bukkit.craftbukkit.inventory.CraftItemFactory; +import org.bukkit.craftbukkit.util.CraftMagicNumbers; +import org.bukkit.entity.Player; +import org.bukkit.entity.Snowball; +import org.bukkit.event.HandlerList; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.plugin.PluginDescriptionFile; +import org.bukkit.plugin.PluginManager; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PluginLifecycleTest { + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + @Test @SuppressWarnings("try") + void enableRegistersNativeEventsCommandAndTickTaskAndDisableRemovesEverything() throws Exception { + var server = mock(org.bukkit.Server.class); + when(server.isPrimaryThread()).thenReturn(true); + var scheduler = mock(BukkitScheduler.class); + when(server.getScheduler()).thenReturn(scheduler); + var manager = mock(PluginManager.class); + when(server.getPluginManager()).thenReturn(manager); + var events = new NativeEvents(server); + var plugin = mock(PurpurPotato.class); + doCallRealMethod().when(plugin).onEnable(); + doCallRealMethod().when(plugin).onDisable(); + when(plugin.getServer()).thenReturn(server); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("PotatoTest")); + try (var stream = getClass().getResourceAsStream("/plugin.yml")) { + var description = new PluginDescriptionFile(stream); + assertEquals("26.2", description.getAPIVersion()); + when(plugin.getDescription()).thenReturn(description); + } + var constructor = PluginCommand.class.getDeclaredConstructor(String.class, org.bukkit.plugin.Plugin.class); + constructor.setAccessible(true); + var command = constructor.newInstance("potato", plugin); + command.setPermission("purpurpotato.help"); + command.setUsage("/potato [help]"); + when(plugin.getCommand("potato")).thenReturn(command); + doAnswer(call -> { events.register(call.getArgument(0), plugin); return null; }).when(manager).registerEvents(any(), eq(plugin)); + var tick = new AtomicReference(); + var task = mock(BukkitTask.class); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(1L))).thenAnswer(call -> { + tick.set(call.getArgument(1)); return task; + }); + try (var platform = mockStatic(Bukkit.class)) { + platform.when(Bukkit::getItemFactory).thenReturn(CraftItemFactory.instance()); + platform.when(Bukkit::getUnsafe).thenReturn(CraftMagicNumbers.INSTANCE); + platform.when(Bukkit::getPluginManager).thenReturn(manager); + plugin.onEnable(); + assertNotNull(tick.get(), "A registered synchronous tick task is required"); + assertInstanceOf(PotatoCommand.class, command.getExecutor()); + assertSame(command.getExecutor(), command.getTabCompleter()); + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + when(console.hasPermission("purpurpotato.help")).thenReturn(true); + assertTrue(command.execute(console, "potato", new String[]{"help"})); + verify(console).sendMessage(contains("half a heart")); + command.execute(console, "potato", new String[]{"wrong"}); + verify(console).sendMessage("/potato [help]"); + var player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + var inventory = mock(PlayerInventory.class); + when(player.getInventory()).thenReturn(inventory); + when(inventory.getItem(EquipmentSlot.HAND)).thenReturn(new ItemStack(Material.POISONOUS_POTATO, 3)); + var ball = mock(Snowball.class); + when(ball.getUniqueId()).thenReturn(UUID.randomUUID()); + when(ball.isValid()).thenReturn(true); + when(player.launchProjectile(eq(Snowball.class), isNull(), any())).thenAnswer(call -> { + Consumer setup = call.getArgument(2); setup.accept(ball); return ball; + }); + events.call(new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, + inventory.getItem(EquipmentSlot.HAND), null, null, EquipmentSlot.HAND)); + verify(inventory).setItem(eq(EquipmentSlot.HAND), argThat(item -> item.getAmount() == 2)); + for (int i = 0; i < 99; i++) tick.get().run(); + verify(ball, never()).remove(); + tick.get().run(); + verify(ball).remove(); + events.call(new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, + inventory.getItem(EquipmentSlot.HAND), null, null, EquipmentSlot.HAND)); + plugin.onDisable(); + verify(task).cancel(); + verify(ball, times(2)).remove(); + assertEquals(0, PlayerInteractEvent.getHandlerList().getRegisteredListeners().length); + plugin.onDisable(); + verify(ball, times(2)).remove(); + } finally { + HandlerList.unregisterAll(plugin); + } + } +} diff --git a/src/nativeTest/java/games/dmg/purpurpotato/PotatoEventsTest.java b/src/nativeTest/java/games/dmg/purpurpotato/PotatoEventsTest.java new file mode 100644 index 0000000..828ea57 --- /dev/null +++ b/src/nativeTest/java/games/dmg/purpurpotato/PotatoEventsTest.java @@ -0,0 +1,394 @@ +package games.dmg.purpurpotato; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.UUID; +import java.util.function.Consumer; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.craftbukkit.inventory.CraftItemFactory; +import org.bukkit.craftbukkit.util.CraftMagicNumbers; +import org.bukkit.entity.Player; +import org.bukkit.entity.Snowball; +import org.bukkit.event.Event; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.junit.jupiter.api.*; +import org.mockito.MockedStatic; + +class PotatoEventsTest { + final Player player = mock(Player.class); + final PlayerInventory inventory = mock(PlayerInventory.class); + final Snowball ball = mock(Snowball.class); + final PotatoListener listener = new PotatoListener(); + ItemStack main; + ItemStack off; + MockedStatic bukkit; + + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + @BeforeEach void setup() { + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getItemFactory).thenReturn(CraftItemFactory.instance()); + bukkit.when(Bukkit::getUnsafe).thenReturn(CraftMagicNumbers.INSTANCE); + main = new ItemStack(Material.POISONOUS_POTATO, 3); + off = new ItemStack(Material.POISONOUS_POTATO, 4); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.getInventory()).thenReturn(inventory); + when(inventory.getItem(EquipmentSlot.HAND)).thenAnswer(call -> main); + when(inventory.getItem(EquipmentSlot.OFF_HAND)).thenAnswer(call -> off); + doAnswer(call -> { + if (call.getArgument(0) == EquipmentSlot.HAND) main = call.getArgument(1); + else off = call.getArgument(1); + return null; + }).when(inventory).setItem(any(EquipmentSlot.class), any()); + when(ball.getUniqueId()).thenReturn(UUID.randomUUID()); + when(ball.isValid()).thenReturn(true); + when(player.launchProjectile(eq(Snowball.class), isNull(), any())).thenAnswer(call -> { + Consumer configure = call.getArgument(2); + configure.accept(ball); + return ball; + }); + } + @AfterEach void close() { bukkit.close(); } + + PlayerInteractEvent interact(EquipmentSlot hand) { + return new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, + hand == EquipmentSlot.HAND ? main : off, null, null, hand); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(value = Material.class, names = {"POTATO", "BAKED_POTATO", "BREAD", "SNOWBALL", "AIR"}) + void ordinaryItemsAreUnchanged(Material material) { + main = new ItemStack(material, material == Material.AIR ? 0 : 3); + var event = interact(EquipmentSlot.HAND); + var original = event.useItemInHand(); + listener.onInteract(event); + verify(player, never()).launchProjectile(any(), any(), any()); + assertEquals(original, event.useItemInHand()); + assertEquals(material == Material.AIR ? 0 : 3, main.getAmount()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(value = Action.class, names = {"LEFT_CLICK_AIR", "LEFT_CLICK_BLOCK", "PHYSICAL"}) + void onlyRightClicksThrow(Action action) { + var event = new PlayerInteractEvent(player, action, main, null, null, EquipmentSlot.HAND); + listener.onInteract(event); + verify(player, never()).launchProjectile(any(), any(), any()); + assertEquals(3, main.getAmount()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"cancel", "item-denied", "block-denied", "spectator"}) + void respectsInteractionProtections(String protection) { + var event = protection.equals("block-denied") + ? new PlayerInteractEvent(player, Action.RIGHT_CLICK_BLOCK, main, + mock(org.bukkit.block.Block.class), org.bukkit.block.BlockFace.UP, EquipmentSlot.HAND) + : interact(EquipmentSlot.HAND); + switch (protection) { + case "cancel" -> event.setCancelled(true); + case "item-denied" -> event.setUseItemInHand(Event.Result.DENY); + case "block-denied" -> event.setUseInteractedBlock(Event.Result.DENY); + case "spectator" -> when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SPECTATOR); + default -> fail(); + } + listener.onInteract(event); + verify(player, never()).launchProjectile(any(), any(), any()); + assertEquals(3, main.getAmount()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"cancelled", "null", "exception"}) + void failedLaunchConsumesNothing(String failure) { + switch (failure) { + case "cancelled" -> when(ball.isValid()).thenReturn(false); + case "null" -> when(player.launchProjectile(eq(Snowball.class), isNull(), any())).thenReturn(null); + case "exception" -> when(player.launchProjectile(eq(Snowball.class), isNull(), any())).thenThrow(new IllegalStateException("launch failed")); + default -> fail(); + } + listener.onInteract(interact(EquipmentSlot.HAND)); + assertEquals(3, main.getAmount()); + assertEquals(4, off.getAmount()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"cancelled", "null", "exception"}) + void failedLaunchSuppressesDuplicateHandsButCanRetryOnTheNextTick(String failure) { + switch (failure) { + case "cancelled" -> when(ball.isValid()).thenReturn(false); + case "null" -> when(player.launchProjectile(eq(Snowball.class), isNull(), any())).thenReturn(null); + case "exception" -> when(player.launchProjectile(eq(Snowball.class), isNull(), any())).thenThrow(new IllegalStateException()); + default -> fail(); + } + listener.onInteract(interact(EquipmentSlot.HAND)); + listener.onInteract(interact(EquipmentSlot.OFF_HAND)); + verify(player, times(1)).launchProjectile(eq(Snowball.class), isNull(), any()); + assertEquals(3, main.getAmount()); + assertEquals(4, off.getAmount()); + doAnswer(call -> { + Consumer setup = call.getArgument(2); setup.accept(ball); return ball; + }).when(player).launchProjectile(eq(Snowball.class), isNull(), any()); + when(ball.isValid()).thenReturn(true); + listener.tick(); + listener.onInteract(interact(EquipmentSlot.OFF_HAND)); + assertEquals(3, off.getAmount()); + } + + @Test void preservesItemMetadataAndConsumesTheLastPotatoWithoutRefund() { + var meta = main.getItemMeta(); + meta.displayName(net.kyori.adventure.text.Component.text("My potato")); + main.setItemMeta(meta); + var before = main.clone(); + listener.onInteract(interact(EquipmentSlot.HAND)); + assertEquals(before.getItemMeta(), main.getItemMeta()); + assertEquals(2, main.getAmount()); + for (int i = 0; i < 20; i++) listener.tick(); + main.setAmount(1); + listener.onInteract(interact(EquipmentSlot.HAND)); + assertEquals(0, main.getAmount()); + listener.onInteract(interact(EquipmentSlot.HAND)); + verify(player, times(2)).launchProjectile(eq(Snowball.class), isNull(), any()); + } + + @Test void duplicateHandsAndCooldownConsumeExactlyOnceIncludingCreative() { + when(player.getGameMode()).thenReturn(org.bukkit.GameMode.CREATIVE); + listener.onInteract(interact(EquipmentSlot.HAND)); + listener.onInteract(interact(EquipmentSlot.OFF_HAND)); + for (int i = 0; i < 19; i++) listener.tick(); + listener.onInteract(interact(EquipmentSlot.OFF_HAND)); + verify(player, times(1)).launchProjectile(eq(Snowball.class), isNull(), any()); + assertEquals(2, main.getAmount()); + assertEquals(4, off.getAmount()); + listener.tick(); + listener.onInteract(interact(EquipmentSlot.OFF_HAND)); + verify(player, times(2)).launchProjectile(eq(Snowball.class), isNull(), any()); + assertEquals(3, off.getAmount()); + } + + @Test void lifecycleCallbacksCannotReleaseAnInProgressLaunchReservation() { + var calls = new java.util.concurrent.atomic.AtomicInteger(); + when(player.launchProjectile(eq(Snowball.class), isNull(), any())).thenAnswer(call -> { + Consumer configure = call.getArgument(2); + configure.accept(ball); + if (calls.incrementAndGet() == 1) { + listener.onQuit(new org.bukkit.event.player.PlayerQuitEvent(player, net.kyori.adventure.text.Component.empty())); + listener.onInteract(interact(EquipmentSlot.OFF_HAND)); + } + return ball; + }); + listener.onInteract(interact(EquipmentSlot.HAND)); + verify(player, times(1)).launchProjectile(eq(Snowball.class), isNull(), any()); + assertEquals(2, main.getAmount()); + assertEquals(4, off.getAmount()); + } + + @Test void shutdownInsideLaunchRollsBackWithoutChargingOrLeavingAnUntrackedProjectile() { + when(player.launchProjectile(eq(Snowball.class), isNull(), any())).thenAnswer(call -> { + Consumer configure = call.getArgument(2); + configure.accept(ball); + listener.shutdown(); + return ball; + }); + listener.onInteract(interact(EquipmentSlot.HAND)); + assertEquals(3, main.getAmount()); + verify(ball).remove(); + var damage = damage(ball, 0); + listener.onDamage(damage); + assertEquals(0, damage.getDamage()); + } + + @Test void reentrantLaunchCannotDuplicateOrConsumeAReplacedHand() { + when(player.launchProjectile(eq(Snowball.class), isNull(), any())).thenAnswer(call -> { + Consumer configure = call.getArgument(2); + configure.accept(ball); + listener.onInteract(interact(EquipmentSlot.OFF_HAND)); + main = new ItemStack(Material.DIAMOND, 5); + return ball; + }); + listener.onInteract(interact(EquipmentSlot.HAND)); + verify(player, times(1)).launchProjectile(eq(Snowball.class), isNull(), any()); + assertEquals(Material.DIAMOND, main.getType()); + assertEquals(5, main.getAmount()); + assertEquals(4, off.getAmount()); + verify(ball).remove(); + } + + org.bukkit.event.entity.EntityDamageByEntityEvent damage(Snowball projectile, double amount) { + var source = mock(org.bukkit.damage.DamageSource.class); + when(source.getCausingEntity()).thenReturn(player); + when(source.getDirectEntity()).thenReturn(projectile); + return new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, + mock(org.bukkit.entity.LivingEntity.class), + org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, amount); + } + + @Test void changesOnlyTheTrackedNativeDamageBaseWithoutAnonymousExtraDamageOrPoison() { + listener.onInteract(interact(EquipmentSlot.HAND)); + var event = damage(ball, 0); + listener.onDamage(event); + assertEquals(1, event.getDamage()); + assertSame(player, event.getDamageSource().getCausingEntity()); + assertSame(ball, event.getDamageSource().getDirectEntity()); + verifyNoInteractions(event.getEntity()); + var ordinary = damage(mock(Snowball.class), 3); + listener.onDamage(ordinary); + assertEquals(3, ordinary.getDamage()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void damageIsAtMostOnceEvenWhenProtectionCancelsTheFirstEvent(boolean cancelled) { + listener.onInteract(interact(EquipmentSlot.HAND)); + var first = damage(ball, 0); + first.setCancelled(cancelled); + listener.onDamage(first); + assertEquals(cancelled, first.isCancelled()); + assertEquals(cancelled ? 0 : 1, first.getDamage()); + var duplicate = damage(ball, 3); + listener.onDamage(duplicate); + assertTrue(duplicate.isCancelled(), "Must not let native fallback deal extra damage"); + verifyNoInteractions(first.getEntity(), duplicate.getEntity()); + } + + @Test void expiresAtExactlyOneHundredTicksWithoutRefundOrChunkAccess() { + listener.onInteract(interact(EquipmentSlot.HAND)); + for (int i = 0; i < 99; i++) listener.tick(); + verify(ball, never()).remove(); + listener.tick(); + verify(ball).remove(); + listener.tick(); + verify(ball, times(1)).remove(); + assertEquals(2, main.getAmount()); + verify(ball, never()).getChunk(); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void terminalImpactStopsAtNextTickButDoesNotEraseTheFollowingNativeDamage(boolean cancelled) { + listener.onInteract(interact(EquipmentSlot.HAND)); + var hit = new org.bukkit.event.entity.ProjectileHitEvent(ball, mock(org.bukkit.entity.LivingEntity.class)); + hit.setCancelled(cancelled); + listener.onHit(hit); + if (!cancelled) { + var damage = damage(ball, 0); + listener.onDamage(damage); + assertEquals(1, damage.getDamage()); + } + listener.tick(); + verify(ball).remove(); + for (int i = 0; i < 100; i++) listener.tick(); + verify(ball, times(1)).remove(); + assertEquals(2, main.getAmount()); + } + + @Test void expiryIsSafeWhenRemoveSynchronouslyFiresTheNativeRemovalEvent() { + listener.onInteract(interact(EquipmentSlot.HAND)); + doAnswer(call -> { + listener.onRemove(new org.bukkit.event.entity.EntityRemoveEvent(ball, + org.bukkit.event.entity.EntityRemoveEvent.Cause.PLUGIN)); + return null; + }).when(ball).remove(); + assertDoesNotThrow(() -> { for (int i = 0; i < 101; i++) listener.tick(); }); + verify(ball, times(1)).remove(); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"chunk", "entities", "world", "disable"}) + void unloadAndDisableRemoveOnlyTrackedEntitiesWithoutChunkLoading(String reason) { + var world = mock(org.bukkit.World.class); + var chunk = mock(org.bukkit.Chunk.class); + when(chunk.getWorld()).thenReturn(world); + when(chunk.getX()).thenReturn(-2); + when(chunk.getZ()).thenReturn(3); + when(ball.getWorld()).thenReturn(world); + when(ball.getLocation()).thenReturn(new org.bukkit.Location(world, -17, 65, 48)); + listener.onInteract(interact(EquipmentSlot.HAND)); + switch (reason) { + case "chunk" -> listener.onChunkUnload(new org.bukkit.event.world.ChunkUnloadEvent(chunk)); + case "entities" -> listener.onEntitiesUnload(new org.bukkit.event.world.EntitiesUnloadEvent(chunk, java.util.List.of(ball))); + case "world" -> listener.onWorldUnload(new org.bukkit.event.world.WorldUnloadEvent(world)); + case "disable" -> listener.shutdown(); + default -> fail(); + } + verify(ball).remove(); + var later = damage(ball, 0); + listener.onDamage(later); + assertEquals(0, later.getDamage()); + for (int i = 0; i < 101; i++) listener.tick(); + verify(ball, times(1)).remove(); + verify(ball, never()).getChunk(); + verify(chunk, never()).getEntities(); + verifyNoInteractions(world); + } + + @Test void quitClearsCooldownAndShutdownRejectsReentrantAndLateInteractions() { + listener.onInteract(interact(EquipmentSlot.HAND)); + listener.onQuit(new org.bukkit.event.player.PlayerQuitEvent(player, net.kyori.adventure.text.Component.empty())); + listener.onInteract(interact(EquipmentSlot.OFF_HAND)); + verify(player, times(2)).launchProjectile(eq(Snowball.class), isNull(), any()); + doAnswer(call -> { + listener.onQuit(new org.bukkit.event.player.PlayerQuitEvent(player, net.kyori.adventure.text.Component.empty())); + listener.onInteract(interact(EquipmentSlot.HAND)); + return null; + }).when(ball).remove(); + listener.shutdown(); + for (int i = 0; i < 21; i++) listener.tick(); + listener.onInteract(interact(EquipmentSlot.HAND)); + verify(player, times(2)).launchProjectile(eq(Snowball.class), isNull(), any()); + } + + @Test void unrelatedChunksWorldsEntitiesAndCancelledWorldUnloadLeaveShotsAlone() { + var world = mock(org.bukkit.World.class); + var otherWorld = mock(org.bukkit.World.class); + when(ball.getWorld()).thenReturn(world); + when(ball.getLocation()).thenReturn(new org.bukkit.Location(world, -17, 65, 48)); + listener.onInteract(interact(EquipmentSlot.HAND)); + var otherChunk = mock(org.bukkit.Chunk.class); + when(otherChunk.getWorld()).thenReturn(world); + when(otherChunk.getX()).thenReturn(-1); + when(otherChunk.getZ()).thenReturn(3); + listener.onChunkUnload(new org.bukkit.event.world.ChunkUnloadEvent(otherChunk)); + when(otherChunk.getX()).thenReturn(-2); + when(otherChunk.getWorld()).thenReturn(otherWorld); + listener.onChunkUnload(new org.bukkit.event.world.ChunkUnloadEvent(otherChunk)); + listener.onEntitiesUnload(new org.bukkit.event.world.EntitiesUnloadEvent(otherChunk, + java.util.List.of(mock(Snowball.class)))); + listener.onWorldUnload(new org.bukkit.event.world.WorldUnloadEvent(otherWorld)); + var cancelled = new org.bukkit.event.world.WorldUnloadEvent(world); + cancelled.setCancelled(true); + listener.onWorldUnload(cancelled); + verify(ball, never()).remove(); + listener.onDamage(damage(ball, 0)); + listener.onRemove(new org.bukkit.event.entity.EntityRemoveEvent(mock(Snowball.class), + org.bukkit.event.entity.EntityRemoveEvent.Cause.HIT)); + listener.shutdown(); + verify(ball).remove(); + } + + @Test void nativeRemovalImmediatelyReleasesTrackingAndNeverResurrectsOrRefunds() { + listener.onInteract(interact(EquipmentSlot.HAND)); + listener.onRemove(new org.bukkit.event.entity.EntityRemoveEvent(ball, + org.bukkit.event.entity.EntityRemoveEvent.Cause.HIT)); + var later = damage(ball, 0); + listener.onDamage(later); + assertEquals(0, later.getDamage()); + for (int i = 0; i < 101; i++) listener.tick(); + verify(ball, never()).remove(); + assertEquals(2, main.getAmount()); + } + + @Test void rightClickLaunchesOneNativeSnowballWithPotatoVisualAndConsumesOneInsteadOfEating() { + var event = interact(EquipmentSlot.HAND); + listener.onInteract(event); + verify(player).launchProjectile(eq(Snowball.class), isNull(), any()); + verify(ball).setItem(argThat(item -> item.getType() == Material.POISONOUS_POTATO && item.getAmount() == 1)); + assertEquals(2, main.getAmount()); + assertEquals(4, off.getAmount()); + assertEquals(Event.Result.DENY, event.useItemInHand()); + verify(ball, never()).setVelocity(any()); + verify(ball, never()).setGravity(anyBoolean()); + } +} diff --git a/src/nativeTest/java/io/papermc/paper/plugin/manager/NativeEvents.java b/src/nativeTest/java/io/papermc/paper/plugin/manager/NativeEvents.java new file mode 100644 index 0000000..fe20671 --- /dev/null +++ b/src/nativeTest/java/io/papermc/paper/plugin/manager/NativeEvents.java @@ -0,0 +1,14 @@ +package io.papermc.paper.plugin.manager; + +import org.bukkit.Server; +import org.bukkit.event.Event; +import org.bukkit.event.Listener; +import org.bukkit.plugin.Plugin; + +/** Test-only access to Purpur's actual annotation registration and event dispatcher. */ +public final class NativeEvents { + private final PaperEventManager events; + public NativeEvents(Server server) { events = new PaperEventManager(server); } + public void register(Listener listener, Plugin plugin) { events.registerEvents(listener, plugin); } + public void call(Event event) { events.callEvent(event); } +} diff --git a/src/test/java/games/dmg/purpurpotato/DistributionTest.java b/src/test/java/games/dmg/purpurpotato/DistributionTest.java new file mode 100644 index 0000000..12c2a54 --- /dev/null +++ b/src/test/java/games/dmg/purpurpotato/DistributionTest.java @@ -0,0 +1,42 @@ +package games.dmg.purpurpotato; + +import static org.junit.jupiter.api.Assertions.*; +import java.io.DataInputStream; +import java.nio.file.Path; +import java.util.Map; +import java.util.jar.JarFile; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +class DistributionTest { + @Test void shipsOnlyTheVersionedJava25PluginWithItsDeclaredPublicContract() throws Exception { + var path = Path.of(System.getProperty("distribution.jar")); + String version = System.getProperty("distribution.version"); + assertEquals("purpur-potato-" + version + ".jar", path.getFileName().toString()); + try (var jar = new JarFile(path.toFile())) { + assertNotNull(jar.getEntry("plugin.yml"), "A loadable plugin needs its descriptor"); + Map metadata = new Yaml().load(jar.getInputStream(jar.getEntry("plugin.yml"))); + assertEquals("PurpurPotato", metadata.get("name")); + assertEquals(version, metadata.get("version")); + assertEquals("games.dmg.purpurpotato.PurpurPotato", metadata.get("main")); + assertEquals("26.2", metadata.get("api-version")); + var commands = (Map) metadata.get("commands"); + assertEquals(1, commands.size()); + var potato = (Map) commands.get("potato"); + assertEquals("/potato [help]", potato.get("usage")); + assertEquals("purpurpotato.help", potato.get("permission")); + var permissions = (Map) metadata.get("permissions"); + assertEquals(true, ((Map) permissions.get("purpurpotato.help")).get("default")); + var entry = jar.getEntry("games/dmg/purpurpotato/PurpurPotato.class"); + assertNotNull(entry); + try (var bytes = new DataInputStream(jar.getInputStream(entry))) { + assertEquals(0xcafebabe, bytes.readInt()); + bytes.readUnsignedShort(); + assertEquals(69, bytes.readUnsignedShort()); + } + assertTrue(jar.stream().filter(e -> e.getName().endsWith(".class")) + .allMatch(e -> e.getName().startsWith("games/dmg/purpurpotato/") + && !e.getName().endsWith("Test.class")), "No server or test dependencies may be bundled"); + } + } +} diff --git a/src/test/java/games/dmg/purpurpotato/PotatoCommandTest.java b/src/test/java/games/dmg/purpurpotato/PotatoCommandTest.java new file mode 100644 index 0000000..7c40396 --- /dev/null +++ b/src/test/java/games/dmg/purpurpotato/PotatoCommandTest.java @@ -0,0 +1,60 @@ +package games.dmg.purpurpotato; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class PotatoCommandTest { + @Test void permissionIsCheckedAtExecutionAndCompletionEvenForConsole() { + var command = new PotatoCommand(); + for (Class type : List.of(Player.class, ConsoleCommandSender.class)) { + var sender = mock(type); + assertTrue(command.onCommand(sender, mock(Command.class), "potato", new String[0])); + verify(sender).sendMessage("You do not have permission to use /potato."); + assertEquals(List.of(), command.onTabComplete(sender, mock(Command.class), "potato", new String[]{""})); + } + } + + @Test void usageAndCompletionAreNarrowAndCaseInsensitive() { + var command = new PotatoCommand(); + var sender = mock(CommandSender.class); + when(sender.hasPermission("purpurpotato.help")).thenReturn(true); + for (String[] args : List.of(new String[]{"give"}, new String[]{"help", "extra"})) { + assertFalse(command.onCommand(sender, mock(Command.class), "potato", args)); + } + verify(sender, never()).sendMessage(anyString()); + for (String prefix : List.of("", "h", "HE", "help")) { + assertEquals(List.of("help"), command.onTabComplete(sender, mock(Command.class), "potato", new String[]{prefix})); + } + for (String[] args : List.of(new String[0], new String[]{"give"}, new String[]{"help", ""})) { + assertEquals(List.of(), command.onTabComplete(sender, mock(Command.class), "potato", args)); + } + } + + @Test void helpIsIdenticalForPlayersAndConsoleWithEveryApprovedRule() { + var command = new PotatoCommand(); + List expected = null; + for (Class type : List.of(Player.class, ConsoleCommandSender.class)) { + for (String[] args : List.of(new String[0], new String[]{"help"}, new String[]{"HELP"})) { + var sender = mock(type); + when(sender.hasPermission("purpurpotato.help")).thenReturn(true); + var lines = new ArrayList(); + doAnswer(call -> { lines.add(call.getArgument(0)); return null; }).when(sender).sendMessage(anyString()); + assertTrue(command.onCommand(sender, mock(Command.class), "potato", args)); + if (expected == null) expected = List.copyOf(lines); + assertEquals(expected, lines); + } + } + String text = String.join(" ", expected).toLowerCase(java.util.Locale.ROOT); + for (String phrase : List.of("right-click", "poisonous potato", "half a heart", "base damage", "one second", "one potato", "no poison")) { + assertTrue(text.contains(phrase), phrase); + } + } +} diff --git a/src/test/java/games/dmg/purpurpotato/ThrowGateTest.java b/src/test/java/games/dmg/purpurpotato/ThrowGateTest.java new file mode 100644 index 0000000..cdb8c4d --- /dev/null +++ b/src/test/java/games/dmg/purpurpotato/ThrowGateTest.java @@ -0,0 +1,41 @@ +package games.dmg.purpurpotato; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class ThrowGateTest { + @Test void pruningQuitAndShutdownBoundRetainedState() { + var gate = new ThrowGate(); + var player = UUID.randomUUID(); + gate.begin(player, 0); + gate.prune(0); + assertEquals(1, gate.size()); + gate.prune(1); + assertEquals(0, gate.size()); + gate.begin(player, 1); + gate.launched(player, 1); + gate.prune(20); + assertEquals(1, gate.size()); + gate.prune(21); + assertEquals(0, gate.size()); + gate.launched(player, 22); + gate.forget(player); + assertTrue(gate.begin(player, 22)); + gate.clear(); + assertEquals(0, gate.size()); + } + + @Test void admitsOnlyOncePerTickAndTwentyTicksAfterSuccess() { + var gate = new ThrowGate(); + var player = UUID.randomUUID(); + assertTrue(gate.begin(player, 0)); + assertFalse(gate.begin(player, 0), "Duplicate hand event on failed launch"); + assertTrue(gate.begin(player, 1), "Failure must not impose success cooldown"); + gate.launched(player, 1); + assertFalse(gate.begin(player, 1)); + assertFalse(gate.begin(player, 20)); + assertTrue(gate.begin(player, 21)); + assertTrue(gate.begin(UUID.randomUUID(), 21)); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3a627c4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "outDir": "build/release-tools", + "rootDir": "scripts", + "types": ["node"], + "lib": ["ES2022", "DOM"], + "noEmitOnError": true + }, + "include": ["scripts/*.ts"] +}