feat: add JobsReborn firefighter gameplay and Gradle wrapper
Build / build (push) Failing after 7m10s

This commit is contained in:
Michael Burgess
2026-08-15 22:03:40 -04:00
commit d0f0133f57
25 changed files with 2208 additions and 0 deletions
+22
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
.gradle/
build/
.idea/
*.iml
+163
View File
@@ -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 <name>
/firefighter site list
/firefighter emergency start <site>
/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.
+37
View File
@@ -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<JavaCompile>().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)
}
}
Binary file not shown.
+10
View File
@@ -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
Vendored Executable
+248
View File
@@ -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" "$@"
Vendored
+82
View File
@@ -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%
+61
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
rootProject.name = "RoseFirefighter"
@@ -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;
}
}
@@ -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);
}
}
@@ -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<String, EmergencySite> sites = new LinkedHashMap<>();
private final Set<BlockKey> 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<EmergencySite> 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<String> 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<BlockKey> 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<EmergencySite> 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<BlockKey> generateFireLocations(EmergencySite site, int targetCount) {
Set<BlockKey> 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;
}
}
}
@@ -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);
}
}
@@ -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<BlockKey, Long> 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<Map.Entry<BlockKey, Long>> iterator = expirations.entrySet().iterator();
while (iterator.hasNext()) {
if (iterator.next().getValue() < now) {
iterator.remove();
}
}
}
}
@@ -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<RewardCandidate> 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<RewardCandidate> snapshotAround(Block center, int radius) {
Map<BlockKey, RewardCandidate> 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<RewardCandidate> 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();
}
}
@@ -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();
}
}
@@ -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 <add|remove|list> ..."));
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 <name> [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 <name>"));
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 <add|remove|list> ..."));
}
}
private void handleEmergency(CommandSender sender, String[] args) {
if (args.length < 2) {
sender.sendMessage(plugin.prefix() + plugin.color("&eUsage: /firefighter emergency <start|stop> [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 <start|stop> [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 <name> [radius] &8- &7Add a response site here"));
sender.sendMessage(plugin.color("&e/firefighter site remove <name> &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<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String alias, @NotNull String[] args) {
List<String> 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;
}
}
@@ -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();
}
}
@@ -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);
}
}
}
@@ -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:
*
* <ul>
* <li>Active sources remain tainted for as long as the source block still exists.</li>
* <li>A configurable expiring memory remains after the active source disappears.</li>
* </ul>
*
* 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<BlockKey> 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<Block> stillActive) {
memory.cleanup();
Iterator<BlockKey> 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();
}
}
@@ -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) {
}
@@ -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, ""));
}
}
+85
View File
@@ -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 <name> [radius]
sites: {}
+15
View File
@@ -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