commit 7bccc727b14034b88084b99e3fd708ab8954b894 Author: Dylan Garvis Date: Fri Sep 4 11:28:24 2026 -0400 feat: add stature potions and tiny-player launchers diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..6d11727 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 25 + cache: gradle + + - name: Validate conventional commits + if: github.event_name == 'pull_request' + run: | + npx -y \ + -p @commitlint/cli \ + -p @commitlint/config-conventional \ + commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.sha }}" \ + --extends @commitlint/config-conventional + + - name: Build and test + run: ./gradlew clean check jar + + - name: Upload development artifact + uses: actions/upload-artifact@v3 + with: + name: spigot-heights-${{ github.sha }} + path: build/libs/*.jar + if-no-files-found: error diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..7f7e634 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,141 @@ +name: Release + +on: + push: + branches: + - main + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN }} + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 25 + cache: gradle + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Build and test before release + run: ./gradlew clean check + + - name: Capture previous tag + id: previous_tag + run: | + if git describe --tags --abbrev=0 >/dev/null 2>&1; then + echo "value=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT" + else + echo "value=" >> "$GITHUB_OUTPUT" + fi + + - name: Run semantic-release + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + 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 \ + --plugins @semantic-release/commit-analyzer,@semantic-release/release-notes-generator + + - name: Capture current tag + id: current_tag + run: | + if git describe --tags --exact-match HEAD >/dev/null 2>&1; then + echo "value=$(git describe --tags --exact-match HEAD)" >> "$GITHUB_OUTPUT" + else + echo "value=" >> "$GITHUB_OUTPUT" + fi + + - name: Resolve release + id: release + env: + PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }} + CURRENT_TAG: ${{ steps.current_tag.outputs.value }} + run: | + if [ -n "$CURRENT_TAG" ] && [ "$CURRENT_TAG" != "$PREVIOUS_TAG" ]; then + echo "created=true" >> "$GITHUB_OUTPUT" + echo "tag=$CURRENT_TAG" >> "$GITHUB_OUTPUT" + echo "version=${CURRENT_TAG#v}" >> "$GITHUB_OUTPUT" + else + echo "created=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build versioned plugin + if: steps.release.outputs.created == 'true' + env: + VERSION: ${{ steps.release.outputs.version }} + run: ./gradlew clean jar -PreleaseVersion="$VERSION" + + - name: Upload release workflow artifact + if: steps.release.outputs.created == 'true' + uses: actions/upload-artifact@v3 + with: + name: spigot-heights-${{ steps.release.outputs.version }} + path: build/libs/spigot-heights-${{ steps.release.outputs.version }}.jar + if-no-files-found: error + + - name: Create Gitea release and upload plugin + if: steps.release.outputs.created == 'true' + env: + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + GITEA_SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + TAG: ${{ steps.release.outputs.tag }} + VERSION: ${{ steps.release.outputs.version }} + PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }} + run: | + api_url="${GITEA_SERVER_URL}/api/v1" + jar="build/libs/spigot-heights-${VERSION}.jar" + export RELEASE_BODY + if [ -n "$PREVIOUS_TAG" ]; then + RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD") + else + RELEASE_BODY=$(git log --pretty='format:- %s (%h)' HEAD) + fi + payload=$(node -e ' + const payload = { + tag_name: process.env.TAG, + name: process.env.TAG, + body: process.env.RELEASE_BODY, + draft: false, + prerelease: false + }; + process.stdout.write(JSON.stringify(payload)); + ') + response=$(curl --fail-with-body --silent --show-error \ + -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + "${api_url}/repos/${REPOSITORY}/releases" \ + --data "$payload") + release_id=$(printf '%s' "$response" | node -e ' + let input = ""; + process.stdin.on("data", chunk => input += chunk); + process.stdin.on("end", () => { + const response = JSON.parse(input); + if (!response.id) process.exit(1); + process.stdout.write(String(response.id)); + }); + ') + curl --fail-with-body --silent --show-error \ + -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${jar}" \ + "${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=spigot-heights-${VERSION}.jar" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f37d6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +out/ +.idea/ +*.iml +*.log +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d419dc2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# Spigot Heights Agent Guide + +## Canonical design + +- `design/` is the canonical OKF v0.1 knowledge bundle. +- Read relevant user stories before implementation and keep acceptance criteria and `design/log.md` synchronized with verified behavior. + +## Engineering + +- Target Java 25 and Purpur `26.2.build.2618-stable`. +- Use Gradle Kotlin DSL and JUnit 5. +- Prefer small, server-independent domain classes with Bukkit adapters at the boundary. +- Develop test-first where practical and verify with `./gradlew clean check jar`. +- Treat custom item identity as persistent metadata; never trust display names. +- Validate all configuration before enabling gameplay behavior. +- Persist player data by UUID and use atomic replacement where supported. +- Keep event handlers on the server thread and avoid unnecessary work on player movement. +- Use conventional commits in the form `type(scope): description`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ffea99 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Spigot Heights + +A Purpur 26.2 plugin adding craftable player-scaling potions and dispenser launch tubes for tiny players. + +Approved behavior is specified in the [OKF design bundle](design/index.md). + +## Requirements + +- Purpur 26.2 build 2618 +- Java 25 or newer + +## Potions + +### Potion of Shifting Stature + +```text +Amethyst Shard | Chorus Fruit | Amethyst Shard +Amethyst Shard | Awkward Potion | Amethyst Shard +Amethyst Shard | Chorus Fruit | Amethyst Shard +``` + +Drinking it chooses a random configured scale in `0.1` increments. + +### Potion of Growth + +```text +Gold Ingot | Amethyst Shard | Gold Ingot +Amethyst | Potion of Shifting Stature | Amethyst +Gold Ingot | Rabbit's Foot | Gold Ingot +``` + +### Potion of Diminution + +Use the Growth recipe with a Fermented Spider Eye instead of the Rabbit's Foot. Growth and Diminution adjust scale by one configured step and clamp at the limits. + +## Tiny-player launchers + +A player below the configured scale threshold can walk onto a hopper whose output points into a dispenser. If the block in front of that dispenser is passable, the player is moved there and launched in the direction the dispenser faces. The launcher does not require redstone. + +## Configuration + +Defaults are in `src/main/resources/config.yml`: + +```yaml +height: + minimum: 0.4 + maximum: 2.0 + adjustment-step: 0.1 +launcher: + maximum-player-scale-exclusive: 0.5 + speed: 1.5 + cooldown-ticks: 20 +``` + +Player scales are stored by UUID in `plugins/SpigotHeights/state.yml`. + +## Build + +```bash +./gradlew clean check jar +``` + +The plugin JAR is written to `build/libs/`. + +## Releases + +Gitea Actions checks pushes and pull requests and stores a development JAR. Pull requests validate conventional commits. Main-branch conventional commits drive semantic releases when the repository defines a `RELEASE_TOKEN` with contents-write permission. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..71c839b --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + java +} + +group = "games.dmg" +version = providers.gradleProperty("releaseVersion").orElse("0.1.0-SNAPSHOT").get() + +repositories { + mavenCentral() + maven("https://repo.purpurmc.org/snapshots/") +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} + +tasks.withType().configureEach { + options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror")) +} + +dependencies { + compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable") + testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable") + testImplementation(platform("org.junit:junit-bom:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.yaml:snakeyaml:2.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() +} + +val pluginVersion = version +tasks.processResources { + filesMatching("plugin.yml") { + expand("version" to pluginVersion) + } +} diff --git a/design/index.md b/design/index.md new file mode 100644 index 0000000..9821ff9 --- /dev/null +++ b/design/index.md @@ -0,0 +1,15 @@ +--- +type: Index +title: Spigot Heights Design +description: Entry point for the Spigot Heights OKF knowledge bundle. +okf_version: "0.1" +--- + +# Spigot Heights Design + +This bundle documents craftable player-scaling potions, configurable stature limits, tiny-player dispenser launchers, persistence, and delivery requirements. + +## Explore + +- [User stories](user-stories/index.md) +- [Design log](log.md) diff --git a/design/log.md b/design/log.md new file mode 100644 index 0000000..bd2b98e --- /dev/null +++ b/design/log.md @@ -0,0 +1,20 @@ +--- +type: Log +title: Spigot Heights Design Log +description: Chronological record of material decisions affecting Spigot Heights. +--- + +# Spigot Heights Design Log + +## 2026-09-04 — Initial design approved + +- Player scale defaults to a configurable range of `0.4` through `2.0`. +- A moderate recipe creates a random stature potion; more expensive upgrades create targeted growth and diminution potions. +- Random stature is selected uniformly in configurable `0.1` increments. +- Players strictly below scale `0.5` can be launched through a dispenser fed by the hopper beneath them. +- Launcher speed defaults to 1.5 blocks per tick with a 20-tick cooldown and safe-exit checks. +- The project follows the neighboring Spigot Base Java 25, Purpur, Gradle, OKF, CI, and release conventions. + +## 2026-09-04 — Implementation started + +- Approved implementation began with user stories, tests, and the Gradle/Purpur foundation. diff --git a/design/user-stories/index.md b/design/user-stories/index.md new file mode 100644 index 0000000..1cb6a47 --- /dev/null +++ b/design/user-stories/index.md @@ -0,0 +1,13 @@ +--- +type: Index +title: Spigot Heights User Stories +description: Catalog of user stories for the Spigot Heights plugin. +--- + +# Spigot Heights User Stories + +1. [US-001: Drink a Potion of Shifting Stature](us-001-drink-shifting-stature-potion.md) +2. [US-002: Make precise stature adjustments](us-002-adjust-stature.md) +3. [US-003: Launch tiny players through dispensers](us-003-launch-tiny-players.md) +4. [US-004: Configure and persist stature behavior](us-004-configure-and-persist.md) +5. [US-005: Build and release the plugin](us-005-build-and-release.md) diff --git a/design/user-stories/us-001-drink-shifting-stature-potion.md b/design/user-stories/us-001-drink-shifting-stature-potion.md new file mode 100644 index 0000000..11fb137 --- /dev/null +++ b/design/user-stories/us-001-drink-shifting-stature-potion.md @@ -0,0 +1,25 @@ +--- +type: User Story +title: "US-001: Drink a Potion of Shifting Stature" +description: Let players craft and drink a potion that gives them a random configured scale. +status: done +--- + +# US-001: Drink a Potion of Shifting Stature + +As a **player**, I want to drink a craftable potion that changes my stature unpredictably so that player size becomes a fun survival mechanic. + +## Acceptance criteria + +- [x] The distinct potion is authenticated with persistent item metadata rather than its display name alone. +- [x] Its shaped recipe is `ACA/AWA/ACA`, where `A` is Amethyst Shard, `C` is Chorus Fruit, and `W` is an Awkward Potion. +- [x] Drinking it consumes one potion, leaves normal bottle handling intact, and selects a scale from the configured inclusive range. +- [x] Outcomes are uniformly selected in configured adjustment-step increments, including both endpoints when aligned. +- [x] The resulting Minecraft scale attribute is applied and reported to the player. +- [x] The resulting scale persists across logout, restart, world change, and death. +- [x] Automated tests cover bounds, endpoint reachability, and recipe identity. + +## Related + +- [US-002: Make precise stature adjustments](us-002-adjust-stature.md) +- [US-004: Configure and persist stature behavior](us-004-configure-and-persist.md) diff --git a/design/user-stories/us-002-adjust-stature.md b/design/user-stories/us-002-adjust-stature.md new file mode 100644 index 0000000..d6aabb5 --- /dev/null +++ b/design/user-stories/us-002-adjust-stature.md @@ -0,0 +1,24 @@ +--- +type: User Story +title: "US-002: Make precise stature adjustments" +description: Let players craft upgraded potions that increase or decrease scale by one configured step. +status: done +--- + +# US-002: Make precise stature adjustments + +As a **player**, I want more expensive growth and diminution potions so that I can adjust my stature predictably. + +## Acceptance criteria + +- [x] The Potion of Growth uses `GAG/ASA/GRG`, where `G` is Gold Ingot, `A` is Amethyst Shard, `S` is an authenticated Potion of Shifting Stature, and `R` is Rabbit's Foot. +- [x] The Potion of Diminution uses `GAG/ASA/GFG`, where `F` is Fermented Spider Eye. +- [x] Each resulting potion has distinct persistent metadata. +- [x] Growth adds one configured adjustment step and clamps at the maximum. +- [x] Diminution subtracts one configured adjustment step and clamps at the minimum. +- [x] Adjusted scales are reported and persisted under the same rules as random stature. +- [x] Automated tests cover adjustment, clamping, and potion identity. + +## Related + +- [US-001: Drink a Potion of Shifting Stature](us-001-drink-shifting-stature-potion.md) diff --git a/design/user-stories/us-003-launch-tiny-players.md b/design/user-stories/us-003-launch-tiny-players.md new file mode 100644 index 0000000..85126d7 --- /dev/null +++ b/design/user-stories/us-003-launch-tiny-players.md @@ -0,0 +1,25 @@ +--- +type: User Story +title: "US-003: Launch tiny players through dispensers" +description: Launch sufficiently small players from a dispenser connected to the hopper beneath them. +status: done +--- + +# US-003: Launch tiny players through dispensers + +As a **tiny player**, I want connected hoppers and dispensers to act as launch tubes so that my stature enables playful transport systems. + +## Acceptance criteria + +- [x] A player strictly below the configured threshold triggers when walking onto a hopper whose output points into a dispenser. +- [x] No redstone signal is required. +- [x] The player moves to a safe centered position immediately in front of the dispenser and receives velocity in its facing direction. +- [x] Velocity magnitude and cooldown are configurable and default to 1.5 blocks per tick and 20 ticks. +- [x] Obstructed or unsafe exits abort without moving the player. +- [x] Players at or above the threshold are not launched. +- [x] Cooldown prevents immediate repeated or cyclic launching. +- [x] Automated tests cover threshold boundaries, orientations, cooldown, and obstruction policy. + +## Related + +- [US-004: Configure and persist stature behavior](us-004-configure-and-persist.md) diff --git a/design/user-stories/us-004-configure-and-persist.md b/design/user-stories/us-004-configure-and-persist.md new file mode 100644 index 0000000..267d5ed --- /dev/null +++ b/design/user-stories/us-004-configure-and-persist.md @@ -0,0 +1,25 @@ +--- +type: User Story +title: "US-004: Configure and persist stature behavior" +description: Give operators validated settings and durable UUID-keyed player scales. +status: done +--- + +# US-004: Configure and persist stature behavior + +As a **server operator**, I want validated stature and launcher settings with durable state so that behavior remains safe and predictable. + +## Acceptance criteria + +- [x] Defaults are minimum `0.4`, maximum `2.0`, adjustment step `0.1`, launcher threshold `0.5`, speed `1.5`, and cooldown `20` ticks. +- [x] Configuration requires finite positive values, minimum no greater than maximum, and a launcher threshold within the supported scale range. +- [x] Invalid startup configuration disables the plugin with a clear error. +- [x] Player scales are stored by UUID using atomic file replacement where supported. +- [x] Updating known state preserves unknown forward-compatible YAML fields. +- [x] Missing state defaults safely to scale `1.0` clamped to the configured range. +- [x] Saved out-of-range state is clamped before it is applied. +- [x] Configuration and state behavior have automated tests. + +## Related + +- [User-story catalog](index.md) diff --git a/design/user-stories/us-005-build-and-release.md b/design/user-stories/us-005-build-and-release.md new file mode 100644 index 0000000..530c649 --- /dev/null +++ b/design/user-stories/us-005-build-and-release.md @@ -0,0 +1,24 @@ +--- +type: User Story +title: "US-005: Build and release the plugin" +description: Give maintainers repeatable Purpur builds, automated verification, and versioned Gitea releases. +status: in-progress +--- + +# US-005: Build and release the plugin + +As a **plugin maintainer**, I want automated builds and releases modeled on Spigot Base so that tested artifacts can be distributed consistently. + +## Acceptance criteria + +- [ ] Gradle compiles against Purpur API `26.2.build.2618-stable` using Java 25. +- [ ] Compiler lint warnings fail the build and JUnit 5 tests run during `check`. +- [ ] Gitea Actions verifies pushes and pull requests and stores a development JAR. +- [ ] Pull requests validate conventional commits. +- [ ] Main-branch conventional commits drive semantic releases and attach versioned JARs to Gitea releases. +- [ ] The README documents requirements, recipes, configuration, building, and releases. +- [ ] `./gradlew clean check jar` succeeds. + +## Related + +- [User-story catalog](index.md) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a351597 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..203529c --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..7e60b72 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..ccfb9c5 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +rootProject.name = "spigot-heights" diff --git a/src/main/java/games/dmg/spigotheights/HeightMath.java b/src/main/java/games/dmg/spigotheights/HeightMath.java new file mode 100644 index 0000000..5113f38 --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/HeightMath.java @@ -0,0 +1,40 @@ +package games.dmg.spigotheights; + +import java.util.function.IntUnaryOperator; + +public final class HeightMath { + private HeightMath() { + } + + public static double randomScale(HeightSettings settings, IntUnaryOperator randomIndex) { + int outcomes = settings.randomStepCount() + 1; + int index = randomIndex.applyAsInt(outcomes); + if (index < 0 || index >= outcomes) { + throw new IllegalArgumentException("random index is outside the requested bound"); + } + return normalize(settings.minimum() + index * settings.adjustmentStep()); + } + + public static double grow(double current, HeightSettings settings) { + return clamp(current + settings.adjustmentStep(), settings); + } + + public static double shrink(double current, HeightSettings settings) { + return clamp(current - settings.adjustmentStep(), settings); + } + + public static double safeStoredScale(Double stored, HeightSettings settings) { + if (stored == null || !Double.isFinite(stored)) { + return clamp(1.0, settings); + } + return clamp(stored, settings); + } + + public static double clamp(double value, HeightSettings settings) { + return normalize(Math.max(settings.minimum(), Math.min(settings.maximum(), value))); + } + + private static double normalize(double value) { + return Math.rint(value * 1_000_000.0) / 1_000_000.0; + } +} diff --git a/src/main/java/games/dmg/spigotheights/HeightSettings.java b/src/main/java/games/dmg/spigotheights/HeightSettings.java new file mode 100644 index 0000000..9c0814f --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/HeightSettings.java @@ -0,0 +1,40 @@ +package games.dmg.spigotheights; + +public record HeightSettings( + double minimum, + double maximum, + double adjustmentStep, + double launcherThreshold, + double launcherSpeed, + int launcherCooldownTicks) { + + public HeightSettings { + requirePositiveFinite("height.minimum", minimum); + requirePositiveFinite("height.maximum", maximum); + requirePositiveFinite("height.adjustment-step", adjustmentStep); + requirePositiveFinite("launcher.maximum-player-scale-exclusive", launcherThreshold); + requirePositiveFinite("launcher.speed", launcherSpeed); + if (minimum > maximum) { + throw new IllegalArgumentException("height.minimum must not exceed height.maximum"); + } + if (minimum < 0.0625 || maximum > 16.0) { + throw new IllegalArgumentException("height range must stay within Minecraft's 0.0625 to 16.0 scale range"); + } + if (launcherThreshold < minimum || launcherThreshold > maximum) { + throw new IllegalArgumentException("launcher threshold must be within the height range"); + } + if (launcherCooldownTicks < 0) { + throw new IllegalArgumentException("launcher.cooldown-ticks must not be negative"); + } + } + + public int randomStepCount() { + return (int) Math.floor((maximum - minimum) / adjustmentStep + 1.0e-9); + } + + private static void requirePositiveFinite(String key, double value) { + if (!Double.isFinite(value) || value <= 0.0) { + throw new IllegalArgumentException(key + " must be a finite positive number"); + } + } +} diff --git a/src/main/java/games/dmg/spigotheights/HeightStore.java b/src/main/java/games/dmg/spigotheights/HeightStore.java new file mode 100644 index 0000000..5a279e4 --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/HeightStore.java @@ -0,0 +1,43 @@ +package games.dmg.spigotheights; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.UUID; +import org.bukkit.configuration.file.YamlConfiguration; + +public final class HeightStore { + private final Path statePath; + private final YamlConfiguration state; + + public HeightStore(File dataFolder) { + statePath = dataFolder.toPath().resolve("state.yml"); + state = YamlConfiguration.loadConfiguration(statePath.toFile()); + } + + public synchronized Double find(UUID playerId) { + String path = path(playerId); + return state.contains(path) ? state.getDouble(path) : null; + } + + public synchronized void save(UUID playerId, double scale) throws IOException { + state.set(path(playerId), scale); + Files.createDirectories(statePath.getParent()); + Path temporary = statePath.resolveSibling("state.yml.tmp"); + Files.writeString(temporary, state.saveToString(), StandardCharsets.UTF_8); + try { + Files.move(temporary, statePath, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(temporary, statePath, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static String path(UUID playerId) { + return "players." + playerId + ".scale"; + } +} diff --git a/src/main/java/games/dmg/spigotheights/LaunchVector.java b/src/main/java/games/dmg/spigotheights/LaunchVector.java new file mode 100644 index 0000000..55dbaf4 --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/LaunchVector.java @@ -0,0 +1,11 @@ +package games.dmg.spigotheights; + +public record LaunchVector(double x, double y, double z) { + public static LaunchVector fromDirection(int x, int y, int z, double speed) { + double length = Math.sqrt((double) x * x + (double) y * y + (double) z * z); + if (length == 0.0) { + throw new IllegalArgumentException("launch direction must not be stationary"); + } + return new LaunchVector(x / length * speed, y / length * speed, z / length * speed); + } +} diff --git a/src/main/java/games/dmg/spigotheights/LauncherPolicy.java b/src/main/java/games/dmg/spigotheights/LauncherPolicy.java new file mode 100644 index 0000000..f8990a5 --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/LauncherPolicy.java @@ -0,0 +1,18 @@ +package games.dmg.spigotheights; + +public final class LauncherPolicy { + private LauncherPolicy() { + } + + public static boolean isSmallEnough(double scale, double exclusiveThreshold) { + return scale < exclusiveThreshold; + } + + public static boolean cooldownExpired(Long previousTick, long currentTick, int cooldownTicks) { + return previousTick == null || currentTick - previousTick >= cooldownTicks; + } + + public static boolean isSafeExit(boolean empty) { + return empty; + } +} diff --git a/src/main/java/games/dmg/spigotheights/PotionRecipes.java b/src/main/java/games/dmg/spigotheights/PotionRecipes.java new file mode 100644 index 0000000..06c398b --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/PotionRecipes.java @@ -0,0 +1,106 @@ +package games.dmg.spigotheights; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Color; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.RecipeChoice; +import org.bukkit.inventory.ShapedRecipe; +import org.bukkit.inventory.meta.PotionMeta; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.potion.PotionType; + +public final class PotionRecipes { + static final String[] SHIFTING_SHAPE = {"ACA", "AWA", "ACA"}; + static final String[] GROWTH_SHAPE = {"GAG", "ASA", "GRG"}; + static final String[] DIMINUTION_SHAPE = {"GAG", "ASA", "GFG"}; + + private final JavaPlugin plugin; + private final NamespacedKey potionKindKey; + + public PotionRecipes(JavaPlugin plugin) { + this.plugin = plugin; + potionKindKey = new NamespacedKey(plugin, "stature_potion"); + } + + public void register() { + registerShifting(); + registerGrowth(); + registerDiminution(); + } + + public ItemStack create(StaturePotion kind) { + ItemStack item = new ItemStack(Material.POTION); + PotionMeta meta = (PotionMeta) item.getItemMeta(); + meta.displayName(Component.text(kind.displayName(), NamedTextColor.LIGHT_PURPLE)); + meta.getPersistentDataContainer().set(potionKindKey, PersistentDataType.STRING, kind.name()); + meta.setColor(color(kind)); + item.setItemMeta(meta); + return item; + } + + public StaturePotion identify(ItemStack item) { + if (item == null || item.getType() != Material.POTION || !(item.getItemMeta() instanceof PotionMeta meta)) { + return null; + } + String value = meta.getPersistentDataContainer().get(potionKindKey, PersistentDataType.STRING); + if (value == null) { + return null; + } + try { + return StaturePotion.valueOf(value); + } catch (IllegalArgumentException exception) { + return null; + } + } + + public boolean isAwkwardPotion(ItemStack item) { + return item != null && item.getItemMeta() instanceof PotionMeta meta + && meta.getBasePotionType() == PotionType.AWKWARD; + } + + public NamespacedKey shiftingKey() { + return new NamespacedKey(plugin, "shifting_stature"); + } + + private void registerShifting() { + ShapedRecipe recipe = new ShapedRecipe(shiftingKey(), create(StaturePotion.SHIFTING)); + recipe.shape(SHIFTING_SHAPE); + recipe.setIngredient('A', Material.AMETHYST_SHARD); + recipe.setIngredient('C', Material.CHORUS_FRUIT); + recipe.setIngredient('W', Material.POTION); + plugin.getServer().addRecipe(recipe); + } + + private void registerGrowth() { + ShapedRecipe recipe = new ShapedRecipe(new NamespacedKey(plugin, "growth"), create(StaturePotion.GROWTH)); + recipe.shape(GROWTH_SHAPE); + recipe.setIngredient('G', Material.GOLD_INGOT); + recipe.setIngredient('A', Material.AMETHYST_SHARD); + recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING))); + recipe.setIngredient('R', Material.RABBIT_FOOT); + plugin.getServer().addRecipe(recipe); + } + + private void registerDiminution() { + ShapedRecipe recipe = new ShapedRecipe(new NamespacedKey(plugin, "diminution"), + create(StaturePotion.DIMINUTION)); + recipe.shape(DIMINUTION_SHAPE); + recipe.setIngredient('G', Material.GOLD_INGOT); + recipe.setIngredient('A', Material.AMETHYST_SHARD); + recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING))); + recipe.setIngredient('F', Material.FERMENTED_SPIDER_EYE); + plugin.getServer().addRecipe(recipe); + } + + private static Color color(StaturePotion kind) { + return switch (kind) { + case SHIFTING -> Color.PURPLE; + case GROWTH -> Color.LIME; + case DIMINUTION -> Color.FUCHSIA; + }; + } +} diff --git a/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java b/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java new file mode 100644 index 0000000..63ca6ac --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java @@ -0,0 +1,37 @@ +package games.dmg.spigotheights; + +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.plugin.java.JavaPlugin; + +public final class SpigotHeightsPlugin extends JavaPlugin { + @Override + public void onEnable() { + saveDefaultConfig(); + HeightSettings settings; + try { + settings = loadSettings(getConfig()); + } catch (IllegalArgumentException exception) { + getLogger().severe("Invalid configuration: " + exception.getMessage()); + getServer().getPluginManager().disablePlugin(this); + return; + } + + HeightStore store = new HeightStore(getDataFolder()); + PotionRecipes potions = new PotionRecipes(this); + potions.register(); + getServer().getPluginManager().registerEvents( + new StatureListener(this, settings, store, potions), this); + getServer().getPluginManager().registerEvents(new TinyPlayerLauncher(settings), this); + getLogger().info("Spigot Heights enabled."); + } + + static HeightSettings loadSettings(FileConfiguration config) { + return new HeightSettings( + config.getDouble("height.minimum"), + config.getDouble("height.maximum"), + config.getDouble("height.adjustment-step"), + config.getDouble("launcher.maximum-player-scale-exclusive"), + config.getDouble("launcher.speed"), + config.getInt("launcher.cooldown-ticks")); + } +} diff --git a/src/main/java/games/dmg/spigotheights/StatureListener.java b/src/main/java/games/dmg/spigotheights/StatureListener.java new file mode 100644 index 0000000..7d0d374 --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/StatureListener.java @@ -0,0 +1,97 @@ +package games.dmg.spigotheights; + +import java.io.IOException; +import java.util.concurrent.ThreadLocalRandom; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.PrepareItemCraftEvent; +import org.bukkit.event.player.PlayerItemConsumeEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.Recipe; +import org.bukkit.Keyed; +import org.bukkit.plugin.java.JavaPlugin; + +public final class StatureListener implements Listener { + private final JavaPlugin plugin; + private final HeightSettings settings; + private final HeightStore store; + private final PotionRecipes potions; + + public StatureListener(JavaPlugin plugin, HeightSettings settings, HeightStore store, PotionRecipes potions) { + this.plugin = plugin; + this.settings = settings; + this.store = store; + this.potions = potions; + } + + @EventHandler + public void onCraftPrepare(PrepareItemCraftEvent event) { + Recipe recipe = event.getRecipe(); + if (!(recipe instanceof Keyed keyed) || !keyed.getKey().equals(potions.shiftingKey())) { + return; + } + ItemStack[] matrix = event.getInventory().getMatrix(); + if (matrix.length < 5 || !potions.isAwkwardPotion(matrix[4])) { + event.getInventory().setResult(null); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onConsume(PlayerItemConsumeEvent event) { + StaturePotion kind = potions.identify(event.getItem()); + if (kind == null) { + return; + } + Player player = event.getPlayer(); + double current = currentScale(player); + double scale = switch (kind) { + case SHIFTING -> HeightMath.randomScale(settings, + bound -> ThreadLocalRandom.current().nextInt(bound)); + case GROWTH -> HeightMath.grow(current, settings); + case DIMINUTION -> HeightMath.shrink(current, settings); + }; + applyAndSave(player, scale); + player.sendMessage("Your scale is now " + scale + "."); + } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + apply(event.getPlayer(), HeightMath.safeStoredScale(store.find(event.getPlayer().getUniqueId()), settings)); + } + + @EventHandler + public void onRespawn(PlayerRespawnEvent event) { + plugin.getServer().getScheduler().runTask(plugin, () -> { + Player player = event.getPlayer(); + apply(player, HeightMath.safeStoredScale(store.find(player.getUniqueId()), settings)); + }); + } + + private void applyAndSave(Player player, double scale) { + apply(player, scale); + try { + store.save(player.getUniqueId(), scale); + } catch (IOException exception) { + plugin.getLogger().severe("Could not save scale for " + player.getUniqueId() + ": " + + exception.getMessage()); + } + } + + private static void apply(Player player, double scale) { + AttributeInstance attribute = player.getAttribute(Attribute.SCALE); + if (attribute != null) { + attribute.setBaseValue(scale); + } + } + + private static double currentScale(Player player) { + AttributeInstance attribute = player.getAttribute(Attribute.SCALE); + return attribute == null ? 1.0 : attribute.getBaseValue(); + } +} diff --git a/src/main/java/games/dmg/spigotheights/StaturePotion.java b/src/main/java/games/dmg/spigotheights/StaturePotion.java new file mode 100644 index 0000000..a1cd73e --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/StaturePotion.java @@ -0,0 +1,17 @@ +package games.dmg.spigotheights; + +public enum StaturePotion { + SHIFTING("Potion of Shifting Stature"), + GROWTH("Potion of Growth"), + DIMINUTION("Potion of Diminution"); + + private final String displayName; + + StaturePotion(String displayName) { + this.displayName = displayName; + } + + public String displayName() { + return displayName; + } +} diff --git a/src/main/java/games/dmg/spigotheights/TinyPlayerLauncher.java b/src/main/java/games/dmg/spigotheights/TinyPlayerLauncher.java new file mode 100644 index 0000000..e58f157 --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/TinyPlayerLauncher.java @@ -0,0 +1,82 @@ +package games.dmg.spigotheights; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.Directional; +import org.bukkit.block.data.type.Hopper; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.util.Vector; + +public final class TinyPlayerLauncher implements Listener { + private final HeightSettings settings; + private final Map lastLaunchTicks = new HashMap<>(); + + public TinyPlayerLauncher(HeightSettings settings) { + this.settings = settings; + } + + @EventHandler(ignoreCancelled = true) + public void onMove(PlayerMoveEvent event) { + Location destination = event.getTo(); + if (destination == null || sameBlock(event.getFrom(), destination)) { + return; + } + Player player = event.getPlayer(); + if (!LauncherPolicy.isSmallEnough(scale(player), settings.launcherThreshold())) { + return; + } + long tick = Bukkit.getCurrentTick(); + Long lastTick = lastLaunchTicks.get(player.getUniqueId()); + if (!LauncherPolicy.cooldownExpired(lastTick, tick, settings.launcherCooldownTicks())) { + return; + } + Block hopperBlock = destination.clone().subtract(0.0, 0.1, 0.0).getBlock(); + if (!(hopperBlock.getBlockData() instanceof Hopper hopper)) { + return; + } + Block dispenser = hopperBlock.getRelative(hopper.getFacing()); + if (dispenser.getType() != Material.DISPENSER + || !(dispenser.getBlockData() instanceof Directional directional)) { + return; + } + BlockFace facing = directional.getFacing(); + Block exit = dispenser.getRelative(facing); + if (!LauncherPolicy.isSafeExit(exit.isEmpty())) { + return; + } + Location exitLocation = exit.getLocation().add(0.5, 0.1, 0.5); + exitLocation.setYaw(player.getLocation().getYaw()); + exitLocation.setPitch(player.getLocation().getPitch()); + if (!player.teleport(exitLocation)) { + return; + } + Vector direction = facing.getDirection(); + LaunchVector launch = LaunchVector.fromDirection( + direction.getBlockX(), direction.getBlockY(), direction.getBlockZ(), settings.launcherSpeed()); + player.setVelocity(new Vector(launch.x(), launch.y(), launch.z())); + lastLaunchTicks.put(player.getUniqueId(), tick); + } + + private static boolean sameBlock(Location first, Location second) { + return first.getWorld().equals(second.getWorld()) + && first.getBlockX() == second.getBlockX() + && first.getBlockY() == second.getBlockY() + && first.getBlockZ() == second.getBlockZ(); + } + + private static double scale(Player player) { + AttributeInstance attribute = player.getAttribute(Attribute.SCALE); + return attribute == null ? 1.0 : attribute.getValue(); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..2d98708 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,9 @@ +height: + minimum: 0.4 + maximum: 2.0 + adjustment-step: 0.1 + +launcher: + maximum-player-scale-exclusive: 0.5 + speed: 1.5 + cooldown-ticks: 20 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..7023502 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: SpigotHeights +version: ${version} +main: games.dmg.spigotheights.SpigotHeightsPlugin +api-version: "1.21" +description: Craftable player stature potions and tiny-player dispenser launchers. +author: dmg.games diff --git a/src/test/java/games/dmg/spigotheights/HeightMathTest.java b/src/test/java/games/dmg/spigotheights/HeightMathTest.java new file mode 100644 index 0000000..fd203a0 --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/HeightMathTest.java @@ -0,0 +1,29 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class HeightMathTest { + private static final HeightSettings SETTINGS = new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20); + + @Test + void randomSelectionCanReachBothInclusiveEndpoints() { + assertEquals(0.4, HeightMath.randomScale(SETTINGS, bound -> 0), 0.000001); + assertEquals(2.0, HeightMath.randomScale(SETTINGS, bound -> bound - 1), 0.000001); + } + + @Test + void adjustmentsClampAtLimitsWithoutFloatingPointDrift() { + assertEquals(2.0, HeightMath.grow(1.95, SETTINGS), 0.000001); + assertEquals(0.4, HeightMath.shrink(0.42, SETTINGS), 0.000001); + assertEquals(1.1, HeightMath.grow(1.0, SETTINGS), 0.000001); + } + + @Test + void missingAndStoredValuesAreSafelyClamped() { + assertEquals(1.0, HeightMath.safeStoredScale(null, SETTINGS), 0.000001); + assertEquals(0.4, HeightMath.safeStoredScale(-2.0, SETTINGS), 0.000001); + assertEquals(2.0, HeightMath.safeStoredScale(4.0, SETTINGS), 0.000001); + } +} diff --git a/src/test/java/games/dmg/spigotheights/HeightSettingsTest.java b/src/test/java/games/dmg/spigotheights/HeightSettingsTest.java new file mode 100644 index 0000000..470a493 --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/HeightSettingsTest.java @@ -0,0 +1,27 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class HeightSettingsTest { + @Test + void acceptsDocumentedDefaults() { + HeightSettings settings = new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20); + assertEquals(0.4, settings.minimum()); + assertEquals(16, settings.randomStepCount()); + } + + @Test + void rejectsInvalidRangesAndNumbers() { + assertThrows(IllegalArgumentException.class, + () -> new HeightSettings(2.0, 0.4, 0.1, 0.5, 1.5, 20)); + assertThrows(IllegalArgumentException.class, + () -> new HeightSettings(0.4, 2.0, 0.0, 0.5, 1.5, 20)); + assertThrows(IllegalArgumentException.class, + () -> new HeightSettings(0.4, 2.0, 0.1, 2.1, 1.5, 20)); + assertThrows(IllegalArgumentException.class, + () -> new HeightSettings(0.4, Double.NaN, 0.1, 0.5, 1.5, 20)); + } +} diff --git a/src/test/java/games/dmg/spigotheights/HeightStoreTest.java b/src/test/java/games/dmg/spigotheights/HeightStoreTest.java new file mode 100644 index 0000000..52eb9dd --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/HeightStoreTest.java @@ -0,0 +1,28 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class HeightStoreTest { + @TempDir + Path temporaryDirectory; + + @Test + void storesByUuidAndPreservesUnknownYamlFields() throws Exception { + UUID playerId = UUID.randomUUID(); + Path stateFile = temporaryDirectory.resolve("state.yml"); + Files.writeString(stateFile, "future-setting: retained\n"); + HeightStore store = new HeightStore(temporaryDirectory.toFile()); + + store.save(playerId, 0.7); + + HeightStore reloaded = new HeightStore(temporaryDirectory.toFile()); + assertEquals(0.7, reloaded.find(playerId)); + assertEquals(true, Files.readString(stateFile).contains("future-setting: retained")); + } +} diff --git a/src/test/java/games/dmg/spigotheights/LaunchVectorTest.java b/src/test/java/games/dmg/spigotheights/LaunchVectorTest.java new file mode 100644 index 0000000..488dd4a --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/LaunchVectorTest.java @@ -0,0 +1,23 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class LaunchVectorTest { + @Test + void supportsEveryDispenserAxisAtConfiguredSpeed() { + assertVector(1.5, 0.0, 0.0, LaunchVector.fromDirection(1, 0, 0, 1.5)); + assertVector(-1.5, 0.0, 0.0, LaunchVector.fromDirection(-1, 0, 0, 1.5)); + assertVector(0.0, 1.5, 0.0, LaunchVector.fromDirection(0, 1, 0, 1.5)); + assertVector(0.0, -1.5, 0.0, LaunchVector.fromDirection(0, -1, 0, 1.5)); + assertVector(0.0, 0.0, 1.5, LaunchVector.fromDirection(0, 0, 1, 1.5)); + assertVector(0.0, 0.0, -1.5, LaunchVector.fromDirection(0, 0, -1, 1.5)); + } + + private static void assertVector(double x, double y, double z, LaunchVector actual) { + assertEquals(x, actual.x(), 0.000001); + assertEquals(y, actual.y(), 0.000001); + assertEquals(z, actual.z(), 0.000001); + } +} diff --git a/src/test/java/games/dmg/spigotheights/LauncherPolicyTest.java b/src/test/java/games/dmg/spigotheights/LauncherPolicyTest.java new file mode 100644 index 0000000..1f677c5 --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/LauncherPolicyTest.java @@ -0,0 +1,28 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class LauncherPolicyTest { + @Test + void thresholdIsStrictlyExclusive() { + assertTrue(LauncherPolicy.isSmallEnough(0.4999, 0.5)); + assertFalse(LauncherPolicy.isSmallEnough(0.5, 0.5)); + assertFalse(LauncherPolicy.isSmallEnough(1.0, 0.5)); + } + + @Test + void cooldownExpiresAtConfiguredTick() { + assertFalse(LauncherPolicy.cooldownExpired(100L, 119, 20)); + assertTrue(LauncherPolicy.cooldownExpired(100L, 120, 20)); + assertTrue(LauncherPolicy.cooldownExpired(null, 1, 20)); + } + + @Test + void onlyEmptyDispenserExitsAreSafe() { + assertTrue(LauncherPolicy.isSafeExit(true)); + assertFalse(LauncherPolicy.isSafeExit(false)); + } +} diff --git a/src/test/java/games/dmg/spigotheights/PotionRecipesTest.java b/src/test/java/games/dmg/spigotheights/PotionRecipesTest.java new file mode 100644 index 0000000..b9cbf97 --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/PotionRecipesTest.java @@ -0,0 +1,22 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import org.junit.jupiter.api.Test; + +class PotionRecipesTest { + @Test + void recipesUseApprovedShapes() { + assertArrayEquals(new String[] {"ACA", "AWA", "ACA"}, PotionRecipes.SHIFTING_SHAPE); + assertArrayEquals(new String[] {"GAG", "ASA", "GRG"}, PotionRecipes.GROWTH_SHAPE); + assertArrayEquals(new String[] {"GAG", "ASA", "GFG"}, PotionRecipes.DIMINUTION_SHAPE); + } + + @Test + void everyPotionHasASeparatePersistentIdentity() { + assertNotEquals(StaturePotion.SHIFTING.name(), StaturePotion.GROWTH.name()); + assertNotEquals(StaturePotion.GROWTH.name(), StaturePotion.DIMINUTION.name()); + assertNotEquals(StaturePotion.DIMINUTION.name(), StaturePotion.SHIFTING.name()); + } +}