diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..6a9787c7 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,91 @@ +name: E2E (plugwright) + +# In-game end-to-end tests via plugwright (boots a real Paper server + Mineflayer bot) — +# the layer the Maven/MockBukkit unit tests can't reach. Run ON DEMAND as a regression check. +# +# Trigger it from the GitHub UI (Actions tab -> "E2E (plugwright)" -> "Run workflow", pick the +# branch/tag) or from the CLI: gh workflow run e2e.yml --ref +# +# It is deliberately NOT wired to every push/PR: a run boots a server and generates worlds +# (~1-2 min) and is heavier/flakier than the unit tests. For a nightly regression, add: +# schedule: +# - cron: "0 3 * * *" +on: + workflow_dispatch: + inputs: + test_names: + description: "Only run tests whose name contains this (comma-separated substrings). Blank = all." + required: false + default: "" + +jobs: + e2e: + name: E2E tests + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Set up Gradle (with caching) + uses: gradle/actions/setup-gradle@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4 + + # Reuse the Paper jar + downloaded libraries and the BentoBox/BSkyBlock jars across runs. + # plugwright's clean step preserves server.jar / cache / libraries, and build.gradle.kts + # caches the dependency jars in .deps, so this avoids re-downloading ~60 MB every run. + - name: Cache Paper + dependency jars + uses: actions/cache@v4 + with: + path: | + e2e/.deps + e2e/run/server.jar + e2e/run/cache + e2e/run/libraries + key: plugwright-${{ hashFiles('e2e/build.gradle.kts') }} + + - name: Cache npm modules + uses: actions/cache@v4 + with: + path: e2e/src/test/e2e/node_modules + # Key on the committed lockfile so the cache tracks the exact resolved versions + # (mineflayer/minecraft-data must support the target Minecraft version). + key: e2e-npm-${{ hashFiles('e2e/src/test/e2e/package-lock.json') }} + + - name: Build Challenges jar (Maven) + run: mvn -B -DskipTests package + + - name: Run E2E tests + working-directory: e2e + env: + PLUGWRIGHT_DEBUG: "1" + # Bind the user-controlled input to an env var and reference it as a shell variable + # below — never interpolate ${{ inputs.* }} directly into a run block (script injection). + TEST_NAMES: ${{ inputs.test_names }} + run: | + if [ -n "$TEST_NAMES" ]; then + ./gradlew plugwrightTest "-PtestNames=$TEST_NAMES" --no-daemon + else + ./gradlew plugwrightTest --no-daemon + fi + + # Always upload the server log + any plugwright report so a regression is debuggable. + - name: Upload server log & report + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-logs + path: | + e2e/run/logs/** + e2e/run/plugwright-report/** + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d03b20f0..238d043e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ on: jobs: publish: - uses: bentoboxworld/.github/.github/workflows/publish-platforms.yml@71bf927bce32586216baa6995f21852d944b98b9 # master + uses: bentoboxworld/.github/.github/workflows/publish-platforms.yml@ca2dcd167e8db4e0f671a976080744dda43801a6 # master with: use_release_asset: "true" # publish the jar attached to the release; do not rebuild hangar_slug: "Challenges-For-BentoBox" # blank = skip Hangar @@ -27,4 +27,4 @@ jobs: version: ${{ inputs.version }} # empty on release events -> falls back to the release tag secrets: HANGAR_API_KEY: ${{ secrets.HANGAR_API_KEY }} - CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} + CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 29db8f55..d6349d7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Challenges is a BentoBox addon (Bukkit/Spigot plugin module) that adds player challenges to any BentoBox GameMode addon (BSkyBlock, AcidIsland, SkyGrid, CaveBlock). It is not a standalone plugin — it is loaded by BentoBox at runtime. -- Java 21, Maven, Spigot `1.21.3-R0.1-SNAPSHOT` -- Depends on BentoBox `3.4.0`, optionally Level `2.6.3` and Vault `1.7` +- Java 21, Maven, Paper `1.21.11-R0.1-SNAPSHOT` (server API dep is `paper.version` in `pom.xml`; `addon.yml` declares `api-version: 3.12.0`) +- Depends on BentoBox `3.14.0`, optionally Level `2.6.3` and Vault `1.7` - Version is set via `` in `pom.xml` (uses `${revision}` / CI `-bNNN` suffix) ## Commands diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 00000000..67248143 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,12 @@ +# Plugwright server run directory (Paper jar, worlds, downloaded plugins, logs) +run/ +# Cached dependency jars (BentoBox, BSkyBlock) downloaded by build.gradle.kts +.deps/ +# Gradle +.gradle/ +build/ +# Node / TypeScript test artifacts +src/test/e2e/node_modules/ +src/test/e2e/dist/ +# package-lock.json IS committed — it pins mineflayer/minecraft-data to versions that +# support the target Minecraft version, so CI installs are reproducible. diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..d040a993 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,57 @@ +# End-to-end (in-game) tests — plugwright spike + +This is a spike using [plugwright](https://github.com/Drownek/plugwright) to verify Challenges +**in a real running server** — something the JUnit/MockBukkit unit tests can't do. Plugwright boots +a real Paper server, loads the plugins, and drives a headless [Mineflayer](https://github.com/PrismarineJS/mineflayer) +bot that runs commands and clicks GUIs, then asserts on what the bot sees. + +It is a **separate Gradle sidecar** — it does not touch the Maven build. It deploys *prebuilt* jars: + +- **BentoBox 3.14.0** and **BSkyBlock 1.20.0** are downloaded (cached in `.deps/`). +- The **Challenges** jar is taken from `../target/` (build it first with `mvn -DskipTests package`). + +> **Gotcha that cost most of the spike:** BentoBox *addons* (BSkyBlock, Challenges) must be staged +> into `plugins/BentoBox/addons/`, **not** `plugins/`. In `plugins/` they load as bare Paper plugins +> and never register as gamemodes, so no `bskyblock_world` is created and every `/island`, +> `/bsbadmin`, `/challenges` command comes back "Unknown". See `build.gradle.kts`. + +## Running locally + +```bash +# 1. Build the Challenges jar (Maven) +mvn -DskipTests package + +# 2. Run the E2E tests (needs Java 21, Node 18+) +cd e2e +JAVA_HOME=/path/to/jdk-21 ./gradlew plugwrightTest + +# add PLUGWRIGHT_DEBUG=1 to see every message the bot receives +``` + +First run downloads Paper 1.21.11 (~50 MB) and the plugin jars; later runs reuse them +(`run/server.jar`, `.deps/`). A full run is ~30–40s (server boot + world gen dominate). + +## What the tests cover (`src/test/e2e/challenges.spec.ts`) + +- **`bot can interact with the server`** — smoke test: bot joins Paper 1.21.11, ops, runs a command, + receives the reply. Proves the whole stack (BentoBox 3.14.0 + BSkyBlock + Challenges 1.8.0) loads + and is drivable. +- **`confirmation prompts tell the player how to answer (#329)`** — opens the Challenges admin GUI, + clicks "Challenge Wipe", and asserts the bot receives the confirmation instruction line + ("Type 'confirm' ... or 'cancel' ...") added in #415. A real in-game validation of a 1.8.0 feature. +- **`open-anywhere setting toggles in the admin settings GUI (#349)`** — opens admin → Settings, + finds the new "Open GUI Anywhere" (Elytra) toggle, clicks it and asserts the Enabled/Disabled + lore flips, then clicks again to restore it. Proves the full config → Settings → panel button → + click-handler → saveSettings chain works on a live server. +- **`include-undeployed setting toggles in the admin settings GUI (#179)`** — same live toggle check + for the "Include Undeployed Challenges" (Barrel) button that now ships in config.yml. + +Both toggle tests are self-restoring (they read the current state, flip it, assert, then flip back), +so they don't depend on the persisted run-dir config or the order the tests run in. + +## Notes / next steps + +- Tests requiring an **island** (block/biome/completion flows) still need island bootstrap — either + a bot-driven `/island create` (blueprint GUI) or a pre-staged world via `writeFiles`. +- In CI this runs via `.github/workflows/e2e.yml` (manual `workflow_dispatch` for now — advisory, + not a required check). diff --git a/e2e/build.gradle.kts b/e2e/build.gradle.kts new file mode 100644 index 00000000..add50300 --- /dev/null +++ b/e2e/build.gradle.kts @@ -0,0 +1,57 @@ +import java.net.URI + +plugins { + // Plugwright: boots a real Paper server, deploys plugins, drives Mineflayer bots. + id("io.github.drownek.plugwright") version "2.0.2" +} + +// --- Dependency jars ------------------------------------------------------------------------- +// BentoBox is a Paper plugin (goes in plugins/). BSkyBlock and Challenges are BentoBox *addons* +// and MUST live in plugins/BentoBox/addons/ so BentoBox loads them as gamemode/addon (creating +// the gamemode world and registering commands) — dropping them straight into plugins/ makes them +// load as bare Paper plugins that never register with BentoBox. + +val depsDir = layout.projectDirectory.dir(".deps").asFile.apply { mkdirs() } + +fun cachedJar(name: String, url: String): File { + val f = File(depsDir, name) + if (!f.exists()) { + logger.lifecycle("plugwright: downloading $name ...") + URI(url).toURL().openStream().use { input -> f.outputStream().use { input.copyTo(it) } } + } + return f +} + +val bentoboxJar = cachedJar( + "BentoBox-3.14.0.jar", + "https://github.com/BentoBoxWorld/BentoBox/releases/download/3.14.0/BentoBox-3.14.0.jar" +) +val bskyblockJar = cachedJar( + "BSkyBlock-1.20.0.jar", + "https://github.com/BentoBoxWorld/BSkyBlock/releases/download/1.20.0/BSkyBlock-1.20.0.jar" +) + +// Maven-built Challenges jar (version/profile suffix varies: -SNAPSHOT-LOCAL locally, plain on CI). +val challengesJar: File = (file("../target").listFiles()?.toList() ?: emptyList()) + .firstOrNull { + it.name.startsWith("Challenges-") && it.name.endsWith(".jar") && + !it.name.contains("sources") && !it.name.contains("javadoc") + } + ?: throw GradleException( + "Challenges jar not found in ../target — run 'mvn -q -DskipTests package' first." + ) + +plugwright { + minecraftVersion.set("1.21.11") + acceptEula.set(true) + // We deploy prebuilt jars (this is a Maven project, not a Gradle plugin build). + useExternalPluginsOnly.set(true) + testsDir.set(file("src/test/e2e")) + jvmArgs.set(listOf("-Xmx3G")) + + writeFiles { + file("plugins/BentoBox.jar", bentoboxJar) + file("plugins/BentoBox/addons/BSkyBlock.jar", bskyblockJar) + file("plugins/BentoBox/addons/Challenges.jar", challengesJar) + } +} diff --git a/e2e/gradle/wrapper/gradle-wrapper.jar b/e2e/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..f8e1ee31 Binary files /dev/null and b/e2e/gradle/wrapper/gradle-wrapper.jar differ diff --git a/e2e/gradle/wrapper/gradle-wrapper.properties b/e2e/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..9355b415 --- /dev/null +++ b/e2e/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/e2e/gradlew b/e2e/gradlew new file mode 100755 index 00000000..adff685a --- /dev/null +++ b/e2e/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 +# + +############################################################################## +# +# Gradle 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 Gradle +# +# 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/HEAD/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='"-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/e2e/gradlew.bat b/e2e/gradlew.bat new file mode 100644 index 00000000..c4bdd3ab --- /dev/null +++ b/e2e/gradlew.bat @@ -0,0 +1,93 @@ +@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 Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +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="-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 + +goto fail + +: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 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/e2e/settings.gradle.kts b/e2e/settings.gradle.kts new file mode 100644 index 00000000..25e524e9 --- /dev/null +++ b/e2e/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "challenges-e2e" diff --git a/e2e/src/test/e2e/challenges.spec.ts b/e2e/src/test/e2e/challenges.spec.ts new file mode 100644 index 00000000..c78d3249 --- /dev/null +++ b/e2e/src/test/e2e/challenges.spec.ts @@ -0,0 +1,97 @@ +import { expect, test, PlayerWrapper } from '@drownek/plugwright'; + +// LiveGuiHandle / GuiItemLocator are not re-exported from the package entry point, so derive them +// from PlayerWrapper's public API instead of importing them directly. +type LiveGuiHandle = Awaited>; +type GuiItemLocator = ReturnType; + +/** + * Baseline: bot joins the real Paper 1.21.11 server (BentoBox 3.14.0 + BSkyBlock + the staged + * Challenges 1.8.0) and can run a command and receive its reply — proves the toolchain. + */ +test('bot can interact with the server', async ({ player }) => { + await player.makeOp(); + player.chat('/help'); + await expect(player).toHaveReceivedMessage('Help'); +}); + +/** + * Feature validation for 1.8.0 (#329): confirmation prompts now append an instruction line + * telling the player to type confirm/cancel. Open the Challenges admin GUI, left-click the + * "Challenge Wipe" button (which starts a confirmation conversation) and assert the bot receives + * the new instruction text in chat. + */ +test('confirmation prompts tell the player how to answer (#329)', async ({ player }) => { + await player.makeOp(); + + // Open the Challenges admin GUI (registered under the BSkyBlock admin command). + player.chat('/bsbadmin challenges'); + const gui = await player.gui({ title: /Challenges Admin/i }); + + // Left-click "Challenge Wipe" -> starts the confirmation conversation. + await gui.locator(item => item.getDisplayName().includes('Challenge Wipe')).click(); + + // The #329 change appends this instruction to every confirmation prompt. + await expect(player).toHaveReceivedMessage('to proceed, or'); +}); + +/** + * Feature validation for 1.8.0 (#349): a new "Open GUI Anywhere" setting lets players open the + * challenges GUI while off their island. It surfaces as an Elytra toggle in the admin settings + * GUI. Confirm the button renders in a real server and that clicking it flips the Enabled/Disabled + * state — proving the whole config -> Settings -> panel button -> click-handler -> saveSettings + * chain is wired up, not just present in the JUnit tests. + */ +test('open-anywhere setting toggles in the admin settings GUI (#349)', async ({ player }) => { + await player.makeOp(); + + const settings = await openSettingsPanel(player); + const openAnywhere = settings.locator(item => item.getDisplayName().includes('Open GUI Anywhere')); + + await expectToggleFlips(openAnywhere); +}); + +/** + * Feature validation for 1.8.0 (#179): the include-undeployed option now ships in config.yml and + * is editable from the admin settings GUI as an "Include Undeployed Challenges" barrel toggle + * (it controls whether undeployed challenges count toward level completion). Same live wiring + * check as above, on a different setting. + */ +test('include-undeployed setting toggles in the admin settings GUI (#179)', async ({ player }) => { + await player.makeOp(); + + const settings = await openSettingsPanel(player); + const includeUndeployed = settings.locator(item => item.getDisplayName().includes('Include Undeployed')); + + await expectToggleFlips(includeUndeployed); +}); + +/** + * Open the Challenges admin GUI and click through to the Settings sub-panel, returning a live + * handle to it. The admin menu is registered under the BSkyBlock admin command; the "Settings" + * button opens EditSettingsPanel (window title "Settings"). + */ +async function openSettingsPanel(player: PlayerWrapper): Promise { + player.chat('/bsbadmin challenges'); + const admin = await player.gui({ title: /Challenges Admin/i }); + await admin.locator(item => item.getDisplayName().includes('Settings')).click(); + return player.gui({ title: /Settings/i }); +} + +/** + * Assert a settings toggle button really toggles on a live server: read its current Enabled/Disabled + * state, click it and assert the state flipped, then click again to restore it. Restoring keeps the + * test independent of the persisted run-dir config and of the order tests run in. + */ +async function expectToggleFlips(button: GuiItemLocator): Promise { + // Wait for the panel to finish drawing this button with a known toggle state. + await expect.poll(() => button.loreText()).toMatch(/Enabled|Disabled/); + const wasEnabled = button.loreText().includes('Enabled'); + + await button.click(); + await expect(button).toHaveLore(wasEnabled ? 'Disabled' : 'Enabled'); + + // Put it back the way we found it. + await button.click(); + await expect(button).toHaveLore(wasEnabled ? 'Enabled' : 'Disabled'); +} diff --git a/e2e/src/test/e2e/package-lock.json b/e2e/src/test/e2e/package-lock.json new file mode 100644 index 00000000..9a8a052f --- /dev/null +++ b/e2e/src/test/e2e/package-lock.json @@ -0,0 +1,1043 @@ +{ + "name": "e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@drownek/plugwright": "^2.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.3" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.16.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz", + "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.3.tgz", + "integrity": "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@drownek/plugwright": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@drownek/plugwright/-/plugwright-2.0.2.tgz", + "integrity": "sha512-VopUAnSH7qVVNdLDKa64Rpae7vepiR3cy0Z0y9FHEDAi5jHFXGVGFPL+OClAA5ZD/p2WI5Er3uROmk4K69z/xw==", + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0", + "mineflayer": "^4.0.0", + "picocolors": "^1.1.1", + "source-map-support": "^0.5.21" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-rsa": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/node-rsa/-/node-rsa-1.1.4.tgz", + "integrity": "sha512-dB0ECel6JpMnq5ULvpUTunx3yNm8e/dIkv8Zu9p2c8me70xIRUUG3q+qXRwcSf9rN3oqamv4116iHy90dJGRpA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz", + "integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xboxreplay/xboxlive-auth": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@xboxreplay/xboxlive-auth/-/xboxlive-auth-5.1.0.tgz", + "integrity": "sha512-UngHHsehZbiTjyyNmo8HvdoUDKMID1U9uVfrpFWUK/2UxPuVTKy5n+CzZQ3S488sW5vOhgh0lHqqynT8ouwgvw==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/aes-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz", + "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asn1": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha512-6i37w/+EhlWlGUJff3T/Q8u1RGmP5wgbiwYnOnbOqvtrPxT63/sYFyP9RcpxtxGymtfA075IvmOnL7ycNOWl3w==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz", + "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", + "license": "MIT", + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/endian-toggle": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/endian-toggle/-/endian-toggle-0.0.0.tgz", + "integrity": "sha512-ShfqhXeHRE4TmggSlHXG8CMGIcsOsqDw/GcoPcosToE59Rm9e4aXaMhEQf2kPBsBRrKem1bbOAv5gOKnkliMFQ==", + "license": "MIT" + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.reduce": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz", + "integrity": "sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==", + "license": "MIT" + }, + "node_modules/macaddress": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/macaddress/-/macaddress-0.5.4.tgz", + "integrity": "sha512-i8xVWoUjj2woYU8kbpQby86Kq7uF7xl2brtKREXUBWpfgqx1fKXEeYzDiVMVxA/IufC1d3xxwJRHtFCX+9IspA==", + "license": "MIT" + }, + "node_modules/minecraft-data": { + "version": "3.111.0", + "resolved": "https://registry.npmjs.org/minecraft-data/-/minecraft-data-3.111.0.tgz", + "integrity": "sha512-0kKHqNZL/D1IbH7tJXBJ3z7YSn1/ylnWWR7X2zFwtEV+yr23nimItpGjP3o8UaLYrC+OV1gKLFQoew03XvJyoA==", + "license": "MIT" + }, + "node_modules/minecraft-folder-path": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minecraft-folder-path/-/minecraft-folder-path-1.2.0.tgz", + "integrity": "sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==", + "license": "MIT" + }, + "node_modules/minecraft-protocol": { + "version": "1.66.2", + "resolved": "https://registry.npmjs.org/minecraft-protocol/-/minecraft-protocol-1.66.2.tgz", + "integrity": "sha512-keY1IY1E2AeurcekCfcXrg0TDbykGVFiMe1E4wR8QkNtQRieNwfr2xaF3g3vT9ChkwzvENqp3jxgmtFCKSUKPg==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/node-rsa": "^1.1.4", + "@types/readable-stream": "^4.0.0", + "aes-js": "^3.1.2", + "buffer-equal": "^1.0.0", + "debug": "^4.3.2", + "endian-toggle": "^0.0.0", + "lodash.merge": "^4.3.0", + "minecraft-data": "^3.78.0", + "minecraft-folder-path": "^1.2.0", + "node-fetch": "^2.6.1", + "node-rsa": "^0.4.2", + "prismarine-auth": "^3.1.1", + "prismarine-chat": "^1.10.0", + "prismarine-nbt": "^2.5.0", + "prismarine-realms": "^1.2.0", + "protodef": "^1.17.0", + "readable-stream": "^4.1.0", + "uuid-1345": "^1.0.1", + "yggdrasil": "^1.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/mineflayer": { + "version": "4.37.1", + "resolved": "https://registry.npmjs.org/mineflayer/-/mineflayer-4.37.1.tgz", + "integrity": "sha512-kchZCJb1znzz8ZhE0+gLQ3e2t/9xUsqUy/IM/sGfceINxi3h6KXKY9luaUEa59vnD/x0OKwYdERY4sscm0ErNQ==", + "license": "MIT", + "dependencies": { + "minecraft-data": "^3.108.0", + "minecraft-protocol": "^1.66.0", + "mojangson": "^2.0.4", + "prismarine-biome": "^1.1.1", + "prismarine-block": "^1.22.0", + "prismarine-chat": "^1.7.1", + "prismarine-chunk": "^1.39.0", + "prismarine-entity": "^2.5.0", + "prismarine-item": "^1.17.0", + "prismarine-nbt": "^2.0.0", + "prismarine-physics": "^1.9.0", + "prismarine-recipe": "^1.5.0", + "prismarine-registry": "^1.10.0", + "prismarine-windows": "^2.9.0", + "prismarine-world": "^3.6.0", + "protodef": "^1.18.0", + "typed-emitter": "^1.0.0", + "uuid-1345": "^1.0.2", + "vec3": "^0.1.7" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/mojangson": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/mojangson/-/mojangson-2.0.4.tgz", + "integrity": "sha512-HYmhgDjr1gzF7trGgvcC/huIg2L8FsVbi/KacRe6r1AswbboGVZDS47SOZlomPuMWvZLas8m9vuHHucdZMwTmQ==", + "license": "MIT", + "dependencies": { + "nearley": "^2.19.5" + } + }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "license": "BSD-3-Clause" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-rsa": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-0.4.2.tgz", + "integrity": "sha512-Bvso6Zi9LY4otIZefYrscsUpo2mUpiAVIEmSZV2q41sP8tHZoert3Yu6zv4f/RXJqMNZQKCtnhDugIuCma23YA==", + "license": "MIT", + "dependencies": { + "asn1": "0.2.3" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/prismarine-auth": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/prismarine-auth/-/prismarine-auth-3.1.1.tgz", + "integrity": "sha512-NuNrMGZdoigFKsvi1ZZgAEvNYNuE5qe6lo/tw+bqeNbkhpjHC0u1JNxLEujnfqduXI18e19PvUtWNMDl/gH7yw==", + "license": "MIT", + "dependencies": { + "@azure/msal-node": "^2.0.2", + "@xboxreplay/xboxlive-auth": "^5.1.0", + "debug": "^4.3.3", + "smart-buffer": "^4.1.0", + "uuid-1345": "^1.0.2" + } + }, + "node_modules/prismarine-biome": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/prismarine-biome/-/prismarine-biome-1.4.0.tgz", + "integrity": "sha512-fD2WmjN8Zr/xA/jeMInReLgaDlznwA5xlaK529PzWuGzgjpc5ijVu1Lp1oqHyZn3WxOG/bRVtW1bU+tmgCurWA==", + "license": "MIT", + "peerDependencies": { + "prismarine-registry": "^1.1.0" + } + }, + "node_modules/prismarine-block": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/prismarine-block/-/prismarine-block-1.23.0.tgz", + "integrity": "sha512-j2UoU4KbXMvNlBw+aLkMOnEuMayYefznUfbrfv1VIbckG3RA9LpNWltOMHXuOR5YkHp8uIZPOclj95XC88jgGw==", + "license": "MIT", + "dependencies": { + "minecraft-data": "^3.38.0", + "prismarine-biome": "^1.1.0", + "prismarine-chat": "^1.5.0", + "prismarine-item": "^1.10.1", + "prismarine-nbt": "^2.0.0", + "prismarine-registry": "^1.1.0" + } + }, + "node_modules/prismarine-chat": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/prismarine-chat/-/prismarine-chat-1.13.0.tgz", + "integrity": "sha512-tvDbrQmJEoy09yLE5nnedGhQYxnRDaPRePMv7W39dFaHr2LGcA2JfCmH0vG5193+BsEFz3a5+0EpQSK8OW7YmA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.2", + "mojangson": "^2.0.1", + "prismarine-nbt": "^2.0.0", + "prismarine-registry": "^1.4.0" + } + }, + "node_modules/prismarine-chunk": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/prismarine-chunk/-/prismarine-chunk-1.40.0.tgz", + "integrity": "sha512-TtT84Bys7+aGA94HwcK0QDp+jkWcLOLErKYtaWWl+EJya28NqPoBASr5L/lPZ8ZWLQUugg/aFIefZI/rEhEQWw==", + "license": "MIT", + "dependencies": { + "prismarine-biome": "^1.2.0", + "prismarine-block": "^1.14.1", + "prismarine-nbt": "^2.2.1", + "prismarine-registry": "^1.1.0", + "smart-buffer": "^4.1.0", + "uint4": "^0.1.2", + "vec3": "^0.1.3", + "xxhash-wasm": "^0.4.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/prismarine-entity": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/prismarine-entity/-/prismarine-entity-2.6.0.tgz", + "integrity": "sha512-/LlZRLOpACiXk+GqoaKi0XPBFnNMjb1d4OIzuSCSEgNMK6FUo3Wnin5yeSZ7ff3Ztt7yagN9lX2jSOafn6IIzg==", + "license": "MIT", + "dependencies": { + "prismarine-chat": "^1.4.1", + "prismarine-item": "^1.11.2", + "prismarine-registry": "^1.4.0", + "vec3": "^0.1.4" + } + }, + "node_modules/prismarine-item": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/prismarine-item/-/prismarine-item-1.18.0.tgz", + "integrity": "sha512-8pEq6YfcneVvarvUFnex09a3+MR8/4NCQVyawIKAa3kh/g9dHLexoEcpQEgM3cmpg4gbLmspSiARGwed5uGhlg==", + "license": "MIT", + "dependencies": { + "prismarine-nbt": "^2.0.0", + "prismarine-registry": "^1.4.0" + } + }, + "node_modules/prismarine-nbt": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/prismarine-nbt/-/prismarine-nbt-2.8.0.tgz", + "integrity": "sha512-5D6FUZq0PNtf3v/41ImDlwThVesOv5adyqCRMZLzmkUGEmRJNNh5C6AsnvrClBftXs+IF0yqPnZoj8kcNPiMGg==", + "license": "MIT", + "dependencies": { + "protodef": "^1.18.0" + } + }, + "node_modules/prismarine-physics": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/prismarine-physics/-/prismarine-physics-1.11.0.tgz", + "integrity": "sha512-P25VSDi3kJHQAb/AJBiJCQuxyRCVXRSdEiDjx56ywocgt65N/exatVTiJjOK5HgEKHJSfw0sXSAohQhvutnGAA==", + "license": "MIT", + "dependencies": { + "minecraft-data": "^3.0.0", + "prismarine-nbt": "^2.0.0", + "vec3": "^0.1.7" + } + }, + "node_modules/prismarine-realms": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/prismarine-realms/-/prismarine-realms-1.6.0.tgz", + "integrity": "sha512-AwemW0vwxG9hQaFtg1twSV7eymB6QtYxGK0jjpxfdA2sdK15kU8jh8uD1o5XF0oxSMU+BbpzZMCmXtXq4QE6bw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.3", + "node-fetch": "^2.6.1" + } + }, + "node_modules/prismarine-recipe": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prismarine-recipe/-/prismarine-recipe-1.5.0.tgz", + "integrity": "sha512-GRZHbsyBIUgVNF10vFRv2YWZj86vokCT5EWX6iK6gfx6h4FapgZT29V2DNkjv5+hmdzBCLZvfx1/RYr8VPeoGQ==", + "license": "MIT", + "peerDependencies": { + "prismarine-registry": "^1.4.0" + } + }, + "node_modules/prismarine-registry": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prismarine-registry/-/prismarine-registry-1.12.0.tgz", + "integrity": "sha512-OC5U6YrflY6OcAWRZEqe2HGZuNp0bIuP7H+oKEHD6rLfKNDxo8Ymx5eh2VvrZWnMVugpwID1Qj/UjA4MoCzNDw==", + "license": "MIT", + "dependencies": { + "minecraft-data": "^3.70.0", + "prismarine-block": "^1.17.1", + "prismarine-nbt": "^2.0.0" + } + }, + "node_modules/prismarine-windows": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/prismarine-windows/-/prismarine-windows-2.10.0.tgz", + "integrity": "sha512-ssXLGAr7W9JLvvLjYMoo1j4j6AdJaoIb0/HlqkWMWlQqvZJeiS4zyBjJY6+GtR4OzpjkEf6IvF5cNXhHFpbcZQ==", + "license": "MIT", + "dependencies": { + "prismarine-item": "^1.12.2", + "prismarine-registry": "^1.7.0", + "typed-emitter": "^2.1.0" + } + }, + "node_modules/prismarine-windows/node_modules/typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", + "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", + "license": "MIT", + "optionalDependencies": { + "rxjs": "*" + } + }, + "node_modules/prismarine-world": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/prismarine-world/-/prismarine-world-3.7.0.tgz", + "integrity": "sha512-M5euvNjQ3vIk689BSa0YC6PBwpVY35Oc6q6KyZ0IqyFtI+cQ9em+8l5OTAK/uu9/gzDDhR7cmm9L2WXgTXBQCw==", + "license": "MIT", + "dependencies": { + "vec3": "^0.1.7" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/protodef": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/protodef/-/protodef-1.19.0.tgz", + "integrity": "sha512-94f3GR7pk4Qi5YVLaLvWBfTGUIzzO8hyo7vFVICQuu5f5nwKtgGDaeC1uXIu49s5to/49QQhEYeL0aigu1jEGA==", + "license": "MIT", + "dependencies": { + "lodash.reduce": "^4.6.0", + "protodef-validator": "^1.3.0", + "readable-stream": "^4.4.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/protodef-validator": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/protodef-validator/-/protodef-validator-1.4.0.tgz", + "integrity": "sha512-2y2coBolqCEuk5Kc3QwO7ThR+/7TZiOit4FrpAgl+vFMvq8w76nDhh09z08e2NQOdrgPLsN2yzXsvRvtADgUZQ==", + "license": "MIT", + "dependencies": { + "ajv": "^6.5.4" + }, + "bin": { + "protodef-validator": "cli.js" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "license": "CC0-1.0" + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "license": "MIT", + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typed-emitter": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.4.0.tgz", + "integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint4": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/uint4/-/uint4-0.1.2.tgz", + "integrity": "sha512-lhEx78gdTwFWG+mt6cWAZD/R6qrIj0TTBeH5xwyuDJyswLNlGe+KVlUPQ6+mx5Ld332pS0AMUTo9hIly7YsWxQ==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/uuid-1345": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uuid-1345/-/uuid-1345-1.0.2.tgz", + "integrity": "sha512-bA5zYZui+3nwAc0s3VdGQGBfbVsJLVX7Np7ch2aqcEWFi5lsAEcmO3+lx3djM1npgpZI8KY2FITZ2uYTnYUYyw==", + "license": "MIT", + "dependencies": { + "macaddress": "^0.5.1" + } + }, + "node_modules/vec3": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz", + "integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==", + "license": "BSD" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/xxhash-wasm": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-0.4.2.tgz", + "integrity": "sha512-/eyHVRJQCirEkSZ1agRSCwriMhwlyUcFkXD5TPVSLP+IPzjsqMVzZwdoczLp1SoQU0R3dxz1RpIK+4YNQbCVOA==", + "license": "MIT" + }, + "node_modules/yggdrasil": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/yggdrasil/-/yggdrasil-1.8.0.tgz", + "integrity": "sha512-r5bKOhkZ52DJ6q034uSkdsdZLoFVhOmfDOagRs6h/JX5W7+XIPOMb+peCbElhLEoIckwt43NCUoNQbydOzuPcQ==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "uuid": "^10.0.0" + } + }, + "node_modules/yggdrasil/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + } + } +} diff --git a/e2e/src/test/e2e/package.json b/e2e/src/test/e2e/package.json new file mode 100644 index 00000000..345ba604 --- /dev/null +++ b/e2e/src/test/e2e/package.json @@ -0,0 +1,13 @@ +{ + "type": "module", + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@drownek/plugwright": "^2.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.3" + } +} diff --git a/e2e/src/test/e2e/tsconfig.json b/e2e/src/test/e2e/tsconfig.json new file mode 100644 index 00000000..af96c496 --- /dev/null +++ b/e2e/src/test/e2e/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "node", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true, + "inlineSources": true + }, + "include": ["*.spec.ts"] +} diff --git a/pom.xml b/pom.xml index 6e509d8d..980f2292 100644 --- a/pom.xml +++ b/pom.xml @@ -53,12 +53,16 @@ ${build.version}-SNAPSHOT - 1.7.0 + 1.8.0 -LOCAL BentoBoxWorld_Challenges bentobox-world https://sonarcloud.io + + src/main/java/world/bentobox/challenges/database/object/*.java diff --git a/src/main/java/world/bentobox/challenges/ChallengesAddon.java b/src/main/java/world/bentobox/challenges/ChallengesAddon.java index 847bbd98..39c9c530 100644 --- a/src/main/java/world/bentobox/challenges/ChallengesAddon.java +++ b/src/main/java/world/bentobox/challenges/ChallengesAddon.java @@ -234,6 +234,7 @@ public void allLoaded() this.log("Challenges Addon hooked into Level addon."); }, () -> { this.levelAddon = null; + this.levelProvided = false; this.logWarning("Level add-on not found so level challenges will not work!"); }); @@ -355,6 +356,19 @@ private void registerPlaceholders(GameModeAddon gameModeAddon) user -> String.valueOf(this.challengesManager.getChallengeCount(world) - this.challengesManager.getCompletedChallengeCount(user, world))); + // Completed challenge percent placeholder + this.getPlugin().getPlaceholdersManager().registerPlaceholder(gameModeAddon, + addonName + "_completed_percent", + user -> { + int totalCount = this.challengesManager.getChallengeCount(world); + if (totalCount == 0) { + return "0"; + } + long completedCount = this.challengesManager.getCompletedChallengeCount(user, world); + // Clamp to 100: completions of since-undeployed challenges can exceed the visible total. + return String.valueOf(Math.min(100, (completedCount * 100) / totalCount)); + }); + // Completed challenge level count placeholder this.getPlugin().getPlaceholdersManager().registerPlaceholder(gameModeAddon, addonName + "_completed_level_count", @@ -421,6 +435,31 @@ private void registerPlaceholders(GameModeAddon gameModeAddon) return String.valueOf(challengeCount - this.challengesManager.getLevelCompletedChallengeCount(user, world, level)); }); + + // Completed challenge percent in latest level + this.getPlugin().getPlaceholdersManager().registerPlaceholder(gameModeAddon, + addonName + "_latest_level_completed_percent", + user -> { + ChallengeLevel level = this.challengesManager.getLatestUnlockedLevel(user, world); + + if (level == null) + { + return "0"; + } + + int challengeCount = this.getChallengesSettings().isIncludeUndeployed() ? + level.getChallenges().size() : + this.challengesManager.getLevelChallenges(level, false).size(); + + if (challengeCount == 0) + { + return "0"; + } + + long completedCount = this.challengesManager.getLevelCompletedChallengeCount(user, world, level); + // Clamp to 100: completions of since-undeployed challenges can exceed the visible total. + return String.valueOf(Math.min(100, (completedCount * 100) / challengeCount)); + }); } diff --git a/src/main/java/world/bentobox/challenges/commands/ChallengesPlayerCommand.java b/src/main/java/world/bentobox/challenges/commands/ChallengesPlayerCommand.java index e0b517ad..c2bc82d4 100644 --- a/src/main/java/world/bentobox/challenges/commands/ChallengesPlayerCommand.java +++ b/src/main/java/world/bentobox/challenges/commands/ChallengesPlayerCommand.java @@ -66,10 +66,11 @@ public boolean canExecute(User user, String label, List args) Utils.sendMessage(user, this.getWorld(), "general.errors.no-island"); return false; } else if (ChallengesAddon.CHALLENGES_WORLD_PROTECTION.isSetForWorld(this.getWorld()) && + !((ChallengesAddon) this.getAddon()).getChallengesSettings().isOpenAnywhere() && !this.getIslands().locationIsOnIsland(user.getPlayer(), user.getLocation())) { // Do not open gui if player is not on the island, but challenges requires island for - // completion. + // completion, unless open-anywhere is enabled. Utils.sendMessage(user, this.getWorld(), Constants.ERRORS + "not-on-island"); return false; } diff --git a/src/main/java/world/bentobox/challenges/commands/admin/CompleteCommand.java b/src/main/java/world/bentobox/challenges/commands/admin/CompleteCommand.java index 71f5b3a1..fb1c79a9 100644 --- a/src/main/java/world/bentobox/challenges/commands/admin/CompleteCommand.java +++ b/src/main/java/world/bentobox/challenges/commands/admin/CompleteCommand.java @@ -108,6 +108,8 @@ else if (!args.get(1).isEmpty()) this.addon.getChallengesManager().setChallengeComplete( targetUUID, this.getWorld(), challenge, user.getUniqueId()); + // Try to complete the level if all challenges are done + this.addon.getChallengesManager().tryCompleteLevelAdmin(target, this.getWorld(), challenge); if (user.isPlayer()) { diff --git a/src/main/java/world/bentobox/challenges/config/Settings.java b/src/main/java/world/bentobox/challenges/config/Settings.java index 1822c133..f1d61cb0 100644 --- a/src/main/java/world/bentobox/challenges/config/Settings.java +++ b/src/main/java/world/bentobox/challenges/config/Settings.java @@ -112,9 +112,31 @@ public class Settings implements ConfigObject @ConfigComment("Valid values are:") @ConfigComment(" 'VISIBLE' - there will be no hidden challenges. All challenges will be viewable in GUI.") @ConfigComment(" 'HIDDEN' - shows only deployed challenges.") + @ConfigComment(" 'TOGGLEABLE' - adds a button to the player GUI so each player can show or hide") + @ConfigComment(" undeployed challenges themselves (defaults to shown).") @ConfigEntry(path = "gui-settings.undeployed-view-mode") private VisibilityMode visibilityMode = VisibilityMode.VISIBLE; + @ConfigComment("") + @ConfigComment("Allow players to open the challenges GUI without being on their island.") + @ConfigComment("Note: Challenges completion still requires being on the island when world protection is enabled.") + @ConfigEntry(path = "gui-settings.open-anywhere") + private boolean openAnywhere = false; + + @ConfigComment("") + @ConfigComment("Default colour applied to every line of a challenge's own description text, so you") + @ConfigComment("do not have to prefix each challenge with the same colour. Uses '&' colour codes or") + @ConfigComment("hex (e.g. '&b' or '7FFFF'). Leave empty for no default. A colour written in the") + @ConfigComment("description itself still overrides this. Does not affect per-challenge locale overrides.") + @ConfigEntry(path = "gui-settings.description-color") + private String descriptionColor = ""; + + @ConfigComment("") + @ConfigComment("Default colour applied to every line of a challenge's own reward text (both first-time") + @ConfigComment("and repeat reward text). Same format as description-color. Leave empty for no default.") + @ConfigEntry(path = "gui-settings.reward-text-color") + private String rewardTextColor = ""; + @ConfigComment("") @ConfigComment("This allows to change default locked level icon. This option may be") @@ -150,8 +172,9 @@ public class Settings implements ConfigObject private boolean resetChallenges = true; @ConfigComment("") - @ConfigComment("This option indicates if undepolyed challenges should be counted to level completion.") - @ConfigComment("Disabling this option will make it so that only deployed challenges will be counted.") + @ConfigComment("This option indicates if undeployed challenges should be counted towards level completion.") + @ConfigComment("Disabling this option will make it so that only deployed challenges are counted, so an") + @ConfigComment("undeployed challenge will not block a level from being completed.") @ConfigComment("Default: true") @ConfigEntry(path = "include-undeployed") private boolean includeUndeployed = true; @@ -712,4 +735,70 @@ public void setIncludeUndeployed(boolean includeUndeployed) { this.includeUndeployed = includeUndeployed; } + + + /** + * Is open anywhere boolean. + * + * @return the boolean + */ + public boolean isOpenAnywhere() + { + return openAnywhere; + } + + + /** + * Sets open anywhere. + * + * @param openAnywhere whether to allow opening GUI from anywhere + */ + public void setOpenAnywhere(boolean openAnywhere) + { + this.openAnywhere = openAnywhere; + } + + + /** + * Returns the default colour applied to challenge description text. + * + * @return the description colour code, or an empty string for no default. + */ + public String getDescriptionColor() + { + return descriptionColor; + } + + + /** + * Sets the default colour applied to challenge description text. + * + * @param descriptionColor the colour code (e.g. "&b" or "7FFFF"), or empty for none. + */ + public void setDescriptionColor(String descriptionColor) + { + this.descriptionColor = descriptionColor; + } + + + /** + * Returns the default colour applied to challenge reward text. + * + * @return the reward text colour code, or an empty string for no default. + */ + public String getRewardTextColor() + { + return rewardTextColor; + } + + + /** + * Sets the default colour applied to challenge reward text. + * + * @param rewardTextColor the colour code (e.g. "&b" or "7FFFF"), or empty for none. + */ + public void setRewardTextColor(String rewardTextColor) + { + this.rewardTextColor = rewardTextColor; + } } diff --git a/src/main/java/world/bentobox/challenges/database/object/Challenge.java b/src/main/java/world/bentobox/challenges/database/object/Challenge.java index 360a7123..1b402826 100644 --- a/src/main/java/world/bentobox/challenges/database/object/Challenge.java +++ b/src/main/java/world/bentobox/challenges/database/object/Challenge.java @@ -224,12 +224,27 @@ public enum ChallengeType @Expose private double rewardMoney = 0; + /** + * Island level reward. Level addon required for this option. + */ + @Expose + private long rewardIslandLevel = 0; + /** * Commands to run when the player completes the challenge for the first time. String List */ @Expose private List rewardCommands = new ArrayList<>(); + /** + * Percentage chance (0-100) that material rewards are given on completion. + * Items, money, and experience are gated by a single roll. + * Default 100 so existing challenges are unchanged. + * Reward commands are never gated. + */ + @Expose + private int rewardChance = 100; + /** * Set of item stacks that should ignore metadata. */ @@ -282,6 +297,12 @@ public enum ChallengeType @Expose private double repeatMoneyReward; + /** + * Repeat island level reward. Level addon required for this option. + */ + @Expose + private long repeatIslandLevel = 0; + /** * Commands to run when challenge is repeated. String List. */ @@ -477,6 +498,15 @@ public double getRewardMoney() } + /** + * @return the rewardIslandLevel + */ + public long getRewardIslandLevel() + { + return rewardIslandLevel; + } + + /** * @return the rewardCommands */ @@ -486,6 +516,16 @@ public List getRewardCommands() } + /** + * Gets the reward chance percentage (0-100). + * @return the rewardChance + */ + public int getRewardChance() + { + return rewardChance; + } + + /** * @return the repeatable */ @@ -540,6 +580,15 @@ public double getRepeatMoneyReward() } + /** + * @return the repeatIslandLevel + */ + public long getRepeatIslandLevel() + { + return repeatIslandLevel; + } + + /** * @return the repeatRewardCommands */ @@ -773,6 +822,17 @@ public void setRewardMoney(double rewardMoney) } + /** + * This method sets the rewardIslandLevel value. + * @param rewardIslandLevel the rewardIslandLevel new value. + * + */ + public void setRewardIslandLevel(long rewardIslandLevel) + { + this.rewardIslandLevel = rewardIslandLevel; + } + + /** * This method sets the rewardCommands value. * @param rewardCommands the rewardCommands new value. @@ -784,6 +844,16 @@ public void setRewardCommands(List rewardCommands) } + /** + * Sets the reward chance percentage. + * @param rewardChance the rewardChance new value (0-100). + */ + public void setRewardChance(int rewardChance) + { + this.rewardChance = rewardChance; + } + + /** * This method sets the repeatable value. * @param repeatable the repeatable new value. @@ -850,6 +920,17 @@ public void setRepeatMoneyReward(double repeatMoneyReward) } + /** + * This method sets the repeatIslandLevel value. + * @param repeatIslandLevel the repeatIslandLevel new value. + * + */ + public void setRepeatIslandLevel(long repeatIslandLevel) + { + this.repeatIslandLevel = repeatIslandLevel; + } + + /** * This method sets the repeatRewardCommands value. * @param repeatRewardCommands the repeatRewardCommands new value. @@ -1028,12 +1109,15 @@ public Challenge copy() clone.setHideIfNoTeam(this.hideIfNoTeam); clone.setRewardExperience(this.rewardExperience); clone.setRewardMoney(this.rewardMoney); + clone.setRewardChance(this.rewardChance); + clone.setRewardIslandLevel(this.rewardIslandLevel); clone.setRepeatable(this.repeatable); clone.setTimeout(this.timeout); clone.setRepeatRewardText(this.repeatRewardText); clone.setMaxTimes(this.maxTimes); clone.setRepeatExperienceReward(this.repeatExperienceReward); clone.setRepeatMoneyReward(this.repeatMoneyReward); + clone.setRepeatIslandLevel(this.repeatIslandLevel); // Copy custom objects clone.setRequirements(this.requirements.copy()); diff --git a/src/main/java/world/bentobox/challenges/database/object/ChallengeLevel.java b/src/main/java/world/bentobox/challenges/database/object/ChallengeLevel.java index 9fba6ac2..20b44d83 100644 --- a/src/main/java/world/bentobox/challenges/database/object/ChallengeLevel.java +++ b/src/main/java/world/bentobox/challenges/database/object/ChallengeLevel.java @@ -108,6 +108,11 @@ public ChallengeLevel() @Expose private double rewardMoney = 0; + @ConfigComment("") + @ConfigComment("Island level reward. Level addon required for this option.") + @Expose + private long rewardIslandLevel = 0; + @ConfigComment("") @ConfigComment("Commands to run when the player completes all challenges in current") @ConfigComment("level. String List") @@ -255,6 +260,16 @@ public double getRewardMoney() } + /** + * This method returns the rewardIslandLevel value. + * @return the value of rewardIslandLevel. + */ + public long getRewardIslandLevel() + { + return rewardIslandLevel; + } + + /** * This method returns the rewardCommands value. * @return the value of rewardCommands. @@ -425,6 +440,17 @@ public void setRewardMoney(double rewardMoney) } + /** + * This method sets the rewardIslandLevel value. + * @param rewardIslandLevel the rewardIslandLevel new value. + * + */ + public void setRewardIslandLevel(long rewardIslandLevel) + { + this.rewardIslandLevel = rewardIslandLevel; + } + + /** * This method sets the rewardCommands value. * @param rewardCommands the rewardCommands new value. @@ -596,6 +622,7 @@ public ChallengeLevel copy() collect(Collectors.toCollection(() -> new ArrayList<>(this.rewardItems.size())))); clone.setRewardExperience(this.rewardExperience); clone.setRewardMoney(this.rewardMoney); + clone.setRewardIslandLevel(this.rewardIslandLevel); clone.setRewardCommands(new ArrayList<>(this.rewardCommands)); clone.setChallenges(new HashSet<>(this.challenges)); clone.setIgnoreRewardMetaData(new HashSet<>(this.ignoreRewardMetaData)); diff --git a/src/main/java/world/bentobox/challenges/database/object/requirements/IslandRequirements.java b/src/main/java/world/bentobox/challenges/database/object/requirements/IslandRequirements.java index 722c2acb..9fab15b1 100644 --- a/src/main/java/world/bentobox/challenges/database/object/requirements/IslandRequirements.java +++ b/src/main/java/world/bentobox/challenges/database/object/requirements/IslandRequirements.java @@ -12,6 +12,7 @@ import java.util.HashSet; import java.util.Map; import java.util.Objects; +import java.util.Set; import org.bukkit.Fluid; import org.bukkit.Material; @@ -79,6 +80,15 @@ public IslandRequirements() { @Expose private int searchRadius = 10; + /** + * Namespaced keys (e.g. "minecraft:plains") of biomes the player must be standing in to + * complete the challenge. Stored as key strings rather than Biome objects so serialization + * is version-robust (Biome is a registry-backed type) and unknown biomes simply never match. + * Empty means no biome restriction. + */ + @Expose + private Set requiredBiomes = new HashSet<>(); + // --------------------------------------------------------------------- // Section: Getters and Setters // --------------------------------------------------------------------- @@ -182,6 +192,25 @@ public void setSearchRadius(int searchRadius) { this.searchRadius = searchRadius; } + + /** + * @return the namespaced keys of biomes the player must stand in (empty for no restriction). + */ + public Set getRequiredBiomes() { + if (this.requiredBiomes == null) { + this.requiredBiomes = new HashSet<>(); + } + return requiredBiomes; + } + + + /** + * @param requiredBiomes the required biome keys to set. + */ + public void setRequiredBiomes(Set requiredBiomes) { + this.requiredBiomes = requiredBiomes; + } + /** * Method isValid returns if given requirement data is valid or not. * @@ -215,6 +244,7 @@ public Requirements copy() { clone.setRemoveEntities(this.removeEntities); clone.setSearchRadius(this.searchRadius); + clone.setRequiredBiomes(new HashSet<>(this.getRequiredBiomes())); return clone; } diff --git a/src/main/java/world/bentobox/challenges/managers/ChallengesManager.java b/src/main/java/world/bentobox/challenges/managers/ChallengesManager.java index 7be3baa0..ffb8e6cb 100644 --- a/src/main/java/world/bentobox/challenges/managers/ChallengesManager.java +++ b/src/main/java/world/bentobox/challenges/managers/ChallengesManager.java @@ -1774,6 +1774,111 @@ public boolean validateLevelCompletion(User user, World world, ChallengeLevel le } + /** + * This method attempts to complete a level for a user by validating all challenges + * in the level are complete. If the level is completable, it marks the level as complete + * and fires a LevelCompletedEvent. + * + * @param user User who completed the level. + * @param world World where level must be completed. + * @param challenge Challenge whose level should be checked. + * @return The completed ChallengeLevel if level was completed, null otherwise. + */ + @Nullable + public ChallengeLevel tryCompleteLevel(User user, World world, Challenge challenge) + { + ChallengeLevel level = this.getCompletableLevel(user, world, challenge); + if (level != null) + { + this.setLevelComplete(user, world, level); + } + return level; + } + + + /** + * This method attempts to complete a level for a user via admin action. + * Similar to tryCompleteLevel but fires an admin LevelCompletedEvent. + * + * @param user User who had the level completed by admin. + * @param world World where level must be completed. + * @param challenge Challenge whose level should be checked. + * @return The completed ChallengeLevel if level was completed, null otherwise. + */ + @Nullable + public ChallengeLevel tryCompleteLevelAdmin(User user, World world, Challenge challenge) + { + ChallengeLevel level = this.getCompletableLevel(user, world, challenge); + if (level != null) + { + this.setLevelCompleteAdmin(user, world, level); + } + return level; + } + + + /** + * Helper method that checks if a level is completable (all challenges done, not already + * completed, and not a free level). + * + * @param user User to check for. + * @param world World to check in. + * @param challenge Challenge whose level to check. + * @return The ChallengeLevel if completable, null otherwise. + */ + @Nullable + private ChallengeLevel getCompletableLevel(User user, World world, Challenge challenge) + { + String levelID = challenge.getLevel(); + if (levelID.equals(ChallengesManager.FREE)) + { + return null; + } + + ChallengeLevel level = this.getLevel(challenge); + if (level == null) + { + return null; + } + + if (this.isLevelCompleted(user, world, level)) + { + return null; + } + + if (!this.validateLevelCompletion(user, world, level)) + { + return null; + } + + return level; + } + + + /** + * Helper method to set a level as complete by admin and fire an admin event. + * + * @param user User who had the level completed. + * @param world World where level was completed. + * @param level Level to mark as complete. + */ + private void setLevelCompleteAdmin(User user, World world, ChallengeLevel level) + { + String storageID = this.getDataUniqueID(user, Util.getWorld(world)); + + this.setLevelComplete(storageID, level.getUniqueId()); + this.addLogEntry(storageID, new LogEntry.Builder("COMPLETE_LEVEL"). + data(USER_ID, user.getUniqueId().toString()). + data("level", level.getUniqueId()).build()); + + // Fire admin event that admin completes level + Bukkit.getPluginManager().callEvent( + new LevelCompletedEvent(level.getUniqueId(), + user.getUniqueId(), + true)); + } + + /** * This method returns LevelStatus object for given challenge level. * @param uniqueId UUID of user who need to be validated. diff --git a/src/main/java/world/bentobox/challenges/panel/CommonPanel.java b/src/main/java/world/bentobox/challenges/panel/CommonPanel.java index 394364eb..4d8fc259 100644 --- a/src/main/java/world/bentobox/challenges/panel/CommonPanel.java +++ b/src/main/java/world/bentobox/challenges/panel/CommonPanel.java @@ -143,8 +143,11 @@ protected List generateChallengeDescription(Challenge challenge, @Nullab String description = this.user .getTranslationOrNothing(Constants.CHALLENGES_CHALLENGES + challenge.getUniqueId() + ".description"); if (description.isEmpty()) { - // Combine the challenge description list into a single string and translate color codes - description = Util.translateColorCodes(String.join("\n", challenge.getDescription())); + // Combine the challenge description list into a single string, apply the configured + // default colour to each line, and translate color codes. + description = Util.translateColorCodes(Utils.applyDefaultColor( + String.join("\n", challenge.getDescription()), + this.addon.getChallengesSettings().getDescriptionColor())); } // Replace any [label] placeholder with the actual top label description = description.replace("[label]", this.topLabel); @@ -395,6 +398,17 @@ private String generateIslandChallenge(IslandRequirements requirement) { entities.insert(0, this.user.getTranslationOrNothing(reference + "entities-title")); } + // Required biomes + StringBuilder biomes = new StringBuilder(); + if (!requirement.getRequiredBiomes().isEmpty()) { + biomes.append(this.user.getTranslationOrNothing(reference + "biomes-title")); + requirement.getRequiredBiomes().stream().sorted().forEach(biomeKey -> { + biomes.append("\n"); + biomes.append(this.user.getTranslationOrNothing(reference + "biome-value", "[biome]", + Utils.prettifyBiome(biomeKey))); + }); + } + String searchRadius = this.user.getTranslationOrNothing(reference + "search-radius", Constants.PARAMETER_NUMBER, String.valueOf(requirement.getSearchRadius())); @@ -406,7 +420,7 @@ private String generateIslandChallenge(IslandRequirements requirement) { : ""; return this.user.getTranslationOrNothing(reference + "lore", "[blocks]", blocks.toString(), "[entities]", - entities.toString(), + entities.toString(), "[biomes]", biomes.toString(), "[warning-block]", warningBlocks, "[warning-entity]", warningEntities, "[search-radius]", searchRadius); } @@ -707,7 +721,9 @@ private String generateRepeatReward(Challenge challenge) { .getTranslationOrNothing(Constants.CHALLENGES_CHALLENGES + challenge.getUniqueId() + ".repeat-reward-text"); if (rewardText.isEmpty()) { - rewardText = wrapToWidth(Util.translateColorCodes(String.join("\n", challenge.getRepeatRewardText())), 30); + rewardText = wrapToWidth(Util.translateColorCodes(Utils.applyDefaultColor( + String.join("\n", challenge.getRepeatRewardText()), + this.addon.getChallengesSettings().getRewardTextColor())), 30); } return this.user.getTranslationOrNothing(reference + "lore", Constants.PARAMETER_TEXT, rewardText, Constants.PARAMETER_ITEMS, items, @@ -786,7 +802,9 @@ private String generateReward(Challenge challenge) { .getTranslationOrNothing(Constants.CHALLENGES_CHALLENGES + challenge.getUniqueId() + ".reward-text"); if (rewardText.isEmpty()) { - rewardText = wrapToWidth(Util.translateColorCodes(String.join("\n", challenge.getRewardText())), 30); + rewardText = wrapToWidth(Util.translateColorCodes(Utils.applyDefaultColor( + String.join("\n", challenge.getRewardText()), + this.addon.getChallengesSettings().getRewardTextColor())), 30); } return this.user.getTranslationOrNothing(reference + "lore", Constants.PARAMETER_TEXT, rewardText, Constants.PARAMETER_ITEMS, items, @@ -864,7 +882,9 @@ protected List generateLevelDescription(LevelStatus levelStatus, User us String description = this.user .getTranslationOrNothing("challenges.levels." + level.getUniqueId() + ".description"); - if (description.isEmpty()) { + if (description.isEmpty() && levelStatus.isUnlocked()) { + // Only fall back to the unlock ("Congratulations...") message once the level is + // actually unlocked. A locked level keeps its "locked / N challenges to go" status. description = Util.translateColorCodes(String.join("\n", level.getUnlockMessage())); } diff --git a/src/main/java/world/bentobox/challenges/panel/ConversationUtils.java b/src/main/java/world/bentobox/challenges/panel/ConversationUtils.java index 6c5bc3d7..8830dcd1 100644 --- a/src/main/java/world/bentobox/challenges/panel/ConversationUtils.java +++ b/src/main/java/world/bentobox/challenges/panel/ConversationUtils.java @@ -117,8 +117,9 @@ public String getPromptText(@NotNull ConversationContext conversationContext) { // Close input GUI. user.closeInventory(); - // There are no editable message. Just return question. - return question; + // Append a standard instruction so players know they must type + // 'confirm' (or 'cancel') in chat rather than clicking anything. + return ConversationUtils.appendConfirmationInstruction(user, question); } }; @@ -135,6 +136,22 @@ public String getPromptText(@NotNull ConversationContext conversationContext) } + /** + * Appends the localised confirmation instruction ("Type 'confirm' ... or 'cancel' ...") + * to a confirmation question so players know they must answer in chat. If the locale + * string is empty (e.g. a translator cleared it) the question is returned unchanged. + * + * @param user User whose locale the instruction is taken from. + * @param question The confirmation question being asked. + * @return The question with the instruction appended on a new line, or the question alone. + */ + static String appendConfirmationInstruction(User user, String question) + { + String instruction = user.getTranslationOrNothing(Constants.CONFIRM_INSTRUCTION); + return instruction.isEmpty() ? question : question + "\n" + instruction; + } + + /** * This method will close opened gui and writes question in chat. After players answers on question in chat, message * will trigger consumer and gui will reopen. Be aware, consumer does not return (and validate) sanitized value, diff --git a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java index 3b4b4abb..aaa32a78 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java @@ -7,12 +7,16 @@ import java.util.Comparator; import java.util.HashSet; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.function.Consumer; import java.util.stream.Collectors; import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; import org.bukkit.World; +import org.bukkit.block.Biome; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.inventory.ItemStack; @@ -35,6 +39,7 @@ import world.bentobox.challenges.panel.ConversationUtils; import world.bentobox.challenges.panel.util.EnvironmentSelector; import world.bentobox.challenges.panel.util.ItemSelector; +import world.bentobox.challenges.panel.util.MultiBiomeSelector; import world.bentobox.challenges.panel.util.MultiBlockSelector; import world.bentobox.challenges.utils.Constants; import world.bentobox.challenges.utils.Utils; @@ -190,6 +195,7 @@ private void buildIslandRequirementsPanel(PanelBuilder panelBuilder) { panelBuilder.item(23, this.createRequirementButton(RequirementButton.SEARCH_RADIUS)); + panelBuilder.item(24, this.createRequirementButton(RequirementButton.REQUIRED_BIOMES)); panelBuilder.item(25, this.createRequirementButton(RequirementButton.REQUIRED_PERMISSIONS)); } @@ -258,6 +264,8 @@ private void buildRewardsPanel(PanelBuilder panelBuilder) { panelBuilder.item(11, this.createRewardButton(RewardButton.REWARD_ITEMS)); panelBuilder.item(20, this.createRewardButton(RewardButton.REWARD_EXPERIENCE)); panelBuilder.item(29, this.createRewardButton(RewardButton.REWARD_MONEY)); + panelBuilder.item(37, this.createRewardButton(RewardButton.REWARD_CHANCE)); + panelBuilder.item(38, this.createRewardButton(RewardButton.REWARD_ISLAND_LEVEL)); panelBuilder.item(22, this.createRewardButton(RewardButton.REPEATABLE)); @@ -279,6 +287,7 @@ private void buildRewardsPanel(PanelBuilder panelBuilder) { panelBuilder.item(16, this.createRewardButton(RewardButton.REPEAT_REWARD_ITEMS)); panelBuilder.item(25, this.createRewardButton(RewardButton.REPEAT_REWARD_EXPERIENCE)); panelBuilder.item(34, this.createRewardButton(RewardButton.REPEAT_REWARD_MONEY)); + panelBuilder.item(43, this.createRewardButton(RewardButton.REPEAT_REWARD_ISLAND_LEVEL)); } } @@ -737,6 +746,11 @@ private PanelItem createRequirementButton(RequirementButton button) { REQUIRED_MATERIALTAGS, REQUIRED_ENTITYTAGS -> { return this.createIslandRequirementButton(button); } + // Biome requirement is a self-contained island requirement, kept out of the large + // createIslandRequirementButton switch. + case REQUIRED_BIOMES -> { + return this.createBiomeRequirementButton(); + } // Buttons for Inventory Requirements case REQUIRED_ITEMS, REMOVE_ITEMS, ADD_IGNORED_META, REMOVE_IGNORED_META -> { return this.createInventoryRequirementButton(button); @@ -930,6 +944,71 @@ private PanelItem createIslandRequirementButton(RequirementButton button) { .clickHandler(clickHandler).build(); } + + /** + * Creates the "Required Biomes" button for island challenges. Left-click opens the biome + * selector (hiding already-chosen biomes); right-click clears the list. + * + * @return the PanelItem for the biome requirement button. + */ + private PanelItem createBiomeRequirementButton() { + final String reference = Constants.BUTTON + RequirementButton.REQUIRED_BIOMES.name().toLowerCase() + "."; + final String name = this.user.getTranslation(reference + "name"); + final IslandRequirements requirements = this.challenge.getRequirements(); + final List description = new ArrayList<>(); + description.add(this.user.getTranslation(reference + Constants.DESCRIPTION_KEY)); + + if (requirements.getRequiredBiomes().isEmpty()) { + description.add(this.user.getTranslation(reference + "none")); + } else { + description.add(this.user.getTranslation(reference + Constants.TITLE_KEY)); + // Sort so the biome list renders in a stable order (the underlying set is unordered). + requirements.getRequiredBiomes().stream() + .sorted(Comparator.comparing(Utils::prettifyBiome)) + .forEach(biomeKey -> description.add( + this.user.getTranslation(reference + "list", "[biome]", Utils.prettifyBiome(biomeKey)))); + } + + description.add(""); + description.add(this.user.getTranslation(Constants.CLICK_TO_ADD)); + + if (!requirements.getRequiredBiomes().isEmpty()) { + description.add(this.user.getTranslation(Constants.TIPS + "right-click-to-clear")); + } + + return new PanelItemBuilder().icon(new ItemStack(Material.GRASS_BLOCK)).name(name).description(description) + .glow(false).clickHandler((panel, user, clickType, slot) -> { + if (clickType.isRightClick()) { + requirements.getRequiredBiomes().clear(); + this.build(); + } else { + this.openBiomeSelector(requirements); + } + return true; + }).build(); + } + + + /** + * Opens the biome selector for the given island requirements, excluding already-chosen + * biomes, and adds the chosen biomes back to the requirement before rebuilding the panel. + * + * @param requirements the island requirements being edited. + */ + private void openBiomeSelector(IslandRequirements requirements) { + Set excluded = requirements.getRequiredBiomes().stream() + .map(key -> Registry.BIOME.get(NamespacedKey.fromString(key))) + .filter(Objects::nonNull).collect(Collectors.toSet()); + + MultiBiomeSelector.open(this.user, excluded, (status, biomes) -> { + if (Boolean.TRUE.equals(status) && biomes != null) { + biomes.forEach(biome -> requirements.getRequiredBiomes().add(MultiBiomeSelector.biomeKey(biome))); + } + + this.build(); + }); + } + /** * This method creates buttons for inventory requirements menu. * @@ -1087,7 +1166,7 @@ private PanelItem createInventoryRequirementButton(RequirementButton button) { glow = false; description.add(""); - description.add(this.user.getTranslation(Constants.TIPS + "click-to-add")); + description.add(this.user.getTranslation(Constants.CLICK_TO_ADD)); } case REMOVE_IGNORED_META -> { icon = new ItemStack(Material.RED_SHULKER_BOX); @@ -1431,6 +1510,52 @@ private PanelItem createRewardButton(RewardButton button) { description.add(""); description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); } + case REWARD_CHANCE -> { + description.add(this.user.getTranslation(reference + Constants.VALUE_KEY, Constants.PARAMETER_NUMBER, + String.valueOf(challenge.getRewardChance()))); + icon = new ItemStack(Material.DIAMOND); + clickHandler = (panel, user, clickType, i) -> { + Consumer numberConsumer = number -> { + if (number != null) { + this.challenge.setRewardChance(number.intValue()); + } + + // reopen panel + this.build(); + }; + ConversationUtils.createNumericInput(numberConsumer, this.user, + this.user.getTranslation(Constants.INPUT_NUMBER), 0, 100); + + return true; + }; + glow = false; + + description.add(""); + description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); + } + case REWARD_ISLAND_LEVEL -> { + description.add(this.user.getTranslation(reference + Constants.VALUE_KEY, Constants.PARAMETER_NUMBER, + String.valueOf(this.challenge.getRewardIslandLevel()))); + icon = new ItemStack(this.addon.isLevelProvided() ? Material.EXPERIENCE_BOTTLE : Material.BARRIER); + clickHandler = (panel, user, clickType, i) -> { + Consumer numberConsumer = number -> { + if (number != null) { + this.challenge.setRewardIslandLevel(number.longValue()); + } + + // reopen panel + this.build(); + }; + ConversationUtils.createNumericInput(numberConsumer, this.user, + this.user.getTranslation(Constants.INPUT_NUMBER), 0, Long.MAX_VALUE); + + return true; + }; + glow = false; + + description.add(""); + description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); + } case REWARD_COMMANDS -> { icon = new ItemStack(Material.COMMAND_BLOCK); @@ -1643,6 +1768,29 @@ private PanelItem createRewardButton(RewardButton button) { description.add(""); description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); } + case REPEAT_REWARD_ISLAND_LEVEL -> { + description.add(this.user.getTranslation(reference + Constants.VALUE_KEY, Constants.PARAMETER_NUMBER, + String.valueOf(this.challenge.getRepeatIslandLevel()))); + icon = new ItemStack(this.addon.isLevelProvided() ? Material.EXPERIENCE_BOTTLE : Material.BARRIER); + clickHandler = (panel, user, clickType, i) -> { + Consumer numberConsumer = number -> { + if (number != null) { + this.challenge.setRepeatIslandLevel(number.longValue()); + } + + // reopen panel + this.build(); + }; + ConversationUtils.createNumericInput(numberConsumer, this.user, + this.user.getTranslation(Constants.INPUT_NUMBER), 0, Long.MAX_VALUE); + + return true; + }; + glow = false; + + description.add(""); + description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); + } case REPEAT_REWARD_COMMANDS -> { icon = new ItemStack(Material.COMMAND_BLOCK); @@ -1723,7 +1871,7 @@ private PanelItem createRewardButton(RewardButton button) { glow = false; description.add(""); - description.add(this.user.getTranslation(Constants.TIPS + "click-to-add")); + description.add(this.user.getTranslation(Constants.CLICK_TO_ADD)); } case REMOVE_IGNORED_META -> { icon = new ItemStack(Material.RED_SHULKER_BOX); @@ -1836,11 +1984,11 @@ private enum Button { * Represents different rewards buttons that are used in menus. */ private enum RewardButton { - REWARD_TEXT, REWARD_ITEMS, REWARD_EXPERIENCE, REWARD_MONEY, REWARD_COMMANDS, + REWARD_TEXT, REWARD_ITEMS, REWARD_EXPERIENCE, REWARD_MONEY, REWARD_CHANCE, REWARD_ISLAND_LEVEL, REWARD_COMMANDS, REPEATABLE, REPEAT_COUNT, COOL_DOWN, - REPEAT_REWARD_TEXT, REPEAT_REWARD_ITEMS, REPEAT_REWARD_EXPERIENCE, REPEAT_REWARD_MONEY, REPEAT_REWARD_COMMANDS, + REPEAT_REWARD_TEXT, REPEAT_REWARD_ITEMS, REPEAT_REWARD_EXPERIENCE, REPEAT_REWARD_MONEY, REPEAT_REWARD_ISLAND_LEVEL, REPEAT_REWARD_COMMANDS, ADD_IGNORED_META, REMOVE_IGNORED_META, } @@ -1854,7 +2002,7 @@ public enum RequirementButton { REQUIRED_LEVEL, REQUIRED_MONEY, REMOVE_MONEY, STATISTIC, STATISTIC_BLOCKS, STATISTIC_ITEMS, STATISTIC_ENTITIES, STATISTIC_AMOUNT, REMOVE_STATISTIC, REQUIRED_MATERIALTAGS, REQUIRED_ENTITYTAGS, REQUIRED_STATISTICS, - REMOVE_STATISTICS, REQUIRED_PAPI, REQUIRED_ADVANCEMENTS, + REMOVE_STATISTICS, REQUIRED_PAPI, REQUIRED_ADVANCEMENTS, REQUIRED_BIOMES, } // --------------------------------------------------------------------- diff --git a/src/main/java/world/bentobox/challenges/panel/admin/EditLevelPanel.java b/src/main/java/world/bentobox/challenges/panel/admin/EditLevelPanel.java index 0863b36b..ca2b66bd 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/EditLevelPanel.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/EditLevelPanel.java @@ -200,6 +200,7 @@ private void buildRewardsPanel(PanelBuilder panelBuilder) panelBuilder.item(13, this.createButton(Button.REWARD_ITEMS)); panelBuilder.item(22, this.createButton(Button.REWARD_EXPERIENCE)); panelBuilder.item(31, this.createButton(Button.REWARD_MONEY)); + panelBuilder.item(40, this.createButton(Button.REWARD_ISLAND_LEVEL)); if (!this.challengeLevel.getRewardItems().isEmpty()) { @@ -489,6 +490,33 @@ private PanelItem createButton(Button button) description.add(""); description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); } + case REWARD_ISLAND_LEVEL -> { + description.add(this.user.getTranslation(reference + Constants.VALUE_KEY, + Constants.PARAMETER_NUMBER, String.valueOf(this.challengeLevel.getRewardIslandLevel()))); + icon = new ItemStack(this.addon.isLevelProvided() ? Material.EXPERIENCE_BOTTLE : Material.BARRIER); + clickHandler = (panel, user, clickType, i) -> { + Consumer numberConsumer = number -> { + if (number != null) + { + this.challengeLevel.setRewardIslandLevel(number.longValue()); + } + + // reopen panel + this.build(); + }; + ConversationUtils.createNumericInput(numberConsumer, + this.user, + this.user.getTranslation(Constants.INPUT_NUMBER), + 0, + Long.MAX_VALUE); + + return true; + }; + glow = false; + + description.add(""); + description.add(this.user.getTranslation(Constants.CLICK_TO_CHANGE)); + } case REWARD_COMMANDS -> { icon = new ItemStack(Material.COMMAND_BLOCK); @@ -967,6 +995,7 @@ private enum Button REWARD_ITEMS, REWARD_EXPERIENCE, REWARD_MONEY, + REWARD_ISLAND_LEVEL, REWARD_COMMANDS, ADD_IGNORED_META, diff --git a/src/main/java/world/bentobox/challenges/panel/admin/EditSettingsPanel.java b/src/main/java/world/bentobox/challenges/panel/admin/EditSettingsPanel.java index 69f0a20e..fe147b4c 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/EditSettingsPanel.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/EditSettingsPanel.java @@ -121,6 +121,7 @@ protected void build() panelBuilder.item(20, this.getSettingsButton(Button.REMOVE_COMPLETED)); panelBuilder.item(29, this.getSettingsButton(Button.VISIBILITY_MODE)); panelBuilder.item(30, this.getSettingsButton(Button.INCLUDE_UNDEPLOYED)); + panelBuilder.item(32, this.getSettingsButton(Button.OPEN_ANYWHERE)); panelBuilder.item(21, this.getSettingsButton(Button.LOCKED_LEVEL_ICON)); @@ -485,6 +486,22 @@ else if (this.settings.getVisibilityMode().equals(VisibilityMode.HIDDEN)) description.add(""); description.add(this.user.getTranslation(Constants.CLICK_TO_TOGGLE)); } + case OPEN_ANYWHERE -> { + description.add(this.user.getTranslation(reference + + (this.settings.isOpenAnywhere() ? Constants.ENABLED_KEY : Constants.DISABLED_KEY))); + + icon = new ItemStack(Material.ELYTRA); + clickHandler = (panel, user1, clickType, i) -> { + this.settings.setOpenAnywhere(!this.settings.isOpenAnywhere()); + panel.getInventory().setItem(i, this.getSettingsButton(button).getItem()); + this.addon.saveSettings(); + return true; + }; + glow = this.settings.isOpenAnywhere(); + + description.add(""); + description.add(this.user.getTranslation(Constants.CLICK_TO_TOGGLE)); + } default -> { icon = new ItemStack(Material.PAPER); clickHandler = null; @@ -596,7 +613,11 @@ private enum Button /** * This allows to switch between different challenges visibility modes. */ - VISIBILITY_MODE + VISIBILITY_MODE, + /** + * This allows players to open the GUI without being on their island. + */ + OPEN_ANYWHERE } diff --git a/src/main/java/world/bentobox/challenges/panel/admin/ListUsersPanel.java b/src/main/java/world/bentobox/challenges/panel/admin/ListUsersPanel.java index fd31eefc..9a29dcf7 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/ListUsersPanel.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/ListUsersPanel.java @@ -222,11 +222,16 @@ protected PanelItem createElementButton(Player player) (status, valueSet) -> { if (Boolean.TRUE.equals(status)) { - valueSet.forEach(challenge -> + valueSet.forEach(challenge -> { manager.setChallengeComplete(player.getUniqueId(), this.world, challenge, - this.user.getUniqueId())); + this.user.getUniqueId()); + // Try to complete the level if all challenges are done + manager.tryCompleteLevelAdmin(User.getInstance(player.getUniqueId()), + this.world, + challenge); + }); } this.build(); diff --git a/src/main/java/world/bentobox/challenges/panel/user/ChallengesPanel.java b/src/main/java/world/bentobox/challenges/panel/user/ChallengesPanel.java index 73709f59..597bbc02 100644 --- a/src/main/java/world/bentobox/challenges/panel/user/ChallengesPanel.java +++ b/src/main/java/world/bentobox/challenges/panel/user/ChallengesPanel.java @@ -102,6 +102,8 @@ protected void build() panelBuilder.registerTypeBuilder("UNASSIGNED_CHALLENGES", this::createFreeChallengesButton); + panelBuilder.registerTypeBuilder("TOGGLE_UNDEPLOYED", this::createToggleUndeployedButton); + panelBuilder.registerTypeBuilder("NEXT", this::createNextButton); panelBuilder.registerTypeBuilder("PREVIOUS", this::createPreviousButton); @@ -114,14 +116,15 @@ private void updateFreeChallengeList() { this.freeChallengeList = this.manager.getFreeChallenges(this.world); - if (this.addon.getChallengesSettings().isRemoveCompleteOneTimeChallenges()) - { - this.freeChallengeList.removeIf(challenge -> !challenge.isRepeatable() && - this.manager.isChallengeComplete(this.user, this.world, challenge)); - } + // Remove completed non-repeatable challenges if global setting is on OR per-challenge flag is set + final boolean globalRemoveCompleted = this.addon.getChallengesSettings().isRemoveCompleteOneTimeChallenges(); + this.freeChallengeList.removeIf(challenge -> !challenge.isRepeatable() && + this.manager.isChallengeComplete(this.user, this.world, challenge) && + (globalRemoveCompleted || challenge.isRemoveWhenCompleted())); - // Remove all undeployed challenges if VisibilityMode is set to Hidden. - if (this.addon.getChallengesSettings().getVisibilityMode().equals(SettingsUtils.VisibilityMode.HIDDEN)) + // Remove all undeployed challenges if they should be hidden (HIDDEN mode, or + // TOGGLEABLE mode with the player currently hiding them). + if (this.hideUndeployedChallenges()) { this.freeChallengeList.removeIf(challenge -> !challenge.isDeployed()); } @@ -134,6 +137,21 @@ private void updateFreeChallengeList() } + /** + * Whether undeployed challenges should be hidden from the viewing player right now. + * True when the visibility mode is HIDDEN, or when it is TOGGLEABLE and the player has + * chosen to hide them. + * + * @return {@code true} if undeployed challenges must be filtered out of the GUI. + */ + private boolean hideUndeployedChallenges() + { + SettingsUtils.VisibilityMode mode = this.addon.getChallengesSettings().getVisibilityMode(); + return mode == SettingsUtils.VisibilityMode.HIDDEN || + (mode == SettingsUtils.VisibilityMode.TOGGLEABLE && !this.showUndeployed); + } + + /** * @return whether the viewing user currently has a team (island membership > 1). */ @@ -155,14 +173,15 @@ private void updateChallengeList() { this.challengeList = this.manager.getLevelChallenges(this.lastSelectedLevel.getLevel(), true); - if (this.addon.getChallengesSettings().isRemoveCompleteOneTimeChallenges()) - { - this.challengeList.removeIf(challenge -> !challenge.isRepeatable() && - this.manager.isChallengeComplete(this.user, this.world, challenge)); - } + // Remove completed non-repeatable challenges if global setting is on OR per-challenge flag is set + final boolean globalRemoveCompleted = this.addon.getChallengesSettings().isRemoveCompleteOneTimeChallenges(); + this.challengeList.removeIf(challenge -> !challenge.isRepeatable() && + this.manager.isChallengeComplete(this.user, this.world, challenge) && + (globalRemoveCompleted || challenge.isRemoveWhenCompleted())); - // Remove all undeployed challenges if VisibilityMode is set to Hidden. - if (this.addon.getChallengesSettings().getVisibilityMode().equals(SettingsUtils.VisibilityMode.HIDDEN)) + // Remove all undeployed challenges if they should be hidden (HIDDEN mode, or + // TOGGLEABLE mode with the player currently hiding them). + if (this.hideUndeployedChallenges()) { this.challengeList.removeIf(challenge -> !challenge.isDeployed()); } @@ -665,6 +684,78 @@ private PanelItem createFreeChallengesButton(@NonNull ItemTemplateRecord templat } + /** + * Creates the button that lets a player show or hide undeployed challenges when the + * visibility mode is TOGGLEABLE. In any other visibility mode there is nothing to toggle, + * so no button is shown (the slot falls back to the panel background). + * + * @param template the button template. + * @param slot the slot the button occupies. + * @return the toggle button, or {@code null} when the mode is not TOGGLEABLE. + */ + @Nullable + private PanelItem createToggleUndeployedButton(@NonNull ItemTemplateRecord template, TemplatedPanel.ItemSlot slot) + { + // Only meaningful in TOGGLEABLE mode. In VISIBLE / HIDDEN there is nothing to toggle. + if (this.addon.getChallengesSettings().getVisibilityMode() != SettingsUtils.VisibilityMode.TOGGLEABLE) + { + return null; + } + + PanelItemBuilder builder = new PanelItemBuilder(); + + if (template.icon() != null) + { + builder.icon(template.icon().clone()); + } + + if (template.title() != null) + { + builder.name(this.user.getTranslation(this.world, template.title())); + } + + if (template.description() != null && !template.description().isBlank()) + { + builder.description(this.user.getTranslation(this.world, template.description())); + } + + // Show whether undeployed challenges are currently shown or hidden for this player. + builder.description(this.user.getTranslation(this.world, + this.showUndeployed ? + Constants.BUTTON + "toggle-undeployed.shown" : + Constants.BUTTON + "toggle-undeployed.hidden")); + + // Add ClickHandler: flip the preference, reset paging, and rebuild. + builder.clickHandler((panel, user, clickType, i) -> + { + this.showUndeployed = !this.showUndeployed; + // The visible challenge count changes, so the current page may be out of range. + this.challengeIndex = 0; + this.build(); + + // Always return true. + return true; + }); + + // Collect tooltips. + List tooltips = template.actions().stream(). + filter(action -> action.tooltip() != null). + map(action -> this.user.getTranslation(this.world, action.tooltip())). + filter(text -> !text.isBlank()). + collect(Collectors.toCollection(() -> new ArrayList<>(template.actions().size()))); + + // Add tooltips. + if (!tooltips.isEmpty()) + { + // Empty line and tooltips. + builder.description(""); + builder.description(tooltips); + } + + return builder.build(); + } + + @Nullable private PanelItem createNextButton(@NonNull ItemTemplateRecord template, TemplatedPanel.ItemSlot slot) { @@ -901,4 +992,12 @@ else if (Constants.LEVEL_BUILDER_KEY.equals(target)) * This indicates last selected level. */ private LevelStatus lastSelectedLevel; + + /** + * Per-session preference for the TOGGLEABLE visibility mode: whether this player + * currently wants to see undeployed challenges. Defaults to {@code true} (shown), so + * TOGGLEABLE behaves like VISIBLE until the player hides them with the toggle button. + * Only consulted when the visibility mode is TOGGLEABLE. + */ + private boolean showUndeployed = true; } diff --git a/src/main/java/world/bentobox/challenges/panel/util/MultiBiomeSelector.java b/src/main/java/world/bentobox/challenges/panel/util/MultiBiomeSelector.java new file mode 100644 index 00000000..6ba1a2e6 --- /dev/null +++ b/src/main/java/world/bentobox/challenges/panel/util/MultiBiomeSelector.java @@ -0,0 +1,144 @@ +package world.bentobox.challenges.panel.util; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +import org.bukkit.Material; +import org.bukkit.Registry; +import org.bukkit.block.Biome; +import org.bukkit.inventory.ItemStack; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.challenges.utils.Utils; + +/** + * A multi-selector GUI for choosing biomes. Extends the unified multi-selector base and + * supplies the biome-specific details (element list, icons, display names). + * + *

Biomes are a registry-backed type in modern Minecraft, so they are handled by their + * namespaced key rather than as an enum. + */ +public class MultiBiomeSelector extends UnifiedMultiSelector { + + private final Set excluded; + + private MultiBiomeSelector(User user, Set excluded, + BiConsumer> consumer) { + super(user, Mode.ANY, consumer); + this.excluded = excluded; + } + + /** + * Opens the biome selector. + * + * @param user the user who opens the GUI. + * @param excluded biomes to hide from the list (e.g. ones already selected). + * @param consumer callback receiving the confirmation flag and the chosen biomes. + */ + public static void open(User user, Set excluded, + BiConsumer> consumer) { + new MultiBiomeSelector(user, excluded, consumer).build(); + } + + /** + * Opens the biome selector with no exclusions. + * + * @param user the user who opens the GUI. + * @param consumer callback receiving the confirmation flag and the chosen biomes. + */ + public static void open(User user, BiConsumer> consumer) { + new MultiBiomeSelector(user, new HashSet<>(), consumer).build(); + } + + @Override + protected List getElements() { + // A mutable list is required: UnifiedMultiSelector's constructor sorts it in place. + return StreamSupport.stream(Registry.BIOME.spliterator(), false) + .filter(biome -> excluded == null || !excluded.contains(biome)) + .sorted(Comparator.comparing(MultiBiomeSelector::biomeKey)) + .collect(Collectors.toCollection(ArrayList::new)); + } + + @Override + protected String getTitleKey() { + return "biome-selector"; + } + + @Override + protected String getElementKeyPrefix() { + return "biome."; + } + + @Override + protected String getElementPlaceholder() { + return "[biome]"; + } + + @Override + protected ItemStack getIcon(Biome element) { + return new ItemStack(iconMaterial(biomeKey(element))); + } + + @Override + protected String getElementDisplayName(Biome element) { + return Utils.prettifyBiome(biomeKey(element)); + } + + @Override + protected String elementToString(Biome element) { + return biomeKey(element); + } + + /** + * Returns the namespaced key string (e.g. "minecraft:plains") for a biome. + * + * @param biome the biome. + * @return its namespaced key. + */ + public static String biomeKey(Biome biome) { + return biome.getKey().toString(); + } + + /** + * Picks a rough representative icon for a biome key. Biomes have no natural item, so this + * is only a visual hint; the biome name in the button title is what identifies it. + * + * @param key the biome key. + * @return a Material to use as the button icon. + */ + private static Material iconMaterial(String key) { + String path = key.contains(":") ? key.substring(key.indexOf(':') + 1) : key; + + if (path.contains("nether") || path.contains("basalt") || path.contains("soul") || path.contains("crimson") + || path.contains("warped")) { + return Material.NETHERRACK; + } + if (path.contains("end")) { + return Material.END_STONE; + } + if (path.contains("ocean") || path.contains("river")) { + return Material.WATER_BUCKET; + } + if (path.contains("desert") || path.contains("beach") || path.contains("badlands")) { + return Material.SAND; + } + if (path.contains("snow") || path.contains("frozen") || path.contains("ice") || path.contains("cold")) { + return Material.SNOW_BLOCK; + } + if (path.contains("cave") || path.contains("deep") || path.contains("lush")) { + return Material.STONE; + } + if (path.contains("mushroom")) { + return Material.RED_MUSHROOM_BLOCK; + } + + return Material.GRASS_BLOCK; + } +} diff --git a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java index 30071ff5..778df469 100644 --- a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java +++ b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java @@ -13,11 +13,12 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Objects; +import java.util.Set; import java.util.PriorityQueue; import java.util.Queue; +import java.util.Random; import java.util.UUID; import java.util.function.BiPredicate; -import java.util.stream.Collectors; import org.bukkit.Bukkit; import org.bukkit.ChatColor; @@ -105,6 +106,11 @@ public class TryToComplete */ private final ChallengeResult emptyResult = new ChallengeResult(); + /** + * Random number generator for reward chance rolls. + */ + private final Random random = new Random(); + // --------------------------------------------------------------------- // Section: Builder // --------------------------------------------------------------------- @@ -253,31 +259,41 @@ ChallengeResult build(int maxTimes) // If challenge was not completed then reward items for completing it first time. if (!result.wasCompleted()) { + // Roll the reward chance once for all recipients and item types + boolean rewardSuccess = this.shouldRewardItems(); + // Reward every recipient (all present members for a team challenge, else just the user). for (User recipient : this.getRewardRecipients()) { - // Item rewards - for (ItemStack reward : this.challenge.getRewardItems()) + if (rewardSuccess) { - // Clone is necessary because otherwise it will chane reward itemstack - // amount. - recipient.getInventory().addItem(reward.clone()).forEach((k, v) -> - recipient.getWorld().dropItem(recipient.getLocation(), v)); - } + // Item rewards + for (ItemStack reward : this.challenge.getRewardItems()) + { + // Clone is necessary because otherwise it will chane reward itemstack + // amount. + recipient.getInventory().addItem(reward.clone()).forEach((k, v) -> + recipient.getWorld().dropItem(recipient.getLocation(), v)); + } - // Money Reward - if (this.addon.isEconomyProvided()) - { - this.addon.getEconomyProvider().deposit(recipient, this.challenge.getRewardMoney()); - } + // Money Reward + if (this.addon.isEconomyProvided()) + { + this.addon.getEconomyProvider().deposit(recipient, this.challenge.getRewardMoney()); + } - // Experience Reward - recipient.getPlayer().giveExp(this.challenge.getRewardExperience()); + // Experience Reward + recipient.getPlayer().giveExp(this.challenge.getRewardExperience()); + } - // Run commands + // Run commands (not gated by reward chance) this.runCommands(this.challenge.getRewardCommands(), recipient); } + // Island Level Reward. The island is shared, so this is applied once per + // completion, not once per team member. + this.rewardIslandLevel(this.challenge.getRewardIslandLevel()); + // Send message about first completion only if it is completed only once. if (result.getFactor() == 1) { @@ -315,37 +331,55 @@ ChallengeResult build(int maxTimes) // Reward every recipient (all present members for a team challenge, else just the user). for (User recipient : this.getRewardRecipients()) { - // Item Repeat Rewards - for (ItemStack reward : this.challenge.getRepeatItemReward()) + // One roll per completion instance gates that instance's item, money and XP rewards + // together. With the default chance of 100 every roll succeeds, so the summed + // rewards match the previous behaviour exactly. + int rewardedCount = 0; + + for (int i = 0; i < rewardFactor; i++) { - // Clone is necessary because otherwise it will chane reward itemstack - // amount. + if (!this.shouldRewardItems()) + { + continue; + } + + rewardedCount++; - for (int i = 0; i < rewardFactor; i++) + // Item Repeat Rewards + for (ItemStack reward : this.challenge.getRepeatItemReward()) { + // Clone is necessary because otherwise it will chane reward itemstack + // amount. recipient.getInventory().addItem(reward.clone()).forEach((k, v) -> recipient.getWorld().dropItem(recipient.getLocation(), v)); } } - // Money Repeat Reward - if (this.addon.isEconomyProvided()) + if (rewardedCount > 0) { - this.addon.getEconomyProvider().deposit(recipient, - this.challenge.getRepeatMoneyReward() * rewardFactor); - } + // Money Repeat Reward + if (this.addon.isEconomyProvided()) + { + this.addon.getEconomyProvider().deposit(recipient, + this.challenge.getRepeatMoneyReward() * rewardedCount); + } - // Experience Repeat Reward - recipient.getPlayer().giveExp( - this.challenge.getRepeatExperienceReward() * rewardFactor); + // Experience Repeat Reward + recipient.getPlayer().giveExp( + this.challenge.getRepeatExperienceReward() * rewardedCount); + } - // Run commands + // Run commands (not gated by reward chance) for (int i = 0; i < rewardFactor; i++) { this.runCommands(this.challenge.getRepeatRewardCommands(), recipient); } } + // Island Level Repeat Reward. The island is shared, so this is applied once per + // completion, not once per team member. + this.rewardIslandLevel(this.challenge.getRepeatIslandLevel() * rewardFactor); + if (result.getFactor() > 1) { Utils.sendMessage(this.user, @@ -364,60 +398,57 @@ ChallengeResult build(int maxTimes) this.manager.setChallengeComplete(this.user, this.world, this.challenge, result.getFactor()); // Check level completion for non-free challenges - if (!result.wasCompleted() && - !this.challenge.getLevel().equals(ChallengesManager.FREE)) + if (!result.wasCompleted()) { - ChallengeLevel level = this.manager.getLevel(this.challenge); + ChallengeLevel level = this.manager.tryCompleteLevel(this.user, this.world, this.challenge); - if (level != null && !this.manager.isLevelCompleted(this.user, this.world, level)) + if (level != null) { - if (this.manager.validateLevelCompletion(this.user, this.world, level)) + // Item rewards + for (ItemStack reward : level.getRewardItems()) { - // Item rewards - for (ItemStack reward : level.getRewardItems()) - { - // Clone is necessary because otherwise it will chane reward itemstack - // amount. - this.user.getInventory().addItem(reward.clone()).forEach((k, v) -> - this.user.getWorld().dropItem(this.user.getLocation(), v)); - } + // Clone is necessary because otherwise it will chane reward itemstack + // amount. + this.user.getInventory().addItem(reward.clone()).forEach((k, v) -> + this.user.getWorld().dropItem(this.user.getLocation(), v)); + } - // Money Reward - if (this.addon.isEconomyProvided()) - { - this.addon.getEconomyProvider().deposit(this.user, level.getRewardMoney()); - } + // Money Reward + if (this.addon.isEconomyProvided()) + { + this.addon.getEconomyProvider().deposit(this.user, level.getRewardMoney()); + } - // Experience Reward - this.user.getPlayer().giveExp(level.getRewardExperience()); + // Experience Reward + this.user.getPlayer().giveExp(level.getRewardExperience()); - // Run commands - this.runCommands(level.getRewardCommands()); + // Island Level Reward + this.rewardIslandLevel(level.getRewardIslandLevel()); - Utils.sendMessage(this.user, - this.world, Constants.MESSAGES + "you-completed-level", Constants.PARAMETER_VALUE, - level.getFriendlyName()); + // Run commands + this.runCommands(level.getRewardCommands()); - if (this.addon.getChallengesSettings().isBroadcastMessages()) - { - Bukkit.getOnlinePlayers().stream(). - map(User::getInstance).forEach(user -> Utils.sendMessage(user, - this.world, - Constants.MESSAGES + "name-has-completed-level", - Constants.PARAMETER_NAME, this.user.getName(), - Constants.PARAMETER_VALUE, level.getFriendlyName())); - } + Utils.sendMessage(this.user, + this.world, Constants.MESSAGES + "you-completed-level", Constants.PARAMETER_VALUE, + level.getFriendlyName()); - this.manager.setLevelComplete(this.user, this.world, level); + if (this.addon.getChallengesSettings().isBroadcastMessages()) + { + Bukkit.getOnlinePlayers().stream(). + map(User::getInstance).forEach(user -> Utils.sendMessage(user, + this.world, + Constants.MESSAGES + "name-has-completed-level", + Constants.PARAMETER_NAME, this.user.getName(), + Constants.PARAMETER_VALUE, level.getFriendlyName())); + } - // sends title to player on level completion - if (this.addon.getChallengesSettings().isShowCompletionTitle()) - { - this.user.getPlayer().sendTitle( - this.parseLevel(this.user.getTranslation("challenges.titles.level-title"), level), - this.parseLevel(this.user.getTranslation("challenges.titles.level-subtitle"), level), - 10, this.addon.getChallengesSettings().getTitleShowtime(), 20); - } + // sends title to player on level completion + if (this.addon.getChallengesSettings().isShowCompletionTitle()) + { + this.user.getPlayer().sendTitle( + this.parseLevel(this.user.getTranslation("challenges.titles.level-title"), level), + this.parseLevel(this.user.getTranslation("challenges.titles.level-subtitle"), level), + 10, this.addon.getChallengesSettings().getTitleShowtime(), 20); } } } @@ -426,6 +457,42 @@ ChallengeResult build(int maxTimes) } + /** + * Checks if rewards should be given based on the challenge's reward chance. + * This method is protected to allow testing frameworks to override it. + * @return true if rewards should be given, false otherwise + */ + protected boolean shouldRewardItems() + { + int chance = this.challenge.getRewardChance(); + if (chance >= 100) + { + return true; + } + if (chance <= 0) + { + return false; + } + return this.random.nextInt(100) < chance; + } + + + /** + * This method increases the completing user's island level via the Level addon. + * Does nothing if the Level addon is not present or the reward is 0. + * @param reward Number of island levels to add. + */ + private void rewardIslandLevel(long reward) + { + if (this.addon.isLevelProvided() && reward != 0) + { + world.bentobox.level.Level levelAddon = this.addon.getLevelAddon(); + levelAddon.setIslandLevel(this.world, this.user.getUniqueId(), + levelAddon.getIslandLevel(this.world, this.user.getUniqueId()) + reward); + } + } + + /** * This method fulfills all challenge type requirements, that is not fulfilled yet. * @param result Challenge Results @@ -1071,26 +1138,12 @@ Map removeItems(List requiredItemList, int factor for (ItemStack required : requiredItemList) { int amountToBeRemoved = required.getAmount() * factor; - List itemsInInventory; - if (this.user.getInventory() == null) - { - // Sanity check. User always has inventory at this point of code. - itemsInInventory = Collections.emptyList(); - } - else if (this.getInventoryRequirements().getIgnoreMetaData().contains(required.getType())) - { - // Use collecting method that ignores item meta. - itemsInInventory = Arrays.stream(user.getInventory().getContents()). - filter(Objects::nonNull).filter(i -> i.getType().equals(required.getType())) - .collect(Collectors.toList()); - } - else - { - // Use collecting method that compares item meta. - itemsInInventory = Arrays.stream(user.getInventory().getContents()). - filter(Objects::nonNull).filter(i -> i.isSimilar(required)).collect(Collectors.toList()); - } + // Use helper method that handles ignore-metadata logic including potion types. + List itemsInInventory = Arrays.stream(user.getInventory().getContents()). + filter(Objects::nonNull). + filter(i -> itemsMatch(i, required, this.getInventoryRequirements().getIgnoreMetaData())). + toList(); for (ItemStack itemStack : itemsInInventory) { @@ -1198,6 +1251,21 @@ private ChallengeResult checkSurrounding(int factor) return emptyResult; } + // Check biome requirement: the player must be standing in one of the required biomes. + Set requiredBiomes = this.getIslandRequirements().getRequiredBiomes(); + + if (!requiredBiomes.isEmpty()) + { + String currentBiome = this.user.getLocation().getBlock().getBiome().getKey().toString(); + + if (!requiredBiomes.contains(currentBiome)) + { + Utils.sendMessage(this.user, this.world, Constants.ERRORS + "wrong-biome", + "[biome]", Utils.prettifyBiome(currentBiome)); + return emptyResult; + } + } + // Init location in player position. BoundingBox boundingBox = this.user.getPlayer().getBoundingBox().clone(); @@ -1882,6 +1950,45 @@ private boolean hasRequiredTeamPresence() } + /** + * Checks if two items match, considering the ignore-metadata setting. For potion-like materials + * that are in the ignore-metadata set, compares the base potion type while ignoring other metadata. + * For non-potion materials in the ignore-metadata set, uses type-only comparison. For materials + * not in the ignore-metadata set, uses full similarity comparison. + * + * @param candidate candidate item from inventory + * @param required required item template + * @param ignoreMetaData set of materials to ignore metadata for + * @return true if the items match + */ + private static boolean itemsMatch(ItemStack candidate, ItemStack required, Set ignoreMetaData) + { + if (candidate == null || required == null) + { + return false; + } + + if (!candidate.getType().equals(required.getType())) + { + return false; + } + + // If metadata should not be ignored, use full similarity check + if (!ignoreMetaData.contains(required.getType())) + { + return candidate.isSimilar(required); + } + + // Metadata is being ignored. For potion-like materials, still compare base potion type. + if (Utils.isPotionLike(required.getType())) + { + return Utils.comparePotionType(candidate, required); + } + + // For non-potion materials, type-only matching is sufficient + return true; + } + /** * Counts how many of {@code required} a single player holds, honouring the challenge's * ignore-meta-data setting. @@ -1892,17 +1999,9 @@ private boolean hasRequiredTeamPresence() */ private int countInInventory(Player player, ItemStack required) { - if (this.getInventoryRequirements().getIgnoreMetaData().contains(required.getType())) - { - return Arrays.stream(player.getInventory().getContents()). - filter(Objects::nonNull). - filter(i -> i.getType().equals(required.getType())). - mapToInt(ItemStack::getAmount).sum(); - } - return Arrays.stream(player.getInventory().getContents()). filter(Objects::nonNull). - filter(i -> i.isSimilar(required)). + filter(i -> itemsMatch(i, required, this.getInventoryRequirements().getIgnoreMetaData())). mapToInt(ItemStack::getAmount).sum(); } @@ -1922,22 +2021,10 @@ private int removeFromInventory(Player player, ItemStack required, int amount) return 0; } - List itemsInInventory; - - if (this.getInventoryRequirements().getIgnoreMetaData().contains(required.getType())) - { - itemsInInventory = Arrays.stream(player.getInventory().getContents()). - filter(Objects::nonNull). - filter(i -> i.getType().equals(required.getType())). - toList(); - } - else - { - itemsInInventory = Arrays.stream(player.getInventory().getContents()). - filter(Objects::nonNull). - filter(i -> i.isSimilar(required)). - toList(); - } + List itemsInInventory = Arrays.stream(player.getInventory().getContents()). + filter(Objects::nonNull). + filter(i -> itemsMatch(i, required, this.getInventoryRequirements().getIgnoreMetaData())). + toList(); int toRemove = amount; diff --git a/src/main/java/world/bentobox/challenges/utils/Constants.java b/src/main/java/world/bentobox/challenges/utils/Constants.java index b36590b2..8e6280df 100644 --- a/src/main/java/world/bentobox/challenges/utils/Constants.java +++ b/src/main/java/world/bentobox/challenges/utils/Constants.java @@ -249,6 +249,8 @@ public class Constants public static final String CLICK_TO_CHANGE = TIPS + "click-to-change"; + public static final String CLICK_TO_ADD = TIPS + "click-to-add"; + public static final String CLICK_TO_TOGGLE = TIPS + "click-to-toggle"; public static final String CLICK_TO_OPEN = TIPS + "click-to-open"; @@ -265,6 +267,8 @@ public class Constants public static final String CANCELLED = CONVERSATIONS + "cancelled"; + public static final String CONFIRM_INSTRUCTION = CONVERSATIONS + "confirm-instruction"; + public static final String PREFIX = CONVERSATIONS + "prefix"; // --------------------------------------------------------------------- diff --git a/src/main/java/world/bentobox/challenges/utils/Utils.java b/src/main/java/world/bentobox/challenges/utils/Utils.java index ab0f62f5..35a642e0 100644 --- a/src/main/java/world/bentobox/challenges/utils/Utils.java +++ b/src/main/java/world/bentobox/challenges/utils/Utils.java @@ -59,6 +59,52 @@ else if (stack == input) } + /** + * Checks if a material is potion-like (potions, splash potions, lingering potions, tipped arrows). + * + * @param material the material to check + * @return true if the material is potion-like + */ + public static boolean isPotionLike(Material material) + { + return material == Material.POTION || + material == Material.SPLASH_POTION || + material == Material.LINGERING_POTION || + material == Material.TIPPED_ARROW; + } + + /** + * Compares the base potion type of two potion items. Returns true if they have the same + * base potion type, ignoring custom effects, lore, and other metadata. + * + * @param first first potion item + * @param second second potion item + * @return true if both items have the same base potion type + */ + public static boolean comparePotionType(@Nullable ItemStack first, @Nullable ItemStack second) + { + if (first == null || second == null) + { + return false; + } + + PotionType firstType = null; + PotionType secondType = null; + + if (first.hasItemMeta() && first.getItemMeta() instanceof PotionMeta potionMeta) + { + firstType = potionMeta.getBasePotionType(); + } + + if (second.hasItemMeta() && second.getItemMeta() instanceof PotionMeta potionMeta) + { + secondType = potionMeta.getBasePotionType(); + } + + // If either has no potion meta, they're only equal if both are missing the meta + return java.util.Objects.equals(firstType, secondType); + } + /** * This method groups input items in single itemstack with correct amount and returns it. * Allows to remove duplicate items from list. @@ -84,7 +130,7 @@ public static List groupEqualItems(List requiredItems, Set // Merge items which meta can be ignored or is similar to item in required list. if (Utils.isSimilarNoDurability(required, item) || - ignoreMetaData.contains(item.getType()) && item.getType().equals(required.getType())) + itemsMatchIgnoreMetadata(required, item, ignoreMetaData)) { required.setAmount(required.getAmount() + item.getAmount()); isUnique = false; @@ -103,6 +149,42 @@ public static List groupEqualItems(List requiredItems, Set return returnItems; } + /** + * Checks if two items match when metadata should be ignored. For potion-like materials, + * compares the base potion type. For non-potion materials, uses type-only comparison. + * + * @param first first item + * @param second second item + * @param ignoreMetaData set of materials to ignore metadata for + * @return true if items match according to the ignore-metadata rules + */ + private static boolean itemsMatchIgnoreMetadata(@Nullable ItemStack first, @Nullable ItemStack second, Set ignoreMetaData) + { + if (first == null || second == null) + { + return false; + } + + if (!first.getType().equals(second.getType())) + { + return false; + } + + if (!ignoreMetaData.contains(first.getType())) + { + return false; + } + + // Metadata is being ignored. For potion-like materials, still compare base potion type. + if (isPotionLike(first.getType())) + { + return comparePotionType(first, second); + } + + // For non-potion materials, type-only matching is sufficient + return true; + } + /** * This method transforms given World into GameMode name. If world is not a GameMode @@ -174,6 +256,43 @@ public static T getPreviousValue(T[] values, T currentValue) } + /** + * Turns a biome key such as "minecraft:snowy_taiga" into a readable "Snowy Taiga". + * The namespace is dropped and snake_case becomes Title Case. + * + * @param key the namespaced (or plain) biome key. + * @return a human-readable name, or an empty string for a blank key. + */ + public static String prettifyBiome(String key) + { + if (key == null || key.isBlank()) + { + return ""; + } + + String path = key.contains(":") ? key.substring(key.indexOf(':') + 1) : key; + StringBuilder builder = new StringBuilder(path.length()); + + for (String word : path.split("_")) + { + if (word.isEmpty()) + { + continue; + } + + if (!builder.isEmpty()) + { + builder.append(' '); + } + + builder.append(Character.toUpperCase(word.charAt(0))). + append(word.substring(1).toLowerCase(Locale.ENGLISH)); + } + + return builder.toString(); + } + + /** * Sanitizes the provided input. It replaces spaces and hyphens with underscores and lower cases the input. * This code also removes all color codes from the input. @@ -965,4 +1084,42 @@ public static String parseDuration(Duration duration, User user) return returnString; } + + + /** + * Prefixes every line of the given text with a default colour code so the colour applies + * to each rendered lore line, not only the first (each lore line is rendered + * independently). A colour written at the start of a line still overrides the default, + * because a later colour code wins over an earlier one. The text is returned unchanged + * when the colour is blank or the text is empty. + * + *

The colour is applied before {@code Util.translateColorCodes}, so it uses the same + * '&' colour codes (or hex, e.g. {@code 7FFFF}) as the challenge text itself. + * + * @param text the text whose lines should be coloured (may contain '\n'). + * @param color the default colour code; blank means no change. + * @return the text with the colour prefixed to each line. + */ + public static String applyDefaultColor(String text, String color) + { + if (color == null || color.isBlank() || text == null || text.isEmpty()) + { + return text; + } + + String[] lines = text.split("\n", -1); + StringBuilder builder = new StringBuilder(text.length() + lines.length * color.length()); + + for (int i = 0; i < lines.length; i++) + { + if (i > 0) + { + builder.append('\n'); + } + + builder.append(color).append(lines[i]); + } + + return builder.toString(); + } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 64f9eeab..98a3f66f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -72,10 +72,24 @@ gui-settings: # Valid values are: # 'VISIBLE' - there will be no hidden challenges. All challenges will be viewable in GUI. # 'HIDDEN' - shows only deployed challenges. - # 'TOGGLEABLE' - there will be button in GUI that allows users to switch from ALL modes. - # TOGGLEABLE - Currently not implemented. + # 'TOGGLEABLE' - adds a button to the player GUI so each player can show or hide + # undeployed challenges themselves (defaults to shown). undeployed-view-mode: VISIBLE # + # Allow players to open the challenges GUI without being on their island. + # Note: Challenges completion still requires being on the island when world protection is enabled. + open-anywhere: false + # + # Default colour applied to every line of a challenge's own description text, so you + # do not have to prefix each challenge with the same colour. Uses '&' colour codes or + # hex (e.g. '&b' or '7FFFF'). Leave empty for no default. A colour written in the + # description itself still overrides this. Does not affect per-challenge locale overrides. + description-color: '' + # + # Default colour applied to every line of a challenge's own reward text (both first-time + # and repeat reward text). Same format as description-color. Leave empty for no default. + reward-text-color: '' + # # This allows to change default locked level icon. This option may be # overwritten by each challenge level. If challenge level has specified # their locked level icon, then it will be used, instead of this one. @@ -92,6 +106,12 @@ store-island-data: true # challenges by doing them repeatedly. reset-challenges: true # +# This option indicates if undeployed challenges should be counted towards level completion. +# Disabling this option will make it so that only deployed challenges are counted, so an +# undeployed challenge will not block a level from being completed. +# Default: true +include-undeployed: true +# # Broadcast 1st time challenge completion messages to all players. # Change to false if the spam becomes too much. broadcast-messages: true diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index 66dbc305..5580f728 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -467,6 +467,14 @@ challenges: opakování peněz za odměnu za výzvu. value: "Aktuální hodnota: [number]" + reward_chance: + name: "Šance na odměnu" + description: |- + Šance v procentech (0-100), že budou + při splnění uděleny hmotné odměny. + Předměty, peníze a zkušenosti sdílí jeden los. + Příkazy se provedou vždy. + value: "Šance: [number]%" reward_commands: name: "Příkazy odměny" description: |- diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index bedde3c2..1078f69c 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -458,6 +458,14 @@ challenges: Wiederholung des Belohnungsgeldes für die Herausforderung. value: "Aktueller Wert: [number]" + reward_chance: + name: "Belohnungschance" + description: |- + Prozentuale Chance (0-100), dass materielle + Belohnungen beim Abschluss vergeben werden. + Gegenstände, Geld und Erfahrung nutzen einen Wurf. + Befehle werden immer ausgeführt. + value: "Chance: [number]%" reward_commands: name: "Belohnungsbefehle" description: |- diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index c2a95997..a6d6a786 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -73,6 +73,7 @@ challenges: challenge-selector: "Challenge Selector" statistic-selector: "Statistic Selector" environment-selector: "Environment Selector" + biome-selector: "Biome Selector" buttons: # Button in the Challenges GUI that allows to select free challenges. free-challenges: @@ -80,6 +81,16 @@ challenges: description: |- Displays a list of free challenges + # Button shown only when undeployed-view-mode is TOGGLEABLE. It lets each player + # show or hide the challenges that are not yet deployed. + toggle-undeployed: + name: "Undeployed Challenges" + shown: |- + Undeployed challenges are + currently shown. + hidden: |- + Undeployed challenges are + currently hidden. # Button that is used to return to previous GUI or exit it completely. return: name: "Return" @@ -352,11 +363,20 @@ challenges: name: "Required Blocks" description: |- Allows you to change the required - blocks for this challenge to be + blocks for this challenge to be completed. title: "Blocks: " list: " - [number] x [block]" none: "No blocks have been added." + required_biomes: + name: "Required Biomes" + description: |- + The player must be standing in one + of these biomes to complete this + island challenge. + title: "Biomes: " + list: " - [biome]" + none: "No biomes required (any biome)." required_statistics: name: "Required Statistics" description: |- @@ -584,9 +604,30 @@ challenges: repeat_reward_money: name: "Repeat Reward Money" description: |- - Allows you to change the repeat + Allows you to change the repeat reward money for the challenge. value: "Current value: [number]" + reward_chance: + name: "Reward Chance" + description: |- + Chance percentage (0-100) that material + rewards are given on completion. + Items, money, and experience use one roll. + Commands are always executed. + value: "Chance: [number]%" + reward_island_level: + name: "Reward Island Level" + description: |- + Allows you to change the reward island level. + Requires the Level addon. + value: "Current value: [number]" + repeat_reward_island_level: + name: "Repeat Reward Island Level" + description: |- + Allows you to change the repeat reward + island level for the challenge. + Requires the Level addon. + value: "Current value: [number]" reward_commands: name: "Reward Commands" description: |- @@ -770,6 +811,14 @@ challenges: should be counted towards level completion. enabled: "Enabled" disabled: "Disabled" + open_anywhere: + name: "Open GUI Anywhere" + description: |- + Allows players to open the challenges GUI + from anywhere. Completion still requires + being on the island when protected. + enabled: "Enabled" + disabled: "Disabled" download: name: "Download Libraries" description: |- @@ -874,6 +923,11 @@ challenges: description: |- Entity ID: [id] selected: "Selected" + biome: + name: "[biome]" + description: |- + Biome ID: [id] + selected: "Selected" entity-group: name: "[id]" description: "" @@ -966,6 +1020,7 @@ challenges: click-to-change: "Click to change." shift-click-to-reset: "Shift Click to reset." click-to-add: "Click to add." + right-click-to-clear: "Right Click to clear." click-to-remove: "Click to remove." left-click-to-cycle: "Left Click to cycle down." right-click-to-cycle: "Right Click to cycle up." @@ -1053,9 +1108,14 @@ challenges: lore: |- [blocks] [entities] + [biomes] [search-radius] [warning-block] [warning-entity] + # Title that will be used if there are defined biomes in an island challenge + biomes-title: "Required Biome(s):" + # Listing of biomes the player must stand in. + biome-value: " - [biome]" # Title that will be used if there are defined blocks in an island challenge blocks-title: "Required Blocks:" # Listing of blocks that are required on the island. @@ -1189,6 +1249,7 @@ challenges: cancel-string: "cancel" exit-string: "cancel, exit, quit" cancelled: "Conversation cancelled!" + confirm-instruction: "Type 'confirm' in chat to proceed, or 'cancel' to abort." input-number: "Please enter a number in the chat." input-seconds: "Please enter a number of seconds in the chat." numeric-only: "The given [value] is not a number!" @@ -1265,6 +1326,7 @@ challenges: challenge-level-not-available: "You have not unlocked the required level to complete this challenge." not-repeatable: "This challenge is not repeatable!" wrong-environment: "You are in the wrong environment!" + wrong-biome: "You must be standing in the [biome] biome to complete this challenge!" not-enough-items: "You do not have enough [items] to complete this challenge!" not-close-enough: "You must be standing within [number] blocks of all required items." you-still-need: "You still need [amount] x [item]" diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index 4d404a88..9f6c4764 100755 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -470,6 +470,14 @@ challenges: repetir dinero de recompensa para el desafío. value: "Valor actual: [number]" + reward_chance: + name: "Probabilidad de Recompensa" + description: |- + Probabilidad (0-100) de que se entreguen + las recompensas materiales al completar. + Objetos, dinero y experiencia usan una sola tirada. + Los comandos siempre se ejecutan. + value: "Probabilidad: [number]%" reward_commands: name: "Comandos de recompensa" description: |- diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index d03dc1e8..540839fc 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -462,6 +462,14 @@ challenges: répéter l'argent de la récompense pour le défi. value: "Valeur actuelle : [number]" + reward_chance: + name: "Chance de récompense" + description: |- + Pourcentage de chance (0-100) que les récompenses + matérielles soient données à la fin du défi. + Objets, argent et expérience partagent un seul tirage. + Les commandes sont toujours exécutées. + value: "Chance : [number]%" reward_commands: name: "Commandes de récompense" description: |- diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index 144344e2..bae616a7 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -467,6 +467,14 @@ challenges: ismételt jutalompénz a kihíváshoz. value: "Jelenlegi érték: [number]" + reward_chance: + name: "Jutalom esélye" + description: |- + Százalékos esély (0-100) arra, hogy a teljesítéskor + megkapod a tárgyi jutalmakat. + A tárgyak, a pénz és a tapasztalat egy sorsoláson osztozik. + A parancsok mindig lefutnak. + value: "Esély: [number]%" reward_commands: name: "Jutalomparancsok" description: |- diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml index 990f76d7..2b92b64d 100644 --- a/src/main/resources/locales/ja.yml +++ b/src/main/resources/locales/ja.yml @@ -519,6 +519,14 @@ challenges: 繰り返しを変更できます チャレンジにお金に報いる。 value: 現在の値:[number] + reward_chance: + name: 報酬確率 + description: |- + 達成時に物質的な報酬が与えられる + 確率(0-100)です。 + アイテム、お金、経験値は1回の抽選で決まります。 + コマンドは常に実行されます。 + value: 確率:[number]% reward_commands: name: 報酬コマンド description: |- diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml index a5b015fa..910fa1f5 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -485,6 +485,14 @@ challenges: Ļauj uzstādīt atkārtotas atlīdzības naudu spēlētājam. value: "Vērtība: [number]" + reward_chance: + name: "Atlīdzības Iespēja" + description: |- + Iespēja procentos (0-100), ka izpildot + izaicinājumu tiks piešķirtas atlīdzības. + Priekšmeti, nauda un pieredze izmanto vienu izlozi. + Komandas tiek izpildītas vienmēr. + value: "Iespēja: [number]%" reward_commands: name: "Atlīdzības Komandas" description: |- diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index 9c5ecd49..0232b3b9 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -437,6 +437,14 @@ challenges: name: "Powtarzaj nagrody pieniężne" description: "Pozwala na zmianę nagrody \npieniężnej\npo ponownym wykonaniu zadania" value: "Aktualna wartość: [number]" + reward_chance: + name: "Szansa na nagrodę" + description: |- + Procentowa szansa (0-100), że nagrody + materialne zostaną przyznane po ukończeniu. + Przedmioty, pieniądze i doświadczenie używają jednego losowania. + Komendy są wykonywane zawsze. + value: "Szansa: [number]%" reward_commands: name: "Polecenia nagrody" description: "Konkretne polecenie nagrody:\nPodpowiedź:\nKomenda nie wymaga '/'\njest on dodawany automatycznie.\nDomyślnie polecenie wykonuje serwer\njeżeli chcesz by polecenie zostało \nwykonane przez gracza przed\nnim wpisz [SELF] \nWartość [player] użyta w poleceniu\nbędzie zamieniona na nick gracza\nktóry wykonał zadanie" diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index 5ddca18b..4b053e9f 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -469,6 +469,14 @@ challenges: repetir dinheiro de recompensa para o desafio. value: "Valor atual: [number]" + reward_chance: + name: "Chance de Recompensa" + description: |- + Chance percentual (0-100) de que as recompensas + materiais sejam dadas ao completar. + Itens, dinheiro e experiência usam um único sorteio. + Os comandos são sempre executados. + value: "Chance: [number]%" reward_commands: name: "Comandos de Recompensa" description: |- diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index bec99211..dea0a820 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -490,6 +490,14 @@ challenges: name: Деньги повторной награды description: "Позволяет изменить деньги повторной\nнаграды для испытания." value: 'Текущее значение: [number]' + reward_chance: + name: Шанс награды + description: |- + Шанс в процентах (0-100), что за выполнение + будут выданы материальные награды. + Предметы, деньги и опыт используют один бросок. + Команды выполняются всегда. + value: 'Шанс: [number]%' reward_commands: name: Команды награды description: "Задает команды награды.\nПодсказка:\nКоманда не требует начального `/` так как она\nприменяется автоматически.\nПо умолчанию команды выполняются сервером.\nОднако добавление `[SELF]` в начало приведет к\nвыполнению команды игроком.\nОна также поддерживает заполнитель `[player]` который будет\nзаменен на имя игрока который завершил испытание." diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index 379f33b3..54ac8a8f 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -467,6 +467,14 @@ challenges: повторити грошову винагороду за виклик. value: "Поточне значення: [number]" + reward_chance: + name: "Шанс винагороди" + description: |- + Шанс у відсотках (0-100), що за виконання + будуть видані матеріальні винагороди. + Предмети, гроші та досвід використовують один кидок. + Команди виконуються завжди. + value: "Шанс: [number]%" reward_commands: name: "Команди винагороди" description: |- diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index b3419743..d8630bfa 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -370,6 +370,14 @@ challenges: name: "重复奖励金钱" description: "允许更改重复挑战的金钱奖励" value: "当前值:[number]" + reward_chance: + name: "奖励几率" + description: |- + 完成挑战时发放物质奖励的 + 几率百分比(0-100)。 + 物品、金钱和经验共用一次判定。 + 命令始终会执行。 + value: "几率: [number]%" reward_commands: name: "奖励命令" description: |- diff --git a/src/main/resources/locales/zh-HK.yml b/src/main/resources/locales/zh-HK.yml index 337e1c77..0bd64678 100644 --- a/src/main/resources/locales/zh-HK.yml +++ b/src/main/resources/locales/zh-HK.yml @@ -386,6 +386,14 @@ challenges: name: "獎勵遊戲幣" description: '修改重覆挑戰的獎勵遊戲幣' value: "目前數量: [number]" + reward_chance: + name: "獎勵機率" + description: |- + 完成挑戰時發放物質獎勵的 + 機率百分比(0-100)。 + 物品、金錢和經驗共用一次判定。 + 指令必定會執行。 + value: "機率: [number]%" reward_commands: name: '獎勵指令' description: |- diff --git a/src/main/resources/locales/zh-TW.yml b/src/main/resources/locales/zh-TW.yml index 7091b91b..9065b3cf 100644 --- a/src/main/resources/locales/zh-TW.yml +++ b/src/main/resources/locales/zh-TW.yml @@ -481,6 +481,14 @@ challenges: name: 重複獎勵金錢 description: "允許你變更挑戰的\n重複獎勵金錢。" value: 目前值:[number] + reward_chance: + name: 獎勵機率 + description: |- + 完成挑戰時發放物質獎勵的 + 機率百分比(0-100)。 + 物品、金錢和經驗共用一次判定。 + 指令一定會執行。 + value: 機率:[number]% reward_commands: name: 獎勵指令 description: "指定獎勵指令。\n提示:\n指令不需要前導 `/` 因為它會\n自動套用。\n預設情況下,指令由伺服器執行。\n但是,在開頭新增 `[SELF]` 將導致\n由玩家執行指令。\n它還支援佔位符 `[player]` 將被\n取代為完成挑戰的玩家名稱。" diff --git a/src/main/resources/panels/main_panel.yml b/src/main/resources/panels/main_panel.yml index 8508b735..f4272a6a 100644 --- a/src/main/resources/panels/main_panel.yml +++ b/src/main/resources/panels/main_panel.yml @@ -82,6 +82,15 @@ main_panel: left: tooltip: challenges.gui.tips.click-to-next 6: + 3: + # Only shown when undeployed-view-mode is TOGGLEABLE; hidden in VISIBLE / HIDDEN modes. + icon: SPYGLASS + title: challenges.gui.buttons.toggle-undeployed.name + data: + type: TOGGLE_UNDEPLOYED + action: + left: + tooltip: challenges.gui.tips.click-to-toggle 5: icon: IRON_BARS title: challenges.gui.buttons.free-challenges.name diff --git a/src/test/java/world/bentobox/challenges/ChallengesAddonTest.java b/src/test/java/world/bentobox/challenges/ChallengesAddonTest.java index 56c66ed3..2a5e9a2d 100644 --- a/src/test/java/world/bentobox/challenges/ChallengesAddonTest.java +++ b/src/test/java/world/bentobox/challenges/ChallengesAddonTest.java @@ -12,6 +12,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import org.mockito.ArgumentCaptor; + import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; @@ -33,6 +35,7 @@ import org.bukkit.Bukkit; import org.bukkit.Server; import org.bukkit.UnsafeValues; +import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemFactory; import org.bukkit.inventory.meta.ItemMeta; @@ -362,4 +365,48 @@ void testGetLevelAddon() { assertNull(addon.getLevelAddon()); } + @Test + void testPlaceholderCompletedPercentRegistered() { + addon.onLoad(); + when(plugin.isEnabled()).thenReturn(true); + addon.setState(State.LOADED); + + // Mock the world + World world = mock(World.class); + when(gameMode.getOverWorld()).thenReturn(world); + + addon.onEnable(); + + // Capture every registered placeholder name; assert the one under test is present without + // pinning the exact total (which changes whenever any placeholder is added or removed). + ArgumentCaptor nameCaptor = ArgumentCaptor.forClass(String.class); + verify(phm, org.mockito.Mockito.atLeastOnce()).registerPlaceholder(any(GameModeAddon.class), nameCaptor.capture(), any()); + + List names = nameCaptor.getAllValues(); + assertTrue(names.contains("challenges_completed_percent"), + "Placeholder 'challenges_completed_percent' was not registered. Registered: " + names); + } + + @Test + void testPlaceholderLatestLevelCompletedPercentRegistered() { + addon.onLoad(); + when(plugin.isEnabled()).thenReturn(true); + addon.setState(State.LOADED); + + // Mock the world + World world = mock(World.class); + when(gameMode.getOverWorld()).thenReturn(world); + + addon.onEnable(); + + // Capture every registered placeholder name; assert the one under test is present without + // pinning the exact total (which changes whenever any placeholder is added or removed). + ArgumentCaptor nameCaptor = ArgumentCaptor.forClass(String.class); + verify(phm, org.mockito.Mockito.atLeastOnce()).registerPlaceholder(any(GameModeAddon.class), nameCaptor.capture(), any()); + + List names = nameCaptor.getAllValues(); + assertTrue(names.contains("challenges_latest_level_completed_percent"), + "Placeholder 'challenges_latest_level_completed_percent' was not registered. Registered: " + names); + } + } diff --git a/src/test/java/world/bentobox/challenges/commands/ChallengesCommandTest.java b/src/test/java/world/bentobox/challenges/commands/ChallengesCommandTest.java index e3061199..ae974fea 100644 --- a/src/test/java/world/bentobox/challenges/commands/ChallengesCommandTest.java +++ b/src/test/java/world/bentobox/challenges/commands/ChallengesCommandTest.java @@ -247,4 +247,25 @@ void testSetup() { assertEquals(1, cc.getSubCommands(true).size()); } + @Test + void testCanExecuteOffIslandWithProtectionAndNoOpenAnywhere() { + // Player is off island and world protection is on, but openAnywhere is false + when(im.locationIsOnIsland(any(Player.class), any())).thenReturn(false); + // Note: Since CHALLENGES_WORLD_PROTECTION flag has default setting true, + // and TestWorldSetting.getWorldFlags() returns an empty map by default, + // the flag will check as enabled. We test behavior when it's restricted. + assertFalse(cc.canExecute(user, "challenges", Collections.emptyList())); + verify(user).getTranslation(world, "challenges.errors.not-on-island"); + } + + @Test + void testCanExecuteOffIslandWithProtectionAndOpenAnywhere() { + // Player is off island but openAnywhere is enabled + when(im.locationIsOnIsland(any(Player.class), any())).thenReturn(false); + Settings settings = (Settings) addon.getChallengesSettings(); + settings.setOpenAnywhere(true); + assertTrue(cc.canExecute(user, "challenges", Collections.emptyList())); + verify(user, never()).getTranslation(world, "challenges.errors.not-on-island"); + } + } diff --git a/src/test/java/world/bentobox/challenges/panel/CommonPanelTest.java b/src/test/java/world/bentobox/challenges/panel/CommonPanelTest.java index 654be426..ae964cb8 100644 --- a/src/test/java/world/bentobox/challenges/panel/CommonPanelTest.java +++ b/src/test/java/world/bentobox/challenges/panel/CommonPanelTest.java @@ -2,6 +2,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Collections; @@ -24,7 +27,9 @@ import world.bentobox.bentobox.api.user.User; import world.bentobox.challenges.ChallengesAddon; import world.bentobox.challenges.database.object.Challenge; +import world.bentobox.challenges.database.object.ChallengeLevel; import world.bentobox.challenges.managers.ChallengesManager; +import world.bentobox.challenges.utils.LevelStatus; /** * Tests for {@link CommonPanel} including description generation. @@ -69,6 +74,10 @@ public List callGenerateChallengeDescription(Challenge challenge, User t return this.generateChallengeDescription(challenge, target); } + public List callGenerateLevelDescription(LevelStatus status, User target) { + return this.generateLevelDescription(status, target); + } + public PanelItem getReturnButton() { return this.returnButton; } @@ -208,4 +217,55 @@ void testGenerateChallengeDescriptionEmptyDescription() { List description = panel.callGenerateChallengeDescription(challenge, null); assertNotNull(description); } + + /** + * Builds a mocked level (with empty description translation so the unlock-message + * fallback path is exercised) plus the deep stubs the description generator needs. + */ + private ChallengeLevel mockLevelWithUnlockMessage() { + ChallengeLevel level = Mockito.mock(ChallengeLevel.class); + when(level.getUniqueId()).thenReturn("bskyblock_level"); + when(level.getUnlockMessage()).thenReturn("Congratulations!"); + when(level.getWaiverAmount()).thenReturn(0); + when(level.getRewardItems()).thenReturn(Collections.emptyList()); + when(level.getRewardCommands()).thenReturn(Collections.emptyList()); + when(level.getRewardExperience()).thenReturn(0); + when(level.getRewardText()).thenReturn(""); + + // Empty custom description forces the unlock-message fallback branch. + when(user.getTranslationOrNothing("challenges.levels.bskyblock_level.description")) + .thenReturn(""); + + // generateLevelDescription reads addon.getPlugin().getIWM().getPermissionPrefix(world) + world.bentobox.bentobox.BentoBox plugin = + Mockito.mock(world.bentobox.bentobox.BentoBox.class, Mockito.RETURNS_DEEP_STUBS); + when(plugin.getIWM().getPermissionPrefix(world)).thenReturn("bskyblock."); + when(addon.getPlugin()).thenReturn(plugin); + return level; + } + + @Test + void testLockedLevelDoesNotShowUnlockMessage() { + ChallengeLevel level = mockLevelWithUnlockMessage(); + LevelStatus locked = new LevelStatus(level, null, 3, false, false); + + List description = panel.callGenerateLevelDescription(locked, user); + + assertNotNull(description); + // A locked level must not fall back to the "Congratulations" unlock message. + verify(level, never()).getUnlockMessage(); + } + + @Test + void testUnlockedLevelShowsUnlockMessage() { + ChallengeLevel level = mockLevelWithUnlockMessage(); + when(manager.getLevelChallenges(level)).thenReturn(Collections.emptyList()); + LevelStatus unlocked = new LevelStatus(level, null, 0, false, true); + + List description = panel.callGenerateLevelDescription(unlocked, user); + + assertNotNull(description); + // An unlocked level with no custom description still falls back to the unlock message. + verify(level, atLeastOnce()).getUnlockMessage(); + } } diff --git a/src/test/java/world/bentobox/challenges/panel/ConversationUtilsTest.java b/src/test/java/world/bentobox/challenges/panel/ConversationUtilsTest.java index 934def31..8d765224 100644 --- a/src/test/java/world/bentobox/challenges/panel/ConversationUtilsTest.java +++ b/src/test/java/world/bentobox/challenges/panel/ConversationUtilsTest.java @@ -1,5 +1,6 @@ package world.bentobox.challenges.panel; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -88,6 +89,24 @@ void testCreateConfirmationWithNullSuccessMessage() { verify(user).getPlayer(); } + @Test + void testConfirmationInstructionAppended() { + when(user.getTranslationOrNothing( + world.bentobox.challenges.utils.Constants.CONFIRM_INSTRUCTION)) + .thenReturn("Type confirm to proceed"); + String result = ConversationUtils.appendConfirmationInstruction(user, "Are you sure?"); + assertEquals("Are you sure?\nType confirm to proceed", result); + } + + @Test + void testConfirmationInstructionEmptyFallsBackToQuestion() { + when(user.getTranslationOrNothing( + world.bentobox.challenges.utils.Constants.CONFIRM_INSTRUCTION)) + .thenReturn(""); + String result = ConversationUtils.appendConfirmationInstruction(user, "Are you sure?"); + assertEquals("Are you sure?", result); + } + @Test void testCreateIDStringInput() { @SuppressWarnings("unchecked") diff --git a/src/test/java/world/bentobox/challenges/panel/user/ChallengesPanelTest.java b/src/test/java/world/bentobox/challenges/panel/user/ChallengesPanelTest.java new file mode 100644 index 00000000..5eaf7036 --- /dev/null +++ b/src/test/java/world/bentobox/challenges/panel/user/ChallengesPanelTest.java @@ -0,0 +1,319 @@ +package world.bentobox.challenges.panel.user; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.challenges.ChallengesAddon; +import world.bentobox.challenges.config.Settings; +import world.bentobox.challenges.config.SettingsUtils; +import world.bentobox.challenges.database.object.Challenge; +import world.bentobox.challenges.managers.ChallengesManager; +import world.bentobox.challenges.panel.PanelTestHelper; + +/** + * Tests for {@link ChallengesPanel} challenge filtering logic (issue #337). + */ +@DisplayName("ChallengesPanel - Remove when completed flag") +class ChallengesPanelTest { + + @Mock + private ChallengesAddon addon; + @Mock + private User user; + @Mock + private World world; + @Mock + private ChallengesManager manager; + @Mock + private Settings settings; + + private AutoCloseable closeable; + private MockedStatic mockedBukkit; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + ServerMock mbServer = MockBukkit.mock(); + PanelTestHelper.primeBukkitRegistry(); + + when(addon.getChallengesManager()).thenReturn(manager); + when(addon.getChallengesSettings()).thenReturn(settings); + PanelTestHelper.setupUserTranslations(user); + when(user.getWorld()).thenReturn(world); + when(user.getUniqueId()).thenReturn(UUID.randomUUID()); + when(manager.hasAnyChallengeData(world)).thenReturn(true); + + mockedBukkit = Mockito.mockStatic(Bukkit.class, Mockito.RETURNS_DEEP_STUBS); + mockedBukkit.when(Bukkit::getServer).thenReturn(mbServer); + mockedBukkit.when(Bukkit::getItemFactory).thenReturn(mbServer.getItemFactory()); + mockedBukkit.when(Bukkit::getUnsafe).thenReturn(mbServer.getUnsafe()); + } + + @AfterEach + void tearDown() throws Exception { + if (mockedBukkit != null) mockedBukkit.closeOnDemand(); + if (closeable != null) closeable.close(); + MockBukkit.unmock(); + Mockito.framework().clearInlineMocks(); + } + + private ChallengesPanel createPanel() throws Exception { + // Create panel via reflection since constructor is private + java.lang.reflect.Constructor ctor = + ChallengesPanel.class.getDeclaredConstructor( + ChallengesAddon.class, World.class, User.class, String.class, String.class); + ctor.setAccessible(true); + return ctor.newInstance(addon, world, user, "challenges", "challenges"); + } + + private void callUpdateFreeChallengeList(ChallengesPanel panel) throws Exception { + Method method = ChallengesPanel.class.getDeclaredMethod("updateFreeChallengeList"); + method.setAccessible(true); + method.invoke(panel); + } + + private List getFreeChallengeList(ChallengesPanel panel) throws Exception { + Field field = ChallengesPanel.class.getDeclaredField("freeChallengeList"); + field.setAccessible(true); + return (List) field.get(panel); + } + + @Test + @DisplayName("Global OFF, per-challenge OFF: completed non-repeatable should NOT be hidden") + void testGlobalOffPerChallengeOff() throws Exception { + // Setup + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(false); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.VISIBLE); + + Challenge completed = PanelTestHelper.createBasicChallenge("TestChallenge", true); + when(completed.isRepeatable()).thenReturn(false); + when(completed.isRemoveWhenCompleted()).thenReturn(false); + + List challenges = new ArrayList<>(); + challenges.add(completed); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + when(manager.isChallengeComplete(user, world, completed)).thenReturn(true); + + // Execute + ChallengesPanel panel = createPanel(); + callUpdateFreeChallengeList(panel); + + // Verify: challenge should still be in the list + assertTrue(getFreeChallengeList(panel).contains(completed), + "Completed challenge should be visible when both global and per-challenge flags are OFF"); + } + + @Test + @DisplayName("Global ON, per-challenge OFF: completed non-repeatable should be hidden") + void testGlobalOnPerChallengeOff() throws Exception { + // Setup + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(true); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.VISIBLE); + + Challenge completed = PanelTestHelper.createBasicChallenge("TestChallenge", true); + when(completed.isRepeatable()).thenReturn(false); + when(completed.isRemoveWhenCompleted()).thenReturn(false); + + List challenges = new ArrayList<>(); + challenges.add(completed); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + when(manager.isChallengeComplete(user, world, completed)).thenReturn(true); + + // Execute + ChallengesPanel panel = createPanel(); + callUpdateFreeChallengeList(panel); + + // Verify: challenge should be hidden + assertFalse(getFreeChallengeList(panel).contains(completed), + "Completed challenge should be hidden when global setting is ON"); + } + + @Test + @DisplayName("Global OFF, per-challenge ON: completed non-repeatable should be hidden") + void testGlobalOffPerChallengeOn() throws Exception { + // Setup + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(false); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.VISIBLE); + + Challenge completed = PanelTestHelper.createBasicChallenge("TestChallenge", true); + when(completed.isRepeatable()).thenReturn(false); + when(completed.isRemoveWhenCompleted()).thenReturn(true); // Per-challenge flag is ON + + List challenges = new ArrayList<>(); + challenges.add(completed); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + when(manager.isChallengeComplete(user, world, completed)).thenReturn(true); + + // Execute + ChallengesPanel panel = createPanel(); + callUpdateFreeChallengeList(panel); + + // Verify: challenge should be hidden due to per-challenge flag + assertFalse(getFreeChallengeList(panel).contains(completed), + "Completed challenge should be hidden when per-challenge flag is ON"); + } + + @Test + @DisplayName("Global ON, per-challenge ON: completed non-repeatable should be hidden") + void testGlobalOnPerChallengeOn() throws Exception { + // Setup + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(true); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.VISIBLE); + + Challenge completed = PanelTestHelper.createBasicChallenge("TestChallenge", true); + when(completed.isRepeatable()).thenReturn(false); + when(completed.isRemoveWhenCompleted()).thenReturn(true); // Per-challenge flag is ON + + List challenges = new ArrayList<>(); + challenges.add(completed); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + when(manager.isChallengeComplete(user, world, completed)).thenReturn(true); + + // Execute + ChallengesPanel panel = createPanel(); + callUpdateFreeChallengeList(panel); + + // Verify: challenge should be hidden + assertFalse(getFreeChallengeList(panel).contains(completed), + "Completed challenge should be hidden when both flags are ON"); + } + + @Test + @DisplayName("Completed repeatable challenge should never be hidden, regardless of flags") + void testRepeatableChallengeNeverHidden() throws Exception { + // Setup + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(true); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.VISIBLE); + + Challenge completed = PanelTestHelper.createBasicChallenge("TestChallenge", true); + when(completed.isRepeatable()).thenReturn(true); // Repeatable + when(completed.isRemoveWhenCompleted()).thenReturn(true); + when(completed.getMaxTimes()).thenReturn(5); + + List challenges = new ArrayList<>(); + challenges.add(completed); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + when(manager.isChallengeComplete(user, world, completed)).thenReturn(true); + + // Execute + ChallengesPanel panel = createPanel(); + callUpdateFreeChallengeList(panel); + + // Verify: repeatable challenge should still be in the list + assertTrue(getFreeChallengeList(panel).contains(completed), + "Repeatable challenge should always be visible, regardless of completion status or flags"); + } + + @Test + @DisplayName("Incomplete non-repeatable challenge should never be hidden, regardless of flags") + void testIncompleteNonRepeatableNeverHidden() throws Exception { + // Setup + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(true); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.VISIBLE); + + Challenge incomplete = PanelTestHelper.createBasicChallenge("TestChallenge", true); + when(incomplete.isRepeatable()).thenReturn(false); + when(incomplete.isRemoveWhenCompleted()).thenReturn(true); + + List challenges = new ArrayList<>(); + challenges.add(incomplete); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + when(manager.isChallengeComplete(user, world, incomplete)).thenReturn(false); + + // Execute + ChallengesPanel panel = createPanel(); + callUpdateFreeChallengeList(panel); + + // Verify: incomplete challenge should still be in the list + assertTrue(getFreeChallengeList(panel).contains(incomplete), + "Incomplete challenge should always be visible"); + } + + private void setShowUndeployed(ChallengesPanel panel, boolean value) throws Exception { + Field field = ChallengesPanel.class.getDeclaredField("showUndeployed"); + field.setAccessible(true); + field.setBoolean(panel, value); + } + + @Test + @DisplayName("HIDDEN mode: undeployed challenges are removed") + void testHiddenModeRemovesUndeployed() throws Exception { + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(false); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.HIDDEN); + + Challenge deployed = PanelTestHelper.createBasicChallenge("Deployed", true); + Challenge undeployed = PanelTestHelper.createBasicChallenge("Undeployed", false); + List challenges = new ArrayList<>(List.of(deployed, undeployed)); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + + ChallengesPanel panel = createPanel(); + callUpdateFreeChallengeList(panel); + + assertTrue(getFreeChallengeList(panel).contains(deployed), "Deployed challenge stays"); + assertFalse(getFreeChallengeList(panel).contains(undeployed), + "Undeployed challenge is hidden in HIDDEN mode"); + } + + @Test + @DisplayName("TOGGLEABLE mode, showing (default): undeployed challenges are visible") + void testToggleableShowingKeepsUndeployed() throws Exception { + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(false); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.TOGGLEABLE); + + Challenge deployed = PanelTestHelper.createBasicChallenge("Deployed", true); + Challenge undeployed = PanelTestHelper.createBasicChallenge("Undeployed", false); + List challenges = new ArrayList<>(List.of(deployed, undeployed)); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + + ChallengesPanel panel = createPanel(); + // Default showUndeployed == true, so undeployed challenges must remain visible. + callUpdateFreeChallengeList(panel); + + assertTrue(getFreeChallengeList(panel).contains(deployed), "Deployed challenge stays"); + assertTrue(getFreeChallengeList(panel).contains(undeployed), + "Undeployed challenge is shown in TOGGLEABLE mode when the player has not hidden them"); + } + + @Test + @DisplayName("TOGGLEABLE mode, hidden by player: undeployed challenges are removed") + void testToggleableHiddenRemovesUndeployed() throws Exception { + when(settings.isRemoveCompleteOneTimeChallenges()).thenReturn(false); + when(settings.getVisibilityMode()).thenReturn(SettingsUtils.VisibilityMode.TOGGLEABLE); + + Challenge deployed = PanelTestHelper.createBasicChallenge("Deployed", true); + Challenge undeployed = PanelTestHelper.createBasicChallenge("Undeployed", false); + List challenges = new ArrayList<>(List.of(deployed, undeployed)); + when(manager.getFreeChallenges(world)).thenReturn(challenges); + + ChallengesPanel panel = createPanel(); + setShowUndeployed(panel, false); + callUpdateFreeChallengeList(panel); + + assertTrue(getFreeChallengeList(panel).contains(deployed), "Deployed challenge stays"); + assertFalse(getFreeChallengeList(panel).contains(undeployed), + "Undeployed challenge is hidden in TOGGLEABLE mode once the player toggles them off"); + } +} diff --git a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java index 3a0a259c..2311ed4e 100644 --- a/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java +++ b/src/test/java/world/bentobox/challenges/tasks/TryToCompleteTest.java @@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeast; @@ -29,10 +31,15 @@ import org.bukkit.Statistic; import org.bukkit.World; import org.bukkit.World.Environment; +import org.bukkit.NamespacedKey; +import org.bukkit.block.Biome; +import org.bukkit.block.Block; import org.bukkit.entity.Entity; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.PotionMeta; +import org.bukkit.potion.PotionType; import org.bukkit.util.BoundingBox; import org.eclipse.jdt.annotation.NonNull; import org.junit.jupiter.api.AfterEach; @@ -55,6 +62,7 @@ import world.bentobox.challenges.database.object.requirements.StatisticRequirements.StatisticRec; import world.bentobox.challenges.managers.ChallengesManager; import world.bentobox.challenges.tasks.TryToComplete.ChallengeResult; +import world.bentobox.challenges.utils.Utils; import world.bentobox.level.Level; /** @@ -372,6 +380,49 @@ void testCompleteChallengesAddonUserChallengeWorldStringStringIslandFailMultiple eq("[item]"), eq("challenges.entities.chicken.name")); } + /** + * Mocks the biome at the player's location and returns the requirements for further setup. + */ + private IslandRequirements setupBiomeChallenge(String currentBiomeKey, Set requiredBiomes) { + challenge.setChallengeType(ChallengeType.ISLAND_TYPE); + IslandRequirements req = new IslandRequirements(); + req.setSearchRadius(1); + req.setRequiredBiomes(requiredBiomes); + challenge.setRequirements(req); + + Block block = mock(Block.class); + Biome biome = mock(Biome.class); + String[] parts = currentBiomeKey.split(":"); + when(biome.getKey()).thenReturn(NamespacedKey.minecraft(parts[parts.length - 1])); + when(block.getBiome()).thenReturn(biome); + when(user.getLocation().getBlock()).thenReturn(block); + return req; + } + + @Test + void testIslandChallengeSucceedsWhenInRequiredBiome() { + setupBiomeChallenge("minecraft:plains", new HashSet<>(Set.of("minecraft:plains"))); + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + } + + @Test + void testIslandChallengeFailsWhenNotInRequiredBiome() { + setupBiomeChallenge("minecraft:plains", new HashSet<>(Set.of("minecraft:desert"))); + assertFalse(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + verify(user).getTranslation(any(World.class), eq("challenges.errors.wrong-biome"), eq("[biome]"), + eq("Plains")); + } + + @Test + void testIslandChallengeIgnoresBiomeWhenNoneRequired() { + // No required biomes: the biome gate is skipped and the (empty) island challenge completes. + challenge.setChallengeType(ChallengeType.ISLAND_TYPE); + IslandRequirements req = new IslandRequirements(); + req.setSearchRadius(1); + challenge.setRequirements(req); + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + } + @Test void testCompleteChallengesAddonUserChallengeWorldStringStringIslandFailPartialMultipleEntities() { challenge.setChallengeType(ChallengeType.ISLAND_TYPE); @@ -809,14 +860,17 @@ void testLevelCompletionTriggered() { lvl.setFriendlyName("Novice"); lvl.setRewardExperience(200); // Stub both overloads: getLevel(String) used in checkIfCanCompleteChallenge, - // getLevel(Challenge) used in build() for level completion check + // getLevel(Challenge) used in tryCompleteLevel() when(cm.getLevel(GAME_MODE_NAME + "_novice")).thenReturn(lvl); when(cm.getLevel(any(Challenge.class))).thenReturn(lvl); when(cm.isLevelCompleted(any(), any(), any())).thenReturn(false); when(cm.validateLevelCompletion(any(), any(), any())).thenReturn(true); + // Mock tryCompleteLevel to return the level (which triggers reward logic) + when(cm.tryCompleteLevel(any(), any(), any())).thenReturn(lvl); when(inv.addItem(any())).thenReturn(new HashMap<>()); assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); - verify(cm).setLevelComplete(any(), any(), eq(lvl)); + // Verify that tryCompleteLevel was called to complete the level + verify(cm).tryCompleteLevel(any(), any(), eq(challenge)); verify(player).giveExp(200); } @@ -830,9 +884,12 @@ void testLevelCompletionAlreadyDone() { when(cm.getLevel(GAME_MODE_NAME + "_novice")).thenReturn(lvl); when(cm.getLevel(any(Challenge.class))).thenReturn(lvl); when(cm.isLevelCompleted(any(), any(), any())).thenReturn(true); + // Mock tryCompleteLevel to return null since level is already completed + when(cm.tryCompleteLevel(any(), any(), any())).thenReturn(null); when(inv.addItem(any())).thenReturn(new HashMap<>()); assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); - verify(cm, never()).setLevelComplete(any(), any(), any()); + // Verify tryCompleteLevel was called but didn't complete the level (returned null) + verify(cm).tryCompleteLevel(any(), any(), eq(challenge)); } @Test @@ -843,4 +900,614 @@ void testFreeChallengeNoLevelCheck() { assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); verify(cm, never()).getLevel(any(Challenge.class)); } + + // ------------------------------------------------------------------------- + // Reward chance tests + // ------------------------------------------------------------------------- + + @Test + void testRewardChanceDefaultIs100() { + Challenge c = new Challenge(); + assertEquals(100, c.getRewardChance()); + } + + @Test + void testRewardChance100GivesRewards() { + challenge.setRewardChance(100); + challenge.setRewardExperience(50); + challenge.setRewardMoney(100); + challenge.setRewardItems(Collections.singletonList(new ItemStack(Material.EMERALD))); + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(false); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + mockEconomy(true, 1000); + + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + verify(inv, atLeast(1)).addItem(any()); + verify(addon.getEconomyProvider()).deposit(any(), eq(100.0)); + verify(player).giveExp(50); + } + + @Test + void testRewardChance0NoItems() { + challenge.setRewardChance(0); + challenge.setRewardExperience(50); + challenge.setRewardMoney(100); + challenge.setRewardItems(Collections.singletonList(new ItemStack(Material.EMERALD))); + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(false); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + mockEconomy(true, 1000); + + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Should not add reward items + verify(inv, never()).addItem(any()); + // Should not deposit money + verify(addon.getEconomyProvider(), never()).deposit(any(), eq(100.0)); + // Should not give experience + verify(player, never()).giveExp(50); + } + + @Test + void testRewardChanceRepeatRewards() { + challenge.setRewardChance(100); + challenge.setRepeatable(true); + challenge.setRepeatExperienceReward(25); + challenge.setRepeatMoneyReward(50); + challenge.setRepeatItemReward(Collections.singletonList(new ItemStack(Material.DIAMOND))); + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(true); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + mockEconomy(true, 1000); + + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Repeat rewards with 100% chance should be given + verify(inv, atLeast(1)).addItem(any()); + verify(addon.getEconomyProvider()).deposit(any(), eq(50.0)); + verify(player).giveExp(25); + } + + @Test + void testFirstTimeRewardIslandLevel() { + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(false); + challenge.setRewardIslandLevel(50L); + Level levelAddon = mockLevelAddon(100L); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + verify(levelAddon).setIslandLevel(world, user.getUniqueId(), 150L); + } + + @Test + void testRepeatRewardIslandLevel() { + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(true); + challenge.setRepeatIslandLevel(25L); + Level levelAddon = mockLevelAddon(100L); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + verify(levelAddon).setIslandLevel(world, user.getUniqueId(), 125L); + } + + @Test + void testFirstTimeRewardIslandLevelNoLevel() { + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(false); + challenge.setRewardIslandLevel(50L); + when(addon.isLevelProvided()).thenReturn(false); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + // Should not call Level addon methods + verify(addon, never()).getLevelAddon(); + } + + @Test + void testFirstTimeRewardIslandLevelZero() { + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(false); + challenge.setRewardIslandLevel(0L); + Level levelAddon = mockLevelAddon(100L); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + // Should not call setIslandLevel when reward is 0 + verify(levelAddon, never()).setIslandLevel(any(), any(), anyLong()); + } + + @Test + void testLevelCompletionRewardIslandLevel() { + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(false); + ChallengeLevel lvl = new ChallengeLevel(); + lvl.setUniqueId(GAME_MODE_NAME + "_novice"); + lvl.setFriendlyName("Novice"); + lvl.setRewardIslandLevel(100L); + when(cm.getLevel(GAME_MODE_NAME + "_novice")).thenReturn(lvl); + when(cm.getLevel(any(Challenge.class))).thenReturn(lvl); + when(cm.tryCompleteLevel(any(), any(), any())).thenReturn(lvl); + Level levelAddon = mockLevelAddon(100L); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + verify(levelAddon).setIslandLevel(world, user.getUniqueId(), 200L); + } + + // ------------------------------------------------------------------------- + // Tests for issue #320: Potion type comparison when metadata is ignored + // ------------------------------------------------------------------------- + + @Test + void testPotionComparisonIgnoreMetadataSameType() { + // Test that potions with same base type match when metadata is ignored + // by using Utils.groupEqualItems which should group them together + ItemStack swiftnessPotion1 = createPotion(Material.POTION, PotionType.SWIFTNESS); + swiftnessPotion1.setAmount(1); + ItemStack swiftnessPotion2 = createPotion(Material.POTION, PotionType.SWIFTNESS); + swiftnessPotion2.setAmount(1); + // Add different custom name to swiftnessPotion2 to ensure metadata differs + PotionMeta meta = (PotionMeta) swiftnessPotion2.getItemMeta(); + if (meta != null) { + meta.setDisplayName("Fancy Swiftness"); + swiftnessPotion2.setItemMeta(meta); + } + + // When grouping items with ignore-metadata set for potions, + // potions with the same base type should be grouped together + Set ignoreMetaData = Set.of(Material.POTION); + List requiredItems = Arrays.asList(swiftnessPotion1, swiftnessPotion2); + List grouped = Utils.groupEqualItems(requiredItems, ignoreMetaData); + + // Both swiftness potions should be grouped into one stack with amount 2 + assertEquals(1, grouped.size(), "Potions with same base type should be grouped together"); + assertEquals(2, grouped.get(0).getAmount(), "Grouped potion should have combined amount"); + assertEquals(Material.POTION, grouped.get(0).getType(), "Grouped potion should be POTION"); + } + + @Test + void testPotionComparisonIgnoreMetadataDifferentType() { + // Test that potions with different base types DON'T group when metadata is ignored + ItemStack swiftnessPotion = createPotion(Material.POTION, PotionType.SWIFTNESS); + swiftnessPotion.setAmount(1); + ItemStack strengthPotion = createPotion(Material.POTION, PotionType.STRENGTH); + strengthPotion.setAmount(1); + + // When grouping potions with different base types and ignore-metadata set, + // they should NOT be grouped together + Set ignoreMetaData = Set.of(Material.POTION); + List requiredItems = Arrays.asList(swiftnessPotion, strengthPotion); + List grouped = Utils.groupEqualItems(requiredItems, ignoreMetaData); + + // Different potion types should NOT be grouped + assertEquals(2, grouped.size(), "Potions with different base types should NOT be grouped"); + assertEquals(1, grouped.get(0).getAmount(), "First potion should keep original amount"); + assertEquals(1, grouped.get(1).getAmount(), "Second potion should keep original amount"); + } + + @Test + void testPotionComparisonHelperMethod() { + // Unit test for the helper method that checks if items match with potion type comparison + ItemStack swiftnessPotion1 = createPotion(Material.POTION, PotionType.SWIFTNESS); + ItemStack swiftnessPotion2 = createPotion(Material.POTION, PotionType.SWIFTNESS); + ItemStack strengthPotion = createPotion(Material.POTION, PotionType.STRENGTH); + + // Test that same potion type matches + assertTrue(Utils.comparePotionType(swiftnessPotion1, swiftnessPotion2), + "Potions with same base type should compare as equal"); + + // Test that different potion types don't match + assertFalse(Utils.comparePotionType(swiftnessPotion1, strengthPotion), + "Potions with different base types should not compare as equal"); + + // Test that potion-like check works + assertTrue(Utils.isPotionLike(Material.POTION), + "POTION should be recognized as potion-like"); + assertTrue(Utils.isPotionLike(Material.SPLASH_POTION), + "SPLASH_POTION should be recognized as potion-like"); + assertTrue(Utils.isPotionLike(Material.LINGERING_POTION), + "LINGERING_POTION should be recognized as potion-like"); + assertTrue(Utils.isPotionLike(Material.TIPPED_ARROW), + "TIPPED_ARROW should be recognized as potion-like"); + assertFalse(Utils.isPotionLike(Material.DIRT), + "DIRT should not be recognized as potion-like"); + } + + @Test + void testNonPotionIgnoreMetadataUnchanged() { + // Test that non-potion materials still use type-only comparison + ItemStack dirt1 = new ItemStack(Material.DIRT); + dirt1.setAmount(1); + ItemStack dirt2 = new ItemStack(Material.DIRT); + dirt2.setAmount(1); + + // When grouping non-potion items with ignore-metadata set, + // they should be grouped together by type alone + Set ignoreMetaData = Set.of(Material.DIRT); + List requiredItems = Arrays.asList(dirt1, dirt2); + List grouped = Utils.groupEqualItems(requiredItems, ignoreMetaData); + + // Both dirt items should be grouped into one stack with amount 2 + assertEquals(1, grouped.size(), "Non-potion items with same type should be grouped"); + assertEquals(2, grouped.get(0).getAmount(), "Grouped items should have combined amount"); + } + + /** + * Helper method to create a potion ItemStack with specified base potion type + */ + private ItemStack createPotion(Material material, PotionType potionType) { + ItemStack potion = new ItemStack(material); + PotionMeta meta = (PotionMeta) potion.getItemMeta(); + if (meta != null) { + meta.setBasePotionType(potionType); + potion.setItemMeta(meta); + } + return potion; + } + + // ------------------------------------------------------------------------- + // Consumption/Removal tests (Issue #111) + // ------------------------------------------------------------------------- + + @Test + void testInventoryChallengeWithTakeItemsTrue() { + // Setup inventory challenge with takeItems=true + InventoryRequirements req = new InventoryRequirements(); + ItemStack requiredItem = new ItemStack(Material.EMERALD_BLOCK, 1); + req.setRequiredItems(Collections.singletonList(requiredItem)); + req.setTakeItems(true); + challenge.setRequirements(req); + + // Mock inventory with the required item + ItemStack inventoryItem = new ItemStack(Material.EMERALD_BLOCK, 5); + when(inv.getContents()).thenReturn(new ItemStack[]{inventoryItem}); + when(player.getInventory()).thenReturn(inv); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify items were removed by checking the inventory item amount changed + // The removeItems method modifies the ItemStack in-place via setAmount + assertEquals(4, inventoryItem.getAmount(), "Item amount should be reduced by 1"); + } + + @Test + void testInventoryChallengeWithTakeItemsFalse() { + // Setup inventory challenge with takeItems=false + InventoryRequirements req = new InventoryRequirements(); + ItemStack requiredItem = new ItemStack(Material.EMERALD_BLOCK, 1); + req.setRequiredItems(Collections.singletonList(requiredItem)); + req.setTakeItems(false); + challenge.setRequirements(req); + + // Mock inventory with the required item + ItemStack inventoryItem = new ItemStack(Material.EMERALD_BLOCK, 5); + when(inv.getContents()).thenReturn(new ItemStack[]{inventoryItem}); + when(player.getInventory()).thenReturn(inv); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify items were NOT removed + assertEquals(5, inventoryItem.getAmount(), "Item amount should remain unchanged when takeItems=false"); + } + + @Test + void testInventoryChallengeMultipleItemsRemoved() { + // Test that multiple required items are all removed + InventoryRequirements req = new InventoryRequirements(); + ItemStack item1 = new ItemStack(Material.EMERALD, 2); + ItemStack item2 = new ItemStack(Material.DIAMOND, 3); + req.setRequiredItems(Arrays.asList(item1, item2)); + req.setTakeItems(true); + challenge.setRequirements(req); + + // Mock inventory with required items + ItemStack invEmerald = new ItemStack(Material.EMERALD, 10); + ItemStack invDiamond = new ItemStack(Material.DIAMOND, 10); + when(inv.getContents()).thenReturn(new ItemStack[]{invEmerald, invDiamond}); + when(player.getInventory()).thenReturn(inv); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify both items were removed + assertEquals(8, invEmerald.getAmount(), "Emeralds should be reduced by 2"); + assertEquals(7, invDiamond.getAmount(), "Diamonds should be reduced by 3"); + } + + @Test + void testIslandChallengeWithRemoveBlocksTrue() { + // Setup island challenge with removeBlocks=true + challenge.setChallengeType(ChallengeType.ISLAND_TYPE); + IslandRequirements req = new IslandRequirements(); + req.setSearchRadius(10); + req.setRequiredBlocks(Collections.singletonMap(Material.STONE, 1)); + req.setRemoveBlocks(true); + challenge.setRequirements(req); + + // Mock a block in the world + Block mockBlock = mock(Block.class); + when(mockBlock.getType()).thenReturn(Material.STONE); + Location blockLoc = mock(Location.class); + when(mockBlock.getLocation()).thenReturn(blockLoc); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(mockBlock); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify the block was set to AIR + verify(mockBlock).setType(Material.AIR); + } + + @Test + void testIslandChallengeWithRemoveBlocksFalse() { + // Setup island challenge with removeBlocks=false + challenge.setChallengeType(ChallengeType.ISLAND_TYPE); + IslandRequirements req = new IslandRequirements(); + req.setSearchRadius(10); + req.setRequiredBlocks(Collections.singletonMap(Material.STONE, 1)); + req.setRemoveBlocks(false); + challenge.setRequirements(req); + + // Mock a block in the world + Block mockBlock = mock(Block.class); + when(mockBlock.getType()).thenReturn(Material.STONE); + Location blockLoc = mock(Location.class); + when(mockBlock.getLocation()).thenReturn(blockLoc); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(mockBlock); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify the block was NOT removed (setType not called) + verify(mockBlock, never()).setType(any()); + } + + @Test + void testIslandChallengeWithRemoveEntitiesTrue() { + // Setup island challenge with removeEntities=true + challenge.setChallengeType(ChallengeType.ISLAND_TYPE); + IslandRequirements req = new IslandRequirements(); + req.setSearchRadius(10); + req.setRequiredEntities(Collections.singletonMap(EntityType.GHAST, 1)); + req.setRemoveEntities(true); + challenge.setRequirements(req); + + // Mock an entity in the world + Entity mockEntity = mock(Entity.class); + when(mockEntity.getType()).thenReturn(EntityType.GHAST); + Location entityLoc = mock(Location.class); + when(mockEntity.getLocation()).thenReturn(entityLoc); + List entities = Collections.singletonList(mockEntity); + when(world.getNearbyEntities(any(BoundingBox.class))).thenReturn(entities); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify the entity was removed + verify(mockEntity).remove(); + } + + @Test + void testIslandChallengeWithRemoveEntitiesFalse() { + // Setup island challenge with removeEntities=false + challenge.setChallengeType(ChallengeType.ISLAND_TYPE); + IslandRequirements req = new IslandRequirements(); + req.setSearchRadius(10); + req.setRequiredEntities(Collections.singletonMap(EntityType.GHAST, 1)); + req.setRemoveEntities(false); + challenge.setRequirements(req); + + // Mock an entity in the world + Entity mockEntity = mock(Entity.class); + when(mockEntity.getType()).thenReturn(EntityType.GHAST); + Location entityLoc = mock(Location.class); + when(mockEntity.getLocation()).thenReturn(entityLoc); + List entities = Collections.singletonList(mockEntity); + when(world.getNearbyEntities(any(BoundingBox.class))).thenReturn(entities); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify the entity was NOT removed + verify(mockEntity, never()).remove(); + } + + @Test + void testOtherChallengeMoneyWithdrawn() { + // Setup OTHER challenge with money requirement and takeMoney=true + OtherRequirements req = new OtherRequirements(); + req.setRequiredMoney(50.0); + req.setTakeMoney(true); + setupOtherChallenge(req); + + when(addon.isLevelProvided()).thenReturn(false); + VaultHook vault = mockEconomy(true, 100.0); + when(plugin.getHooks()).thenReturn(mock(world.bentobox.bentobox.managers.HooksManager.class)); + when(plugin.getHooks().getHook("PlaceholderAPI")).thenReturn(Optional.empty()); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify money was withdrawn + verify(vault).withdraw(user, 50.0); + } + + @Test + void testOtherChallengeMoneyNotWithdrawn() { + // Setup OTHER challenge with money but takeMoney=false + OtherRequirements req = new OtherRequirements(); + req.setRequiredMoney(50.0); + req.setTakeMoney(false); + setupOtherChallenge(req); + + when(addon.isLevelProvided()).thenReturn(false); + VaultHook vault = mockEconomy(true, 100.0); + when(plugin.getHooks()).thenReturn(mock(world.bentobox.bentobox.managers.HooksManager.class)); + when(plugin.getHooks().getHook("PlaceholderAPI")).thenReturn(Optional.empty()); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify money was NOT withdrawn + verify(vault, never()).withdraw(any(), any(double.class)); + } + + @Test + void testOtherChallengeExperienceWithdrawn() { + // Setup OTHER challenge with XP requirement and takeExperience=true + OtherRequirements req = new OtherRequirements(); + req.setRequiredExperience(50); + req.setTakeExperience(true); + setupOtherChallenge(req); + + when(addon.isLevelProvided()).thenReturn(false); + when(player.getTotalExperience()).thenReturn(100); + when(plugin.getHooks()).thenReturn(mock(world.bentobox.bentobox.managers.HooksManager.class)); + when(plugin.getHooks().getHook("PlaceholderAPI")).thenReturn(Optional.empty()); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify XP was taken + verify(player).setTotalExperience(50); + } + + @Test + void testOtherChallengeExperienceNotWithdrawn() { + // Setup OTHER challenge with XP but takeExperience=false + OtherRequirements req = new OtherRequirements(); + req.setRequiredExperience(50); + req.setTakeExperience(false); + setupOtherChallenge(req); + + when(addon.isLevelProvided()).thenReturn(false); + when(player.getTotalExperience()).thenReturn(100); + when(plugin.getHooks()).thenReturn(mock(world.bentobox.bentobox.managers.HooksManager.class)); + when(plugin.getHooks().getHook("PlaceholderAPI")).thenReturn(Optional.empty()); + + // Complete the challenge + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify XP was NOT taken + verify(player, never()).setTotalExperience(any(int.class)); + } + + @Test + void testInventoryChallengeFailureDoesNotRemoveItems() { + // Setup inventory challenge with an item we don't have + InventoryRequirements req = new InventoryRequirements(); + ItemStack requiredItem = new ItemStack(Material.EMERALD_BLOCK, 10); + req.setRequiredItems(Collections.singletonList(requiredItem)); + req.setTakeItems(true); + challenge.setRequirements(req); + + // Mock inventory with only 5 items (not enough) + ItemStack inventoryItem = new ItemStack(Material.EMERALD_BLOCK, 5); + when(inv.getContents()).thenReturn(new ItemStack[]{inventoryItem}); + when(player.getInventory()).thenReturn(inv); + + // Try to complete (should fail) + assertFalse(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify no items were removed (amount should still be 5) + assertEquals(5, inventoryItem.getAmount(), "Items should not be removed on failed completion"); + } + + @Test + void testIslandChallengeBlockRemovalFailureDoesNotRemoveBlocks() { + // Setup island challenge with a block we don't have + challenge.setChallengeType(ChallengeType.ISLAND_TYPE); + IslandRequirements req = new IslandRequirements(); + req.setSearchRadius(10); + req.setRequiredBlocks(Collections.singletonMap(Material.DIAMOND_BLOCK, 1)); + req.setRemoveBlocks(true); + challenge.setRequirements(req); + + // Mock world with no diamond blocks + Block mockBlock = mock(Block.class); + when(mockBlock.getType()).thenReturn(Material.STONE); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(mockBlock); + + // Try to complete (should fail) + assertFalse(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify no blocks were modified + verify(mockBlock, never()).setType(any()); + } + + @Test + void testIslandChallengeEntityRemovalFailureDoesNotRemoveEntities() { + // Setup island challenge with an entity we don't have + challenge.setChallengeType(ChallengeType.ISLAND_TYPE); + IslandRequirements req = new IslandRequirements(); + req.setSearchRadius(10); + req.setRequiredEntities(Collections.singletonMap(EntityType.WITHER, 1)); + req.setRemoveEntities(true); + challenge.setRequirements(req); + + // Mock world with wrong entity type + Entity mockEntity = mock(Entity.class); + when(mockEntity.getType()).thenReturn(EntityType.CHICKEN); + Location entityLoc = mock(Location.class); + when(mockEntity.getLocation()).thenReturn(entityLoc); + List entities = Collections.singletonList(mockEntity); + when(world.getNearbyEntities(any(BoundingBox.class))).thenReturn(entities); + + // Try to complete (should fail) + assertFalse(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify entity was not removed + verify(mockEntity, never()).remove(); + } + + @Test + void testOtherChallengeFailureDoesNotWithdrawMoney() { + // Setup OTHER challenge with money we don't have + OtherRequirements req = new OtherRequirements(); + req.setRequiredMoney(100.0); + req.setTakeMoney(true); + setupOtherChallenge(req); + + when(addon.isLevelProvided()).thenReturn(false); + VaultHook vault = mockEconomy(false, 50.0); // Only 50, need 100 + + // Try to complete (should fail) + assertFalse(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify money was NOT withdrawn + verify(vault, never()).withdraw(any(), any(double.class)); + } + + @Test + void testOtherChallengeFailureDoesNotWithdrawExperience() { + // Setup OTHER challenge with XP we don't have + OtherRequirements req = new OtherRequirements(); + req.setRequiredExperience(100); + req.setTakeExperience(true); + setupOtherChallenge(req); + + when(addon.isLevelProvided()).thenReturn(false); + when(player.getTotalExperience()).thenReturn(50); // Only 50, need 100 + + // Try to complete (should fail) + assertFalse(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify XP was NOT taken + verify(player, never()).setTotalExperience(any(int.class)); + } + + @Test + void testMultipleInventoryItemsPartialRemovalFailure() { + // Test that if ANY item removal fails, the entire challenge fails and items are returned + InventoryRequirements req = new InventoryRequirements(); + ItemStack item1 = new ItemStack(Material.EMERALD, 2); + ItemStack item2 = new ItemStack(Material.DIAMOND, 3); + req.setRequiredItems(Arrays.asList(item1, item2)); + req.setTakeItems(true); + challenge.setRequirements(req); + + // Mock inventory with only first item (missing diamonds) + ItemStack invEmerald = new ItemStack(Material.EMERALD, 10); + when(inv.getContents()).thenReturn(new ItemStack[]{invEmerald}); + when(player.getInventory()).thenReturn(inv); + + // Try to complete (should fail due to missing diamonds) + assertFalse(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + // Verify items were not removed + assertEquals(10, invEmerald.getAmount(), "Items should not be removed when requirement fails"); + } } diff --git a/src/test/java/world/bentobox/challenges/utils/UtilsTest.java b/src/test/java/world/bentobox/challenges/utils/UtilsTest.java index 45aae7df..d3c2a760 100644 --- a/src/test/java/world/bentobox/challenges/utils/UtilsTest.java +++ b/src/test/java/world/bentobox/challenges/utils/UtilsTest.java @@ -156,4 +156,59 @@ void testGetPreviousValue() { assertEquals(VisibilityMode.VISIBLE, Utils.getPreviousValue(VisibilityMode.values(), VisibilityMode.HIDDEN)); assertEquals(VisibilityMode.HIDDEN, Utils.getPreviousValue(VisibilityMode.values(), VisibilityMode.TOGGLEABLE)); } + + @Test + void testApplyDefaultColorBlankColorReturnsTextUnchanged() { + assertEquals("Hello", Utils.applyDefaultColor("Hello", "")); + assertEquals("Hello", Utils.applyDefaultColor("Hello", " ")); + assertEquals("Hello", Utils.applyDefaultColor("Hello", null)); + } + + @Test + void testApplyDefaultColorEmptyOrNullTextReturnedUnchanged() { + assertEquals("", Utils.applyDefaultColor("", "&b")); + assertNull(Utils.applyDefaultColor(null, "&b")); + } + + @Test + void testApplyDefaultColorSingleLinePrefixed() { + assertEquals("&bHello", Utils.applyDefaultColor("Hello", "&b")); + } + + @Test + void testApplyDefaultColorPrefixesEveryLine() { + assertEquals("&bLine1\n&bLine2\n&bLine3", + Utils.applyDefaultColor("Line1\nLine2\nLine3", "&b")); + } + + @Test + void testApplyDefaultColorPreservesBlankLines() { + // Blank lines still get the prefix, and no trailing/leading lines are dropped. + assertEquals("&bLine1\n&b\n&bLine2", + Utils.applyDefaultColor("Line1\n\nLine2", "&b")); + } + + @Test + void testApplyDefaultColorSupportsHexColor() { + assertEquals("7FFFFHello", Utils.applyDefaultColor("Hello", "7FFFF")); + } + + @Test + void testPrettifyBiomeStripsNamespaceAndTitleCases() { + assertEquals("Plains", Utils.prettifyBiome("minecraft:plains")); + assertEquals("Snowy Taiga", Utils.prettifyBiome("minecraft:snowy_taiga")); + assertEquals("Mangrove Swamp", Utils.prettifyBiome("minecraft:mangrove_swamp")); + } + + @Test + void testPrettifyBiomeWithoutNamespace() { + assertEquals("Desert", Utils.prettifyBiome("desert")); + } + + @Test + void testPrettifyBiomeBlankInput() { + assertEquals("", Utils.prettifyBiome(null)); + assertEquals("", Utils.prettifyBiome("")); + assertEquals("", Utils.prettifyBiome(" ")); + } }