diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a023597 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +*.iml +.idea/ +.DS_Store diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..8801d8c --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + java +} + +group = "games.dmg" +version = providers.gradleProperty("releaseVersion") + .orElse("0.1.0-SNAPSHOT") + .get() + +repositories { + mavenCentral() + maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/") +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +tasks.withType().configureEach { + options.compilerArgs.add("-Xlint:deprecation") +} + +dependencies { + compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT") + implementation("org.xerial:sqlite-jdbc:3.50.3.0") + + testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT") + testImplementation(platform("org.junit:junit-bom:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.mockito:mockito-core:5.18.0") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() +} + +val pluginVersion = version + +tasks.processResources { + filesMatching("plugin.yml") { + expand("version" to pluginVersion) + } +} + +tasks.jar { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + manifest { + attributes["Multi-Release"] = "true" + } + from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) }) + exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA") +} diff --git a/design/architecture.md b/design/architecture.md new file mode 100644 index 0000000..aa67c6b --- /dev/null +++ b/design/architecture.md @@ -0,0 +1,46 @@ +--- +type: Architecture +title: Creeper Fear Plugin Architecture +description: Runtime boundaries, persistence model, and event flow for Creeper Aura progression. +--- + +# Creeper Fear Plugin Architecture + +## Runtime + +Creeper Fear targets Java 17 and Spigot API 26.2. The plugin entry point owns listeners, commands, player feedback, configuration, and a progression service. + +## Progression model + +Each known player is identified by UUID and has: + +- a last-known player name for administrative lookup; +- a current rank (`LOCKED`, `I`, `II`, `III`, `IV`, `V`, or `VI`); +- a non-negative number of creeper kills earned within the current tier. + +A qualifying kill increments current-tier progress. Unlocking the next rank resets that progress to zero. Rank VI accumulates no further progress. Lifetime kill totals are deliberately not retained. + +## Persistence and threading + +SQLite stores player progression in the plugin data directory. Gameplay listeners submit persistence work to a dedicated single-thread executor so database latency does not block the Minecraft server thread. Bukkit API state is captured before work leaves the server thread and is not accessed by persistence workers. + +Player progress is loaded into an online cache before synchronous aura decisions. Offline administrative operations use the same serialized persistence boundary. + +## Event flow + +A creeper death is attributed to a player when the player directly dealt the final damage or is attributable through a projectile or owned tameable. The progression service deduplicates a creeper death and records one point. + +For explosions, player damage events establish whether an unlocked player would have been hit before armor mitigation. Each protected player receives their own rank multiplier. If any aura activates, the corresponding creeper explosion's affected block list is cleared for everyone. Rank VI cancels the player's damage event entirely. + +## Commands and configuration + +`/creeperaura` exposes player progress and permission-protected offline administration. Rank requirements, multipliers, feedback duration, and messages are loaded from YAML. Valid command-based changes are written back to YAML and survive restart. + +## Verification + +Domain and persistence behavior is exercised through public interfaces with JUnit. Bukkit-facing adapters remain thin, while build verification ensures their compatibility with Spigot API 26.2. + +## Related + +- [Design index](index.md) +- [User stories](user-stories/index.md) diff --git a/design/index.md b/design/index.md index b144377..286db08 100644 --- a/design/index.md +++ b/design/index.md @@ -12,4 +12,5 @@ This bundle documents a Spigot plugin in which players earn Creeper Aura ranks b ## Explore - [User stories](user-stories/index.md) +- [Plugin architecture](architecture.md) - [Design log](log.md) diff --git a/design/log.md b/design/log.md index 4146595..f72147d 100644 --- a/design/log.md +++ b/design/log.md @@ -11,3 +11,6 @@ description: Chronological record of material changes to the Spigot Creeper Fear - Established the OKF v0.1 design bundle. - Defined creeper-kill progression, six Creeper Aura ranks, explosion protection, player feedback, administration, configuration, and build/release stories. - Selected Spigot API 26.2 and Java 17 to match the neighboring `spigot-event-producer` project. +- Replaced lifetime cumulative kill tracking with a persisted rank and current-tier progress model. +- Added the initial plugin architecture. +- Completed US-001 with asynchronous SQLite current-tier progress, direct and indirect kill attribution, bounded death deduplication, and automated tests. diff --git a/design/user-stories/us-001-track-creeper-defeats.md b/design/user-stories/us-001-track-creeper-defeats.md index 1145277..ae6bc8c 100644 --- a/design/user-stories/us-001-track-creeper-defeats.md +++ b/design/user-stories/us-001-track-creeper-defeats.md @@ -1,8 +1,8 @@ --- type: User Story title: "US-001: Track creeper defeats" -description: Record persistent player progress from creeper kills attributable to the player. -status: backlog +description: Record persistent current-tier progress from creeper kills attributable to the player. +status: done --- # US-001: Track creeper defeats @@ -11,15 +11,19 @@ As a **player**, I want my qualifying creeper kills recorded so that my Creeper ## Acceptance criteria -- [ ] A creeper kill attributable to a player adds one kill to that player's lifetime progress. -- [ ] Direct melee kills and indirect kills attributable to the player, including projectiles and the player's tamed wolves, count. -- [ ] A single creeper death cannot award progress more than once. -- [ ] Progress is stored using the player's UUID rather than their mutable name. -- [ ] Progress survives logout, server restarts, and player-name changes. -- [ ] Progress updates are persisted without blocking the Minecraft server thread on slow storage work. -- [ ] Missing or invalid persisted data is handled safely and reported to server administrators. +- [x] A creeper kill attributable to a player adds one point to that player's current-tier progress. +- [x] Direct melee kills and indirect kills attributable to the player, including projectiles and the player's tamed wolves, count. +- [x] A single creeper death cannot award progress more than once. +- [x] Progress is stored using the player's UUID rather than their mutable name. +- [x] The player's current rank and current-tier progress are persisted separately. +- [x] Lifetime creeper-kill totals are not retained. +- [x] Rank VI does not accumulate further progress. +- [x] Progress survives logout, server restarts, and player-name changes. +- [x] Progress updates are persisted without blocking the Minecraft server thread on slow storage work. +- [x] Missing or invalid persisted data is handled safely and reported to server administrators. ## Related - [Creeper Aura ranks](us-002-unlock-creeper-aura-ranks.md) +- [Plugin architecture](../architecture.md) - [User-story catalog](index.md) diff --git a/design/user-stories/us-002-unlock-creeper-aura-ranks.md b/design/user-stories/us-002-unlock-creeper-aura-ranks.md index 0e25633..ec9fbda 100644 --- a/design/user-stories/us-002-unlock-creeper-aura-ranks.md +++ b/design/user-stories/us-002-unlock-creeper-aura-ranks.md @@ -11,19 +11,20 @@ As a **player**, I want Creeper Aura to become stronger as I defeat creepers so ## Default progression -| State | Required lifetime kills | Damage to protected player | Creeper block damage | +| Current state | Kills needed to unlock next rank | Damage to protected player | Creeper block damage | | --- | ---: | ---: | --- | -| Locked | 0–99 | 1× | Normal | -| Creeper Aura I | 100 | 3× | Prevented | -| Creeper Aura II | 200 | 2× | Prevented | -| Creeper Aura III | 300 | 1.5× | Prevented | -| Creeper Aura IV | 400 | 1× | Prevented | -| Creeper Aura V | 500 | 0.5× | Prevented | -| Creeper Aura VI | 600 | 0× | Prevented | +| Locked | 100 to unlock I | 1× | Normal | +| Creeper Aura I | 100 to unlock II | 3× | Prevented | +| Creeper Aura II | 100 to unlock III | 2× | Prevented | +| Creeper Aura III | 100 to unlock IV | 1.5× | Prevented | +| Creeper Aura IV | 100 to unlock V | 1× | Prevented | +| Creeper Aura V | 100 to unlock VI | 0.5× | Prevented | +| Creeper Aura VI | Maximum rank | 0× | Prevented | ## Acceptance criteria -- [ ] A player unlocks ranks according to the configured cumulative kill thresholds. +- [ ] A player unlocks the next rank after earning the configured number of kills within their current tier. +- [ ] Unlocking a rank resets current-tier progress to zero. - [ ] A player below rank I receives normal creeper explosion behavior. - [ ] An aura activates when an unlocked player would have been hit by the creeper explosion, even when armor or another modifier would reduce the eventual damage to zero. - [ ] An activated aura prevents that creeper explosion from breaking or removing blocks for everyone affected by the explosion. diff --git a/design/user-stories/us-003-show-progression-and-rank-advancement.md b/design/user-stories/us-003-show-progression-and-rank-advancement.md index ffbce9a..94fa4bb 100644 --- a/design/user-stories/us-003-show-progression-and-rank-advancement.md +++ b/design/user-stories/us-003-show-progression-and-rank-advancement.md @@ -1,7 +1,7 @@ --- type: User Story title: "US-003: Show progression and rank advancement" -description: Give players brief kill progress displays and prominent rank-up notifications. +description: Give players brief current-tier progress displays and prominent rank-up notifications. status: backlog --- @@ -12,15 +12,14 @@ As a **player**, I want visible progress and rank-up notifications so that I und ## Acceptance criteria - [ ] After each qualifying creeper kill, a temporary boss bar shows the player's current state and progress toward the next rank. -- [ ] A locked player sees progress toward Creeper Aura I. -- [ ] A ranked player sees their current Roman-numeral rank, current kill total, and the next threshold. +- [ ] A locked player sees current-tier progress toward Creeper Aura I. +- [ ] A ranked player sees their current Roman-numeral rank, current-tier progress, and the next rank requirement. - [ ] The display duration is configurable and defaults to a short period measured in seconds. - [ ] The boss bar is hidden automatically when its display period expires. - [ ] A rank VI player no longer sees a progress boss bar. - [ ] Each newly attained rank displays a full-screen title naming the rank. - [ ] Joining the server does not replay a previously acknowledged rank-up title. -- [ ] Administrative progress changes update the display state of an online affected player. -- [ ] When one progress event satisfies multiple ranks, the resulting rank and notification behavior is deterministic and tested. +- [ ] Administrative changes update an online player's boss bar if it is currently visible. ## Related diff --git a/design/user-stories/us-004-check-personal-progress.md b/design/user-stories/us-004-check-personal-progress.md index 1aaf193..3047b33 100644 --- a/design/user-stories/us-004-check-personal-progress.md +++ b/design/user-stories/us-004-check-personal-progress.md @@ -1,7 +1,7 @@ --- type: User Story title: "US-004: Check personal progress" -description: Let players request their current Creeper Aura status through a command. +description: Let players request their current Creeper Aura rank and tier progress through a command. status: backlog --- @@ -11,8 +11,8 @@ As a **player**, I want a command that reports my Creeper Aura progress so that ## Acceptance criteria -- [ ] `/creeperaura progress` reports the player's lifetime creeper kills and current locked or ranked state. -- [ ] Before rank VI, the response reports the next rank threshold and the number of additional kills needed. +- [ ] `/creeperaura progress` reports the player's current locked or ranked state and current-tier progress. +- [ ] Before rank VI, the response reports the next rank requirement and the number of additional kills needed. - [ ] At rank VI, the response clearly reports that progression is complete. - [ ] The self-service command is available to ordinary players without administrative permission. - [ ] Console use, invalid arguments, and unavailable player data produce clear responses. diff --git a/design/user-stories/us-005-administer-player-progression.md b/design/user-stories/us-005-administer-player-progression.md index 944a57e..3a2f6e6 100644 --- a/design/user-stories/us-005-administer-player-progression.md +++ b/design/user-stories/us-005-administer-player-progression.md @@ -1,7 +1,7 @@ --- type: User Story title: "US-005: Administer player progression" -description: Let authorized administrators inspect and modify online or offline player progression. +description: Let authorized administrators inspect and modify online or offline player rank and tier progress. status: backlog --- @@ -11,13 +11,15 @@ As a **server administrator**, I want to inspect and modify player progress so t ## Acceptance criteria -- [ ] `/creeperaura progress ` reports another player's kills, rank, next threshold, and remaining kills. -- [ ] `/creeperaura set ` sets a non-negative lifetime kill total and reconciles the player's rank to that explicit administrative value. -- [ ] `/creeperaura add ` adjusts a player's kill total without allowing a negative result. +- [ ] `/creeperaura progress ` reports another player's rank, current-tier progress, next requirement, and remaining kills. +- [ ] `/creeperaura set ` sets a non-negative current-tier progress value without implicitly changing rank. +- [ ] `/creeperaura add ` adjusts current-tier progress without allowing a negative result. +- [ ] `/creeperaura rank ` explicitly changes rank and resets current-tier progress to zero. +- [ ] Rank VI never retains current-tier progress. - [ ] Inspection and modification work for known offline players as well as online players. - [ ] Players are resolved to stored UUIDs so name changes do not create duplicate progression records. - [ ] Administrative changes are persisted immediately. -- [ ] An online affected player's boss bar and rank state are updated after a change. +- [ ] An online affected player's feedback and aura state are updated after a change. - [ ] Administrative commands require distinct, documented permissions suitable for inspection and modification. - [ ] Unauthorized use does not disclose another player's progression. - [ ] Invalid player names, ambiguous identities, invalid numbers, and storage failures produce clear responses without partial changes. diff --git a/design/user-stories/us-006-configure-aura-progression.md b/design/user-stories/us-006-configure-aura-progression.md index 9441398..b0e9456 100644 --- a/design/user-stories/us-006-configure-aura-progression.md +++ b/design/user-stories/us-006-configure-aura-progression.md @@ -1,7 +1,7 @@ --- type: User Story title: "US-006: Configure aura progression" -description: Let administrators safely configure and persist aura thresholds, multipliers, and feedback. +description: Let administrators safely configure and persist per-rank requirements, multipliers, and feedback. status: backlog --- @@ -11,17 +11,17 @@ As a **server administrator**, I want to configure progression and aura behavior ## Acceptance criteria -- [ ] Configuration provides documented defaults for all six cumulative kill thresholds and damage multipliers. -- [ ] Rank thresholds are non-negative and strictly increasing. +- [ ] Configuration provides documented defaults for the six per-rank kill requirements and six unlocked-rank damage multipliers. +- [ ] Rank requirements are positive integers. - [ ] Damage multipliers are finite and non-negative. - [ ] Progress boss-bar duration and player-facing rank messages are configurable. -- [ ] `/creeperaura threshold ` validates, applies, and persists a threshold change. +- [ ] `/creeperaura threshold ` validates, applies, and persists the points required to unlock that rank. - [ ] `/creeperaura reload` safely loads externally edited configuration without requiring a server restart. - [ ] Threshold-management and reload commands require documented administrative permissions. - [ ] Invalid configuration is rejected with actionable diagnostics while the last valid configuration remains active. -- [ ] Changing thresholds never automatically removes an already unlocked rank or grants a new rank immediately. -- [ ] After a threshold change, the player's next qualifying kill evaluates newly satisfied progression and can grant the next eligible rank immediately. -- [ ] Existing progress within the player's retained rank is displayed against the updated next threshold. +- [ ] Changing requirements never automatically removes an unlocked rank, grants a new rank, or discards current-tier progress. +- [ ] After a requirement change, the player's next qualifying kill can grant at most the next rank when its requirement is satisfied. +- [ ] Rank advancement resets current-tier progress to zero rather than carrying excess progress forward. - [ ] Configuration-command changes survive plugin and server restarts. ## Related 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..3ed3132 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "spigot-creeper-fear" diff --git a/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java b/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java new file mode 100644 index 0000000..60ded3d --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java @@ -0,0 +1,40 @@ +package games.dmg.creeperfear; + +import games.dmg.creeperfear.listener.CreeperDeathListener; +import games.dmg.creeperfear.progress.ProgressService; +import games.dmg.creeperfear.progress.SqliteProgressRepository; +import java.nio.file.Path; +import java.util.logging.Level; +import org.bukkit.plugin.java.JavaPlugin; + +public final class CreeperFearPlugin extends JavaPlugin { + private ProgressService progressService; + + @Override + public void onEnable() { + try { + Path databasePath = getDataFolder().toPath().resolve("player-progress.sqlite3"); + progressService = new ProgressService(new SqliteProgressRepository(databasePath)); + getServer().getPluginManager().registerEvents( + new CreeperDeathListener(progressService, getLogger()), this); + getLogger().info("Creeper Fear enabled"); + } catch (RuntimeException exception) { + getLogger().log(Level.SEVERE, "Creeper Fear could not initialize its progress storage", exception); + throw exception; + } + } + + @Override + public void onDisable() { + if (progressService == null) { + return; + } + try { + progressService.close(); + } catch (RuntimeException exception) { + getLogger().log(Level.SEVERE, "Creeper Fear could not close its progress storage cleanly", exception); + } finally { + progressService = null; + } + } +} diff --git a/src/main/java/games/dmg/creeperfear/listener/CreeperDeathListener.java b/src/main/java/games/dmg/creeperfear/listener/CreeperDeathListener.java new file mode 100644 index 0000000..da2be6b --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/listener/CreeperDeathListener.java @@ -0,0 +1,65 @@ +package games.dmg.creeperfear.listener; + +import games.dmg.creeperfear.progress.PlayerProgress; +import games.dmg.creeperfear.progress.ProgressService; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.bukkit.entity.Creeper; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDeathEvent; + +public final class CreeperDeathListener implements Listener { + private static final int RECENT_DEATH_LIMIT = 4096; + + private final BiFunction> killRecorder; + private final Logger logger; + private final Map recentDeaths = new LinkedHashMap<>(128, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > RECENT_DEATH_LIMIT; + } + }; + + public CreeperDeathListener(ProgressService progressService, Logger logger) { + this(progressService::recordCreeperKill, logger); + } + + CreeperDeathListener( + BiFunction> killRecorder, + Logger logger) { + this.killRecorder = killRecorder; + this.logger = logger; + } + + @EventHandler + public void onEntityDeath(EntityDeathEvent event) { + if (!(event.getEntity() instanceof Creeper creeper)) { + return; + } + Player player = CreeperKillAttributor.findPlayer(creeper).orElse(null); + if (player == null || !markNewDeath(creeper.getUniqueId())) { + return; + } + + UUID playerId = player.getUniqueId(); + String playerName = player.getName(); + killRecorder.apply(playerId, playerName).whenComplete((progress, failure) -> { + if (failure != null) { + logger.log(Level.SEVERE, + "Could not persist creeper progress for " + playerName + " (" + playerId + ")", + failure); + } + }); + } + + private synchronized boolean markNewDeath(UUID creeperId) { + return recentDeaths.putIfAbsent(creeperId, Boolean.TRUE) == null; + } +} diff --git a/src/main/java/games/dmg/creeperfear/listener/CreeperKillAttributor.java b/src/main/java/games/dmg/creeperfear/listener/CreeperKillAttributor.java new file mode 100644 index 0000000..3741a71 --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/listener/CreeperKillAttributor.java @@ -0,0 +1,44 @@ +package games.dmg.creeperfear.listener; + +import java.util.Optional; +import org.bukkit.entity.Creeper; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.entity.Projectile; +import org.bukkit.entity.Tameable; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.projectiles.ProjectileSource; + +public final class CreeperKillAttributor { + private CreeperKillAttributor() { + } + + public static Optional findPlayer(Creeper creeper) { + Player bukkitKiller = creeper.getKiller(); + if (bukkitKiller != null) { + return Optional.of(bukkitKiller); + } + if (!(creeper.getLastDamageCause() instanceof EntityDamageByEntityEvent damageEvent)) { + return Optional.empty(); + } + return playerResponsibleFor(damageEvent.getDamager()); + } + + private static Optional playerResponsibleFor(Entity damager) { + if (damager instanceof Player player) { + return Optional.of(player); + } + if (damager instanceof Projectile projectile) { + ProjectileSource shooter = projectile.getShooter(); + if (shooter instanceof Player player) { + return Optional.of(player); + } + } + if (damager instanceof Tameable tameable + && tameable.isTamed() + && tameable.getOwner() instanceof Player player) { + return Optional.of(player); + } + return Optional.empty(); + } +} diff --git a/src/main/java/games/dmg/creeperfear/progress/AuraRank.java b/src/main/java/games/dmg/creeperfear/progress/AuraRank.java new file mode 100644 index 0000000..1192553 --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/progress/AuraRank.java @@ -0,0 +1,15 @@ +package games.dmg.creeperfear.progress; + +public enum AuraRank { + LOCKED, + I, + II, + III, + IV, + V, + VI; + + public boolean isMaximum() { + return this == VI; + } +} diff --git a/src/main/java/games/dmg/creeperfear/progress/PlayerProgress.java b/src/main/java/games/dmg/creeperfear/progress/PlayerProgress.java new file mode 100644 index 0000000..efb2a6b --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/progress/PlayerProgress.java @@ -0,0 +1,21 @@ +package games.dmg.creeperfear.progress; + +import java.util.Objects; +import java.util.UUID; + +public record PlayerProgress(UUID playerId, String lastKnownName, AuraRank rank, int tierKills) { + public PlayerProgress { + Objects.requireNonNull(playerId, "playerId"); + Objects.requireNonNull(lastKnownName, "lastKnownName"); + Objects.requireNonNull(rank, "rank"); + if (lastKnownName.isBlank()) { + throw new IllegalArgumentException("lastKnownName must not be blank"); + } + if (tierKills < 0) { + throw new IllegalArgumentException("tierKills must not be negative"); + } + if (rank.isMaximum() && tierKills != 0) { + throw new IllegalArgumentException("rank VI cannot retain tier progress"); + } + } +} diff --git a/src/main/java/games/dmg/creeperfear/progress/ProgressRepository.java b/src/main/java/games/dmg/creeperfear/progress/ProgressRepository.java new file mode 100644 index 0000000..edaceda --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/progress/ProgressRepository.java @@ -0,0 +1,15 @@ +package games.dmg.creeperfear.progress; + +import java.util.Optional; +import java.util.UUID; + +public interface ProgressRepository extends AutoCloseable { + PlayerProgress recordCreeperKill(UUID playerId, String playerName); + + Optional find(UUID playerId); + + PlayerProgress save(PlayerProgress progress); + + @Override + void close(); +} diff --git a/src/main/java/games/dmg/creeperfear/progress/ProgressService.java b/src/main/java/games/dmg/creeperfear/progress/ProgressService.java new file mode 100644 index 0000000..7bcbb18 --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/progress/ProgressService.java @@ -0,0 +1,50 @@ +package games.dmg.creeperfear.progress; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +public final class ProgressService implements AutoCloseable { + private final ProgressRepository repository; + private final ExecutorService executor; + + public ProgressService(ProgressRepository repository) { + this.repository = repository; + this.executor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "creeper-fear-progress"); + thread.setDaemon(true); + return thread; + }); + } + + public CompletableFuture recordCreeperKill(UUID playerId, String playerName) { + return CompletableFuture.supplyAsync( + () -> repository.recordCreeperKill(playerId, playerName), executor); + } + + public CompletableFuture> find(UUID playerId) { + return CompletableFuture.supplyAsync(() -> repository.find(playerId), executor); + } + + public CompletableFuture save(PlayerProgress progress) { + return CompletableFuture.supplyAsync(() -> repository.save(progress), executor); + } + + @Override + public void close() { + executor.shutdown(); + try { + if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException exception) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } finally { + repository.close(); + } + } +} diff --git a/src/main/java/games/dmg/creeperfear/progress/ProgressStorageException.java b/src/main/java/games/dmg/creeperfear/progress/ProgressStorageException.java new file mode 100644 index 0000000..e03c590 --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/progress/ProgressStorageException.java @@ -0,0 +1,7 @@ +package games.dmg.creeperfear.progress; + +public final class ProgressStorageException extends RuntimeException { + public ProgressStorageException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/games/dmg/creeperfear/progress/SqliteProgressRepository.java b/src/main/java/games/dmg/creeperfear/progress/SqliteProgressRepository.java new file mode 100644 index 0000000..5db5287 --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/progress/SqliteProgressRepository.java @@ -0,0 +1,169 @@ +package games.dmg.creeperfear.progress; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Optional; +import java.util.UUID; + +public final class SqliteProgressRepository implements ProgressRepository { + private final Connection connection; + + public SqliteProgressRepository(Path databasePath) { + try { + Path parent = databasePath.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + connection = DriverManager.getConnection("jdbc:sqlite:" + databasePath.toAbsolutePath()); + initialize(); + } catch (IOException | SQLException exception) { + throw new ProgressStorageException("Could not initialize player progress database", exception); + } + } + + private void initialize() throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute("PRAGMA journal_mode = WAL"); + statement.execute("PRAGMA synchronous = NORMAL"); + statement.execute(""" + CREATE TABLE IF NOT EXISTS player_progress ( + player_uuid TEXT PRIMARY KEY NOT NULL, + last_known_name TEXT NOT NULL, + rank TEXT NOT NULL CHECK (rank IN ('LOCKED', 'I', 'II', 'III', 'IV', 'V', 'VI')), + tier_kills INTEGER NOT NULL CHECK (tier_kills >= 0), + updated_at INTEGER NOT NULL + ) + """); + } + } + + @Override + public synchronized PlayerProgress recordCreeperKill(UUID playerId, String playerName) { + String sql = """ + INSERT INTO player_progress(player_uuid, last_known_name, rank, tier_kills, updated_at) + VALUES (?, ?, 'LOCKED', 1, ?) + ON CONFLICT(player_uuid) DO UPDATE SET + last_known_name = excluded.last_known_name, + tier_kills = CASE + WHEN player_progress.rank = 'VI' THEN 0 + ELSE player_progress.tier_kills + 1 + END, + updated_at = excluded.updated_at + """; + try { + connection.setAutoCommit(false); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, playerId.toString()); + statement.setString(2, playerName); + statement.setLong(3, System.currentTimeMillis()); + statement.executeUpdate(); + } + PlayerProgress progress = findRequired(playerId); + connection.commit(); + return progress; + } catch (SQLException | RuntimeException exception) { + rollbackAfterFailure(exception); + throw storageFailure("Could not record creeper kill for " + playerId, exception); + } finally { + restoreAutoCommit(); + } + } + + @Override + public synchronized Optional find(UUID playerId) { + try { + return findInternal(playerId); + } catch (SQLException | RuntimeException exception) { + throw storageFailure("Could not load progress for " + playerId, exception); + } + } + + @Override + public synchronized PlayerProgress save(PlayerProgress progress) { + String sql = """ + INSERT INTO player_progress(player_uuid, last_known_name, rank, tier_kills, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(player_uuid) DO UPDATE SET + last_known_name = excluded.last_known_name, + rank = excluded.rank, + tier_kills = excluded.tier_kills, + updated_at = excluded.updated_at + """; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, progress.playerId().toString()); + statement.setString(2, progress.lastKnownName()); + statement.setString(3, progress.rank().name()); + statement.setInt(4, progress.tierKills()); + statement.setLong(5, System.currentTimeMillis()); + statement.executeUpdate(); + return progress; + } catch (SQLException exception) { + throw storageFailure("Could not save progress for " + progress.playerId(), exception); + } + } + + private PlayerProgress findRequired(UUID playerId) throws SQLException { + return findInternal(playerId).orElseThrow( + () -> new ProgressStorageException("Progress disappeared for " + playerId, null)); + } + + private Optional findInternal(UUID playerId) throws SQLException { + String sql = """ + SELECT player_uuid, last_known_name, rank, tier_kills + FROM player_progress + WHERE player_uuid = ? + """; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, playerId.toString()); + try (ResultSet result = statement.executeQuery()) { + if (!result.next()) { + return Optional.empty(); + } + return Optional.of(new PlayerProgress( + UUID.fromString(result.getString("player_uuid")), + result.getString("last_known_name"), + AuraRank.valueOf(result.getString("rank")), + result.getInt("tier_kills"))); + } + } + } + + private void rollbackAfterFailure(Throwable original) { + try { + connection.rollback(); + } catch (SQLException rollbackFailure) { + original.addSuppressed(rollbackFailure); + } + } + + private void restoreAutoCommit() { + try { + connection.setAutoCommit(true); + } catch (SQLException exception) { + throw new ProgressStorageException("Could not restore database transaction state", exception); + } + } + + private ProgressStorageException storageFailure(String message, Throwable cause) { + if (cause instanceof ProgressStorageException storageException) { + return storageException; + } + return new ProgressStorageException(message, cause); + } + + @Override + public synchronized void close() { + try { + connection.close(); + } catch (SQLException exception) { + throw new ProgressStorageException("Could not close player progress database", exception); + } + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..3ea427a --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: CreeperFear +version: '${version}' +main: games.dmg.creeperfear.CreeperFearPlugin +api-version: '26.2' +author: dmg.games +description: Unlock Creeper Aura ranks by defeating creepers. diff --git a/src/test/java/games/dmg/creeperfear/listener/CreeperDeathListenerTest.java b/src/test/java/games/dmg/creeperfear/listener/CreeperDeathListenerTest.java new file mode 100644 index 0000000..190588a --- /dev/null +++ b/src/test/java/games/dmg/creeperfear/listener/CreeperDeathListenerTest.java @@ -0,0 +1,43 @@ +package games.dmg.creeperfear.listener; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import games.dmg.creeperfear.progress.AuraRank; +import games.dmg.creeperfear.progress.PlayerProgress; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; +import java.util.logging.Logger; +import org.bukkit.entity.Creeper; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.EntityDeathEvent; +import org.junit.jupiter.api.Test; + +class CreeperDeathListenerTest { + @Test + void recordsOnePointWhenTheSameDeathIsObservedMoreThanOnce() { + UUID creeperId = UUID.randomUUID(); + UUID playerId = UUID.randomUUID(); + Creeper creeper = mock(Creeper.class); + Player player = mock(Player.class); + EntityDeathEvent event = mock(EntityDeathEvent.class); + @SuppressWarnings("unchecked") + BiFunction> recorder = mock(BiFunction.class); + PlayerProgress progress = new PlayerProgress(playerId, "Player", AuraRank.LOCKED, 1); + when(event.getEntity()).thenReturn(creeper); + when(creeper.getUniqueId()).thenReturn(creeperId); + when(creeper.getKiller()).thenReturn(player); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getName()).thenReturn("Player"); + when(recorder.apply(playerId, "Player")).thenReturn(CompletableFuture.completedFuture(progress)); + CreeperDeathListener listener = new CreeperDeathListener(recorder, Logger.getAnonymousLogger()); + + listener.onEntityDeath(event); + listener.onEntityDeath(event); + + verify(recorder, times(1)).apply(playerId, "Player"); + } +} diff --git a/src/test/java/games/dmg/creeperfear/listener/CreeperKillAttributorTest.java b/src/test/java/games/dmg/creeperfear/listener/CreeperKillAttributorTest.java new file mode 100644 index 0000000..8bca013 --- /dev/null +++ b/src/test/java/games/dmg/creeperfear/listener/CreeperKillAttributorTest.java @@ -0,0 +1,58 @@ +package games.dmg.creeperfear.listener; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.bukkit.entity.Creeper; +import org.bukkit.entity.Player; +import org.bukkit.entity.Projectile; +import org.bukkit.entity.Tameable; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.junit.jupiter.api.Test; + +class CreeperKillAttributorTest { + @Test + void attributesDirectPlayerKills() { + Creeper creeper = mock(Creeper.class); + Player player = mock(Player.class); + when(creeper.getKiller()).thenReturn(player); + + assertEquals(player, CreeperKillAttributor.findPlayer(creeper).orElseThrow()); + } + + @Test + void attributesProjectileKillsToTheShooter() { + Creeper creeper = mock(Creeper.class); + Projectile projectile = mock(Projectile.class); + Player player = mock(Player.class); + EntityDamageByEntityEvent damage = mock(EntityDamageByEntityEvent.class); + when(creeper.getLastDamageCause()).thenReturn(damage); + when(damage.getDamager()).thenReturn(projectile); + when(projectile.getShooter()).thenReturn(player); + + assertEquals(player, CreeperKillAttributor.findPlayer(creeper).orElseThrow()); + } + + @Test + void attributesTamedEntityKillsToTheOwner() { + Creeper creeper = mock(Creeper.class); + Tameable tameable = mock(Tameable.class); + Player player = mock(Player.class); + EntityDamageByEntityEvent damage = mock(EntityDamageByEntityEvent.class); + when(creeper.getLastDamageCause()).thenReturn(damage); + when(damage.getDamager()).thenReturn(tameable); + when(tameable.isTamed()).thenReturn(true); + when(tameable.getOwner()).thenReturn(player); + + assertEquals(player, CreeperKillAttributor.findPlayer(creeper).orElseThrow()); + } + + @Test + void ignoresKillsWithoutAPlayerAttribution() { + Creeper creeper = mock(Creeper.class); + + assertTrue(CreeperKillAttributor.findPlayer(creeper).isEmpty()); + } +} diff --git a/src/test/java/games/dmg/creeperfear/progress/ProgressServiceTest.java b/src/test/java/games/dmg/creeperfear/progress/ProgressServiceTest.java new file mode 100644 index 0000000..7faea54 --- /dev/null +++ b/src/test/java/games/dmg/creeperfear/progress/ProgressServiceTest.java @@ -0,0 +1,64 @@ +package games.dmg.creeperfear.progress; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class ProgressServiceTest { + @Test + void recordsKillsAwayFromTheCallingThread() throws Exception { + BlockingRepository repository = new BlockingRepository(); + Thread caller = Thread.currentThread(); + + try (ProgressService service = new ProgressService(repository)) { + var result = service.recordCreeperKill(UUID.randomUUID(), "Player"); + + assertTrue(repository.started.await(2, TimeUnit.SECONDS)); + assertFalse(result.isDone()); + assertNotEquals(caller, repository.worker.get()); + + repository.release.countDown(); + result.get(2, TimeUnit.SECONDS); + } + } + + private static final class BlockingRepository implements ProgressRepository { + private final CountDownLatch started = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private final AtomicReference worker = new AtomicReference<>(); + + @Override + public PlayerProgress recordCreeperKill(UUID playerId, String playerName) { + worker.set(Thread.currentThread()); + started.countDown(); + try { + assertTrue(release.await(2, TimeUnit.SECONDS)); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(exception); + } + return new PlayerProgress(playerId, playerName, AuraRank.LOCKED, 1); + } + + @Override + public Optional find(UUID playerId) { + return Optional.empty(); + } + + @Override + public PlayerProgress save(PlayerProgress progress) { + return progress; + } + + @Override + public void close() { + } + } +} diff --git a/src/test/java/games/dmg/creeperfear/progress/SqliteProgressRepositoryTest.java b/src/test/java/games/dmg/creeperfear/progress/SqliteProgressRepositoryTest.java new file mode 100644 index 0000000..b249b41 --- /dev/null +++ b/src/test/java/games/dmg/creeperfear/progress/SqliteProgressRepositoryTest.java @@ -0,0 +1,53 @@ +package games.dmg.creeperfear.progress; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.file.Path; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SqliteProgressRepositoryTest { + @TempDir + Path tempDir; + + @Test + void recordsCurrentTierProgressAndPersistsItAcrossRestart() { + UUID playerId = UUID.randomUUID(); + Path database = tempDir.resolve("progress.sqlite3"); + + try (SqliteProgressRepository repository = new SqliteProgressRepository(database)) { + PlayerProgress first = repository.recordCreeperKill(playerId, "FirstName"); + PlayerProgress second = repository.recordCreeperKill(playerId, "NewName"); + + assertEquals(AuraRank.LOCKED, first.rank()); + assertEquals(1, first.tierKills()); + assertEquals(2, second.tierKills()); + assertEquals("NewName", second.lastKnownName()); + } + + try (SqliteProgressRepository repository = new SqliteProgressRepository(database)) { + PlayerProgress persisted = repository.find(playerId).orElseThrow(); + + assertEquals(AuraRank.LOCKED, persisted.rank()); + assertEquals(2, persisted.tierKills()); + assertEquals("NewName", persisted.lastKnownName()); + } + } + + @Test + void storesRankSeparatelyAndDoesNotAdvanceProgressAtRankSix() { + UUID playerId = UUID.randomUUID(); + Path database = tempDir.resolve("maximum-rank.sqlite3"); + + try (SqliteProgressRepository repository = new SqliteProgressRepository(database)) { + repository.save(new PlayerProgress(playerId, "Player", AuraRank.VI, 0)); + + PlayerProgress progress = repository.recordCreeperKill(playerId, "RenamedPlayer"); + + assertEquals(AuraRank.VI, progress.rank()); + assertEquals(0, progress.tierKills()); + assertEquals("RenamedPlayer", progress.lastKnownName()); + } + } +}