feat(potato): add native poisonous potato throws and releases
This commit is contained in:
commit
6b07d3bf87
37 files changed
+2730
No files matched your search
@@ -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
|
||||||
@@ -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
@@ -0,0 +1,13 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*token*
|
||||||
|
*secret*.local
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||||
@@ -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
@@ -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%
|
||||||
Generated
+50
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+24
@@ -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"
|
||||||
@@ -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, "");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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 } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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:/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(); }
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user