feat(potato): add native poisonous potato throws and releases
Release / release (push) Successful in 9m13s
CI / build (push) Successful in 50s

This commit is contained in:
dmg committed 2026-09-18 08:06:50 -04:00
commit 6b07d3bf87
37 files changed
+2730

No files matched your search

+52
View File
@@ -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
+122
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
.gradle/
build/
node_modules/
*.log
.env
.env.*
*.pem
*.key
*token*
*secret*.local
.idea/
*.iml
.DS_Store
+9
View File
@@ -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.
+26
View File
@@ -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-<version>.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.
+113
View File
@@ -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<JavaCompile>().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<JavaExec>("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<Test>("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<Exec>("installReleaseTools") {
commandLine("npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund")
}
val releaseToolCheck = tasks.register<Exec>("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)
}
}
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+248
View File
@@ -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" "$@"
Vendored
+82
View File
@@ -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%
+50
View File
@@ -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"
}
}
}
+14
View File
@@ -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"
}
}
+24
View File
@@ -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"
+59
View File
@@ -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, "");
});
}
+80
View File
@@ -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");
});
}
+21
View File
@@ -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;
}
+127
View File
@@ -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);
});
}
+70
View File
@@ -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<Response>;
const releases = "https://git.garvis.dev/api/v1/repos/dmg/purpur-potato/releases";
export async function publishRelease(input: ReleaseInput, request: Request): Promise<number> {
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<Record<string, unknown>> {
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<string, unknown>;
}
+67
View File
@@ -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); }
}));
}
+35
View File
@@ -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 } : {}),
};
}
+199
View File
@@ -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:/);
});
});
+5
View File
@@ -0,0 +1,5 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "purpur-potato"
@@ -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<String> 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;
}
}
@@ -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<java.util.UUID> 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<Shot> 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<java.util.UUID, Shot> 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<java.util.UUID>();
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));
}
}
@@ -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;
}
}
}
@@ -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<UUID, Long> 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(); }
}
+14
View File
@@ -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
@@ -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<EntityDamageByEntityEvent>();
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<org.bukkit.entity.Snowball> setup = call.getArgument(2);
setup.accept(ball);
return ball;
});
Class<? extends LivingEntity> 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);
}
}
}
@@ -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<Snowball>();
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);
}
}
}
@@ -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<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> 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;
}
}
@@ -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<Runnable>();
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<Snowball> 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);
}
}
}
@@ -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> 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<Snowball> 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<Snowball> 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<Snowball> 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<Snowball> 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<Snowball> 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());
}
}
@@ -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); }
}
@@ -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<String, Object> 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");
}
}
}
@@ -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<? extends CommandSender> 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<String> expected = null;
for (Class<? extends CommandSender> 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<String>();
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);
}
}
}
@@ -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));
}
}
+15
View File
@@ -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"]
}