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..6a38b7c2 --- /dev/null +++ b/e2e/src/test/e2e/package-lock.json @@ -0,0 +1,1032 @@ +{ + "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": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/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" + } + } + } +} diff --git a/e2e/src/test/e2e/package.json b/e2e/src/test/e2e/package.json new file mode 100644 index 00000000..1b037001 --- /dev/null +++ b/e2e/src/test/e2e/package.json @@ -0,0 +1,16 @@ +{ + "type": "module", + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@drownek/plugwright": "^2.0.2" + }, + "overrides": { + "uuid": "^11.1.1" + }, + "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..8abb3919 100644 --- a/src/main/java/world/bentobox/challenges/config/Settings.java +++ b/src/main/java/world/bentobox/challenges/config/Settings.java @@ -112,9 +112,33 @@ 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 MiniMessage tags,") + @ConfigComment("including hex (e.g. '', '<#55FFFF>' or ''). Legacy '&' codes") + @ConfigComment("(e.g. '&b' or '7FFFF') also still work. Leave empty for no default. A colour") + @ConfigComment("written in the description itself still overrides this. Does not affect") + @ConfigComment("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 +174,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 +737,72 @@ 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 (MiniMessage tag or legacy 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, as a MiniMessage tag (e.g. "<white>" or + * "<#55FFFF>") or legacy code (e.g. "&b"), 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 (MiniMessage tag or legacy 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, as a MiniMessage tag (e.g. "<white>" or + * "<#55FFFF>") or legacy code (e.g. "&b"), 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..3e37c20c 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. */ @@ -262,7 +277,7 @@ public enum ChallengeType * Maximum number of times the challenge can be repeated. 0 or less will mean infinite times. */ @Expose - private int maxTimes = 1; + private int maxTimes = 0; /** * Repeat experience reward @@ -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,17 @@ public void setRewardCommands(List rewardCommands) } + /** + * Sets the reward chance percentage. Values are clamped to 1-100: 0 would mean rewards + * are never given, which is not allowed. + * @param rewardChance the rewardChance new value (1-100). + */ + public void setRewardChance(int rewardChance) + { + this.rewardChance = Math.min(100, Math.max(1, rewardChance)); + } + + /** * This method sets the repeatable value. * @param repeatable the repeatable new value. @@ -850,6 +921,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 +1110,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..44f3b642 100644 --- a/src/main/java/world/bentobox/challenges/panel/ConversationUtils.java +++ b/src/main/java/world/bentobox/challenges/panel/ConversationUtils.java @@ -27,6 +27,7 @@ import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.util.Util; import world.bentobox.challenges.utils.Constants; import world.bentobox.challenges.utils.Utils; @@ -117,8 +118,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 +137,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, @@ -425,7 +443,9 @@ public String getPromptText(@NotNull ConversationContext context) for (String line : ((List) context.getSessionData(SESSION_CONSTANT))) { - sb.append(line); + // Render MiniMessage tags and legacy codes so the echo shows the + // colors the player will actually get. + sb.append(Util.componentToLegacy(Util.parseMiniMessageOrLegacy(line))); sb.append(System.lineSeparator()); } 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..0bb2341c 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), 1, 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); @@ -1484,7 +1609,8 @@ private PanelItem createRewardButton(RewardButton button) { description.add(this.user.getTranslation(Constants.CLICK_TO_TOGGLE)); } case REPEAT_COUNT -> { - description.add(this.user.getTranslation(reference + Constants.VALUE_KEY, Constants.PARAMETER_NUMBER, + String valueKey = this.challenge.getMaxTimes() > 0 ? Constants.VALUE_KEY : "value-infinite"; + description.add(this.user.getTranslation(reference + valueKey, Constants.PARAMETER_NUMBER, String.valueOf(this.challenge.getMaxTimes()))); icon = new ItemStack(Material.COBBLESTONE_WALL); clickHandler = (panel, user, clickType, i) -> { @@ -1497,7 +1623,7 @@ private PanelItem createRewardButton(RewardButton button) { this.build(); }; ConversationUtils.createNumericInput(numberConsumer, this.user, - this.user.getTranslation(Constants.INPUT_NUMBER), 0, Integer.MAX_VALUE); + this.user.getTranslation(Constants.CONVERSATIONS + "input-repeat-count"), 0, Integer.MAX_VALUE); return true; }; @@ -1582,7 +1708,7 @@ private PanelItem createRewardButton(RewardButton button) { icon = new ItemStack(Material.CHEST); clickHandler = (panel, user, clickType, slot) -> { - ItemSelector.open(this.user, this.challenge.getRewardItems(), (status, value) -> { + ItemSelector.open(this.user, this.challenge.getRepeatItemReward(), (status, value) -> { if (Boolean.TRUE.equals(status)) { this.challenge.setRepeatItemReward(value); } @@ -1643,6 +1769,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 +1872,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 +1985,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 +2003,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..15fa3da2 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,39 +259,56 @@ 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) { - Utils.sendMessage(this.user, + Utils.sendMessage(this.user, this.world, Constants.MESSAGES + "you-completed-challenge", Constants.PARAMETER_VALUE, this.challenge.getFriendlyName()); } + // Tell recipients the chance roll failed, so silence is not mistaken for a missing reward. + if (!rewardSuccess && this.hasFirstTimeRewards()) + { + this.getRewardRecipients().forEach(recipient -> Utils.sendMessage(recipient, + this.world, Constants.MESSAGES + "no-reward-this-time")); + } + if (this.addon.getChallengesSettings().isBroadcastMessages()) { Bukkit.getOnlinePlayers().stream(). @@ -312,40 +335,64 @@ ChallengeResult build(int maxTimes) { int rewardFactor = result.getFactor() - (result.wasCompleted() ? 0 : 1); + List missedRecipients = new ArrayList<>(); + // 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); + } + else if (this.hasRepeatRewards()) + { + missedRecipients.add(recipient); + } - // 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, @@ -358,66 +405,67 @@ ChallengeResult build(int maxTimes) this.world, Constants.MESSAGES + "you-repeated-challenge", Constants.PARAMETER_VALUE, this.challenge.getFriendlyName()); } + + // Tell recipients whose every roll failed, so silence is not mistaken for a missing reward. + missedRecipients.forEach(recipient -> Utils.sendMessage(recipient, + this.world, Constants.MESSAGES + "no-reward-this-time")); } // Mark as complete 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 +474,64 @@ 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; + } + + + /** + * @return true if the challenge has any first-time rewards that are gated by the reward chance. + */ + private boolean hasFirstTimeRewards() + { + return !this.challenge.getRewardItems().isEmpty() || + this.challenge.getRewardExperience() > 0 || + this.challenge.getRewardMoney() > 0; + } + + + /** + * @return true if the challenge has any repeat rewards that are gated by the reward chance. + */ + private boolean hasRepeatRewards() + { + return !this.challenge.getRepeatItemReward().isEmpty() || + this.challenge.getRepeatExperienceReward() > 0 || + this.challenge.getRepeatMoneyReward() > 0; + } + + + /** + * 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 +1177,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 +1290,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 +1989,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 +2038,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 +2060,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..e1593344 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -72,10 +72,26 @@ 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 MiniMessage tags, + # including hex (e.g. '', '<#55FFFF>' or ''). Legacy '&' codes + # (e.g. '&b' or '7FFFF') also still work. 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 +108,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..66b25c80 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -57,6 +57,7 @@ challenges: manage-statistics: Správa statistik manage-advancements: Správa pokroků advancement-selector: Výběr pokroku + biome-selector: "Výběr biomu" buttons: free-challenges: name: "Výzvy zdarma" @@ -467,6 +468,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 (1-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: |- @@ -518,7 +527,9 @@ challenges: Umožňuje změnit počet opakování za výzvu. + Nastavte 0 pro neomezené opakování. value: "Aktuální hodnota: [number]" + value-infinite: "Aktuální hodnota: [number] (neomezeno)" cool_down: name: "Vychladnout" description: |- @@ -903,6 +914,48 @@ challenges: name: [name] description: '[description]' selected: Vybráno + toggle-undeployed: + name: "Nenasazené výzvy" + shown: |- + Nenasazené výzvy jsou + nyní zobrazeny. + hidden: |- + Nenasazené výzvy jsou + nyní skryty. + required_biomes: + name: "Požadované biomy" + description: |- + Hráč musí stát v jednom + z těchto biomů pro splnění této + ostrovní výzvy. + title: "Biomy: " + list: " - [biome]" + none: "Žádné biomy nejsou vyžadovány (jakýkoli biom)." + reward_island_level: + name: "Úroveň ostrova jako odměna" + description: |- + Umožňuje změnit odměnu úrovně ostrova. + Vyžaduje addon Level. + value: "Aktuální hodnota: [number]" + repeat_reward_island_level: + name: "Úroveň ostrova za opakování" + description: |- + Umožňuje změnit odměnu úrovně + ostrova za opakování výzvy. + Vyžaduje addon Level. + value: "Aktuální hodnota: [number]" + open_anywhere: + name: "Otevřít GUI kdekoli" + description: |- + Umožňuje hráčům otevřít GUI výzev + odkudkoli. Splnění stále vyžaduje + být na ostrově, pokud je chráněno. + enabled: "Povoleno" + disabled: "Zakázáno" + biome: + name: "[biome]" + description: "ID biomu: [id]" + selected: "Vybráno" tips: click-to-select: "Klepnutím na vyberte." click-to-choose: "Klepnutím na vyberte." @@ -999,6 +1052,8 @@ challenges: search-radius: "Ne dále než [number] metrů" warning-block: "Bloky budou odstraněny " warning-entity: "Entity budou odstraněny" + biomes-title: "Požadované biomy:" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1092,7 +1147,7 @@ challenges: input-number: "Zadejte prosím číslo do chatu." input-seconds: "Zadejte prosím sekundy do chatu." numeric-only: "Daná [value] není číslo!" - not-valid-value: "Dané číslo [value] není platné. Musí být větší než [min] a menší než [max]!" + not-valid-value: "Zadané číslo [value] není platné. Musí být mezi [min] a [max] včetně." user-data-removed: "Všechna uživatelská data pro [gamemode] jsou vymazána z databáze." confirm-user-data-deletion: "Potvrďte prosím, že chcete vymazat databázi uživatelů pro [gamemode]." challenge-data-removed: "Všechna data výzev pro [gamemode] jsou vymazána z databáze." @@ -1130,6 +1185,8 @@ challenges: write-search: "Napište prosím hodnotu hledání. (pro ukončení napiš 'cancel')" search-updated: "Hodnota vyhledávání byla aktualizována." enter-formula: "Zadejte vzorec, který používá zástupné symboly PAPI a symboly =,<>,<+,>=, ==, !=, AND, OR pouze.\nPříklad: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Napište 'confirm' do chatu pro pokračování, nebo 'cancel' pro zrušení." + input-repeat-count: "Zadejte do chatu počet opakování. Zadejte 0 pro neomezené opakování." titles: challenge-title: Úspěšně dokončeno challenge-subtitle: "[friendlyName]" @@ -1148,6 +1205,7 @@ challenges: you-completed-challenge: "Dokončil jsi výzvu [value]!" you-repeated-challenge: "Zopakoval jsi výzvu [value]!" you-repeated-challenge-multiple: "Zopakoval jsi výzvu [value] [count]x!" + no-reward-this-time: "Štěstí ti nepřálo — tentokrát bez odměny!" you-completed-level: "Dokončil jsi úroveň [value]!" name-has-completed-challenge: "[name] dokončil výzvu [value]!" name-has-completed-level: "[name] dokončil úroveň [value]!" @@ -1192,6 +1250,7 @@ challenges: no-team: Toto je týmová výzva. Chcete-li ji splnit, musíte mít tým! insufficient-team: Tato týmová výzva potřebuje [number] členů týmu online. Online je pouze [online]. member-missing-share: [name] nemá svůj podíl ([amount] x [item]) pro tuto týmovou výzvu. + wrong-biome: "Pro splnění této výzvy musíte stát v biomu [biome]!" materials: any: 'Jakýkoli ' all_hanging_signs: diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index bedde3c2..ea0e7f47 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -59,6 +59,7 @@ challenges: manage-statistics: Statistiken verwalten manage-advancements: Fortschritte verwalten advancement-selector: Fortschritt-Auswahl + biome-selector: "Biom wählen" buttons: free-challenges: name: "Kostenlose Herausforderungen" @@ -458,6 +459,14 @@ challenges: Wiederholung des Belohnungsgeldes für die Herausforderung. value: "Aktueller Wert: [number]" + reward_chance: + name: "Belohnungschance" + description: |- + Prozentuale Chance (1-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: |- @@ -509,7 +518,9 @@ challenges: Ermöglicht die Änderung der Anzahl der Wiederholungen für die Herausforderung. + Setze 0 für unbegrenzte Wiederholungen. value: "Aktueller Wert: [number]" + value-infinite: "Aktueller Wert: [number] (unbegrenzt)" cool_down: name: "Abkühlen" description: |- @@ -894,6 +905,48 @@ challenges: name: [name] description: '[description]' selected: Ausgewählt + toggle-undeployed: + name: "Nicht veröffentlichte Herausforderungen" + shown: |- + Nicht veröffentlichte Herausforderungen werden + derzeit angezeigt. + hidden: |- + Nicht veröffentlichte Herausforderungen sind + derzeit ausgeblendet. + required_biomes: + name: "Benötigte Biome" + description: |- + Der Spieler muss in einem dieser + Biome stehen, um diese Insel- + Herausforderung abzuschließen. + title: "Biome: " + list: " - [biome]" + none: "Keine Biome erforderlich (beliebiges Biom)." + reward_island_level: + name: "Belohnung Insellevel" + description: |- + Ermöglicht die Änderung des Insellevels als Belohnung. + Benötigt das Level-Addon. + value: "Aktueller Wert: [number]" + repeat_reward_island_level: + name: "Wiederholungsbelohnung Insellevel" + description: |- + Ermöglicht die Änderung der Insellevel- + Wiederholungsbelohnung für die Herausforderung. + Benötigt das Level-Addon. + value: "Aktueller Wert: [number]" + open_anywhere: + name: "GUI überall öffnen" + description: |- + Erlaubt Spielern, das Herausforderungs-GUI + von überall zu öffnen. Zum Abschließen muss + man weiterhin auf der Insel sein, wenn geschützt. + enabled: "Aktiviert" + disabled: "Deaktiviert" + biome: + name: "[biome]" + description: "Biom-ID: [id]" + selected: "Ausgewählt" tips: click-to-select: "Klick zu markieren." click-to-choose: "Klick zum wählen." @@ -990,6 +1043,8 @@ challenges: search-radius: "Nicht weiter als [number] Meter" warning-block: "Blöcke werden entfernt" warning-entity: "Mobs werden entfernt" + biomes-title: "Benötigte(s) Biom(e):" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1081,7 +1136,7 @@ challenges: input-number: "Bitte geben Sie im Chat eine Nummer ein." input-seconds: "Bitte geben Sie im Chat eine Sekunde ein." numeric-only: "Der angegebene [value] ist keine Zahl!" - not-valid-value: "Die angegebene Zahl [value] ist ungültig. Er muss größer als [min] und kleiner als [max] sein!" + not-valid-value: "Die angegebene Zahl [value] ist ungültig. Sie muss zwischen [min] und [max] (einschließlich) liegen." user-data-removed: "Alle Benutzerdaten für [gamemode] werden aus der Datenbank gelöscht." confirm-user-data-deletion: "Bitte bestätigen Sie, dass Sie die Benutzerdatenbank für [gamemode] löschen möchten." challenge-data-removed: "Alle Herausforderungsdaten für [gamemode] werden aus der Datenbank gelöscht." @@ -1119,6 +1174,8 @@ challenges: write-search: "Bitte geben Sie einen Suchwert ein. (zum Beenden 'cancel' schreiben)" search-updated: "Suchwert aktualisiert." enter-formula: "Gib eine Formel ein, die PAPI-Platzhalter und die Symbole =,<>,<=,>=, ==, !=, AND, OR verwendet.\nBeispiel: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Gib 'confirm' im Chat ein, um fortzufahren, oder 'cancel' zum Abbrechen." + input-repeat-count: "Bitte gib die Anzahl der Wiederholungen im Chat ein. Gib 0 für unbegrenzte Wiederholungen ein." titles: challenge-title: Erfolgreich abgeschlossen challenge-subtitle: "[friendlyName]" @@ -1137,6 +1194,7 @@ challenges: you-completed-challenge: "Du hast die [value] Herausforderungen abgeschlossen!" you-repeated-challenge: "Du hast die [value] Herausforderung wiederholt!" you-repeated-challenge-multiple: "Du hast die [value] Herausforderungen [count] mal wiederholt!" + no-reward-this-time: "Das Glück war dir nicht hold — diesmal keine Belohnung!" you-completed-level: "Du hast den [value] level abgeschlossen!" name-has-completed-challenge: "[name] hat die [value] -Herausforderung abgeschlossen!" name-has-completed-level: "[name] hat den [value] Level abgeschlossen!" @@ -1183,6 +1241,7 @@ challenges: no-team: Dies ist eine Team-Herausforderung. Du musst ein Team haben, um sie abzuschließen! insufficient-team: Diese Team-Herausforderung benötigt [number] Teammitglieder online. Nur [online] sind online. member-missing-share: [name] hat seinen Anteil ([amount] x [item]) nicht für diese Team-Herausforderung. + wrong-biome: "Du musst im Biom [biome] stehen, um diese Herausforderung abzuschließen!" materials: any: 'Beliebig ' all_hanging_signs: diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index c2a95997..5d9613b5 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 (1-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: |- @@ -624,7 +665,9 @@ challenges: description: |- Allows you to change the number of repeats for the challenge. + Set to 0 for unlimited repeats. value: "Current value: [number]" + value-infinite: "Current value: [number] (infinite)" cool_down: name: "Cool Down" description: |- @@ -770,6 +813,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 +925,11 @@ challenges: description: |- Entity ID: [id] selected: "Selected" + biome: + name: "[biome]" + description: |- + Biome ID: [id] + selected: "Selected" entity-group: name: "[id]" description: "" @@ -966,6 +1022,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." @@ -985,7 +1042,6 @@ challenges: Click on an item in your inventory. left-click-to-edit: "Left Click to edit." - right-click-to-clear: "Right Click to clear." click-to-previous: "Click to view previous page." click-to-next: "Click to view next page." descriptions: @@ -1053,9 +1109,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,10 +1250,12 @@ 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-repeat-count: "Please enter the number of repeats in the chat. Enter 0 for unlimited repeats." input-seconds: "Please enter a number of seconds in the chat." numeric-only: "The given [value] is not a number!" - not-valid-value: "The given number [value] is not valid. It must be greater than [min] and less than [max]!" + not-valid-value: "The given number [value] is not valid. It must be between [min] and [max] inclusive." user-data-removed: "All user data for [gamemode] has been cleared from the database." confirm-user-data-deletion: "Please confirm that you want to clear the user database for [gamemode]." challenge-data-removed: "All challenge data for [gamemode] has been cleared from the database." @@ -1248,6 +1311,7 @@ challenges: you-completed-challenge: "You completed the [value] challenge!" you-repeated-challenge: "You repeated the [value] challenge!" you-repeated-challenge-multiple: "You repeated the [value] challenge [count] times!" + no-reward-this-time: "Luck was not on your side — no reward this time!" you-completed-level: "You completed the [value] level!" name-has-completed-challenge: "[name] has completed the [value] challenge!" name-has-completed-level: "[name] has completed the [value] level!" @@ -1265,6 +1329,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..ec7f8c99 100755 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -60,6 +60,7 @@ challenges: manage-statistics: Gestionar Estadísticas manage-advancements: Gestionar Avances advancement-selector: Selector de Avances + biome-selector: "Selector de bioma" buttons: free-challenges: name: "desafíos gratuitos" @@ -470,6 +471,14 @@ challenges: repetir dinero de recompensa para el desafío. value: "Valor actual: [number]" + reward_chance: + name: "Probabilidad de Recompensa" + description: |- + Probabilidad (1-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: |- @@ -521,7 +530,9 @@ challenges: Permite cambiar el número de repeticiones para el desafío. + Establece 0 para repeticiones ilimitadas. value: "Valor actual: [number]" + value-infinite: "Valor actual: [number] (ilimitado)" cool_down: name: "enfriamiento" description: |- @@ -910,6 +921,48 @@ challenges: name: [name] description: '[description]' selected: Seleccionado + toggle-undeployed: + name: "Desafíos no desplegados" + shown: |- + Los desafíos no desplegados están + actualmente visibles. + hidden: |- + Los desafíos no desplegados están + actualmente ocultos. + required_biomes: + name: "Biomas requeridos" + description: |- + El jugador debe estar en uno + de estos biomas para completar este + desafío de isla. + title: "Biomas: " + list: " - [biome]" + none: "No se requieren biomas (cualquier bioma)." + reward_island_level: + name: "Nivel de isla de recompensa" + description: |- + Permite cambiar el nivel de isla de recompensa. + Requiere el addon Level. + value: "Valor actual: [number]" + repeat_reward_island_level: + name: "Nivel de isla por repetición" + description: |- + Permite cambiar la recompensa de nivel + de isla por repetir el desafío. + Requiere el addon Level. + value: "Valor actual: [number]" + open_anywhere: + name: "Abrir GUI en cualquier lugar" + description: |- + Permite a los jugadores abrir el GUI de desafíos + desde cualquier lugar. Completarlos aún requiere + estar en la isla cuando está protegida. + enabled: "Habilitado" + disabled: "Deshabilitado" + biome: + name: "[biome]" + description: "ID del bioma: [id]" + selected: "seleccionado" tips: click-to-select: "Haga clic en para seleccionar." click-to-choose: "Haga clic en para elegir." @@ -1006,6 +1059,8 @@ challenges: search-radius: "No más allá de [number] metros" warning-block: "Los bloques serán eliminados" warning-entity: "Entidades serán eliminadas" + biomes-title: "Bioma(s) requerido(s):" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1099,7 +1154,7 @@ challenges: input-number: "Por favor ingresa un número en el chat." input-seconds: "Por favor ingrese unos segundos en el chat." numeric-only: "¡El [value] dado no es un número!" - not-valid-value: "El número dado [value] no es válido. ¡Debe ser mayor que [min] y menor que [max]!" + not-valid-value: "El número dado [value] no es válido. Debe estar entre [min] y [max], ambos inclusive." user-data-removed: "Todos los datos del usuario para [gamemode] se borran de la base de datos." confirm-user-data-deletion: "Confirme que desea borrar la base de datos de usuarios para [gamemode]." challenge-data-removed: "Todos los datos de los desafíos para [gamemode] se borran de la base de datos." @@ -1137,6 +1192,8 @@ challenges: write-search: "Por favor escriba un valor de búsqueda. (escriba 'cancelar' para salir)" search-updated: "&un valor de búsqueda actualizado." enter-formula: "Introduce una fórmula que utilice marcadores de posición PAPI y símbolos =,<>,<+,>=, ==, !=, AND, OR solamente.\nEjemplo: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Escribe 'confirm' en el chat para continuar, o 'cancel' para cancelar." + input-repeat-count: "Por favor ingresa el número de repeticiones en el chat. Ingresa 0 para repeticiones ilimitadas." titles: challenge-title: Completado con éxito challenge-subtitle: "[friendlyName]" @@ -1155,6 +1212,7 @@ challenges: you-completed-challenge: "¡Has completado el desafío [value]!" you-repeated-challenge: "¡Has repetido el desafío [value]!" you-repeated-challenge-multiple: "¡Has repetido el desafío [value] [count] veces!" + no-reward-this-time: "La suerte no estuvo de tu lado — ¡sin recompensa esta vez!" you-completed-level: "¡Has completado el nivel [value]!" name-has-completed-challenge: "¡[name] ha completado el desafío [value]!" name-has-completed-level: "¡[name] ha completado el nivel [value]!" @@ -1201,6 +1259,7 @@ challenges: no-team: Este es un desafío de equipo. ¡Debes tener un equipo para completarlo! insufficient-team: Este desafío de equipo necesita [number] miembros del equipo en línea. Solo [online] están en línea. member-missing-share: [name] no tiene su parte ([amount] x [item]) para este desafío de equipo. + wrong-biome: "¡Debes estar en el bioma [biome] para completar este desafío!" materials: any: 'Cualquiera ' all_hanging_signs: diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index d03dc1e8..8ddb7594 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -57,6 +57,7 @@ challenges: manage-statistics: Gérer les Statistiques manage-advancements: Gérer les Avancées advancement-selector: Sélecteur d'Avancées + biome-selector: "Sélecteur de biome" buttons: free-challenges: name: "Challenges gratuits" @@ -462,6 +463,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 (1-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: |- @@ -513,7 +522,9 @@ challenges: Permet de modifier le nombre de répétitions pour le défi. + Mettez 0 pour des répétitions illimitées. value: "Valeur actuelle : [number]" + value-infinite: "Valeur actuelle : [number] (illimité)" cool_down: name: "Refroidissement" description: |- @@ -902,6 +913,48 @@ challenges: name: [name] description: '[description]' selected: Sélectionné + toggle-undeployed: + name: "Défis non déployés" + shown: |- + Les défis non déployés sont + actuellement affichés. + hidden: |- + Les défis non déployés sont + actuellement masqués. + required_biomes: + name: "Biomes requis" + description: |- + Le joueur doit se trouver dans l'un + de ces biomes pour terminer ce + défi d'île. + title: "Biomes : " + list: " - [biome]" + none: "Aucun biome requis (n'importe quel biome)." + reward_island_level: + name: "Niveau d'île en récompense" + description: |- + Permet de modifier le niveau d'île en récompense. + Nécessite l'addon Level. + value: "Valeur actuelle : [number]" + repeat_reward_island_level: + name: "Niveau d'île en récompense répétée" + description: |- + Permet de modifier la récompense de niveau + d'île pour la répétition du défi. + Nécessite l'addon Level. + value: "Valeur actuelle : [number]" + open_anywhere: + name: "Ouvrir le GUI n'importe où" + description: |- + Permet aux joueurs d'ouvrir le GUI des défis + depuis n'importe où. Terminer un défi nécessite + toujours d'être sur l'île si protégée. + enabled: "Activé" + disabled: "Désactivé" + biome: + name: "[biome]" + description: "ID du biome : [id]" + selected: "sélectionné" tips: click-to-select: "Cliquez pour sélectionner." click-to-choose: "Cliquez sur pour choisir." @@ -998,6 +1051,8 @@ challenges: search-radius: "Pas plus loin que [number] mètres" warning-block: "Les blocs seront supprimés" warning-entity: "Les entités seront supprimées" + biomes-title: "Biome(s) requis :" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1091,7 +1146,7 @@ challenges: input-number: "Veuillez saisir un numéro dans le chat." input-seconds: "Veuillez entrer une seconde dans le chat." numeric-only: "La [value] donnée n'est pas un nombre !" - not-valid-value: "Le nombre donné [value] n'est pas valide. Elle doit être supérieure à [min] et inférieure à [max] !" + not-valid-value: "Le nombre donné [value] n'est pas valide. Il doit être compris entre [min] et [max] inclus." user-data-removed: "Toutes les données utilisateur pour [gamemode] sont effacées de la base de données." confirm-user-data-deletion: "Veuillez confirmer que vous souhaitez effacer la base de données utilisateur pour [gamemode]." challenge-data-removed: "Toutes les données des défis pour [gamemode] sont effacées de la base de données." @@ -1129,6 +1184,8 @@ challenges: write-search: "Veuillez saisir une valeur de recherche. (écrire 'cancel' pour quitter)" search-updated: "Valeur de recherche mise à jour." enter-formula: "Entrez une formule qui utilise uniquement des espaces réservés PAPI et des symboles =,<>,<+,>=, ==, !=, AND, OR.\nExemple : %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Tapez 'confirm' dans le chat pour continuer, ou 'cancel' pour annuler." + input-repeat-count: "Veuillez saisir le nombre de répétitions dans le chat. Saisissez 0 pour des répétitions illimitées." titles: challenge-title: Complété avec succès challenge-subtitle: "[friendlyName]" @@ -1147,6 +1204,7 @@ challenges: you-completed-challenge: "Vous avez complété le challenge: [value] !" you-repeated-challenge: "Vous avez répété le challenge: [value] !" you-repeated-challenge-multiple: "Vous avez répété le challenge: [value] [count] fois !" + no-reward-this-time: "La chance n'était pas de votre côté — pas de récompense cette fois !" you-completed-level: "Vous avez complété le niveau: [value] !" name-has-completed-challenge: "[name] a complété le challenge: [value] !" name-has-completed-level: "[name] a complété le niveau: [value] !" @@ -1193,6 +1251,7 @@ challenges: no-team: C'est un défi d'équipe. Vous devez avoir une équipe pour le compléter! insufficient-team: Ce défi d'équipe nécessite [number] membres de l'équipe en ligne. Seul [online] est en ligne. member-missing-share: [name] n'a pas sa part ([amount] x [item]) pour ce défi d'équipe. + wrong-biome: "Vous devez vous trouver dans le biome [biome] pour terminer ce défi !" materials: any: 'Quelconque ' all_hanging_signs: diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index 144344e2..d9701a5e 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -57,6 +57,7 @@ challenges: manage-statistics: Statisztikák kezelése manage-advancements: Haladások kezelése advancement-selector: Haladás választó + biome-selector: "Biomválasztó" buttons: free-challenges: name: "Ingyenes kihívások" @@ -467,6 +468,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 (1-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: |- @@ -518,7 +527,9 @@ challenges: Lehetővé teszi a ismétlésszám a kihíváshoz. + Állítsd 0-ra a korlátlan ismétléshez. value: "Jelenlegi érték: [number]" + value-infinite: "Jelenlegi érték: [number] (korlátlan)" cool_down: name: "Lehűlni" description: |- @@ -903,6 +914,48 @@ challenges: name: [name] description: '[description]' selected: Kiválasztva + toggle-undeployed: + name: "Nem aktivált kihívások" + shown: |- + A nem aktivált kihívások jelenleg + láthatók. + hidden: |- + A nem aktivált kihívások jelenleg + rejtettek. + required_biomes: + name: "Szükséges biomok" + description: |- + A játékosnak ezen biomok egyikében + kell állnia a szigetkihívás + teljesítéséhez. + title: "Biomok: " + list: " - [biome]" + none: "Nincs szükséges biom (bármelyik biom)." + reward_island_level: + name: "Jutalom szigetszint" + description: |- + Lehetővé teszi a jutalom szigetszint módosítását. + A Level addon szükséges hozzá. + value: "Jelenlegi érték: [number]" + repeat_reward_island_level: + name: "Ismétlési jutalom szigetszint" + description: |- + Lehetővé teszi az ismétlési jutalom + szigetszint módosítását a kihíváshoz. + A Level addon szükséges hozzá. + value: "Jelenlegi érték: [number]" + open_anywhere: + name: "GUI megnyitása bárhonnan" + description: |- + Lehetővé teszi a játékosoknak a kihívások GUI + megnyitását bárhonnan. A teljesítéshez továbbra is + a szigeten kell lenni, ha védett. + enabled: "Engedélyezve" + disabled: "Letiltva" + biome: + name: "[biome]" + description: "Biom azonosító: [id]" + selected: "Kiválasztva" tips: click-to-select: "Kattintson a gombra a kiválasztáshoz." click-to-choose: "Kattintson a gombra a kiválasztáshoz." @@ -999,6 +1052,8 @@ challenges: search-radius: "Legfeljebb [number] méternél" warning-block: "Blokkok eltávolításra kerülnek" warning-entity: "Az entitások eltávolításra kerülnek" + biomes-title: "Szükséges biom(ok):" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1092,7 +1147,7 @@ challenges: input-number: "Kérjük, adjon meg egy számot a chatben." input-seconds: "Kérjük, adjon meg egy másodpercet a csevegésben." numeric-only: "A megadott [value] nem szám!" - not-valid-value: "A megadott szám [value] nem érvényes. Nagyobbnak kell lennie, mint [min] és kisebbnek, mint [max]!" + not-valid-value: "A megadott szám ([value]) érvénytelen. [min] és [max] között kell lennie (a határokat is beleértve)." user-data-removed: "A [gamemode] összes felhasználói adata törlődik az adatbázisból." confirm-user-data-deletion: "Kérjük, erősítse meg, hogy törölni szeretné a [gamemode] felhasználói adatbázisát." challenge-data-removed: "[gamemode] adatai törlődnek az adatbázisból." @@ -1130,6 +1185,8 @@ challenges: write-search: "Kérjük, írjon be egy keresési értéket. (a kilépéshez írja be: „Mégse”)" search-updated: "keresési érték frissítve." enter-formula: "Írj be egy képletet, amely csak PAPI helyőrzőket és szimbólumokat =,<>,<+,>=, ==, !=, AND, OR használ.\nPélda: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Írd be a chatbe: 'confirm' a folytatáshoz, vagy 'cancel' a megszakításhoz." + input-repeat-count: "Kérjük, add meg az ismétlések számát a chatben. Írj 0-t a korlátlan ismétléshez." titles: challenge-title: Sikeresen teljesítve challenge-subtitle: "[friendlyName]" @@ -1148,6 +1205,7 @@ challenges: you-completed-challenge: "Teljesítette a [value] kihívást!" you-repeated-challenge: "Megismételte a [value] kihívást!" you-repeated-challenge-multiple: "Ismételted a [value] kihívást [count]!" + no-reward-this-time: "A szerencse most nem állt melléd — ezúttal nincs jutalom!" you-completed-level: "Elérted az [value] szintet!" name-has-completed-challenge: "[name] teljesítette a [value] kihívást!" name-has-completed-level: "[name] befejezte az [value] szintet!" @@ -1194,6 +1252,7 @@ challenges: no-team: Ez egy csapatkihívás. Csapatodnak kell lennie a befejezéshez! insufficient-team: Ez a csapatkihívás [number] csapattag jelenlétét igényli. Csak [online] van online. member-missing-share: [name] nem rendelkezik a saját részével ([amount] x [item]) ehhez a csapatkihíváshoz. + wrong-biome: "A(z) [biome] biomban kell állnod a kihívás teljesítéséhez!" materials: any: 'Bármilyen ' all_hanging_signs: diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml index 990f76d7..3950496f 100644 --- a/src/main/resources/locales/ja.yml +++ b/src/main/resources/locales/ja.yml @@ -65,6 +65,7 @@ challenges: challenge-selector: チャレンジセレクター statistic-selector: 統計セレクター environment-selector: 環境セレクター + biome-selector: "バイオームセレクター" buttons: free-challenges: name: 無料の課題 @@ -519,6 +520,14 @@ challenges: 繰り返しを変更できます チャレンジにお金に報いる。 value: 現在の値:[number] + reward_chance: + name: 報酬確率 + description: |- + 達成時に物質的な報酬が与えられる + 確率(1-100)です。 + アイテム、お金、経験値は1回の抽選で決まります。 + コマンドは常に実行されます。 + value: 確率:[number]% reward_commands: name: 報酬コマンド description: |- @@ -555,7 +564,9 @@ challenges: description: |- リピート数を変更できます 挑戦のために。 + 0 に設定すると無制限に繰り返せます。 value: 現在の値:[number] + value-infinite: "現在の値:[number](無制限)" cool_down: name: クールダウン description: |- @@ -889,6 +900,48 @@ challenges: description: "チャレンジライブラリを\n言語でフィルタリングします。" current: '表示中: [lang]' all: すべて + toggle-undeployed: + name: "未デプロイのチャレンジ" + shown: |- + 未デプロイのチャレンジは + 現在表示されています。 + hidden: |- + 未デプロイのチャレンジは + 現在非表示です。 + required_biomes: + name: "必要なバイオーム" + description: |- + この島チャレンジを完了するには、 + プレイヤーがこれらのバイオームの + いずれかにいる必要があります。 + title: "バイオーム:" + list: " - [biome]" + none: "必要なバイオームはありません(任意のバイオーム)。" + reward_island_level: + name: "報酬の島レベル" + description: |- + 報酬の島レベルを変更できます。 + Levelアドオンが必要です。 + value: "現在の値:[number]" + repeat_reward_island_level: + name: "リピート報酬の島レベル" + description: |- + チャレンジのリピート報酬の + 島レベルを変更できます。 + Levelアドオンが必要です。 + value: "現在の値:[number]" + open_anywhere: + name: "どこでもGUIを開く" + description: |- + プレイヤーがどこからでもチャレンジGUIを + 開けるようにします。保護されている場合、 + 完了には島にいる必要があります。 + enabled: "有効" + disabled: "無効" + biome: + name: "[biome]" + description: "バイオームID:[id]" + selected: "選択" tips: click-to-select: クリックして選択します。 click-to-choose: クリックして選択します。 @@ -985,6 +1038,8 @@ challenges: search-radius: '[number]メートル以下' warning-block: ブロックが削除されます warning-entity: エンティティは削除されます + biomes-title: "必要なバイオーム:" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1077,7 +1132,7 @@ challenges: input-number: チャットに番号を入力してください。 input-seconds: チャットに数秒を入力してください。 numeric-only: '与えられた[value]は数字ではありません!' - not-valid-value: '指定された番号[value]は無効です。 [min]よりも大きく、[max]未満でなければなりません!' + not-valid-value: "指定された数値 [value] は無効です。[min] から [max] までの範囲(両端を含む)で入力してください。" user-data-removed: '[gamemode]のすべてのユーザーデータは、データベースからクリアされています。' confirm-user-data-deletion: '[gamemode]のユーザーデータベースをクリアすることを確認してください。' challenge-data-removed: '[gamemode]のすべてのチャレンジデータは、データベースからクリアされています。' @@ -1117,6 +1172,8 @@ challenges: enter-formula: |- Papiプレースホルダーとシンボルを使用する式を入力します=、<>、<+、> =、==、!=、および、またはのみ。 例:%my_lifetime_count%> = 1000および%Island_Level%> = 100 + confirm-instruction: "続行するにはチャットに 'confirm' と入力し、中止するには 'cancel' と入力してください。" + input-repeat-count: "チャットに繰り返し回数を入力してください。0 を入力すると無制限になります。" titles: challenge-title: 正常に完了しました challenge-subtitle: '[friendlyName]' @@ -1135,6 +1192,7 @@ challenges: you-completed-challenge: &2 [value]チャレンジを完了しました! you-repeated-challenge: &2 [value]チャレンジを繰り返しました! you-repeated-challenge-multiple: &2 [value]チャレンジ [count]回繰り返しました! + no-reward-this-time: "運が味方しませんでした — 今回は報酬なしです!" you-completed-level: &2 [value]レベルを完了しました! name-has-completed-challenge: &5 [name]は[value]&r&5チャレンジ を完了しました! name-has-completed-level: &5 [name]は[value]&r&5レベルを完了しました! @@ -1183,6 +1241,7 @@ challenges: no-team: これはチームチャレンジです。完了するにはチームが必要です! insufficient-team: このチームチャレンジは[number]人のチームメンバーがオンラインである必要があります。[online]人だけがオンラインです。 member-missing-share: [name]はこのチームチャレンジのために自分のシェア([amount] x [item])を持っていません。 + wrong-biome: "このチャレンジを完了するには [biome] バイオームにいる必要があります!" protection: flags: CHALLENGES_ISLAND_PROTECTION: diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml index a5b015fa..2b7930be 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -67,6 +67,7 @@ challenges: manage-statistics: Pārvaldīt Statistiku manage-advancements: Pārvaldīt Sasniegumus advancement-selector: Sasniegumu Atlasītājs + biome-selector: "Biomu Izvēlne" buttons: # Button in the Challenges GUI that allows to select free challenges. free-challenges: @@ -485,6 +486,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 (1-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: |- @@ -534,7 +543,9 @@ challenges: Ļauj uzstādīt cik daudz reižu uzdevumu varēs izpildīt. + Iestati 0, lai atkārtojumi būtu neierobežoti. value: "Vērtība: [number]" + value-infinite: "Vērtība: [number] (neierobežoti)" cool_down: name: "Taimouts" description: |- @@ -911,6 +922,48 @@ challenges: name: [name] description: '[description]' selected: Atlasīts + toggle-undeployed: + name: "Neaktivizētie uzdevumi" + shown: |- + Neaktivizētie uzdevumi pašlaik + ir redzami. + hidden: |- + Neaktivizētie uzdevumi pašlaik + ir paslēpti. + required_biomes: + name: "Nepieciešamie biomi" + description: |- + Spēlētājam jāstāv vienā + no šiem biomiem, lai izpildītu + šo salas uzdevumu. + title: "Biomi: " + list: " - [biome]" + none: "Biomi nav nepieciešami (jebkurš bioms)." + reward_island_level: + name: "Balvas salas līmenis" + description: |- + Ļauj mainīt balvas salas līmeni. + Nepieciešams Level papildinājums. + value: "Vērtība: [number]" + repeat_reward_island_level: + name: "Atkārtojuma balvas salas līmenis" + description: |- + Ļauj mainīt salas līmeņa balvu + par uzdevuma atkārtošanu. + Nepieciešams Level papildinājums. + value: "Vērtība: [number]" + open_anywhere: + name: "Atvērt GUI jebkur" + description: |- + Ļauj spēlētājiem atvērt uzdevumu GUI + no jebkuras vietas. Izpildei joprojām + jāatrodas uz salas, ja tā ir aizsargāta. + enabled: "Ieslēgts" + disabled: "Izslēgts" + biome: + name: "[biome]" + description: "Bioma ID: [id]" + selected: "Iezīmēts" tips: click-to-select: "Klikšķini, lai atlasītu." click-to-choose: "Klikšķini, lai izvēlētos." @@ -1080,6 +1133,8 @@ challenges: # Waning about block/entity removing warning-block: "Bloks(-i) tiks dzēsti" warning-entity: "Radība(-as) tiks dzēstas" + biomes-title: "Nepieciešamie biomi:" + biome-value: " - [biome]" # Message that will generate for inventory type requirements and replace [type-requirements] inventory: lore: |- @@ -1223,7 +1278,7 @@ challenges: # Error message that is showed if user input a value that is not a number. numeric-only: "Šis `[value]` nav skaitlis!" # Error message that is showed if user input a number that is smaller or larger that allowed. - not-valid-value: "Ierakstītais skaitlis [value] nav derīgs. Skaitlis nedrīkst būt mazāks par [min] un lielāks par [max]!" + not-valid-value: "Ierakstītais skaitlis [value] nav derīgs. Tam jābūt no [min] līdz [max] (ieskaitot)." # Message that confirms user data removing. user-data-removed: "Visi lietotāju dati priekš [gamemode] ir dzēsti." # Message that asks confirmation for user data removing. @@ -1298,6 +1353,8 @@ challenges: search-updated: "Meklēšanas vērtība atjaunota." enter-formula: "Ievadiet formulu, kas izmanto tikai PAPI vietturus un simbolus =,<>,<+,>=, ==, !=, AND, OR.\nPiemērs: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Ieraksti čatā 'confirm', lai turpinātu, vai 'cancel', lai atceltu." + input-repeat-count: "Lūdzu ieraksti čatā atkārtojumu skaitu. Ieraksti 0, lai atkārtojumi būtu neierobežoti." titles: # Title and subtitle may contain variables in [] that will be replaced with a proper message from the challenge object. # [friendlyName] will be replaced with challenge friendly name. @@ -1324,6 +1381,7 @@ challenges: you-completed-challenge: 'Tu izpildīji [value] uzdevumu!' you-repeated-challenge: 'Tu atkārtoti izpildīji [value] uzdevumu!' you-repeated-challenge-multiple: 'Tu atkārtoji [value] uzdevumu [count] reizes!' + no-reward-this-time: "Veiksme nebija tavā pusē — šoreiz bez balvas!" you-completed-level: 'Tu pabeidzi [value] līmeni!' name-has-completed-challenge: '[name] pabeidza [value] uzdevumu!' name-has-completed-level: '[name] pabeidza [value] līmeni!' @@ -1362,6 +1420,7 @@ challenges: no-library-entries: "Nevar atrast bibliotēkas ierakstus. Nav ko rādīt." not-hooked: "Uzdevumu Papildinājumam neizdevās atrast Spēles Režīmu." timeout: "Šim uzdevumam ir uzstādīts [timeout] taimauts starp izpildēm. Tev vēl ir jāgaida [wait-time], lai varētu pildīt uzdevumu." + wrong-biome: "Lai izpildītu šo uzdevumu, tev jāstāv [biome] biomā!" # # Showcase for manual material translation # materials: # # Names should be lowercase. diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index 9c5ecd49..1eaf25ef 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -57,6 +57,7 @@ challenges: manage-statistics: Zarządzaj Statystykami manage-advancements: Zarządzaj Osiągnięciami advancement-selector: Selektor Osiągnięć + biome-selector: "Selektor biomów" buttons: free-challenges: name: "Wyzwania bez kategorii" @@ -437,6 +438,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 (1-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" @@ -459,7 +468,9 @@ challenges: Pozwala na zmianę liczby powtórzeń dla zadania. + Ustaw 0 dla nieograniczonych powtórzeń. value: "Aktualna wartość: [number]" + value-infinite: "Aktualna wartość: [number] (nieograniczone)" cool_down: name: "Opóźnienie" description: |- @@ -833,6 +844,48 @@ challenges: name: [name] description: '[description]' selected: Wybrane + toggle-undeployed: + name: "Niewdrożone zadania" + shown: |- + Niewdrożone zadania są + obecnie widoczne. + hidden: |- + Niewdrożone zadania są + obecnie ukryte. + required_biomes: + name: "Wymagane biomy" + description: |- + Gracz musi stać w jednym + z tych biomów, aby ukończyć to + zadanie wyspy. + title: "Biomy: " + list: " - [biome]" + none: "Żadne biomy nie są wymagane (dowolny biom)." + reward_island_level: + name: "Poziom wyspy jako nagroda" + description: |- + Pozwala na zmianę nagrody poziomu wyspy. + Wymaga dodatku Level. + value: "Aktualna wartość: [number]" + repeat_reward_island_level: + name: "Poziom wyspy za powtórzenie" + description: |- + Pozwala na zmianę nagrody poziomu + wyspy za powtórzenie zadania. + Wymaga dodatku Level. + value: "Aktualna wartość: [number]" + open_anywhere: + name: "Otwieraj GUI wszędzie" + description: |- + Pozwala graczom otwierać GUI zadań + z dowolnego miejsca. Ukończenie nadal wymaga + przebywania na wyspie, gdy jest chroniona. + enabled: "Włączone" + disabled: "Wyłączone" + biome: + name: "[biome]" + description: "ID biomu: [id]" + selected: "wybrane" tips: click-to-select: "Kliknij , aby wybrać." click-to-choose: "Kliknij , aby wybrać." @@ -929,6 +982,8 @@ challenges: search-radius: "Nie dalej niż [number] metrów" warning-block: "Bloki zostaną usunięte" warning-entity: "Jednostki zostaną usunięte" + biomes-title: "Wymagane biomy:" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1022,7 +1077,7 @@ challenges: input-number: "Proszę podać numer na czacie." input-seconds: "Wprowadź sekundy na czacie." numeric-only: "Podana [value] nie jest liczbą!" - not-valid-value: "Podana liczba [value] jest nieprawidłowa. Musi być większy niż [min] i mniejszy niż [max]!" + not-valid-value: "Podana liczba [value] jest nieprawidłowa. Musi być w zakresie od [min] do [max] włącznie." user-data-removed: "Wszystkie dane użytkownika dla trybu [gamemode] są usuwane z bazy danych." confirm-user-data-deletion: "Potwierdź, że chcesz wyczyścić bazę danych użytkowników dla [gamemode]." challenge-data-removed: "Wszystkie dane wyzwań dla trybu [gamemode] zostaną usunięte z bazy danych." @@ -1060,6 +1115,8 @@ challenges: write-search: "Proszę wpisać wartość wyszukiwania. (napisz „anuluj”, aby wyjść)" search-updated: "Zaktualizowano wartość wyszukiwania." enter-formula: "Wpisz formułę, która używa symboli zastępczych PAPI i symboli =,<>,<+,>=, ==, !=, AND, OR tylko.\nPrzykład: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Wpisz 'confirm' na czacie, aby kontynuować, lub 'cancel', aby anulować." + input-repeat-count: "Proszę podać liczbę powtórzeń na czacie. Wpisz 0 dla nieograniczonych powtórzeń." titles: challenge-title: Zakończone sukcesem challenge-subtitle: "[friendlyName]" @@ -1078,6 +1135,7 @@ challenges: you-completed-challenge: "Ukończono [value] wyzwanie!" you-repeated-challenge: "Powtórzyłeś [value] wyzwanie!" you-repeated-challenge-multiple: " Powtórzyłeś [value] challenge [count] razy!" + no-reward-this-time: "Szczęście ci nie dopisało — tym razem bez nagrody!" you-completed-level: "Ukończono [value] poziom!" name-has-completed-challenge: " [name] zakończyła [value] wyzwanie!" name-has-completed-level: " [name] uzupełniła [value] poziom!" @@ -1124,6 +1182,7 @@ challenges: no-team: To jest wyzwanie drużyny. Musisz mieć drużynę aby je ukończyć! insufficient-team: To wyzwanie drużyny wymaga [number] członków drużyny online. Tylko [online] są online. member-missing-share: [name] nie ma swojego udziału ([amount] x [item]) dla tego wyzwania drużyny. + wrong-biome: "Musisz stać w biomie [biome], aby ukończyć to zadanie!" materials: any: 'Dowolny ' all_hanging_signs: diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index 5ddca18b..660dbe6e 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -59,6 +59,7 @@ challenges: manage-statistics: Gerenciar Estatísticas manage-advancements: Gerenciar Avanços advancement-selector: Seletor de Avanços + biome-selector: "Seletor de Bioma" buttons: free-challenges: name: "Desafios Gratuitos" @@ -469,6 +470,14 @@ challenges: repetir dinheiro de recompensa para o desafio. value: "Valor atual: [number]" + reward_chance: + name: "Chance de Recompensa" + description: |- + Chance percentual (1-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: |- @@ -520,7 +529,9 @@ challenges: Permite alterar o número de repetições para o desafio. + Defina 0 para repetições ilimitadas. value: "Valor atual: [number]" + value-infinite: "Valor atual: [number] (ilimitado)" cool_down: name: "Resfriamento" description: |- @@ -905,6 +916,48 @@ challenges: name: [name] description: '[description]' selected: Selecionado + toggle-undeployed: + name: "Desafios não implantados" + shown: |- + Os desafios não implantados estão + atualmente visíveis. + hidden: |- + Os desafios não implantados estão + atualmente ocultos. + required_biomes: + name: "Biomas necessários" + description: |- + O jogador deve estar em um + destes biomas para completar este + desafio de ilha. + title: "Biomas: " + list: " - [biome]" + none: "Nenhum bioma necessário (qualquer bioma)." + reward_island_level: + name: "Nível da ilha de recompensa" + description: |- + Permite alterar o nível da ilha de recompensa. + Requer o addon Level. + value: "Valor atual: [number]" + repeat_reward_island_level: + name: "Nível da ilha por repetição" + description: |- + Permite alterar a recompensa de nível + da ilha por repetir o desafio. + Requer o addon Level. + value: "Valor atual: [number]" + open_anywhere: + name: "Abrir GUI em qualquer lugar" + description: |- + Permite que os jogadores abram o GUI de desafios + de qualquer lugar. Completar ainda requer + estar na ilha quando protegida. + enabled: "Ativado" + disabled: "Desativado" + biome: + name: "[biome]" + description: "ID do bioma: [id]" + selected: "Selecionado" tips: click-to-select: "Clique em para selecionar." click-to-choose: "Clique em para escolher." @@ -1001,6 +1054,8 @@ challenges: search-radius: "Não além de [number] metros" warning-block: "Os blocos serão removidos" warning-entity: "As entidades serão removidas" + biomes-title: "Bioma(s) necessário(s):" + biome-value: " - [biome]" inventory: lore: |- [Items] @@ -1094,7 +1149,7 @@ challenges: input-number: "Digite um número no chat." input-seconds: "Por favor, digite um segundo no chat." numeric-only: "O [value] dado não é um número!" - not-valid-value: "O número dado [valor] não é válido. Deve ser maior que [min] e menor que [max]!" + not-valid-value: "O número dado [value] não é válido. Deve estar entre [min] e [max], inclusive." user-data-removed: "Todos os dados do usuário para [gamemode] são apagados do banco de dados." confirm-user-data-deletion: "Confirme que deseja limpar o banco de dados do usuário para [gamemode]." challenge-data-removed: "Todos os dados de desafios para [gamemode] são apagados do banco de dados." @@ -1132,6 +1187,8 @@ challenges: write-search: "Por favor, escreva um valor de pesquisa. (escreva 'cancel' para sair)" search-updated: "Valor de pesquisa atualizado." enter-formula: "Digite uma fórmula que usa placeholders PAPI e símbolos =,<>,<+,>=, ==, !=, AND, OR apenas.\nExemplo: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Digite 'confirm' no chat para continuar, ou 'cancel' para cancelar." + input-repeat-count: "Digite o número de repetições no chat. Digite 0 para repetições ilimitadas." titles: challenge-title: Completado com sucesso challenge-subtitle: "[friendlyName]" @@ -1152,6 +1209,7 @@ challenges: you-repeated-challenge-multiple: |- Você repetiu o [value] desafio [count] vezes! + no-reward-this-time: "A sorte não estava do seu lado — sem recompensa desta vez!" you-completed-level: "Você completou o nível [valor] !" name-has-completed-challenge: "[name] completou [value] o desafio!" name-has-completed-level: "[name] completou o nível [value]!" @@ -1198,6 +1256,7 @@ challenges: no-team: Este é um desafio em equipe. Você deve ter uma equipe para completá-lo! insufficient-team: Este desafio em equipe precisa de [number] membros da equipe online. Apenas [online] estão online. member-missing-share: [name] não tem sua parte ([amount] x [item]) para este desafio em equipe. + wrong-biome: "Você precisa estar no bioma [biome] para completar este desafio!" materials: any: 'Qualquer ' all_hanging_signs: diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index bec99211..12d6ca03 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: |- + Шанс в процентах (1-100), что за выполнение + будут выданы материальные награды. + Предметы, деньги и опыт используют один бросок. + Команды выполняются всегда. + value: 'Шанс: [number]%' reward_commands: name: Команды награды description: "Задает команды награды.\nПодсказка:\nКоманда не требует начального `/` так как она\nприменяется автоматически.\nПо умолчанию команды выполняются сервером.\nОднако добавление `[SELF]` в начало приведет к\nвыполнению команды игроком.\nОна также поддерживает заполнитель `[player]` который будет\nзаменен на имя игрока который завершил испытание." @@ -505,8 +513,12 @@ challenges: disabled: Отключено repeat_count: name: Количество повторений - description: "Позволяет изменить количество повторений\nдля испытания." + description: |- + Позволяет изменить количество повторений + для испытания. + Установите 0 для неограниченных повторений. value: 'Текущее значение: [number]' + value-infinite: "Текущее значение: [number] (неограниченно)" cool_down: name: Перезагрузка description: "Позволяет изменить период перезагрузки\n(в секундах) который должен пройти\nмежду завершениями повторяемого испытания." @@ -720,6 +732,48 @@ challenges: name: Поиск description: "Позволяет искать элемент\nиспользуя значение ввода текста." search: 'Значение: [value]' + toggle-undeployed: + name: "Неактивированные испытания" + shown: |- + Неактивированные испытания сейчас + показаны. + hidden: |- + Неактивированные испытания сейчас + скрыты. + required_biomes: + name: "Требуемые биомы" + description: |- + Игрок должен находиться в одном + из этих биомов, чтобы выполнить это + островное испытание. + title: "Биомы: " + list: " - [biome]" + none: "Биомы не требуются (любой биом)." + reward_island_level: + name: "Уровень острова в награду" + description: |- + Позволяет изменить награду уровнем острова. + Требуется аддон Level. + value: "Текущее значение: [number]" + repeat_reward_island_level: + name: "Уровень острова за повторение" + description: |- + Позволяет изменить награду уровнем + острова за повторение испытания. + Требуется аддон Level. + value: "Текущее значение: [number]" + open_anywhere: + name: "Открывать GUI где угодно" + description: |- + Позволяет игрокам открывать GUI испытаний + откуда угодно. Для выполнения по-прежнему нужно + находиться на острове, если включена защита. + enabled: "Включено" + disabled: "Отключено" + biome: + name: "[biome]" + description: "ID биома: [id]" + selected: "Выбрано" descriptions: admin: input: Открыть текстовое поле ввода. @@ -1005,6 +1059,8 @@ challenges: search-radius: Не дальше [number] метров warning-block: Блоки будут удалены warning-entity: Сущности будут удалены + biomes-title: "Требуемые биомы:" + biome-value: " - [biome]" inventory: lore: "[items]\n[warning]" item-title: 'Требуемые предметы:' @@ -1136,6 +1192,7 @@ challenges: challenge-selector: Выбор испытания statistic-selector: Выбор статистики environment-selector: Выбор измерения + biome-selector: "Выбор биома" tips: click-to-select: Нажмите чтобы выбрать. click-to-choose: Нажмите чтобы выбрать. @@ -1205,6 +1262,7 @@ challenges: you-completed-challenge: "Вы завершили [value] челлендж!" you-repeated-challenge: "Ты перепрошел [value] челлендж!" you-repeated-challenge-multiple: "Вы перепрошли [value] челлендж [count] раз!" + no-reward-this-time: "Удача не на вашей стороне — в этот раз без награды!" you-completed-level: "Вы завершили [value] уровень!" name-has-completed-challenge: "[name] завершил [value] челлендж!" name-has-completed-level: "[name] завершил [value] уровень!" @@ -1273,6 +1331,7 @@ challenges: no-team: Это командное испытание. Вам нужно иметь команду чтобы завершить его! insufficient-team: Это командное испытание требует [number] членов команды онлайн. Только [online] онлайн. member-missing-share: [name] не имеет их доли ([amount] x [item]) для этого командного испытания. + wrong-biome: "Вы должны находиться в биоме [biome], чтобы выполнить это испытание!" materials: any: 'Любой ' all_hanging_signs: @@ -1289,7 +1348,7 @@ challenges: input-number: Пожалуйста введите число в чат. input-seconds: Пожалуйста введите количество секунд в чат. numeric-only: Введенное [value] не является числом! - not-valid-value: Введенное число [value] не действительно. Оно должно быть больше [min] и меньше [max]! + not-valid-value: "Введённое число [value] недопустимо. Оно должно быть от [min] до [max] включительно." user-data-removed: Все данные пользователя для [gamemode] были удалены из базы данных. confirm-user-data-deletion: Пожалуйста подтвердите что вы хотите очистить базу данных пользователя для [gamemode]. challenge-data-removed: Все данные испытаний для [gamemode] были удалены из базы данных. @@ -1327,6 +1386,8 @@ challenges: write-search: Пожалуйста введите значение поиска. (Введите 'cancel' чтобы выйти) search-updated: Значение поиска обновлено. enter-formula: "Введите формулу которая использует только заполнители PAPI и символы =,<>,<+,>=, ==, !=, AND, OR.\nПример: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Введите 'confirm' в чат для продолжения или 'cancel' для отмены." + input-repeat-count: "Пожалуйста, введите количество повторений в чат. Введите 0 для неограниченных повторений." protection: flags: CHALLENGES_ISLAND_PROTECTION: diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index 379f33b3..7f9ce936 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -57,6 +57,7 @@ challenges: manage-statistics: Керування статистикою manage-advancements: Керування досягненнями advancement-selector: Селектор досягнень + biome-selector: "Вибір біому" buttons: free-challenges: name: "Безкоштовні виклики" @@ -467,6 +468,14 @@ challenges: повторити грошову винагороду за виклик. value: "Поточне значення: [number]" + reward_chance: + name: "Шанс винагороди" + description: |- + Шанс у відсотках (1-100), що за виконання + будуть видані матеріальні винагороди. + Предмети, гроші та досвід використовують один кидок. + Команди виконуються завжди. + value: "Шанс: [number]%" reward_commands: name: "Команди винагороди" description: |- @@ -518,7 +527,9 @@ challenges: Дозволяє змінювати кількість повторень за виклик. + Встановіть 0 для необмежених повторень. value: "Поточне значення: [number]" + value-infinite: "Поточне значення: [number] (необмежено)" cool_down: name: "Охолодження" description: |- @@ -907,6 +918,48 @@ challenges: name: [name] description: '[description]' selected: Вибрано + toggle-undeployed: + name: "Неактивовані випробування" + shown: |- + Неактивовані випробування зараз + показані. + hidden: |- + Неактивовані випробування зараз + приховані. + required_biomes: + name: "Потрібні біоми" + description: |- + Гравець має перебувати в одному + з цих біомів, щоб виконати це + острівне випробування. + title: "Біоми: " + list: " - [biome]" + none: "Біоми не потрібні (будь-який біом)." + reward_island_level: + name: "Рівень острова в нагороду" + description: |- + Дозволяє змінити нагороду рівнем острова. + Потрібен аддон Level. + value: "Поточне значення: [number]" + repeat_reward_island_level: + name: "Рівень острова за повторення" + description: |- + Дозволяє змінити нагороду рівнем + острова за повторення випробування. + Потрібен аддон Level. + value: "Поточне значення: [number]" + open_anywhere: + name: "Відкривати GUI будь-де" + description: |- + Дозволяє гравцям відкривати GUI випробувань + звідусіль. Для виконання все одно потрібно + перебувати на острові, якщо він захищений. + enabled: "Увімкнено" + disabled: "Вимкнено" + biome: + name: "[biome]" + description: "ID біому: [id]" + selected: "Вибрано" tips: click-to-select: "Натисніть , щоб вибрати." click-to-choose: "Натисніть , щоб вибрати." @@ -1003,6 +1056,8 @@ challenges: search-radius: "Не далі [number] метрів" warning-block: "Блоки будуть видалені" warning-entity: "Сутності будуть видалені" + biomes-title: "Потрібні біоми:" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -1096,7 +1151,7 @@ challenges: input-number: "Будь ласка, введіть номер у чаті." input-seconds: "Будь ласка, введіть секунди в чаті." numeric-only: "Дане [value] не є числом!" - not-valid-value: "Дане число [value] недійсне. Він має бути більшим за [min] і меншим за [max]!" + not-valid-value: "Введене число [value] неприпустиме. Воно має бути від [min] до [max] включно." user-data-removed: "Усі дані користувача для [gamemode] видалено з бази даних." confirm-user-data-deletion: "Підтвердьте, що ви бажаєте очистити базу даних користувачів для [gamemode]." challenge-data-removed: "Усі дані викликів для [gamemode] видалено з бази даних." @@ -1134,6 +1189,8 @@ challenges: write-search: "Введіть пошукове значення. (напишіть 'cancel', щоб вийти)" search-updated: "Значення пошуку оновлено." enter-formula: "Введіть формулу, яка використовує тільки заповнювачі PAPI та символи =,<>,<+,>=, ==, !=, AND, OR.\nПриклад: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "Введіть 'confirm' у чат, щоб продовжити, або 'cancel', щоб скасувати." + input-repeat-count: "Будь ласка, введіть кількість повторень у чат. Введіть 0 для необмежених повторень." titles: challenge-title: Успішно завершено challenge-subtitle: "[friendlyName]" @@ -1152,6 +1209,7 @@ challenges: you-completed-challenge: "Ви завершили завдання [value] !" you-repeated-challenge: "Ви повторили завдання [value] !" you-repeated-challenge-multiple: "Ви повторили завдання [value] [count] разів!" + no-reward-this-time: "Удача не на вашому боці — цього разу без нагороди!" you-completed-level: "Ви завершили рівень [value] !" name-has-completed-challenge: "[name] виконав завдання [value] !" name-has-completed-level: "[name] завершив рівень [value] !" @@ -1198,6 +1256,7 @@ challenges: no-team: Це командний виклик. У вас повинна бути команда, щоб його завершити! insufficient-team: Цей командний виклик потребує [number] членів команди в мережі. Лише [online] в мережі. member-missing-share: [name] не має свою частину ([amount] x [item]) для цього командного виклику. + wrong-biome: "Ви повинні перебувати в біомі [biome], щоб виконати це випробування!" materials: any: 'Будь-який ' all_hanging_signs: diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index b3419743..1b46255d 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -57,6 +57,7 @@ challenges: manage-statistics: 管理统计 manage-advancements: 管理进度 advancement-selector: 进度选择器 + biome-selector: "选择生物群系" buttons: free-challenges: name: "自由挑战" @@ -370,6 +371,14 @@ challenges: name: "重复奖励金钱" description: "允许更改重复挑战的金钱奖励" value: "当前值:[number]" + reward_chance: + name: "奖励几率" + description: |- + 完成挑战时发放物质奖励的 + 几率百分比(1-100)。 + 物品、金钱和经验共用一次判定。 + 命令始终会执行。 + value: "几率: [number]%" reward_commands: name: "奖励命令" description: |- @@ -395,8 +404,11 @@ challenges: disabled: "已禁用" repeat_count: name: "重复次数" - description: "允许更改挑战的重复次数" + description: |- + 允许更改挑战的重复次数 + 设为 0 表示无限次重复。 value: "当前值:[number]" + value-infinite: "当前值:[number] (无限)" cool_down: name: "冷却" description: "允许更改重复挑战的冷却时间" @@ -698,6 +710,48 @@ challenges: name: [name] description: '[description]' selected: 已选中 + toggle-undeployed: + name: "未部署的挑战" + shown: |- + 未部署的挑战当前 + 显示。 + hidden: |- + 未部署的挑战当前 + 隐藏。 + required_biomes: + name: "所需生物群系" + description: |- + 玩家必须站在这些生物群系 + 之一中才能完成此 + 岛屿挑战。 + title: "生物群系:" + list: " - [biome]" + none: "无需特定生物群系(任意生物群系)。" + reward_island_level: + name: "奖励岛屿等级" + description: |- + 允许更改奖励的岛屿等级。 + 需要 Level 插件。 + value: "当前值:[number]" + repeat_reward_island_level: + name: "重复奖励岛屿等级" + description: |- + 允许更改挑战重复完成时 + 奖励的岛屿等级。 + 需要 Level 插件。 + value: "当前值:[number]" + open_anywhere: + name: "随处打开 GUI" + description: |- + 允许玩家在任何地方打开挑战 GUI。 + 受保护时,完成挑战仍需 + 身处岛屿上。 + enabled: "已启用" + disabled: "已禁用" + biome: + name: "[biome]" + description: "生物群系 ID:[id]" + selected: "已选择" tips: click-to-select: "单击 进行选择。" click-to-choose: "单击 进行选择。" @@ -794,6 +848,8 @@ challenges: search-radius: "必须在在[number] 米范围内" warning-block: "所需方块将在完成挑战时移除" warning-entity: "所需实体将在完成挑战时移除" + biomes-title: "所需生物群系:" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -887,7 +943,7 @@ challenges: input-number: "请在聊天中输入一个数字。" input-seconds: "请在聊天中输入秒数。" numeric-only: "你输入的 [value] 不是数字!" - not-valid-value: "你输入的的数字 [value] 无效。它必须大于 [min] 且小于 [max]!" + not-valid-value: "输入的数字 [value] 无效。必须在 [min] 到 [max] 之间(含边界值)。" user-data-removed: "已清除 [gamemode] 的所有玩家数据。" confirm-user-data-deletion: "请确认您要清除 [gamemode] 的玩家数据。" challenge-data-removed: "已清除 [gamemode] 的所有挑战数据。" @@ -925,6 +981,8 @@ challenges: write-search: "请写一个搜索值。(输入 'cancel' 取消)" search-updated: "搜索值已更新。" enter-formula: "输入仅使用PAPI占位符和符号=,<>,<+,>=, ==, !=, AND, OR的公式。\n示例: %my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "在聊天中输入'confirm'以继续,或输入'cancel'以取消。" + input-repeat-count: "请在聊天中输入重复次数。输入 0 表示无限次。" titles: challenge-title: "已完成" challenge-subtitle: "[friendlyName]" @@ -943,6 +1001,7 @@ challenges: you-completed-challenge: "你完成了挑战 [value] !" you-repeated-challenge: "你再次完成了挑战 [value] !" you-repeated-challenge-multiple: "你完成挑战 [value] [count] 次了!" + no-reward-this-time: "运气不佳——这次没有奖励!" you-completed-level: "恭喜,你已完成挑战等级 [value]!" name-has-completed-challenge: "恭喜! [name] 完成了挑战 [value] !" name-has-completed-level: "恭喜! [name] 已完成挑战等级 [value]!" @@ -987,6 +1046,7 @@ challenges: no-team: 这是一个团队挑战。您必须拥有一个团队才能完成它! insufficient-team: 此团队挑战需要[number]名团队成员在线。只有[online]名在线。 member-missing-share: [name]对于此团队挑战没有他们的份额([amount] x [item])。 + wrong-biome: "你必须站在 [biome] 生物群系中才能完成此挑战!" materials: any: '任意 ' all_hanging_signs: diff --git a/src/main/resources/locales/zh-HK.yml b/src/main/resources/locales/zh-HK.yml index 337e1c77..92900094 100644 --- a/src/main/resources/locales/zh-HK.yml +++ b/src/main/resources/locales/zh-HK.yml @@ -69,6 +69,7 @@ challenges: manage-statistics: 管理統計資料 manage-advancements: 管理進度 advancement-selector: 進度選擇器 + biome-selector: "選擇生態域" buttons: free-challenges: name: '自由挑戰' @@ -386,6 +387,14 @@ challenges: name: "獎勵遊戲幣" description: '修改重覆挑戰的獎勵遊戲幣' value: "目前數量: [number]" + reward_chance: + name: "獎勵機率" + description: |- + 完成挑戰時發放物質獎勵的 + 機率百分比(1-100)。 + 物品、金錢和經驗共用一次判定。 + 指令必定會執行。 + value: "機率: [number]%" reward_commands: name: '獎勵指令' description: |- @@ -428,7 +437,9 @@ challenges: description: |- 修改 挑戰可重覆完成 的最大次數 + 設為 0 代表無限次重複。 value: '目前數: [number]次' + value-infinite: "目前數: [number]次 (無限)" cool_down: name: '冷卻時間' description: |- @@ -767,6 +778,48 @@ challenges: name: [id] description: '' selected: 已選擇 + toggle-undeployed: + name: "未部署的挑戰" + shown: |- + 未部署的挑戰目前 + 顯示。 + hidden: |- + 未部署的挑戰目前 + 隱藏。 + required_biomes: + name: "所需生態域" + description: |- + 玩家必須站在這些生態域 + 之一中才能完成此 + 島嶼挑戰。 + title: "生態域: " + list: " - [biome]" + none: "無需特定生態域(任何生態域)。" + reward_island_level: + name: "獎勵島嶼等級" + description: |- + 允許更改獎勵的島嶼等級。 + 需要 Level 插件。 + value: "目前數: [number]次" + repeat_reward_island_level: + name: "重複獎勵島嶼等級" + description: |- + 允許更改挑戰重複完成時 + 獎勵的島嶼等級。 + 需要 Level 插件。 + value: "目前數: [number]次" + open_anywhere: + name: "隨處開啟 GUI" + description: |- + 允許玩家在任何地方開啟挑戰 GUI。 + 受保護時,完成挑戰仍需 + 身處島嶼上。 + enabled: "已啟用" + disabled: "已禁用" + biome: + name: "[biome]" + description: "生態域ID: [id]" + selected: "已選" tips: click-to-select: "點擊選擇" click-to-choose: "點擊選擇" @@ -863,6 +916,8 @@ challenges: search-radius: '不可超過[number]米' warning-block: '方塊會被收取不退還' warning-entity: '實體會被收取不退還' + biomes-title: "所需生態域:" + biome-value: " - [biome]" inventory: lore: |- [items] @@ -956,7 +1011,7 @@ challenges: input-number: '請在聊天中輸入 一個數字' input-seconds: '請在聊天中輸入 一個秒數' numeric-only: '輸入的[value]不是 有效數字!' - not-valid-value: '輸入的[value]無效. 數字必須於[min]及[max]之間!' + not-valid-value: "輸入的數字 [value] 無效。必須在 [min] 至 [max] 之間(包含邊界值)。" user-data-removed: '空島模式[gamemode]中的所有玩家數據已被清除' confirm-user-data-deletion: |- 請再次 確認 要把空島模式[gamemode]中的 @@ -1000,6 +1055,8 @@ challenges: write-search: '請輸入搜索字條 (輸入''cancel''退出)' search-updated: '搜索完成' enter-formula: "輸入僅使用 PAPI 占位符和符號 =,<>,<+,>=, ==, !=, AND, OR 的公式。\n示例:%my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "在聊天中輸入'confirm'以繼續,或輸入'cancel'以取消。" + input-repeat-count: "請在聊天中輸入重複次數。輸入 0 代表無限次。" titles: # Title and subtitle may contain variables in [] that will be replaced with a proper message from the challenge object. # [friendlyName] will be replaced with challenge friendly name. @@ -1025,6 +1082,7 @@ challenges: you-completed-challenge: '你已完成了 [value]挑戰!' you-repeated-challenge: '你已重覆完成了 [value]挑戰!' you-repeated-challenge-multiple: '你已重覆完成了 [value]挑戰 [count]次!' + no-reward-this-time: "運氣欠佳——這次沒有獎勵!" you-completed-level: '你已完成了 [value]等級!' name-has-completed-challenge: '[name]已完成了 [value]挑戰!' name-has-completed-level: '[name]已完成了 [value]等級!' @@ -1065,6 +1123,7 @@ challenges: not-hooked: '挑戰附加插件沒有發現任何遊戲模式。' timeout: '每次完成這挑戰要求最少等待[timeout]秒。 你必須等待[wait-time]秒才可以再次完成。' requirement-not-met: '這挑戰要求[statistic]達到[number]。 你只有[value]。' + wrong-biome: "你必須站在 [biome] 生態域中才能完成此挑戰!" # # Showcase for manual material translation # materials: # # Names should be lowercase. diff --git a/src/main/resources/locales/zh-TW.yml b/src/main/resources/locales/zh-TW.yml index 7091b91b..0795560a 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: |- + 完成挑戰時發放物質獎勵的 + 機率百分比(1-100)。 + 物品、金錢和經驗共用一次判定。 + 指令一定會執行。 + value: 機率:[number]% reward_commands: name: 獎勵指令 description: "指定獎勵指令。\n提示:\n指令不需要前導 `/` 因為它會\n自動套用。\n預設情況下,指令由伺服器執行。\n但是,在開頭新增 `[SELF]` 將導致\n由玩家執行指令。\n它還支援佔位符 `[player]` 將被\n取代為完成挑戰的玩家名稱。" @@ -496,8 +504,12 @@ challenges: disabled: 已禁用 repeat_count: name: 重複計數 - description: "允許你變更挑戰的\n重複次數。" + description: |- + 允許你變更挑戰的 + 重複次數。 + 設為 0 表示無限次重複。 value: 目前值:[number] + value-infinite: "目前值:[number] (無限)" cool_down: name: 冷卻 description: "允許你變更冷卻\n期間(以秒為單位)必須經過\n可重複挑戰完成之間。" @@ -711,6 +723,48 @@ challenges: name: 搜尋 description: "允許你搜尋\n使用文字輸入值的元素。" search: 值:[value] + toggle-undeployed: + name: "未部署的挑戰" + shown: |- + 未部署的挑戰目前 + 顯示。 + hidden: |- + 未部署的挑戰目前 + 隱藏。 + required_biomes: + name: "所需生態域" + description: |- + 玩家必須站在這些生態域 + 之一中才能完成此 + 島嶼挑戰。 + title: "生態域:" + list: " - [biome]" + none: "無需特定生態域(任何生態域)。" + reward_island_level: + name: "獎勵島嶼等級" + description: |- + 允許你變更獎勵的島嶼等級。 + 需要 Level 插件。 + value: "目前值:[number]" + repeat_reward_island_level: + name: "重複獎勵島嶼等級" + description: |- + 允許你變更挑戰重複完成時 + 獎勵的島嶼等級。 + 需要 Level 插件。 + value: "目前值:[number]" + open_anywhere: + name: "隨處開啟 GUI" + description: |- + 允許玩家在任何地方開啟挑戰 GUI。 + 受保護時,完成挑戰仍需 + 身處島嶼上。 + enabled: "已啟用" + disabled: "已禁用" + biome: + name: "[biome]" + description: "生態域 ID:[id]" + selected: "已選擇" descriptions: admin: save: '保存幷返回上一級菜單' @@ -859,6 +913,8 @@ challenges: search-radius: 不超過 [number] 米 warning-block: 方塊將被 移除 warning-entity: 實體將被 移除 + biomes-title: "所需生態域:" + biome-value: " - [biome]" inventory: lore: "[items]\n[warning]" item-title: 所需物品: @@ -992,6 +1048,7 @@ challenges: challenge-selector: 挑戰選擇器 statistic-selector: 統計資料選擇器 environment-selector: 環境選擇器 + biome-selector: "生態域選擇器" tips: click-to-select: 按一下 以選擇。 click-to-choose: 按一下 以選擇。 @@ -1059,6 +1116,7 @@ challenges: you-completed-challenge: '你已經完成了 [value] 挑戰!' you-repeated-challenge: '你已經重複完成了 [value] 挑戰!' you-repeated-challenge-multiple: '你重複完成了 [value] 挑戰 [count] 次!' + no-reward-this-time: "運氣不佳——這次沒有獎勵!" you-completed-level: '你完成了 [value] 級別!' name-has-completed-challenge: '[name] 已完成 [value] 挑戰!' name-has-completed-level: '[name] 已完成 [value] 挑戰級別!' @@ -1125,6 +1183,7 @@ challenges: no-team: 這是團隊挑戰。你必須有團隊才能完成它! insufficient-team: 此團隊挑戰需要 [number] 個團隊成員在線。只有 [online] 在線。 member-missing-share: [name] 沒有他們的份額([amount] x [item])用於此團隊挑戰。 + wrong-biome: "你必須站在 [biome] 生態域中才能完成此挑戰!" materials: any: '任何 ' all_hanging_signs: @@ -1141,7 +1200,7 @@ challenges: input-number: 請在聊天中輸入數字。 input-seconds: 請在聊天中輸入秒數。 numeric-only: 給定的 [value] 不是數字! - not-valid-value: 給定的數字 [value] 無效。它必須大於 [min] 且小於 [max]! + not-valid-value: "輸入的數字 [value] 無效。必須在 [min] 到 [max] 之間(含邊界值)。" user-data-removed: 所有 [gamemode] 的使用者資料已從資料庫中清除。 confirm-user-data-deletion: 請確認你想清除 [gamemode] 的使用者資料庫。 challenge-data-removed: 所有 [gamemode] 的挑戰資料已從資料庫中清除。 @@ -1179,6 +1238,8 @@ challenges: write-search: 請輸入搜尋值。(輸入「取消」退出) search-updated: 搜尋值已更新。 enter-formula: "輸入使用 PAPI 佔位符和符號 =,<>,<+,>=, ==, !=, AND, OR 的公式。\n範例:%my_lifetime_count% >= 1000 AND %island_level% >= 100" + confirm-instruction: "在聊天中輸入'confirm'以繼續,或輸入'cancel'以取消。" + input-repeat-count: "請在聊天中輸入重複次數。輸入 0 表示無限次。" protection: flags: CHALLENGES_ISLAND_PROTECTION: 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..a75a0ec4 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,685 @@ 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 testRewardChanceClampedTo1To100() { + Challenge c = new Challenge(); + c.setRewardChance(0); + assertEquals(1, c.getRewardChance()); + c.setRewardChance(-5); + assertEquals(1, c.getRewardChance()); + c.setRewardChance(150); + assertEquals(100, c.getRewardChance()); + c.setRewardChance(50); + assertEquals(50, c.getRewardChance()); + } + + /** + * Sets the rewardChance field directly, bypassing the clamping setter, to simulate + * legacy or hand-edited database data. + */ + private void setRewardChanceField(Challenge c, int value) throws Exception { + java.lang.reflect.Field field = Challenge.class.getDeclaredField("rewardChance"); + field.setAccessible(true); + field.setInt(c, value); + } + + @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() throws Exception { + setRewardChanceField(challenge, 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 testRewardChance0SendsNoRewardMessage() throws Exception { + setRewardChanceField(challenge, 0); + 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<>()); + + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + verify(user).getTranslation(any(World.class), eq("challenges.messages.no-reward-this-time")); + } + + @Test + void testRewardChance0NoRewardsConfiguredNoMessage() throws Exception { + setRewardChanceField(challenge, 0); + when(cm.isChallengeComplete(any(world.bentobox.bentobox.api.user.User.class), any(), any())).thenReturn(false); + when(inv.addItem(any())).thenReturn(new HashMap<>()); + + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + verify(user, never()).getTranslation(any(World.class), eq("challenges.messages.no-reward-this-time")); + } + + @Test + void testRewardChance0RepeatSendsNoRewardMessage() throws Exception { + setRewardChanceField(challenge, 0); + challenge.setRepeatable(true); + 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<>()); + + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + verify(user).getTranslation(any(World.class), eq("challenges.messages.no-reward-this-time")); + } + + @Test + void testRewardChance100NoMissMessage() { + challenge.setRewardChance(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<>()); + + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + + verify(user, never()).getTranslation(any(World.class), eq("challenges.messages.no-reward-this-time")); + } + + @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(" ")); + } }