From d0f0133f576b38f5cdfd68790f78375e46e352e2 Mon Sep 17 00:00:00 2001 From: Michael Burgess Date: Sat, 15 Aug 2026 22:03:40 -0400 Subject: [PATCH] feat: add JobsReborn firefighter gameplay and Gradle wrapper --- .github/workflows/build.yml | 22 + .gitignore | 4 + README.md | 163 +++++++ build.gradle.kts | 37 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 10 + gradlew | 248 ++++++++++ gradlew.bat | 82 ++++ jobs/Firefighter.yml | 61 +++ settings.gradle.kts | 1 + .../therosegarden/firefighter/BlockKey.java | 26 ++ .../firefighter/BukkitSchedulerHelper.java | 13 + .../firefighter/EmergencyManager.java | 422 ++++++++++++++++++ .../firefighter/EmergencySite.java | 12 + .../firefighter/ExpiringBlockTracker.java | 52 +++ .../firefighter/FireInteractionListener.java | 181 ++++++++ .../FireSafetyAndOriginListener.java | 131 ++++++ .../firefighter/FirefighterCommand.java | 240 ++++++++++ .../firefighter/FirefighterRewardService.java | 171 +++++++ .../therosegarden/firefighter/JobsFacade.java | 60 +++ .../firefighter/OriginTracker.java | 68 +++ .../firefighter/RewardCandidate.java | 7 + .../firefighter/RoseFirefighterPlugin.java | 97 ++++ src/main/resources/config.yml | 85 ++++ src/main/resources/plugin.yml | 15 + 25 files changed, 2208 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.gradle.kts create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 jobs/Firefighter.yml create mode 100644 settings.gradle.kts create mode 100644 src/main/java/net/therosegarden/firefighter/BlockKey.java create mode 100644 src/main/java/net/therosegarden/firefighter/BukkitSchedulerHelper.java create mode 100644 src/main/java/net/therosegarden/firefighter/EmergencyManager.java create mode 100644 src/main/java/net/therosegarden/firefighter/EmergencySite.java create mode 100644 src/main/java/net/therosegarden/firefighter/ExpiringBlockTracker.java create mode 100644 src/main/java/net/therosegarden/firefighter/FireInteractionListener.java create mode 100644 src/main/java/net/therosegarden/firefighter/FireSafetyAndOriginListener.java create mode 100644 src/main/java/net/therosegarden/firefighter/FirefighterCommand.java create mode 100644 src/main/java/net/therosegarden/firefighter/FirefighterRewardService.java create mode 100644 src/main/java/net/therosegarden/firefighter/JobsFacade.java create mode 100644 src/main/java/net/therosegarden/firefighter/OriginTracker.java create mode 100644 src/main/java/net/therosegarden/firefighter/RewardCandidate.java create mode 100644 src/main/java/net/therosegarden/firefighter/RoseFirefighterPlugin.java create mode 100644 src/main/resources/config.yml create mode 100644 src/main/resources/plugin.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..18e0604 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,22 @@ +name: Build + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '25' + - uses: gradle/actions/setup-gradle@v4 + - run: ./gradlew clean build --warning-mode all + - uses: actions/upload-artifact@v4 + with: + name: RoseFirefighter + path: build/libs/*.jar diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e81a89f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.gradle/ +build/ +.idea/ +*.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..1ebf075 --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# RoseFirefighter + +RoseFirefighter is a Paper 26.2 companion plugin for **JobsReborn**. It does not implement its own economy or leveling system. Instead, it detects legitimate firefighting actions and submits matching JobsReborn actions so JobsReborn continues to handle job membership, levels, job XP, income progression, bonuses, taxes, payment limits, payment visualization, and level-ups. + +## Supported firefighting + +- Extinguishing `FIRE`. +- Extinguishing `SOUL_FIRE`. +- Extinguishing lit `CAMPFIRE` blocks. +- Extinguishing lit `SOUL_CAMPFIRE` blocks. +- Water-bucket extinguishing when the affected fire can be attributed to the player who emptied the bucket. +- Controlled RoseFirefighter emergency fires. + +Only players currently working the configured JobsReborn `Firefighter` job can receive the submitted JobsReborn rewards. + +## Anti-farming behavior + +With `rewards.anti-farm: true`, RoseFirefighter rejects payment for player-created fire, including: + +- Flint-and-steel ignition. +- Fire-charge/direct player ignition. +- Fire started by a projectile shot by a player. +- Fire spread from a player-created fire. +- Fire caused by player-placed lava or tracked lava flow. +- Campfires a player relights and then extinguishes. + +Active player-created fire and active player-placed lava remain tainted for as long as those sources still exist; they do not become payable merely because a timer expires. Configurable retention timers remain after the active source disappears. A separate location cooldown prevents duplicate/delayed-event payouts and repeated same-location farming. + +Creative and spectator players do not receive rewards by default. Creative earning can be enabled explicitly in `config.yml`. + +## Requirements + +- Paper 26.2 +- Java 25 for compilation/server runtime +- JobsReborn 5.2.6.x +- CMILib, as required by JobsReborn +- A JobsReborn-compatible economy if monetary payouts are desired + +## Build + +The repository includes the Gradle 9.6.1 Wrapper. A global Gradle installation is **not** required. + +Windows: + +```text +gradlew.bat clean build +``` + +Linux/macOS: + +```text +./gradlew clean build +``` + +The resulting plugin JAR is written to `build/libs/`. + +## Install + +1. Copy `jobs/Firefighter.yml` to: + + `plugins/Jobs/jobs/Firefighter.yml` + +2. Reload JobsReborn or restart the server. + +3. Copy the RoseFirefighter JAR from `build/libs/` into the server's `plugins/` directory. + +4. Restart Paper. + +5. Players can join the profession with the normal JobsReborn command, for example: + + `/jobs join Firefighter` + +## JobsReborn payouts + +`jobs/Firefighter.yml` contains the base income and job-XP values. RoseFirefighter does not calculate those amounts itself. + +| Verified extinguish | JobsReborn action | Base income | Base job XP | +|---|---|---:|---:| +| `FIRE` | `BREAK/FIRE` | 1.00 | 1.00 | +| `SOUL_FIRE` | `BREAK/SOUL_FIRE` | 1.25 | 1.25 | +| `CAMPFIRE` | `COLLECT/CAMPFIRE` | 0.50 | 0.50 | +| `SOUL_CAMPFIRE` | `COLLECT/SOUL_CAMPFIRE` | 0.75 | 0.75 | + +Campfires intentionally use a synthetic JobsReborn `COLLECT` action. Putting campfires under the JobsReborn `Break` section would also reward players for simply mining the campfire block. + +The per-type `action-count` options in RoseFirefighter's `config.yml` control how many JobsReborn actions are submitted for a verified extinguish. Keep them at `1` for the normal base payout. Emergency fires additionally apply `emergencies.reward-multiplier`, which defaults to `5`. + +## Emergency sites + +Stand at the center of an area where controlled emergencies may appear: + +```text +/firefighter site add RoseGarden 18 +``` + +Other management commands: + +```text +/firefighter status +/firefighter site remove +/firefighter site list +/firefighter emergency start +/firefighter emergency stop +/firefighter reload +``` + +`rosefirefighter.admin` is required for site, emergency, and reload management and defaults to server operators. + +## Emergency behavior + +Automatic emergencies default to a random 30-60 minute interval and require at least one online Firefighter. Manual starts use the same online-Firefighter requirement unless `emergencies.require-firefighters-online` is disabled. + +Emergency fire generation: + +- Uses admin-defined named sites and radii. +- Can be limited to configured world names with `emergencies.allowed-worlds`. +- Places only temporary fire blocks on safe solid support blocks. +- Does not replace the supporting block. +- Cancels emergency-fire spread and spread ignition. +- Cancels nearby block burning. +- Tracks and removes remaining emergency fire on completion, manual stop, expiry, plugin disable, or server shutdown. +- Periodically removes stale tracked entries if an emergency fire disappears through another legitimate game event. +- Broadcasts configurable alerts, by default to all online players. + +Example alert: + +```text +šŸ”„ FIRE ALERT! A fire has been reported at RoseGarden near X:123 Y:64 Z:-245. Firefighters are needed immediately! +``` + +## Configuration + +`src/main/resources/config.yml` includes controls for: + +- JobsReborn job name. +- Normal fire, soul fire, campfire, and soul-campfire action submission. +- Duplicate reward cooldown. +- Player-created-fire and player-lava tracking retention. +- Creative-mode earning. +- Water attribution scan radius. +- Emergency enable/disable and automatic scheduling. +- Minimum online Firefighters. +- Emergency duration and fire counts. +- Emergency reward multiplier. +- Announcement behavior. +- Permitted emergency worlds. +- Saved emergency sites. + +## Development notes + +Paper 26.2 uses the current Paper dependency format and Java 25 toolchain: + +```kotlin +compileOnly("io.papermc.paper:paper-api:26.2.build.+") +``` + +JobsReborn is referenced through its documented JitPack coordinate: + +```kotlin +compileOnly("com.github.Zrips:Jobs:v5.2.6.2") +``` + +The JobsReborn dependency is non-transitive because RoseFirefighter only needs the JobsReborn API classes and should not pull JobsReborn's optional integration dependencies into its compile classpath. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..ea4d313 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + java +} + +group = "net.therosegarden" +version = "1.0.0" + +val pluginVersion = version.toString() + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") + maven("https://jitpack.io") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:26.2.build.+") + compileOnly("com.github.Zrips:Jobs:v5.2.6.2") { + isTransitive = false + } +} + +java { + toolchain.languageVersion.set(JavaLanguageVersion.of(25)) +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + options.compilerArgs.addAll(listOf("-Xlint:deprecation", "-Xlint:unchecked")) +} + +tasks.processResources { + inputs.property("pluginVersion", pluginVersion) + filesMatching("plugin.yml") { + expand("version" to pluginVersion) + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..dbe66e1 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,10 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright Ā© 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions Ā«$varĀ», Ā«${var}Ā», Ā«${var:-default}Ā», Ā«${var+SET}Ā», +# Ā«${var#prefix}Ā», Ā«${var%suffix}Ā», and Ā«$( cmd )Ā»; +# * compound commands having a testable exit status, especially Ā«caseĀ»; +# * various built-in commands including Ā«commandĀ», Ā«setĀ», and Ā«ulimitĀ». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/jobs/Firefighter.yml b/jobs/Firefighter.yml new file mode 100644 index 0000000..1739206 --- /dev/null +++ b/jobs/Firefighter.yml @@ -0,0 +1,61 @@ +Firefighter: + fullname: Firefighter + displayName: '&c&lFirefighter' + shortname: FF + description: Earn money by extinguishing fires. + FullDescription: + - '&c&lFirefighter' + - '&7Earn money and job XP by extinguishing legitimate fires.' + - '&7Player-created fires and player-lava fires do not qualify.' + - '&6Controlled emergency calls use a configurable reward multiplier.' + + ChatColour: RED + BossBarColour: RED + chat-display: full + + max-level: 200 + + leveling-progression-equation: 10*(joblevel)+(joblevel*joblevel*4) + income-progression-equation: baseincome+(baseincome*(joblevel-1)*0.01) + points-progression-equation: basepoints+(basepoints*(joblevel-1)*0.01) + experience-progression-equation: baseexperience + + rejoinCooldown: 10 + + Gui: + ItemStack: WATER_BUCKET + slot: 8 + + maxDailyQuests: 0 + + cmd-on-join: + - 'msg [name] &cYou are now a Firefighter!' + - 'msg [name] &7Extinguish legitimate fires and respond to emergency calls.' + + cmd-on-leave: + - 'msg [name] &7You have left the Firefighter job.' + + reverse-world-blacklist-functionality: false + world-blacklist: [] + ignore-jobs-max: false + + # RoseFirefighter submits verified FIRE and SOUL_FIRE extinguishes to + # JobsReborn as BREAK actions. JobsReborn owns the payout/XP calculations. + Break: + FIRE: + income: 1.00 + experience: 1.00 + SOUL_FIRE: + income: 1.25 + experience: 1.25 + + # Campfires use COLLECT rather than BREAK. This lets RoseFirefighter submit a + # synthetic JobsReborn action for a verified douse without paying Firefighters + # merely for mining a campfire block. + Collect: + CAMPFIRE: + income: 0.50 + experience: 0.50 + SOUL_CAMPFIRE: + income: 0.75 + experience: 0.75 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..f34a302 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "RoseFirefighter" diff --git a/src/main/java/net/therosegarden/firefighter/BlockKey.java b/src/main/java/net/therosegarden/firefighter/BlockKey.java new file mode 100644 index 0000000..59f588b --- /dev/null +++ b/src/main/java/net/therosegarden/firefighter/BlockKey.java @@ -0,0 +1,26 @@ +package net.therosegarden.firefighter; + +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.block.Block; + +record BlockKey(UUID worldId, int x, int y, int z) { + static BlockKey of(Block block) { + return new BlockKey(block.getWorld().getUID(), block.getX(), block.getY(), block.getZ()); + } + + Block block() { + World world = Bukkit.getWorld(worldId); + return world == null ? null : world.getBlockAt(x, y, z); + } + + boolean isWithin(BlockKey other, int radius) { + if (!worldId.equals(other.worldId)) { + return false; + } + return Math.abs(x - other.x) <= radius + && Math.abs(y - other.y) <= radius + && Math.abs(z - other.z) <= radius; + } +} diff --git a/src/main/java/net/therosegarden/firefighter/BukkitSchedulerHelper.java b/src/main/java/net/therosegarden/firefighter/BukkitSchedulerHelper.java new file mode 100644 index 0000000..4fd1f79 --- /dev/null +++ b/src/main/java/net/therosegarden/firefighter/BukkitSchedulerHelper.java @@ -0,0 +1,13 @@ +package net.therosegarden.firefighter; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; + +final class BukkitSchedulerHelper { + private BukkitSchedulerHelper() { + } + + static void runLater(Plugin plugin, long delayTicks, Runnable task) { + Bukkit.getScheduler().runTaskLater(plugin, task, delayTicks); + } +} diff --git a/src/main/java/net/therosegarden/firefighter/EmergencyManager.java b/src/main/java/net/therosegarden/firefighter/EmergencyManager.java new file mode 100644 index 0000000..302ab3d --- /dev/null +++ b/src/main/java/net/therosegarden/firefighter/EmergencyManager.java @@ -0,0 +1,422 @@ +package net.therosegarden.firefighter; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import org.bukkit.Bukkit; +import org.bukkit.HeightMap; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; + +final class EmergencyManager { + private final RoseFirefighterPlugin plugin; + private final JobsFacade jobs; + private final Random random = new Random(); + private final Map sites = new LinkedHashMap<>(); + private final Set activeFires = new HashSet<>(); + + private EmergencySite activeSite; + private BukkitTask expiryTask; + private BukkitTask automaticTask; + private BukkitTask integrityTask; + + EmergencyManager(RoseFirefighterPlugin plugin, JobsFacade jobs) { + this.plugin = plugin; + this.jobs = jobs; + } + + void reload() { + loadSites(); + scheduleNextAutomaticEmergency(); + } + + void shutdown() { + cancelAutomaticTask(); + cancelExpiryTask(); + cancelIntegrityTask(); + clearEmergencyFireBlocks(); + activeFires.clear(); + activeSite = null; + } + + private void loadSites() { + sites.clear(); + ConfigurationSection section = plugin.getConfig().getConfigurationSection("sites"); + if (section == null) { + return; + } + + for (String key : section.getKeys(false)) { + String path = "sites." + key; + String world = plugin.getConfig().getString(path + ".world"); + if (world == null || Bukkit.getWorld(world) == null) { + plugin.getLogger().warning("Ignoring Firefighter site '" + key + "': world '" + world + "' is not loaded."); + continue; + } + + EmergencySite site = new EmergencySite( + key, + world, + plugin.getConfig().getInt(path + ".x"), + plugin.getConfig().getInt(path + ".y"), + plugin.getConfig().getInt(path + ".z"), + Math.max(3, plugin.getConfig().getInt(path + ".radius", 18)) + ); + sites.put(key.toLowerCase(Locale.ROOT), site); + } + } + + boolean addSite(String name, Location location, int radius) { + if (name == null || !name.matches("[A-Za-z0-9_-]{1,32}") || location.getWorld() == null) { + return false; + } + + String path = "sites." + name; + plugin.getConfig().set(path + ".world", location.getWorld().getName()); + plugin.getConfig().set(path + ".x", location.getBlockX()); + plugin.getConfig().set(path + ".y", location.getBlockY()); + plugin.getConfig().set(path + ".z", location.getBlockZ()); + plugin.getConfig().set(path + ".radius", Math.max(3, radius)); + plugin.saveConfig(); + loadSites(); + return true; + } + + boolean removeSite(String name) { + if (name == null || !sites.containsKey(name.toLowerCase(Locale.ROOT))) { + return false; + } + EmergencySite site = sites.get(name.toLowerCase(Locale.ROOT)); + plugin.getConfig().set("sites." + site.name(), null); + plugin.saveConfig(); + loadSites(); + return true; + } + + Collection sites() { + return Collections.unmodifiableCollection(sites.values()); + } + + EmergencySite activeSite() { + return activeSite; + } + + boolean hasActiveEmergency() { + return activeSite != null; + } + + boolean siteExists(String name) { + return name != null && sites.containsKey(name.toLowerCase(Locale.ROOT)); + } + + boolean isSitePermitted(String name) { + if (name == null) { + return false; + } + EmergencySite site = sites.get(name.toLowerCase(Locale.ROOT)); + if (site == null) { + return false; + } + World world = Bukkit.getWorld(site.worldName()); + return world != null && isWorldPermitted(world); + } + + boolean isWorldPermitted(World world) { + if (world == null) { + return false; + } + + List allowed = plugin.getConfig().getStringList("emergencies.allowed-worlds"); + if (allowed.isEmpty()) { + return true; + } + + for (String configured : allowed) { + if (configured.equals("*") || configured.equalsIgnoreCase(world.getName())) { + return true; + } + } + return false; + } + + boolean emergenciesEnabled() { + return plugin.getConfig().getBoolean("emergencies.enabled", true); + } + + int requiredOnlineFirefighters() { + if (!plugin.getConfig().getBoolean("emergencies.require-firefighters-online", true)) { + return 0; + } + return Math.max(1, plugin.getConfig().getInt("emergencies.min-firefighters-online", 1)); + } + + int activeFireCount() { + return activeFires.size(); + } + + boolean isEmergencyFire(BlockKey key) { + return activeFires.contains(key); + } + + boolean isEmergencyFire(Block block) { + return isEmergencyFire(BlockKey.of(block)); + } + + boolean isNearEmergencyFire(Block block, int radius) { + BlockKey target = BlockKey.of(block); + for (BlockKey fire : activeFires) { + if (fire.isWithin(target, radius)) { + return true; + } + } + return false; + } + + boolean startEmergency(String siteName) { + if (!emergenciesEnabled() || activeSite != null || sites.isEmpty()) { + return false; + } + + if (onlineFirefighterCount() < requiredOnlineFirefighters()) { + return false; + } + + EmergencySite site = selectSite(siteName); + if (site == null) { + return false; + } + + Location center = site.center(); + if (center == null || center.getWorld() == null || !isWorldPermitted(center.getWorld())) { + return false; + } + + int minimum = Math.max(1, plugin.getConfig().getInt("emergencies.fires-min", 6)); + int maximum = Math.max(minimum, plugin.getConfig().getInt("emergencies.fires-max", 12)); + int targetCount = minimum + random.nextInt(maximum - minimum + 1); + + Set spawned = generateFireLocations(site, targetCount); + if (spawned.isEmpty()) { + plugin.getLogger().warning("Could not find safe surfaces for a Firefighter emergency at site '" + site.name() + "'."); + return false; + } + + activeSite = site; + activeFires.addAll(spawned); + + int durationMinutes = Math.max(1, plugin.getConfig().getInt("emergencies.duration-minutes", 10)); + expiryTask = Bukkit.getScheduler().runTaskLater(plugin, () -> endEmergency(false), durationMinutes * 60L * 20L); + startIntegrityTask(); + + notifyResponders(plugin.message("messages.alert") + .replace("%site%", site.name()) + .replace("%x%", Integer.toString(site.x())) + .replace("%y%", Integer.toString(site.y())) + .replace("%z%", Integer.toString(site.z()))); + + return true; + } + + private EmergencySite selectSite(String siteName) { + if (siteName != null) { + EmergencySite requested = sites.get(siteName.toLowerCase(Locale.ROOT)); + if (requested == null) { + return null; + } + World world = Bukkit.getWorld(requested.worldName()); + return world != null && isWorldPermitted(world) ? requested : null; + } + + List eligible = new ArrayList<>(); + for (EmergencySite site : sites.values()) { + World world = Bukkit.getWorld(site.worldName()); + if (world != null && isWorldPermitted(world)) { + eligible.add(site); + } + } + return eligible.isEmpty() ? null : eligible.get(random.nextInt(eligible.size())); + } + + private Set generateFireLocations(EmergencySite site, int targetCount) { + Set result = new HashSet<>(); + Location center = site.center(); + if (center == null || center.getWorld() == null) { + return result; + } + + World world = center.getWorld(); + int attempts = Math.max(80, targetCount * 40); + + // Prefer flammable surfaces so the fire remains visually stable without + // changing the underlying block. BlockBurnEvent is cancelled separately. + for (int pass = 0; pass < 2 && result.size() < targetCount; pass++) { + for (int attempt = 0; attempt < attempts && result.size() < targetCount; attempt++) { + int dx = random.nextInt(site.radius() * 2 + 1) - site.radius(); + int dz = random.nextInt(site.radius() * 2 + 1) - site.radius(); + if ((dx * dx) + (dz * dz) > site.radius() * site.radius()) { + continue; + } + + int x = site.x() + dx; + int z = site.z() + dz; + Block surface = world.getHighestBlockAt(x, z, HeightMap.MOTION_BLOCKING); + Block fire = surface.getRelative(BlockFace.UP); + + if (!fire.getType().isAir() || !surface.getType().isSolid()) { + continue; + } + + if (pass == 0 && !surface.getType().isFlammable()) { + continue; + } + + BlockKey key = BlockKey.of(fire); + if (!result.add(key)) { + continue; + } + + fire.setType(Material.FIRE, false); + } + } + + return result; + } + + void extinguished(BlockKey key) { + if (!activeFires.remove(key)) { + return; + } + + if (activeSite != null && activeFires.isEmpty()) { + endEmergency(true); + } + } + + void stopEmergency() { + endEmergency(false); + } + + private void startIntegrityTask() { + cancelIntegrityTask(); + integrityTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> { + if (activeSite == null) { + cancelIntegrityTask(); + return; + } + + activeFires.removeIf(key -> { + Block block = key.block(); + return block == null || (block.getType() != Material.FIRE && block.getType() != Material.SOUL_FIRE); + }); + + if (activeFires.isEmpty()) { + endEmergency(true); + } + }, 20L, 20L); + } + + private void endEmergency(boolean cleared) { + if (activeSite == null) { + return; + } + + EmergencySite endedSite = activeSite; + cancelExpiryTask(); + cancelIntegrityTask(); + + clearEmergencyFireBlocks(); + activeFires.clear(); + activeSite = null; + + String key = cleared ? "messages.cleared" : "messages.expired"; + notifyResponders(plugin.message(key).replace("%site%", endedSite.name())); + } + + private void clearEmergencyFireBlocks() { + for (BlockKey key : new ArrayList<>(activeFires)) { + Block block = key.block(); + if (block != null && (block.getType() == Material.FIRE || block.getType() == Material.SOUL_FIRE)) { + block.setType(Material.AIR, false); + } + } + } + + private void notifyResponders(String message) { + boolean announcements = plugin.getConfig().getBoolean("emergencies.announcements.enabled", true); + boolean everyone = plugin.getConfig().getBoolean("emergencies.announcements.notify-all-players", + plugin.getConfig().getBoolean("emergencies.notify-all-players", true)); + + if (announcements) { + for (Player player : Bukkit.getOnlinePlayers()) { + if (everyone || jobs.isFirefighter(player)) { + player.sendMessage(plugin.prefix() + message); + } + } + } + plugin.getLogger().info(plugin.stripColor(message)); + } + + int onlineFirefighterCount() { + int count = 0; + for (Player player : Bukkit.getOnlinePlayers()) { + if (jobs.isFirefighter(player)) { + count++; + } + } + return count; + } + + private void scheduleNextAutomaticEmergency() { + cancelAutomaticTask(); + + if (!emergenciesEnabled() || !plugin.getConfig().getBoolean("emergencies.automatic", true)) { + return; + } + + int min = Math.max(1, plugin.getConfig().getInt("emergencies.min-interval-minutes", 30)); + int max = Math.max(min, plugin.getConfig().getInt("emergencies.max-interval-minutes", 60)); + int delayMinutes = min + random.nextInt(max - min + 1); + + automaticTask = Bukkit.getScheduler().runTaskLater(plugin, () -> { + automaticTask = null; + if (activeSite == null) { + startEmergency(null); + } + scheduleNextAutomaticEmergency(); + }, delayMinutes * 60L * 20L); + } + + private void cancelAutomaticTask() { + if (automaticTask != null) { + automaticTask.cancel(); + automaticTask = null; + } + } + + private void cancelExpiryTask() { + if (expiryTask != null) { + expiryTask.cancel(); + expiryTask = null; + } + } + + private void cancelIntegrityTask() { + if (integrityTask != null) { + integrityTask.cancel(); + integrityTask = null; + } + } +} diff --git a/src/main/java/net/therosegarden/firefighter/EmergencySite.java b/src/main/java/net/therosegarden/firefighter/EmergencySite.java new file mode 100644 index 0000000..d7cc725 --- /dev/null +++ b/src/main/java/net/therosegarden/firefighter/EmergencySite.java @@ -0,0 +1,12 @@ +package net.therosegarden.firefighter; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; + +record EmergencySite(String name, String worldName, int x, int y, int z, int radius) { + Location center() { + World world = Bukkit.getWorld(worldName); + return world == null ? null : new Location(world, x + 0.5, y, z + 0.5); + } +} diff --git a/src/main/java/net/therosegarden/firefighter/ExpiringBlockTracker.java b/src/main/java/net/therosegarden/firefighter/ExpiringBlockTracker.java new file mode 100644 index 0000000..892d8d7 --- /dev/null +++ b/src/main/java/net/therosegarden/firefighter/ExpiringBlockTracker.java @@ -0,0 +1,52 @@ +package net.therosegarden.firefighter; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +final class ExpiringBlockTracker { + private final Map expirations = new HashMap<>(); + private long ttlMillis; + + ExpiringBlockTracker(long ttlMillis) { + this.ttlMillis = Math.max(1L, ttlMillis); + } + + void setTtlMillis(long ttlMillis) { + this.ttlMillis = Math.max(1L, ttlMillis); + } + + void mark(BlockKey key) { + expirations.put(key, System.currentTimeMillis() + ttlMillis); + } + + boolean contains(BlockKey key) { + Long expires = expirations.get(key); + if (expires == null) { + return false; + } + if (expires < System.currentTimeMillis()) { + expirations.remove(key); + return false; + } + return true; + } + + void remove(BlockKey key) { + expirations.remove(key); + } + + void clear() { + expirations.clear(); + } + + void cleanup() { + long now = System.currentTimeMillis(); + Iterator> iterator = expirations.entrySet().iterator(); + while (iterator.hasNext()) { + if (iterator.next().getValue() < now) { + iterator.remove(); + } + } + } +} diff --git a/src/main/java/net/therosegarden/firefighter/FireInteractionListener.java b/src/main/java/net/therosegarden/firefighter/FireInteractionListener.java new file mode 100644 index 0000000..e8545e9 --- /dev/null +++ b/src/main/java/net/therosegarden/firefighter/FireInteractionListener.java @@ -0,0 +1,181 @@ +package net.therosegarden.firefighter; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.Lightable; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerBucketEmptyEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; + +final class FireInteractionListener implements Listener { + private final RoseFirefighterPlugin plugin; + private final FirefighterRewardService rewards; + private final EmergencyManager emergencies; + + FireInteractionListener(RoseFirefighterPlugin plugin, FirefighterRewardService rewards, EmergencyManager emergencies) { + this.plugin = plugin; + this.rewards = rewards; + this.emergencies = emergencies; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInteract(PlayerInteractEvent event) { + if (event.getHand() != null && event.getHand() != EquipmentSlot.HAND) { + return; + } + if (event.useInteractedBlock() == Event.Result.DENY) { + return; + } + + if (event.getAction() == Action.LEFT_CLICK_BLOCK) { + Block fire = findClickedFire(event); + if (fire != null) { + RewardCandidate candidate = rewards.capture(fire); + verifyAfterChange(event.getPlayer(), candidate, 1L); + } + return; + } + + if (event.getAction() != Action.RIGHT_CLICK_BLOCK || event.getClickedBlock() == null) { + return; + } + + Block clicked = event.getClickedBlock(); + if (!rewards.isCampfire(clicked.getType())) { + return; + } + + BlockData data = clicked.getBlockData(); + if (!(data instanceof Lightable lightable)) { + return; + } + + // Remember a player relighting a campfire so repeatedly lighting and + // dousing the same campfire cannot print money. + if (!lightable.isLit()) { + Material used = event.getMaterial(); + if (used == Material.FLINT_AND_STEEL || used == Material.FIRE_CHARGE) { + rewards.markPlayerCreated(clicked); + } + return; + } + + if (!rewards.isRewardEnabled(clicked.getType())) { + return; + } + + RewardCandidate candidate = rewards.capture(clicked); + verifyAfterChange(event.getPlayer(), candidate, 1L); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onWaterBucket(PlayerBucketEmptyEvent event) { + if (event.getBucket() != Material.WATER_BUCKET) { + return; + } + + int radius = Math.min(8, Math.max(1, plugin.getConfig().getInt("rewards.water-bucket-scan-radius", 4))); + List candidates = snapshotAround(event.getBlock(), radius); + if (candidates.isEmpty()) { + return; + } + + // Water flow can extinguish blocks over several ticks. Multiple verification + // passes are safe because the reward service has location cooldown protection. + verifyCandidates(event.getPlayer(), candidates, 2L); + verifyCandidates(event.getPlayer(), candidates, 8L); + } + + private Block findClickedFire(PlayerInteractEvent event) { + Block targeted = event.getPlayer().getTargetBlockExact(6); + if (targeted != null && rewards.isOrdinaryFire(targeted.getType())) { + return targeted; + } + + Block clicked = event.getClickedBlock(); + if (clicked == null) { + return null; + } + if (rewards.isOrdinaryFire(clicked.getType())) { + return clicked; + } + + Block relative = clicked.getRelative(event.getBlockFace()); + return rewards.isOrdinaryFire(relative.getType()) ? relative : null; + } + + private List snapshotAround(Block center, int radius) { + Map unique = new LinkedHashMap<>(); + for (int x = -radius; x <= radius; x++) { + for (int y = -radius; y <= radius; y++) { + for (int z = -radius; z <= radius; z++) { + Block block = center.getRelative(x, y, z); + Material material = block.getType(); + if (rewards.isOrdinaryFire(material) && rewards.isRewardEnabled(material)) { + RewardCandidate candidate = rewards.capture(block); + if (candidate != null) { + unique.put(candidate.key(), candidate); + } + } else if (rewards.isCampfire(material) && rewards.isRewardEnabled(material) && isLit(block)) { + RewardCandidate candidate = rewards.capture(block); + if (candidate != null) { + unique.put(candidate.key(), candidate); + } + } + } + } + } + return new ArrayList<>(unique.values()); + } + + private void verifyAfterChange(org.bukkit.entity.Player player, RewardCandidate candidate, long delay) { + if (candidate == null) { + return; + } + BukkitSchedulerHelper.runLater(plugin, delay, () -> processIfExtinguished(player, candidate)); + } + + private void verifyCandidates(org.bukkit.entity.Player player, List candidates, long delay) { + BukkitSchedulerHelper.runLater(plugin, delay, () -> { + for (RewardCandidate candidate : candidates) { + processIfExtinguished(player, candidate); + } + }); + } + + private void processIfExtinguished(org.bukkit.entity.Player player, RewardCandidate candidate) { + Block current = candidate.key().block(); + if (current == null || !isExtinguished(current, candidate)) { + return; + } + + emergencies.extinguished(candidate.key()); + rewards.reward(player, candidate); + } + + private boolean isExtinguished(Block current, RewardCandidate candidate) { + if (candidate.actionInfo().getType() == com.gamingmesh.jobs.container.ActionType.BREAK) { + return !rewards.isOrdinaryFire(current.getType()); + } + + if (!rewards.isCampfire(current.getType())) { + return true; + } + return !isLit(current); + } + + private boolean isLit(Block block) { + BlockData data = block.getBlockData(); + return data instanceof Lightable lightable && lightable.isLit(); + } +} diff --git a/src/main/java/net/therosegarden/firefighter/FireSafetyAndOriginListener.java b/src/main/java/net/therosegarden/firefighter/FireSafetyAndOriginListener.java new file mode 100644 index 0000000..39cfeea --- /dev/null +++ b/src/main/java/net/therosegarden/firefighter/FireSafetyAndOriginListener.java @@ -0,0 +1,131 @@ +package net.therosegarden.firefighter; + +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.entity.Projectile; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBurnEvent; +import org.bukkit.event.block.BlockFromToEvent; +import org.bukkit.event.block.BlockIgniteEvent; +import org.bukkit.event.block.BlockSpreadEvent; +import org.bukkit.event.player.PlayerBucketEmptyEvent; + +final class FireSafetyAndOriginListener implements Listener { + private final RoseFirefighterPlugin plugin; + private final FirefighterRewardService rewards; + private final EmergencyManager emergencies; + private final OriginTracker playerLava; + + FireSafetyAndOriginListener(RoseFirefighterPlugin plugin, FirefighterRewardService rewards, EmergencyManager emergencies) { + this.plugin = plugin; + this.rewards = rewards; + this.emergencies = emergencies; + this.playerLava = new OriginTracker(playerLavaMemoryMillis()); + } + + void reload() { + playerLava.setMemoryMillis(playerLavaMemoryMillis()); + } + + private long playerLavaMemoryMillis() { + return Math.max(1L, plugin.getConfig().getLong("rewards.player-placed-lava-memory-seconds", 600L)) * 1000L; + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void protectEmergencySpread(BlockSpreadEvent event) { + if (emergencies.isEmergencyFire(event.getSource())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void protectEmergencyIgnition(BlockIgniteEvent event) { + Block source = event.getIgnitingBlock(); + if (event.getCause() == BlockIgniteEvent.IgniteCause.SPREAD + && source != null && emergencies.isEmergencyFire(source)) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void protectEmergencyBurn(BlockBurnEvent event) { + if (emergencies.isNearEmergencyFire(event.getBlock(), 2)) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void trackIgnition(BlockIgniteEvent event) { + Block fire = event.getBlock(); + + // Direct player ignition, including flint-and-steel and fire charges, + // is never considered a legitimate payable fire. + if (event.getCause() == BlockIgniteEvent.IgniteCause.FLINT_AND_STEEL) { + rewards.markPlayerCreated(fire); + return; + } + + Player directPlayer = event.getPlayer(); + if (directPlayer != null) { + rewards.markPlayerCreated(fire); + return; + } + + Entity igniter = event.getIgnitingEntity(); + if (igniter instanceof Player) { + rewards.markPlayerCreated(fire); + return; + } + if (igniter instanceof Projectile projectile && projectile.getShooter() instanceof Player) { + rewards.markPlayerCreated(fire); + return; + } + + Block source = event.getIgnitingBlock(); + if (source == null) { + return; + } + + if (event.getCause() == BlockIgniteEvent.IgniteCause.SPREAD && rewards.isPlayerCreated(source)) { + rewards.markPlayerCreated(fire); + } else if (event.getCause() == BlockIgniteEvent.IgniteCause.LAVA && playerLava.contains(BlockKey.of(source))) { + rewards.markPlayerCreated(fire); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void trackFireSpread(BlockSpreadEvent event) { + rewards.propagatePlayerCreated(event.getSource(), event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void trackLavaBucket(PlayerBucketEmptyEvent event) { + if (event.getBucket() == Material.LAVA_BUCKET) { + playerLava.mark(BlockKey.of(event.getBlock())); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void trackLavaFlow(BlockFromToEvent event) { + if (event.getBlock().getType() != Material.LAVA) { + return; + } + + BlockKey source = BlockKey.of(event.getBlock()); + if (playerLava.contains(source)) { + playerLava.mark(BlockKey.of(event.getToBlock())); + } + } + + void cleanup() { + playerLava.cleanup(block -> block.getType() == Material.LAVA); + } + + void clear() { + playerLava.clear(); + } +} diff --git a/src/main/java/net/therosegarden/firefighter/FirefighterCommand.java b/src/main/java/net/therosegarden/firefighter/FirefighterCommand.java new file mode 100644 index 0000000..c163213 --- /dev/null +++ b/src/main/java/net/therosegarden/firefighter/FirefighterCommand.java @@ -0,0 +1,240 @@ +package net.therosegarden.firefighter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class FirefighterCommand implements CommandExecutor, TabCompleter { + private final RoseFirefighterPlugin plugin; + private final JobsFacade jobs; + private final EmergencyManager emergencies; + + FirefighterCommand(RoseFirefighterPlugin plugin, JobsFacade jobs, EmergencyManager emergencies) { + this.plugin = plugin; + this.jobs = jobs; + this.emergencies = emergencies; + } + + @Override + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + if (args.length == 0) { + showStatus(sender); + return true; + } + + String root = args[0].toLowerCase(Locale.ROOT); + if (root.equals("help")) { + showHelp(sender); + return true; + } + if (root.equals("status")) { + showStatus(sender); + return true; + } + + if (!sender.hasPermission("rosefirefighter.admin")) { + sender.sendMessage(plugin.prefix() + plugin.color("&cYou do not have permission to manage the Fire Department.")); + return true; + } + + switch (root) { + case "reload" -> reload(sender); + case "site" -> handleSite(sender, args); + case "emergency" -> handleEmergency(sender, args); + default -> showHelp(sender); + } + return true; + } + + private void reload(CommandSender sender) { + plugin.reloadRuntimeConfiguration(true); + sender.sendMessage(plugin.prefix() + plugin.color("&aRoseFirefighter configuration reloaded.")); + } + + private void handleSite(CommandSender sender, String[] args) { + if (args.length < 2) { + sender.sendMessage(plugin.prefix() + plugin.color("&eUsage: /firefighter site ...")); + return; + } + + switch (args[1].toLowerCase(Locale.ROOT)) { + case "list" -> { + if (emergencies.sites().isEmpty()) { + sender.sendMessage(plugin.prefix() + plugin.color("&7No response sites have been configured.")); + return; + } + sender.sendMessage(plugin.prefix() + plugin.color("&eConfigured response sites:")); + for (EmergencySite site : emergencies.sites()) { + sender.sendMessage(plugin.color("&8- &f" + site.name() + " &7(" + site.worldName() + " " + + site.x() + ", " + site.y() + ", " + site.z() + "; radius " + site.radius() + ")")); + } + } + case "add" -> { + if (!(sender instanceof Player player)) { + sender.sendMessage(plugin.prefix() + plugin.color("&cThis command must be run by a player at the response site.")); + return; + } + if (args.length < 3) { + sender.sendMessage(plugin.prefix() + plugin.color("&eUsage: /firefighter site add [radius]")); + return; + } + if (!emergencies.isWorldPermitted(player.getWorld())) { + sender.sendMessage(plugin.prefix() + plugin.color("&cFire emergencies are not permitted in this world by config.yml.")); + return; + } + + int radius = plugin.getConfig().getInt("emergencies.default-site-radius", 18); + if (args.length >= 4) { + try { + radius = Integer.parseInt(args[3]); + } catch (NumberFormatException ex) { + sender.sendMessage(plugin.prefix() + plugin.color("&cRadius must be a whole number.")); + return; + } + } + + if (!emergencies.addSite(args[2], player.getLocation(), radius)) { + sender.sendMessage(plugin.prefix() + plugin.color("&cSite names may only contain letters, numbers, _ and - (maximum 32 characters).")); + return; + } + sender.sendMessage(plugin.prefix() + plugin.color("&aAdded response site &f" + args[2] + "&a with radius &f" + Math.max(3, radius) + "&a.")); + } + case "remove" -> { + if (args.length < 3) { + sender.sendMessage(plugin.prefix() + plugin.color("&eUsage: /firefighter site remove ")); + return; + } + if (!emergencies.removeSite(args[2])) { + sender.sendMessage(plugin.prefix() + plugin.color("&cNo response site named &f" + args[2] + "&c exists.")); + return; + } + sender.sendMessage(plugin.prefix() + plugin.color("&aRemoved response site &f" + args[2] + "&a.")); + } + default -> sender.sendMessage(plugin.prefix() + plugin.color("&eUsage: /firefighter site ...")); + } + } + + private void handleEmergency(CommandSender sender, String[] args) { + if (args.length < 2) { + sender.sendMessage(plugin.prefix() + plugin.color("&eUsage: /firefighter emergency [site]")); + return; + } + + switch (args[1].toLowerCase(Locale.ROOT)) { + case "start" -> { + if (!emergencies.emergenciesEnabled()) { + sender.sendMessage(plugin.prefix() + plugin.color("&cFire emergencies are disabled in config.yml.")); + return; + } + if (emergencies.hasActiveEmergency()) { + sender.sendMessage(plugin.prefix() + plugin.color("&cAn emergency is already active.")); + return; + } + + String site = args.length >= 3 ? args[2] : null; + if (site != null && !emergencies.siteExists(site)) { + sender.sendMessage(plugin.prefix() + plugin.color("&cUnknown response site: &f" + site)); + return; + } + if (site != null && !emergencies.isSitePermitted(site)) { + sender.sendMessage(plugin.prefix() + plugin.color("&cThat response site's world is not permitted by config.yml.")); + return; + } + + int required = emergencies.requiredOnlineFirefighters(); + int online = emergencies.onlineFirefighterCount(); + if (online < required) { + sender.sendMessage(plugin.prefix() + plugin.color("&cAt least &f" + required + " &cFirefighter(s) must be online before an emergency can start.")); + return; + } + + if (!emergencies.startEmergency(site)) { + sender.sendMessage(plugin.prefix() + plugin.color("&cCould not start an emergency. Add a permitted site and make sure it has suitable surfaces.")); + return; + } + EmergencySite active = emergencies.activeSite(); + sender.sendMessage(plugin.prefix() + plugin.color("&aEmergency started at &f" + active.name() + "&a with &f" + + emergencies.activeFireCount() + " &afires.")); + } + case "stop" -> { + if (!emergencies.hasActiveEmergency()) { + sender.sendMessage(plugin.prefix() + plugin.color("&7No emergency is active.")); + return; + } + emergencies.stopEmergency(); + sender.sendMessage(plugin.prefix() + plugin.color("&aThe active emergency was stopped and remaining emergency fires were removed.")); + } + default -> sender.sendMessage(plugin.prefix() + plugin.color("&eUsage: /firefighter emergency [site]")); + } + } + + private void showStatus(CommandSender sender) { + String jobName = plugin.getConfig().getString("job-name", "Firefighter"); + sender.sendMessage(plugin.prefix() + plugin.color("&fJob: &c" + jobName + " &8| &fFirefighters online: &c" + emergencies.onlineFirefighterCount())); + + if (sender instanceof Player player) { + sender.sendMessage(plugin.prefix() + (jobs.isFirefighter(player) + ? plugin.color("&aYou are currently on duty as a Firefighter.") + : plugin.color("&7You are not currently working as a Firefighter."))); + } + + EmergencySite site = emergencies.activeSite(); + if (site == null) { + sender.sendMessage(plugin.prefix() + plugin.color("&7No active fire emergency.")); + } else { + sender.sendMessage(plugin.prefix() + plugin.color("&cACTIVE: &f" + site.name() + " &7near " + site.x() + ", " + site.y() + ", " + site.z() + + " &8| &e" + emergencies.activeFireCount() + " fires remaining")); + } + } + + private void showHelp(CommandSender sender) { + sender.sendMessage(plugin.prefix() + plugin.color("&c&lRoseFirefighter &7commands")); + sender.sendMessage(plugin.color("&e/firefighter status &8- &7Show current response status")); + if (sender.hasPermission("rosefirefighter.admin")) { + sender.sendMessage(plugin.color("&e/firefighter site add [radius] &8- &7Add a response site here")); + sender.sendMessage(plugin.color("&e/firefighter site remove &8- &7Remove a response site")); + sender.sendMessage(plugin.color("&e/firefighter site list &8- &7List response sites")); + sender.sendMessage(plugin.color("&e/firefighter emergency start [site] &8- &7Start a controlled fire")); + sender.sendMessage(plugin.color("&e/firefighter emergency stop &8- &7Stop the active fire")); + sender.sendMessage(plugin.color("&e/firefighter reload &8- &7Reload the addon config")); + } + } + + @Override + public @Nullable List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String alias, @NotNull String[] args) { + List choices = new ArrayList<>(); + if (args.length == 1) { + choices.add("status"); + choices.add("help"); + if (sender.hasPermission("rosefirefighter.admin")) { + choices.add("site"); + choices.add("emergency"); + choices.add("reload"); + } + } else if (sender.hasPermission("rosefirefighter.admin") && args.length == 2 && args[0].equalsIgnoreCase("site")) { + choices.add("add"); + choices.add("remove"); + choices.add("list"); + } else if (sender.hasPermission("rosefirefighter.admin") && args.length == 2 && args[0].equalsIgnoreCase("emergency")) { + choices.add("start"); + choices.add("stop"); + } else if (sender.hasPermission("rosefirefighter.admin") && args.length == 3 + && ((args[0].equalsIgnoreCase("site") && args[1].equalsIgnoreCase("remove")) + || (args[0].equalsIgnoreCase("emergency") && args[1].equalsIgnoreCase("start")))) { + for (EmergencySite site : emergencies.sites()) { + choices.add(site.name()); + } + } + + String token = args.length == 0 ? "" : args[args.length - 1].toLowerCase(Locale.ROOT); + choices.removeIf(choice -> !choice.toLowerCase(Locale.ROOT).startsWith(token)); + return choices; + } +} diff --git a/src/main/java/net/therosegarden/firefighter/FirefighterRewardService.java b/src/main/java/net/therosegarden/firefighter/FirefighterRewardService.java new file mode 100644 index 0000000..591a112 --- /dev/null +++ b/src/main/java/net/therosegarden/firefighter/FirefighterRewardService.java @@ -0,0 +1,171 @@ +package net.therosegarden.firefighter; + +import com.gamingmesh.jobs.actions.BlockActionInfo; +import com.gamingmesh.jobs.actions.ItemActionInfo; +import com.gamingmesh.jobs.container.ActionInfo; +import com.gamingmesh.jobs.container.ActionType; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.Lightable; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +final class FirefighterRewardService { + private final RoseFirefighterPlugin plugin; + private final JobsFacade jobs; + private final OriginTracker playerCreatedFire; + private final ExpiringBlockTracker recentRewards; + private EmergencyManager emergencies; + + FirefighterRewardService(RoseFirefighterPlugin plugin, JobsFacade jobs) { + this.plugin = plugin; + this.jobs = jobs; + this.playerCreatedFire = new OriginTracker(playerFireMemoryMillis()); + this.recentRewards = new ExpiringBlockTracker(duplicateCooldownMillis()); + } + + void setEmergencyManager(EmergencyManager emergencies) { + this.emergencies = emergencies; + } + + void reload() { + playerCreatedFire.setMemoryMillis(playerFireMemoryMillis()); + recentRewards.setTtlMillis(duplicateCooldownMillis()); + } + + private long playerFireMemoryMillis() { + return Math.max(1L, plugin.getConfig().getLong("rewards.player-created-fire-memory-seconds", 300L)) * 1000L; + } + + private long duplicateCooldownMillis() { + if (plugin.getConfig().contains("rewards.duplicate-reward-cooldown-seconds")) { + return Math.max(1L, plugin.getConfig().getLong("rewards.duplicate-reward-cooldown-seconds", 30L)) * 1000L; + } + return Math.max(1L, plugin.getConfig().getLong("rewards.duplicate-reward-cooldown-milliseconds", 1500L)); + } + + boolean isOrdinaryFire(Material material) { + return material == Material.FIRE || material == Material.SOUL_FIRE; + } + + boolean isCampfire(Material material) { + return material == Material.CAMPFIRE || material == Material.SOUL_CAMPFIRE; + } + + boolean isRewardEnabled(Material material) { + return switch (material) { + case FIRE -> plugin.getConfig().getBoolean("rewards.normal-fire.enabled", true); + case SOUL_FIRE -> plugin.getConfig().getBoolean("rewards.soul-fire.enabled", true); + case CAMPFIRE -> plugin.getConfig().getBoolean("rewards.campfire.enabled", + plugin.getConfig().getBoolean("campfires.enabled", true)); + case SOUL_CAMPFIRE -> plugin.getConfig().getBoolean("rewards.soul-campfire.enabled", + plugin.getConfig().getBoolean("campfires.enabled", true)); + default -> false; + }; + } + + private int actionCount(Material material) { + String path = switch (material) { + case FIRE -> "rewards.normal-fire.action-count"; + case SOUL_FIRE -> "rewards.soul-fire.action-count"; + case CAMPFIRE -> "rewards.campfire.action-count"; + case SOUL_CAMPFIRE -> "rewards.soul-campfire.action-count"; + default -> null; + }; + return path == null ? 0 : Math.max(0, plugin.getConfig().getInt(path, 1)); + } + + RewardCandidate capture(Block block) { + Material material = block.getType(); + if (!isRewardEnabled(material)) { + return null; + } + + ActionInfo actionInfo; + if (isOrdinaryFire(material)) { + actionInfo = new BlockActionInfo(block, ActionType.BREAK); + } else if (isCampfire(material)) { + actionInfo = new ItemActionInfo(new ItemStack(material), ActionType.COLLECT); + } else { + return null; + } + + BlockKey key = BlockKey.of(block); + return new RewardCandidate(key, material, actionInfo, emergencies != null && emergencies.isEmergencyFire(key)); + } + + void markPlayerCreated(Block block) { + markPlayerCreated(BlockKey.of(block)); + } + + void markPlayerCreated(BlockKey key) { + playerCreatedFire.mark(key); + } + + boolean isPlayerCreated(Block block) { + return isPlayerCreated(BlockKey.of(block)); + } + + boolean isPlayerCreated(BlockKey key) { + return playerCreatedFire.contains(key); + } + + void propagatePlayerCreated(Block source, Block destination) { + if (isPlayerCreated(source)) { + markPlayerCreated(destination); + } + } + + void reward(Player player, RewardCandidate candidate) { + if (candidate == null) { + return; + } + + if (recentRewards.contains(candidate.key())) { + return; + } + recentRewards.mark(candidate.key()); + + boolean playerCreated = isPlayerCreated(candidate.key()); + playerCreatedFire.deactivate(candidate.key()); + + if (plugin.getConfig().getBoolean("rewards.anti-farm", true) && playerCreated) { + return; + } + + int baseCount = actionCount(candidate.material()); + if (baseCount <= 0) { + return; + } + + int multiplier = candidate.emergency() + ? Math.max(1, plugin.getConfig().getInt("emergencies.reward-multiplier", + plugin.getConfig().getInt("rewards.emergency-multiplier", 5))) + : 1; + + long total = (long) baseCount * multiplier; + jobs.submit(player, candidate.actionInfo(), (int) Math.min(Integer.MAX_VALUE, total)); + } + + void cleanup() { + playerCreatedFire.cleanup(this::isActivePlayerCreatedFire); + recentRewards.cleanup(); + } + + private boolean isActivePlayerCreatedFire(Block block) { + if (isOrdinaryFire(block.getType())) { + return true; + } + if (!isCampfire(block.getType())) { + return false; + } + BlockData data = block.getBlockData(); + return data instanceof Lightable lightable && lightable.isLit(); + } + + void clear() { + playerCreatedFire.clear(); + recentRewards.clear(); + } +} diff --git a/src/main/java/net/therosegarden/firefighter/JobsFacade.java b/src/main/java/net/therosegarden/firefighter/JobsFacade.java new file mode 100644 index 0000000..d73b6da --- /dev/null +++ b/src/main/java/net/therosegarden/firefighter/JobsFacade.java @@ -0,0 +1,60 @@ +package net.therosegarden.firefighter; + +import com.gamingmesh.jobs.Jobs; +import com.gamingmesh.jobs.container.ActionInfo; +import com.gamingmesh.jobs.container.Job; +import com.gamingmesh.jobs.container.JobsPlayer; +import org.bukkit.GameMode; +import org.bukkit.entity.Player; + +final class JobsFacade { + private final RoseFirefighterPlugin plugin; + + JobsFacade(RoseFirefighterPlugin plugin) { + this.plugin = plugin; + } + + Job firefighterJob() { + return Jobs.getJob(plugin.getConfig().getString("job-name", "Firefighter")); + } + + boolean isFirefighter(Player player) { + Job job = firefighterJob(); + if (job == null) { + return false; + } + JobsPlayer jobsPlayer = Jobs.getPlayerManager().getJobsPlayer(player); + return jobsPlayer != null && jobsPlayer.isInJob(job); + } + + boolean canReceivePay(Player player) { + GameMode mode = player.getGameMode(); + if (mode == GameMode.SPECTATOR) { + return false; + } + if (mode == GameMode.CREATIVE && !plugin.getConfig().getBoolean("rewards.pay-in-creative", false)) { + return false; + } + + if (!Jobs.getGCManager().canPerformActionInWorld(player.getWorld())) { + return false; + } + + return Jobs.getPermissionHandler().hasWorldPermission(player, player.getWorld().getName()); + } + + void submit(Player player, ActionInfo info, int count) { + if (count <= 0 || !isFirefighter(player) || !canReceivePay(player)) { + return; + } + + JobsPlayer jobsPlayer = Jobs.getPlayerManager().getJobsPlayer(player); + if (jobsPlayer == null) { + return; + } + + for (int i = 0; i < count; i++) { + Jobs.action(jobsPlayer, info); + } + } +} diff --git a/src/main/java/net/therosegarden/firefighter/OriginTracker.java b/src/main/java/net/therosegarden/firefighter/OriginTracker.java new file mode 100644 index 0000000..6cfc44d --- /dev/null +++ b/src/main/java/net/therosegarden/firefighter/OriginTracker.java @@ -0,0 +1,68 @@ +package net.therosegarden.firefighter; + +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; +import java.util.function.Predicate; +import org.bukkit.block.Block; + +/** + * Tracks player-created sources in two layers: + * + *
    + *
  • Active sources remain tainted for as long as the source block still exists.
  • + *
  • A configurable expiring memory remains after the active source disappears.
  • + *
+ * + * This prevents a long-lived player fire or lava source from becoming payable merely + * because a time-to-live expired while the source was still present. + */ +final class OriginTracker { + private final Set active = new HashSet<>(); + private final ExpiringBlockTracker memory; + + OriginTracker(long memoryMillis) { + this.memory = new ExpiringBlockTracker(memoryMillis); + } + + void setMemoryMillis(long memoryMillis) { + memory.setTtlMillis(memoryMillis); + } + + void mark(BlockKey key) { + active.add(key); + memory.mark(key); + } + + boolean contains(BlockKey key) { + return active.contains(key) || memory.contains(key); + } + + boolean isActive(BlockKey key) { + return active.contains(key); + } + + void deactivate(BlockKey key) { + if (active.remove(key)) { + memory.mark(key); + } + } + + void cleanup(Predicate stillActive) { + memory.cleanup(); + Iterator iterator = active.iterator(); + while (iterator.hasNext()) { + BlockKey key = iterator.next(); + Block block = key.block(); + if (block == null || !stillActive.test(block)) { + iterator.remove(); + memory.mark(key); + } + } + } + + void clear() { + active.clear(); + memory.clear(); + } +} diff --git a/src/main/java/net/therosegarden/firefighter/RewardCandidate.java b/src/main/java/net/therosegarden/firefighter/RewardCandidate.java new file mode 100644 index 0000000..519db65 --- /dev/null +++ b/src/main/java/net/therosegarden/firefighter/RewardCandidate.java @@ -0,0 +1,7 @@ +package net.therosegarden.firefighter; + +import com.gamingmesh.jobs.container.ActionInfo; +import org.bukkit.Material; + +record RewardCandidate(BlockKey key, Material material, ActionInfo actionInfo, boolean emergency) { +} diff --git a/src/main/java/net/therosegarden/firefighter/RoseFirefighterPlugin.java b/src/main/java/net/therosegarden/firefighter/RoseFirefighterPlugin.java new file mode 100644 index 0000000..4029bfb --- /dev/null +++ b/src/main/java/net/therosegarden/firefighter/RoseFirefighterPlugin.java @@ -0,0 +1,97 @@ +package net.therosegarden.firefighter; + +import com.gamingmesh.jobs.container.Job; +import java.util.Objects; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Bukkit; +import org.bukkit.command.PluginCommand; +import org.bukkit.plugin.java.JavaPlugin; + +public final class RoseFirefighterPlugin extends JavaPlugin { + private static final LegacyComponentSerializer AMPERSAND_COLORS = LegacyComponentSerializer.legacyAmpersand(); + private static final LegacyComponentSerializer SECTION_COLORS = LegacyComponentSerializer.legacySection(); + + private JobsFacade jobs; + private FirefighterRewardService rewards; + private EmergencyManager emergencies; + private FireSafetyAndOriginListener safetyListener; + + @Override + public void onEnable() { + saveDefaultConfig(); + + jobs = new JobsFacade(this); + rewards = new FirefighterRewardService(this, jobs); + emergencies = new EmergencyManager(this, jobs); + rewards.setEmergencyManager(emergencies); + safetyListener = new FireSafetyAndOriginListener(this, rewards, emergencies); + + Bukkit.getPluginManager().registerEvents(new FireInteractionListener(this, rewards, emergencies), this); + Bukkit.getPluginManager().registerEvents(safetyListener, this); + + FirefighterCommand commandHandler = new FirefighterCommand(this, jobs, emergencies); + PluginCommand command = Objects.requireNonNull(getCommand("firefighter"), "firefighter command missing from plugin.yml"); + command.setExecutor(commandHandler); + command.setTabCompleter(commandHandler); + + reloadRuntimeConfiguration(false); + + Bukkit.getScheduler().runTaskTimer(this, () -> { + rewards.cleanup(); + safetyListener.cleanup(); + }, 20L * 60L, 20L * 60L); + + Job firefighter = jobs.firefighterJob(); + if (firefighter == null) { + getLogger().warning("JobsReborn job '" + getConfig().getString("job-name", "Firefighter") + "' was not found."); + getLogger().warning("Copy jobs/Firefighter.yml from the RoseFirefighter package into plugins/Jobs/jobs/ and run /jobs reload."); + } + + getLogger().info("RoseFirefighter enabled. Controlled emergency spread protection is active."); + } + + @Override + public void onDisable() { + if (emergencies != null) { + emergencies.shutdown(); + } + if (rewards != null) { + rewards.clear(); + } + if (safetyListener != null) { + safetyListener.clear(); + } + } + + void reloadRuntimeConfiguration(boolean reloadFile) { + if (reloadFile) { + reloadConfig(); + } + if (rewards != null) { + rewards.reload(); + } + if (safetyListener != null) { + safetyListener.reload(); + } + if (emergencies != null) { + emergencies.reload(); + } + } + + String color(String text) { + return SECTION_COLORS.serialize(AMPERSAND_COLORS.deserialize(text == null ? "" : text)); + } + + String stripColor(String text) { + return PlainTextComponentSerializer.plainText().serialize(SECTION_COLORS.deserialize(color(text))); + } + + String prefix() { + return color(getConfig().getString("messages.prefix", "&8[&cFire Dept&8] &r")); + } + + String message(String key) { + return color(getConfig().getString(key, "")); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..36b9573 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,85 @@ +# RoseFirefighter +# +# RoseFirefighter detects legitimate extinguishing. JobsReborn remains responsible +# for the actual money/XP values, job levels, progression, bonuses, taxes, limits, +# visualization, and level-ups. Edit jobs/Firefighter.yml to change base payouts. + +job-name: Firefighter + +rewards: + anti-farm: true + pay-in-creative: false + + # A rewarded location cannot pay again until this cooldown expires. This also + # prevents the delayed water checks from paying the same extinguish twice. + duplicate-reward-cooldown-seconds: 30 + + # After a player-created fire is extinguished, keep the location tainted for + # this long. While a player-created fire is still actively burning, it remains + # tainted regardless of this timer. + player-created-fire-memory-seconds: 300 + + # Same idea for player-placed lava. Active player lava and its tracked flow stay + # tainted for as long as the lava exists; this is the retention time afterward. + player-placed-lava-memory-seconds: 600 + + # Water can extinguish several blocks. RoseFirefighter snapshots eligible fire + # in this radius around the block where the bucket was emptied. Values above 8 + # are clamped to 8 to avoid an excessive per-bucket scan. + water-bucket-scan-radius: 4 + + # These are JobsReborn action counts, not hard-coded currency amounts. Keep 1 + # for the normal payout configured in Firefighter.yml. Setting enabled false or + # action-count 0 disables that extinguish reward type. + normal-fire: + enabled: true + action-count: 1 + soul-fire: + enabled: true + action-count: 1 + campfire: + enabled: true + action-count: 1 + soul-campfire: + enabled: true + action-count: 1 + +emergencies: + enabled: true + automatic: true + + # Applies to both automatic and manual emergency starts. Set false if admins + # should be able to start/test an emergency with no Firefighters online. + require-firefighters-online: true + min-firefighters-online: 1 + + min-interval-minutes: 30 + max-interval-minutes: 60 + duration-minutes: 10 + fires-min: 6 + fires-max: 12 + default-site-radius: 18 + + # Emergency fires submit the normal JobsReborn action this many times. + reward-multiplier: 5 + + # Empty means all loaded worlds are permitted. Otherwise list exact world names. + # Example: + # allowed-worlds: + # - world + # - RoseGarden + allowed-worlds: [] + + announcements: + enabled: true + notify-all-players: true + +messages: + prefix: '&8[&cFire Dept&8] &r' + alert: '&c&lšŸ”„ FIRE ALERT! &eA fire has been reported at &f%site%&e near &fX:%x% Y:%y% Z:%z%&e. Firefighters are needed immediately!' + cleared: '&aFire at &f%site% &ahas been brought under control.' + expired: '&eThe fire response at &f%site% &ehas ended.' + joined-needed: '&7You must be working as a &cFirefighter &7to receive firefighting pay.' + +# Managed with /firefighter site add [radius] +sites: {} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..06c865a --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,15 @@ +name: RoseFirefighter +version: '${version}' +main: net.therosegarden.firefighter.RoseFirefighterPlugin +api-version: '26.2' +depend: [Jobs] +description: Firefighter profession companion and controlled fire emergencies for JobsReborn. +commands: + firefighter: + description: Manage RoseFirefighter emergencies and response sites. + aliases: [ffire] + usage: /firefighter help +permissions: + rosefirefighter.admin: + description: Manage Firefighter sites and emergencies. + default: op