feat: add dynamic fire dispatch and rollback
Build / build (push) Failing after 8s

This commit is contained in:
Michael Burgess
2026-08-17 14:14:15 -04:00
parent f6f2f97f86
commit 0df9b059e2
11 changed files with 860 additions and 193 deletions
+103 -83
View File
@@ -1,6 +1,6 @@
# RoseFirefighter # 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. RoseFirefighter is a Paper 26.2 companion plugin for **JobsReborn**. It detects legitimate firefighting, runs temporary fire emergencies, integrates with GriefPrevention, and submits JobsReborn actions so JobsReborn remains responsible for job membership, levels, XP, income, bonuses, taxes, payment limits, and progression.
## Supported firefighting ## Supported firefighting
@@ -9,9 +9,9 @@ RoseFirefighter is a Paper 26.2 companion plugin for **JobsReborn**. It does not
- Extinguishing lit `CAMPFIRE` blocks. - Extinguishing lit `CAMPFIRE` blocks.
- Extinguishing lit `SOUL_CAMPFIRE` blocks. - Extinguishing lit `SOUL_CAMPFIRE` blocks.
- Water-bucket extinguishing when the affected fire can be attributed to the player who emptied the bucket. - Water-bucket extinguishing when the affected fire can be attributed to the player who emptied the bucket.
- Controlled RoseFirefighter emergency fires. - Controlled RoseFirefighter emergency fires, including fire that naturally spreads during the incident.
Only players currently working the configured JobsReborn `Firefighter` job can receive the submitted JobsReborn rewards. Only players currently working the configured JobsReborn `Firefighter` job can receive submitted JobsReborn rewards.
## Anti-farming behavior ## Anti-farming behavior
@@ -24,22 +24,20 @@ With `rewards.anti-farm: true`, RoseFirefighter rejects payment for player-creat
- Fire caused by player-placed lava or tracked lava flow. - Fire caused by player-placed lava or tracked lava flow.
- Campfires a player relights and then extinguishes. - 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. Active player-created fire and player-placed lava stay tainted while their sources remain active. Configurable retention timers remain afterward, and a separate location cooldown prevents duplicate payouts.
Creative and spectator players do not receive rewards by default. Creative earning can be enabled explicitly in `config.yml`.
## Requirements ## Requirements
- Paper 26.2 - Paper 26.2
- Java 25 for compilation/server runtime - Java 25
- JobsReborn 5.2.6.x - JobsReborn 5.2.6.x
- CMILib, as required by JobsReborn - CMILib, as required by JobsReborn
- GriefPrevention 16.18.6+ (optional; enables protected-claim emergency building and forced entry) - GriefPrevention 16.18.6+ (optional, but recommended for claim-aware dispatch and protected-property response)
- A JobsReborn-compatible economy if monetary payouts are desired - A JobsReborn-compatible economy if monetary payouts are desired
## Build ## Build
The repository includes the Gradle 9.6.1 Wrapper. A global Gradle installation is **not** required. The repository includes the Gradle 9.6.1 Wrapper. A global Gradle installation is not required.
Windows: Windows:
@@ -53,23 +51,27 @@ Linux/macOS:
./gradlew clean build ./gradlew clean build
``` ```
The resulting plugin JAR is written to `build/libs/`. The resulting JAR is written to `build/libs/`.
## Install ## Install
1. Copy `jobs/Firefighter.yml` to: 1. Copy `jobs/Firefighter.yml` to `plugins/Jobs/jobs/Firefighter.yml`.
`plugins/Jobs/jobs/Firefighter.yml`
2. Reload JobsReborn or restart the server. 2. Reload JobsReborn or restart the server.
3. Copy the RoseFirefighter JAR into the server's `plugins/` directory.
3. Copy the RoseFirefighter JAR from `build/libs/` into the server's `plugins/` directory.
4. Restart Paper. 4. Restart Paper.
5. Players can join with the normal JobsReborn command, for example `/jobs join Firefighter`.
5. Players can join the profession with the normal JobsReborn command, for example: ## Duty controls
`/jobs join Firefighter` Firefighters are **on duty by default**. Duty state is persisted by UUID in `duty-status.yml`.
```text
/firefighter onduty
/firefighter offduty
/firefighter status
```
`/firefighter offduty` silences RoseFirefighter dispatch/lifecycle notifications for that Firefighter without forcing them to leave the JobsReborn job. Off-duty players retain emergency capability if they choose to respond manually, but they are not counted toward the minimum on-duty responders required to start an incident.
## JobsReborn payouts ## JobsReborn payouts
@@ -82,113 +84,131 @@ The resulting plugin JAR is written to `build/libs/`.
| `CAMPFIRE` | `COLLECT/CAMPFIRE` | 0.50 | 0.50 | | `CAMPFIRE` | `COLLECT/CAMPFIRE` | 0.50 | 0.50 |
| `SOUL_CAMPFIRE` | `COLLECT/SOUL_CAMPFIRE` | 0.75 | 0.75 | | `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. Emergency fires additionally apply `emergencies.reward-multiplier`, which defaults to `5`.
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`. ## Random incident dispatch
## Emergency sites Fixed sites are optional. Automatic incidents and `/firefighter emergency start random` can select random locations without any configured sites.
Stand at the center of an area where controlled emergencies may appear: When GriefPrevention is installed and `emergencies.random-locations.prefer-griefprevention-claims` is enabled, RoseFirefighter prefers a random top-level player-owned claim. The incident is labeled using the current/known claim owner, for example:
```text
🔥 FIRE ALERT! Fire reported at The_Rose94's home in world near X:123 Y:68 Z:-245.
```
On-duty Firefighters also receive a dispatch-detail line containing:
- Exact world and coordinates.
- Nearby GriefPrevention claim owners.
- Approximate distance to nearby claims, including `inside` when the incident is within the claim.
If no suitable player claim is available, RoseFirefighter can fall back to a random currently loaded world/chunk location and then to a configured fixed site. All of these behaviors are configurable.
## Fixed emergency sites
Fixed sites remain available for special locations or manual testing:
```text ```text
/firefighter site add RoseGarden 18 /firefighter site add RoseGarden 18
```
Other management commands:
```text
/firefighter status
/firefighter site remove <name> /firefighter site remove <name>
/firefighter site list /firefighter site list
/firefighter emergency start <site> /firefighter emergency start <site>
/firefighter emergency start random
/firefighter emergency stop /firefighter emergency stop
/firefighter reload /firefighter reload
``` ```
`rosefirefighter.admin` is required for site, emergency, and reload management and defaults to server operators. `rosefirefighter.admin` is required for site, emergency, and reload management and defaults to server operators.
## Emergency behavior ## Natural fire spread and instant rollback
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 is allowed to behave naturally **inside the active response radius** when `emergencies.fire-spread` and `emergencies.fire-damage` are enabled.
Emergency fire generation and response behavior: RoseFirefighter treats the incident as a temporary world transaction:
- Uses admin-defined named sites and radii. - Initial emergency fires are tracked.
- Can be limited to configured world names with `emergencies.allowed-worlds`. - Every naturally spread fire becomes another tracked emergency fire.
- Places only temporary fire blocks on safe solid support blocks. - Fire spread outside the incident boundary is cancelled.
- Does not replace the supporting block. - Before emergency fire changes or burns a block, RoseFirefighter captures the first pre-incident `BlockState` for that location.
- Cancels emergency-fire spread and spread ignition. - The first snapshot wins, so later changes cannot overwrite the original property state.
- Cancels nearby block burning. - Tile/block-entity state is retained.
- Tracks and removes remaining emergency fire on completion, manual stop, expiry, plugin disable, or server shutdown. - Container inventories are copied and protected from becoming duplicate fire loot before rollback.
- Grants on-duty Firefighters the temporary `rosefirefighter.emergency.build` runtime permission while an incident is active. - Fire-triggered TNT is blocked by default to prevent explosion chains from escaping the controlled incident boundary.
- When GriefPrevention is installed, uses its claim-permission API to allow emergency building, water use, and temporary forced entry only inside the active site. - Secondary physics damage is suppressed by default so falling blocks and support-dependent decorations do not create untracked damage or duplicate blocks.
- With `emergencies.forced-entry: true`, protected doors, windows, walls, and other blocks can be temporarily breached by an on-duty Firefighter. Forced-entry blocks produce no item drops or XP and are restored to their original block states when the incident ends.
- Does **not** grant `/IgnoreClaims` or global `griefprevention.adminclaims`, so an incident does not become a server-wide claim bypass.
- Tracks blocks Firefighters place at the incident and restores the replaced block state when the incident ends.
- Returns one consumed placement item per still-tracked placement to the Firefighter who placed it. Blocks the player already broke are removed from the return ledger to avoid duplication.
- Saves returns for offline Firefighters in `pending-returns.yml` and delivers them on their next join.
- Periodically removes stale tracked fire entries if an emergency fire disappears through another legitimate game event.
- Broadcasts configurable alerts, by default to all online players.
Example alert: When the incident is contained, manually stopped, expires, the plugin disables, or the server performs a normal shutdown, RoseFirefighter immediately:
```text 1. Removes all remaining tracked emergency fire, including spread fire.
🔥 FIRE ALERT! A fire has been reported at RoseGarden near X:123 Y:64 Z:-245. Firefighters are needed immediately! 2. Removes/restores temporary Firefighter construction.
``` 3. Restores forced-entry damage.
4. Restores all captured fire-damaged blocks to their first pre-incident state.
5. Restores preserved container contents.
6. Returns eligible temporary response-block items to the Firefighters who placed them.
## Protected-site integration Offline Firefighter item returns are saved in `pending-returns.yml` and delivered on the player's next join.
`emergencies.protected-site-building` controls temporary emergency build access. The permission is granted with Bukkit's runtime `PermissionAttachment`, so it is temporary and does not create permanent LuckPerms/permissions-plugin data. ## GriefPrevention integration and forced entry
With GriefPrevention installed, RoseFirefighter listens to `ClaimPermissionCheckEvent` and clears a denied `Build` check only when all of the following are true: RoseFirefighter never enables `/IgnoreClaims` and never gives Firefighters a global `griefprevention.adminclaims` bypass.
- An emergency is active. Instead, during an active incident it uses GriefPrevention's claim-permission API to allow the temporary response permission only when:
- The player is currently working the JobsReborn Firefighter job.
- The player currently has RoseFirefighter's temporary emergency-build attachment.
- The affected block is inside the active site's configured radius.
- The action is emergency response construction, water use, extinguishing emergency fire, breaking a tracked temporary Firefighter block, or a forced-entry break when `emergencies.forced-entry` is enabled.
Forced-entry damage is temporary. The plugin snapshots the original block states, suppresses drops and XP, removes the breached blocks without physics, and restores those original states when the emergency is cleared, stopped, expires, or the plugin shuts down. Multi-block doors and beds are handled together so the structure can be restored correctly. - The player is working the JobsReborn Firefighter job.
- The emergency is active.
- The affected block is inside the active incident radius.
`emergencies.cleanup-firefighter-blocks` controls whether placements made by Firefighters inside the active site are added to the temporary response-construction ledger. Inside that radius Firefighters can place response blocks, empty water buckets, extinguish fire, and—when `emergencies.forced-entry: true`—make temporary forced entry through protected doors, windows, walls, and other property.
## Configuration Forced-entry blocks:
- Produce no item drops.
- Produce no XP.
- Are captured in the same first-state rollback ledger.
- Are automatically restored when the incident ends.
Temporary build access uses Bukkit runtime `PermissionAttachment` with `rosefirefighter.emergency.build`; it does not create permanent LuckPerms data.
## Emergency configuration highlights
`src/main/resources/config.yml` includes controls for: `src/main/resources/config.yml` includes controls for:
- JobsReborn job name. - Emergency enable/disable and scheduling.
- Normal fire, soul fire, campfire, and soul-campfire action submission. - Minimum **on-duty** Firefighters.
- Duplicate reward cooldown. - Random claim/location dispatch.
- Player-created-fire and player-lava tracking retention. - Fixed-site fallback.
- Creative-mode earning. - Nearby-claim reporting radius/count.
- Water attribution scan radius. - Fire spread and fire damage.
- Emergency enable/disable and automatic scheduling. - Instant fire-damage restoration.
- Minimum online Firefighters. - Fire-triggered TNT prevention.
- Emergency duration and fire counts. - Secondary physics protection.
- Protected-site building.
- Forced entry.
- Temporary response-block cleanup and item return.
- Emergency duration and initial fire count.
- Emergency reward multiplier. - Emergency reward multiplier.
- Permitted worlds.
- Announcement behavior. - Announcement behavior.
- Permitted emergency worlds.
- Protected-site emergency building, temporary forced entry, and temporary Firefighter block cleanup.
- Saved emergency sites.
## Development notes ## Development dependencies
Paper 26.2 uses the current Paper dependency format and Java 25 toolchain: Paper 26.2:
```kotlin ```kotlin
compileOnly("io.papermc.paper:paper-api:26.2.build.+") compileOnly("io.papermc.paper:paper-api:26.2.build.+")
``` ```
JobsReborn is referenced through its documented JitPack coordinate: JobsReborn:
```kotlin ```kotlin
compileOnly("com.github.Zrips:Jobs:v5.2.6.2") compileOnly("com.github.Zrips:Jobs:v5.2.6.2") {
isTransitive = false
}
``` ```
GriefPrevention is an optional compile-time API dependency and a runtime soft dependency: GriefPrevention:
```kotlin ```kotlin
compileOnly("com.github.GriefPrevention:GriefPrevention:16.18.6") compileOnly("com.github.GriefPrevention:GriefPrevention:16.18.6") {
isTransitive = false
}
``` ```
The JobsReborn and GriefPrevention dependencies are non-transitive because RoseFirefighter only needs their public API classes and should not pull their optional integrations into its compile classpath.
@@ -1,5 +1,6 @@
package net.therosegarden.firefighter; package net.therosegarden.firefighter;
import io.papermc.paper.block.TileStateInventoryHolder;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
@@ -30,7 +31,7 @@ final class EmergencyConstructionManager implements Listener {
private final EmergencyPermissionManager permissions; private final EmergencyPermissionManager permissions;
private final Map<UUID, Placement> placements = new HashMap<>(); private final Map<UUID, Placement> placements = new HashMap<>();
private final Map<BlockKey, UUID> byBlock = new HashMap<>(); private final Map<BlockKey, UUID> byBlock = new HashMap<>();
private final Map<BlockKey, ForcedEntry> forcedEntries = new HashMap<>(); private final Map<BlockKey, IncidentSnapshot> incidentDamage = new HashMap<>();
private final Map<UUID, List<ItemStack>> pendingReturns = new HashMap<>(); private final Map<UUID, List<ItemStack>> pendingReturns = new HashMap<>();
private final File pendingFile; private final File pendingFile;
private EmergencySite activeSite; private EmergencySite activeSite;
@@ -47,7 +48,7 @@ final class EmergencyConstructionManager implements Listener {
activeSite = site; activeSite = site;
placements.clear(); placements.clear();
byBlock.clear(); byBlock.clear();
forcedEntries.clear(); incidentDamage.clear();
} }
boolean isTemporaryBlock(Block block) { boolean isTemporaryBlock(Block block) {
@@ -58,8 +59,65 @@ final class EmergencyConstructionManager implements Listener {
return plugin.getConfig().getBoolean("emergencies.forced-entry", true); return plugin.getConfig().getBoolean("emergencies.forced-entry", true);
} }
boolean isFireDamageRestoreEnabled() {
return plugin.getConfig().getBoolean("emergencies.restore-fire-damage", true);
}
void captureIncidentDamage(Block block) {
captureDamage(block);
}
void captureFireDamage(Block block) {
captureDamage(block);
}
private void captureDamage(Block block) {
if (activeSite == null || block == null || !activeSite.contains(block.getLocation()) || isTemporaryBlock(block)) {
return;
}
BlockKey key = BlockKey.of(block);
if (incidentDamage.containsKey(key)) {
return;
}
BlockState state = block.getState();
ItemStack[] inventoryContents = null;
if (state instanceof TileStateInventoryHolder holder) {
inventoryContents = cloneContents(holder.getSnapshotInventory().getContents());
}
incidentDamage.put(key, new IncidentSnapshot(state, inventoryContents));
}
void prepareFireBurn(Block block) {
if (block == null || !incidentDamage.containsKey(BlockKey.of(block))) {
return;
}
BlockState state = block.getState();
if (state instanceof TileStateInventoryHolder holder) {
holder.getInventory().clear();
}
}
boolean isIncidentDamaged(Block block) {
return block != null && incidentDamage.containsKey(BlockKey.of(block));
}
boolean isNearIncidentDamage(Block block, int radius) {
if (block == null) {
return false;
}
BlockKey target = BlockKey.of(block);
for (BlockKey damaged : incidentDamage.keySet()) {
if (damaged.isWithin(target, radius)) {
return true;
}
}
return false;
}
void cleanupAndReturn() { void cleanupAndReturn() {
if (placements.isEmpty() && forcedEntries.isEmpty()) { if (placements.isEmpty() && incidentDamage.isEmpty()) {
activeSite = null; activeSite = null;
return; return;
} }
@@ -69,7 +127,7 @@ final class EmergencyConstructionManager implements Listener {
boolean restoredAny = false; boolean restoredAny = false;
for (PlacedBlock placed : placement.blocks()) { for (PlacedBlock placed : placement.blocks()) {
Block current = placed.key().block(); Block current = placed.key().block();
if (current != null && current.getType() == placed.placedType()) { if (current != null) {
placed.replacedState().update(true, false); placed.replacedState().update(true, false);
restoredAny = true; restoredAny = true;
} }
@@ -82,14 +140,18 @@ final class EmergencyConstructionManager implements Listener {
placements.clear(); placements.clear();
byBlock.clear(); byBlock.clear();
// Restore forced-entry damage after temporary response construction has // The first pre-incident snapshot wins. Restoring these after temporary
// been removed. BlockState snapshots preserve block data and tile state. // response construction ensures the original property state wins.
for (ForcedEntry entry : new ArrayList<>(forcedEntries.values())) { for (IncidentSnapshot snapshot : new ArrayList<>(incidentDamage.values())) {
for (BlockState state : entry.originalStates()) { snapshot.state().update(true, false);
state.update(true, false); if (snapshot.inventoryContents() != null) {
BlockState restored = snapshot.state().getBlock().getState();
if (restored instanceof TileStateInventoryHolder holder) {
holder.getInventory().setContents(cloneContents(snapshot.inventoryContents()));
} }
} }
forcedEntries.clear(); }
incidentDamage.clear();
activeSite = null; activeSite = null;
for (Map.Entry<UUID, List<ItemStack>> entry : returns.entrySet()) { for (Map.Entry<UUID, List<ItemStack>> entry : returns.entrySet()) {
@@ -173,8 +235,9 @@ final class EmergencyConstructionManager implements Listener {
return; return;
} }
BlockKey primary = BlockKey.of(block); for (BlockState state : states) {
forcedEntries.putIfAbsent(primary, new ForcedEntry(player.getUniqueId(), List.copyOf(states))); captureIncidentDamage(state.getBlock());
}
// Forced entry is temporary incident damage, not resource harvesting. // Forced entry is temporary incident damage, not resource harvesting.
// Remove involved blocks without physics, item drops, or experience. // Remove involved blocks without physics, item drops, or experience.
@@ -224,6 +287,14 @@ final class EmergencyConstructionManager implements Listener {
states.add(block.getState()); states.add(block.getState());
} }
private ItemStack[] cloneContents(ItemStack[] contents) {
ItemStack[] copy = new ItemStack[contents.length];
for (int i = 0; i < contents.length; i++) {
copy[i] = contents[i] == null ? null : contents[i].clone();
}
return copy;
}
@EventHandler @EventHandler
public void onJoin(PlayerJoinEvent event) { public void onJoin(PlayerJoinEvent event) {
List<ItemStack> items = pendingReturns.remove(event.getPlayer().getUniqueId()); List<ItemStack> items = pendingReturns.remove(event.getPlayer().getUniqueId());
@@ -305,5 +376,5 @@ final class EmergencyConstructionManager implements Listener {
private record Placement(UUID id, UUID owner, ItemStack returnItem, List<PlacedBlock> blocks) {} private record Placement(UUID id, UUID owner, ItemStack returnItem, List<PlacedBlock> blocks) {}
private record PlacedBlock(BlockKey key, Material placedType, BlockState replacedState) {} private record PlacedBlock(BlockKey key, Material placedType, BlockState replacedState) {}
private record ForcedEntry(UUID firefighter, List<BlockState> originalStates) {} private record IncidentSnapshot(BlockState state, ItemStack[] inventoryContents) {}
} }
@@ -0,0 +1,12 @@
package net.therosegarden.firefighter;
import java.util.Random;
import java.util.function.Predicate;
import org.bukkit.Location;
import org.bukkit.World;
interface EmergencyLocationProvider {
EmergencySite randomClaimSite(Random random, Predicate<World> worldAllowed, int maxRadius, int minClaimArea);
String nearbyClaims(Location location, int radius, int maxResults);
}
@@ -11,6 +11,7 @@ import java.util.Map;
import java.util.Random; import java.util.Random;
import java.util.Set; import java.util.Set;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.HeightMap; import org.bukkit.HeightMap;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material; import org.bukkit.Material;
@@ -29,6 +30,8 @@ final class EmergencyManager {
private final Set<BlockKey> activeFires = new HashSet<>(); private final Set<BlockKey> activeFires = new HashSet<>();
private EmergencyPermissionManager permissions; private EmergencyPermissionManager permissions;
private EmergencyConstructionManager construction; private EmergencyConstructionManager construction;
private FirefighterDutyManager duty;
private EmergencyLocationProvider locationProvider;
private EmergencySite activeSite; private EmergencySite activeSite;
private BukkitTask expiryTask; private BukkitTask expiryTask;
@@ -45,6 +48,11 @@ final class EmergencyManager {
this.construction = construction; this.construction = construction;
} }
void setDispatchServices(FirefighterDutyManager duty, EmergencyLocationProvider locationProvider) {
this.duty = duty;
this.locationProvider = locationProvider;
}
void reload() { void reload() {
loadSites(); loadSites();
scheduleNextAutomaticEmergency(); scheduleNextAutomaticEmergency();
@@ -93,7 +101,7 @@ final class EmergencyManager {
} }
boolean addSite(String name, Location location, int radius) { boolean addSite(String name, Location location, int radius) {
if (name == null || !name.matches("[A-Za-z0-9_-]{1,32}") || location.getWorld() == null) { if (name == null || name.equalsIgnoreCase("random") || !name.matches("[A-Za-z0-9_-]{1,32}") || location.getWorld() == null) {
return false; return false;
} }
@@ -132,13 +140,22 @@ final class EmergencyManager {
} }
boolean siteExists(String name) { boolean siteExists(String name) {
return name != null && sites.containsKey(name.toLowerCase(Locale.ROOT)); if (name == null) {
return false;
}
if (name.equalsIgnoreCase("random")) {
return randomLocationsEnabled() || !sites.isEmpty();
}
return sites.containsKey(name.toLowerCase(Locale.ROOT));
} }
boolean isSitePermitted(String name) { boolean isSitePermitted(String name) {
if (name == null) { if (name == null) {
return false; return false;
} }
if (name.equalsIgnoreCase("random")) {
return true;
}
EmergencySite site = sites.get(name.toLowerCase(Locale.ROOT)); EmergencySite site = sites.get(name.toLowerCase(Locale.ROOT));
if (site == null) { if (site == null) {
return false; return false;
@@ -169,6 +186,10 @@ final class EmergencyManager {
return plugin.getConfig().getBoolean("emergencies.enabled", true); return plugin.getConfig().getBoolean("emergencies.enabled", true);
} }
boolean randomLocationsEnabled() {
return plugin.getConfig().getBoolean("emergencies.random-locations.enabled", true);
}
int requiredOnlineFirefighters() { int requiredOnlineFirefighters() {
if (!plugin.getConfig().getBoolean("emergencies.require-firefighters-online", true)) { if (!plugin.getConfig().getBoolean("emergencies.require-firefighters-online", true)) {
return 0; return 0;
@@ -185,10 +206,23 @@ final class EmergencyManager {
} }
boolean isEmergencyFire(Block block) { boolean isEmergencyFire(Block block) {
return isEmergencyFire(BlockKey.of(block)); return block != null && isEmergencyFire(BlockKey.of(block));
}
boolean isInsideActiveSite(Location location) {
return activeSite != null && activeSite.contains(location);
}
void registerEmergencyFire(Block block) {
if (block != null && isInsideActiveSite(block.getLocation())) {
activeFires.add(BlockKey.of(block));
}
} }
boolean isNearEmergencyFire(Block block, int radius) { boolean isNearEmergencyFire(Block block, int radius) {
if (block == null) {
return false;
}
BlockKey target = BlockKey.of(block); BlockKey target = BlockKey.of(block);
for (BlockKey fire : activeFires) { for (BlockKey fire : activeFires) {
if (fire.isWithin(target, radius)) { if (fire.isWithin(target, radius)) {
@@ -199,31 +233,50 @@ final class EmergencyManager {
} }
boolean startEmergency(String siteName) { boolean startEmergency(String siteName) {
if (!emergenciesEnabled() || activeSite != null || sites.isEmpty()) { if (!emergenciesEnabled() || activeSite != null) {
return false; return false;
} }
if (onlineFirefighterCount() < requiredOnlineFirefighters()) { if (onlineOnDutyFirefighterCount() < requiredOnlineFirefighters()) {
return false; return false;
} }
EmergencySite site = selectSite(siteName); boolean randomRequested = siteName == null || siteName.equalsIgnoreCase("random");
if (site == null) { int candidateAttempts = randomRequested
return false; ? Math.max(1, plugin.getConfig().getInt("emergencies.random-locations.attempts", 12))
} : 1;
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 minimum = Math.max(1, plugin.getConfig().getInt("emergencies.fires-min", 6));
int maximum = Math.max(minimum, plugin.getConfig().getInt("emergencies.fires-max", 12)); int maximum = Math.max(minimum, plugin.getConfig().getInt("emergencies.fires-max", 12));
int targetCount = minimum + random.nextInt(maximum - minimum + 1); int targetCount = minimum + random.nextInt(maximum - minimum + 1);
Set<BlockKey> spawned = generateFireLocations(site, targetCount); EmergencySite site = null;
if (spawned.isEmpty()) { Set<BlockKey> spawned = Set.of();
plugin.getLogger().warning("Could not find safe surfaces for a Firefighter emergency at site '" + site.name() + "'."); for (int attempt = 0; attempt < candidateAttempts; attempt++) {
site = randomRequested ? selectAutomaticSite() : selectConfiguredSite(siteName);
if (site == null) {
break;
}
Location center = site.center();
if (center == null || center.getWorld() == null || !isWorldPermitted(center.getWorld())) {
if (!randomRequested) {
break;
}
continue;
}
spawned = generateFireLocations(site, targetCount);
if (!spawned.isEmpty()) {
break;
}
if (!randomRequested) {
break;
}
}
if (site == null || spawned.isEmpty()) {
plugin.getLogger().warning("Could not find a suitable location for a Firefighter emergency.");
return false; return false;
} }
@@ -240,17 +293,14 @@ final class EmergencyManager {
expiryTask = Bukkit.getScheduler().runTaskLater(plugin, () -> endEmergency(false), durationMinutes * 60L * 20L); expiryTask = Bukkit.getScheduler().runTaskLater(plugin, () -> endEmergency(false), durationMinutes * 60L * 20L);
startIntegrityTask(); startIntegrityTask();
notifyResponders(plugin.message("messages.alert") notifyEmergencyStart(site);
.replace("%site%", site.name())
.replace("%x%", Integer.toString(site.x()))
.replace("%y%", Integer.toString(site.y()))
.replace("%z%", Integer.toString(site.z())));
return true; return true;
} }
private EmergencySite selectSite(String siteName) { private EmergencySite selectConfiguredSite(String siteName) {
if (siteName != null) { if (siteName == null) {
return randomConfiguredSite();
}
EmergencySite requested = sites.get(siteName.toLowerCase(Locale.ROOT)); EmergencySite requested = sites.get(siteName.toLowerCase(Locale.ROOT));
if (requested == null) { if (requested == null) {
return null; return null;
@@ -259,6 +309,35 @@ final class EmergencyManager {
return world != null && isWorldPermitted(world) ? requested : null; return world != null && isWorldPermitted(world) ? requested : null;
} }
private EmergencySite selectAutomaticSite() {
if (randomLocationsEnabled()) {
if (locationProvider != null
&& plugin.getConfig().getBoolean("emergencies.random-locations.prefer-griefprevention-claims", true)) {
int maxRadius = Math.max(2, plugin.getConfig().getInt("emergencies.random-locations.claim-radius-max",
plugin.getConfig().getInt("emergencies.default-site-radius", 18)));
int minArea = Math.max(1, plugin.getConfig().getInt("emergencies.random-locations.min-claim-area", 25));
EmergencySite claimSite = locationProvider.randomClaimSite(random, this::isWorldPermitted, maxRadius, minArea);
if (claimSite != null) {
return claimSite;
}
}
if (plugin.getConfig().getBoolean("emergencies.random-locations.loaded-world-fallback", true)) {
EmergencySite randomSite = randomLoadedLocation();
if (randomSite != null) {
return randomSite;
}
}
}
if (plugin.getConfig().getBoolean("emergencies.random-locations.configured-sites-fallback", true)
|| !randomLocationsEnabled()) {
return randomConfiguredSite();
}
return null;
}
private EmergencySite randomConfiguredSite() {
List<EmergencySite> eligible = new ArrayList<>(); List<EmergencySite> eligible = new ArrayList<>();
for (EmergencySite site : sites.values()) { for (EmergencySite site : sites.values()) {
World world = Bukkit.getWorld(site.worldName()); World world = Bukkit.getWorld(site.worldName());
@@ -269,6 +348,30 @@ final class EmergencyManager {
return eligible.isEmpty() ? null : eligible.get(random.nextInt(eligible.size())); return eligible.isEmpty() ? null : eligible.get(random.nextInt(eligible.size()));
} }
private EmergencySite randomLoadedLocation() {
List<World> worlds = new ArrayList<>();
for (World world : Bukkit.getWorlds()) {
if (isWorldPermitted(world) && world.getLoadedChunks().length > 0) {
worlds.add(world);
}
}
if (worlds.isEmpty()) {
return null;
}
World world = worlds.get(random.nextInt(worlds.size()));
Chunk[] chunks = world.getLoadedChunks();
if (chunks.length == 0) {
return null;
}
Chunk chunk = chunks[random.nextInt(chunks.length)];
int x = (chunk.getX() << 4) + random.nextInt(16);
int z = (chunk.getZ() << 4) + random.nextInt(16);
int y = world.getHighestBlockAt(x, z, HeightMap.MOTION_BLOCKING).getY() + 1;
int radius = Math.max(3, plugin.getConfig().getInt("emergencies.default-site-radius", 18));
return new EmergencySite("an area in " + world.getName(), world.getName(), x, y, z, radius);
}
private Set<BlockKey> generateFireLocations(EmergencySite site, int targetCount) { private Set<BlockKey> generateFireLocations(EmergencySite site, int targetCount) {
Set<BlockKey> result = new HashSet<>(); Set<BlockKey> result = new HashSet<>();
Location center = site.center(); Location center = site.center();
@@ -279,8 +382,8 @@ final class EmergencyManager {
World world = center.getWorld(); World world = center.getWorld();
int attempts = Math.max(80, targetCount * 40); int attempts = Math.max(80, targetCount * 40);
// Prefer flammable surfaces so the fire remains visually stable without // Prefer flammable surfaces so the incident can develop naturally. The
// changing the underlying block. BlockBurnEvent is cancelled separately. // response boundary and rollback ledger keep that damage temporary.
for (int pass = 0; pass < 2 && result.size() < targetCount; pass++) { for (int pass = 0; pass < 2 && result.size() < targetCount; pass++) {
for (int attempt = 0; attempt < attempts && result.size() < targetCount; attempt++) { for (int attempt = 0; attempt < attempts && result.size() < targetCount; attempt++) {
int dx = random.nextInt(site.radius() * 2 + 1) - site.radius(); int dx = random.nextInt(site.radius() * 2 + 1) - site.radius();
@@ -371,7 +474,7 @@ final class EmergencyManager {
activeSite = null; activeSite = null;
String key = cleared ? "messages.cleared" : "messages.expired"; String key = cleared ? "messages.cleared" : "messages.expired";
notifyResponders(plugin.message(key).replace("%site%", endedSite.name())); notifyResponders(formatMessage(plugin.message(key), endedSite));
} }
private void clearEmergencyFireBlocks() { private void clearEmergencyFireBlocks() {
@@ -383,6 +486,33 @@ final class EmergencyManager {
} }
} }
private void notifyEmergencyStart(EmergencySite site) {
String alert = formatMessage(plugin.message("messages.alert"), site);
String details = formatMessage(plugin.message("messages.dispatch-details"), site);
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()) {
boolean firefighter = jobs.isFirefighter(player);
if (firefighter && duty != null && !duty.isOnDuty(player)) {
continue;
}
if (everyone || firefighter) {
player.sendMessage(plugin.prefix() + alert);
}
if (firefighter && !details.isEmpty()) {
player.sendMessage(plugin.prefix() + details);
}
}
}
plugin.getLogger().info(plugin.stripColor(alert));
if (!details.isEmpty()) {
plugin.getLogger().info(plugin.stripColor(details));
}
}
private void notifyResponders(String message) { private void notifyResponders(String message) {
boolean announcements = plugin.getConfig().getBoolean("emergencies.announcements.enabled", true); boolean announcements = plugin.getConfig().getBoolean("emergencies.announcements.enabled", true);
boolean everyone = plugin.getConfig().getBoolean("emergencies.announcements.notify-all-players", boolean everyone = plugin.getConfig().getBoolean("emergencies.announcements.notify-all-players",
@@ -390,7 +520,11 @@ final class EmergencyManager {
if (announcements) { if (announcements) {
for (Player player : Bukkit.getOnlinePlayers()) { for (Player player : Bukkit.getOnlinePlayers()) {
if (everyone || jobs.isFirefighter(player)) { boolean firefighter = jobs.isFirefighter(player);
if (firefighter && duty != null && !duty.isOnDuty(player)) {
continue;
}
if (everyone || firefighter) {
player.sendMessage(plugin.prefix() + message); player.sendMessage(plugin.prefix() + message);
} }
} }
@@ -398,6 +532,26 @@ final class EmergencyManager {
plugin.getLogger().info(plugin.stripColor(message)); plugin.getLogger().info(plugin.stripColor(message));
} }
private String formatMessage(String message, EmergencySite site) {
String nearby = nearbyClaims(site);
return message
.replace("%site%", site.name())
.replace("%world%", site.worldName())
.replace("%x%", Integer.toString(site.x()))
.replace("%y%", Integer.toString(site.y()))
.replace("%z%", Integer.toString(site.z()))
.replace("%nearby_claims%", nearby);
}
private String nearbyClaims(EmergencySite site) {
if (locationProvider == null) {
return "GriefPrevention unavailable";
}
int radius = Math.max(1, plugin.getConfig().getInt("emergencies.random-locations.nearby-claims-radius", 64));
int max = Math.max(1, plugin.getConfig().getInt("emergencies.random-locations.nearby-claims-max", 5));
return locationProvider.nearbyClaims(site.center(), radius, max);
}
int onlineFirefighterCount() { int onlineFirefighterCount() {
int count = 0; int count = 0;
for (Player player : Bukkit.getOnlinePlayers()) { for (Player player : Bukkit.getOnlinePlayers()) {
@@ -408,6 +562,19 @@ final class EmergencyManager {
return count; return count;
} }
int onlineOnDutyFirefighterCount() {
if (duty == null) {
return onlineFirefighterCount();
}
int count = 0;
for (Player player : Bukkit.getOnlinePlayers()) {
if (duty.isOnDuty(player)) {
count++;
}
}
return count;
}
private void scheduleNextAutomaticEmergency() { private void scheduleNextAutomaticEmergency() {
cancelAutomaticTask(); cancelAutomaticTask();
@@ -18,12 +18,17 @@ final class EmergencyPermissionManager implements Listener {
private final JobsFacade jobs; private final JobsFacade jobs;
private final Map<UUID, PermissionAttachment> attachments = new HashMap<>(); private final Map<UUID, PermissionAttachment> attachments = new HashMap<>();
private EmergencySite activeSite; private EmergencySite activeSite;
private FirefighterDutyManager duty;
EmergencyPermissionManager(RoseFirefighterPlugin plugin, JobsFacade jobs) { EmergencyPermissionManager(RoseFirefighterPlugin plugin, JobsFacade jobs) {
this.plugin = plugin; this.plugin = plugin;
this.jobs = jobs; this.jobs = jobs;
} }
void setDutyManager(FirefighterDutyManager duty) {
this.duty = duty;
}
void activate(EmergencySite site) { void activate(EmergencySite site) {
activeSite = site; activeSite = site;
syncResponders(); syncResponders();
@@ -56,6 +61,10 @@ final class EmergencyPermissionManager implements Listener {
return activeSite != null && activeSite.contains(location); return activeSite != null && activeSite.contains(location);
} }
private boolean shouldNotify(Player player) {
return duty == null || duty.isOnDuty(player);
}
private void grant(Player player) { private void grant(Player player) {
if (attachments.containsKey(player.getUniqueId())) { if (attachments.containsKey(player.getUniqueId())) {
return; return;
@@ -63,11 +72,13 @@ final class EmergencyPermissionManager implements Listener {
PermissionAttachment attachment = player.addAttachment(plugin); PermissionAttachment attachment = player.addAttachment(plugin);
attachment.setPermission(BUILD_PERMISSION, true); attachment.setPermission(BUILD_PERMISSION, true);
attachments.put(player.getUniqueId(), attachment); attachments.put(player.getUniqueId(), attachment);
if (shouldNotify(player)) {
String message = plugin.message("messages.temporary-build-enabled"); String message = plugin.message("messages.temporary-build-enabled");
if (!message.isEmpty()) { if (!message.isEmpty()) {
player.sendMessage(plugin.prefix() + message); player.sendMessage(plugin.prefix() + message);
} }
} }
}
private void revoke(Player player, boolean notify) { private void revoke(Player player, boolean notify) {
PermissionAttachment attachment = attachments.remove(player.getUniqueId()); PermissionAttachment attachment = attachments.remove(player.getUniqueId());
@@ -75,7 +86,7 @@ final class EmergencyPermissionManager implements Listener {
return; return;
} }
player.removeAttachment(attachment); player.removeAttachment(attachment);
if (notify) { if (notify && shouldNotify(player)) {
String message = plugin.message("messages.temporary-build-disabled"); String message = plugin.message("messages.temporary-build-disabled");
if (!message.isEmpty()) { if (!message.isEmpty()) {
player.sendMessage(plugin.prefix() + message); player.sendMessage(plugin.prefix() + message);
@@ -11,19 +11,25 @@ import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBurnEvent; import org.bukkit.event.block.BlockBurnEvent;
import org.bukkit.event.block.BlockFromToEvent; import org.bukkit.event.block.BlockFromToEvent;
import org.bukkit.event.block.BlockIgniteEvent; import org.bukkit.event.block.BlockIgniteEvent;
import org.bukkit.event.block.BlockPhysicsEvent;
import org.bukkit.event.block.BlockSpreadEvent; import org.bukkit.event.block.BlockSpreadEvent;
import org.bukkit.event.block.TNTPrimeEvent;
import org.bukkit.event.hanging.HangingBreakEvent;
import org.bukkit.event.player.PlayerBucketEmptyEvent; import org.bukkit.event.player.PlayerBucketEmptyEvent;
final class FireSafetyAndOriginListener implements Listener { final class FireSafetyAndOriginListener implements Listener {
private final RoseFirefighterPlugin plugin; private final RoseFirefighterPlugin plugin;
private final FirefighterRewardService rewards; private final FirefighterRewardService rewards;
private final EmergencyManager emergencies; private final EmergencyManager emergencies;
private final EmergencyConstructionManager construction;
private final OriginTracker playerLava; private final OriginTracker playerLava;
FireSafetyAndOriginListener(RoseFirefighterPlugin plugin, FirefighterRewardService rewards, EmergencyManager emergencies) { FireSafetyAndOriginListener(RoseFirefighterPlugin plugin, FirefighterRewardService rewards,
EmergencyManager emergencies, EmergencyConstructionManager construction) {
this.plugin = plugin; this.plugin = plugin;
this.rewards = rewards; this.rewards = rewards;
this.emergencies = emergencies; this.emergencies = emergencies;
this.construction = construction;
this.playerLava = new OriginTracker(playerLavaMemoryMillis()); this.playerLava = new OriginTracker(playerLavaMemoryMillis());
} }
@@ -35,25 +41,119 @@ final class FireSafetyAndOriginListener implements Listener {
return Math.max(1L, plugin.getConfig().getLong("rewards.player-placed-lava-memory-seconds", 600L)) * 1000L; return Math.max(1L, plugin.getConfig().getLong("rewards.player-placed-lava-memory-seconds", 600L)) * 1000L;
} }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) private boolean emergencySpreadEnabled() {
public void protectEmergencySpread(BlockSpreadEvent event) { return plugin.getConfig().getBoolean("emergencies.fire-spread", true);
if (emergencies.isEmergencyFire(event.getSource())) {
event.setCancelled(true);
}
} }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) private boolean emergencyFireDamageEnabled() {
public void protectEmergencyIgnition(BlockIgniteEvent event) { return plugin.getConfig().getBoolean("emergencies.fire-damage", true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void controlEmergencySpread(BlockSpreadEvent event) {
if (!emergencies.isEmergencyFire(event.getSource())) {
return;
}
if (!emergencySpreadEnabled() || !emergencies.isInsideActiveSite(event.getBlock().getLocation())) {
event.setCancelled(true);
return;
}
construction.captureIncidentDamage(event.getBlock());
event.setCancelled(false);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void registerEmergencySpread(BlockSpreadEvent event) {
if (emergencies.isEmergencyFire(event.getSource()) && emergencies.isInsideActiveSite(event.getBlock().getLocation())) {
emergencies.registerEmergencyFire(event.getBlock());
}
rewards.propagatePlayerCreated(event.getSource(), event.getBlock());
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void controlEmergencyIgnition(BlockIgniteEvent event) {
Block source = event.getIgnitingBlock(); Block source = event.getIgnitingBlock();
if (event.getCause() == BlockIgniteEvent.IgniteCause.SPREAD if (event.getCause() != BlockIgniteEvent.IgniteCause.SPREAD || source == null || !emergencies.isEmergencyFire(source)) {
&& source != null && emergencies.isEmergencyFire(source)) { return;
}
if (!emergencySpreadEnabled() || !emergencies.isInsideActiveSite(event.getBlock().getLocation())) {
event.setCancelled(true);
return;
}
construction.captureIncidentDamage(event.getBlock());
event.setCancelled(false);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void controlEmergencyBurn(BlockBurnEvent event) {
Block source = event.getIgnitingBlock();
boolean emergencyCaused = source != null
? emergencies.isEmergencyFire(source)
: emergencies.isNearEmergencyFire(event.getBlock(), 1);
if (!emergencyCaused) {
return;
}
if (!emergencyFireDamageEnabled() || !emergencies.isInsideActiveSite(event.getBlock().getLocation())) {
event.setCancelled(true);
return;
}
if (construction.isFireDamageRestoreEnabled()) {
construction.captureFireDamage(event.getBlock());
}
event.setCancelled(false);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void prepareEmergencyBurn(BlockBurnEvent event) {
Block source = event.getIgnitingBlock();
boolean emergencyCaused = source != null
? emergencies.isEmergencyFire(source)
: emergencies.isNearEmergencyFire(event.getBlock(), 1);
if (emergencyCaused && emergencies.isInsideActiveSite(event.getBlock().getLocation())) {
construction.prepareFireBurn(event.getBlock());
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void preventEmergencyTnt(TNTPrimeEvent event) {
if (event.getCause() != TNTPrimeEvent.PrimeCause.FIRE) {
return;
}
Block source = event.getPrimingBlock();
if (source != null && emergencies.isEmergencyFire(source)
&& plugin.getConfig().getBoolean("emergencies.prevent-fire-tnt", true)) {
event.setCancelled(true); event.setCancelled(true);
} }
} }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void protectEmergencyBurn(BlockBurnEvent event) { public void preventSecondaryPhysics(BlockPhysicsEvent event) {
if (emergencies.isNearEmergencyFire(event.getBlock(), 2)) { if (!plugin.getConfig().getBoolean("emergencies.prevent-secondary-physics-damage", true)
|| !emergencies.isInsideActiveSite(event.getBlock().getLocation())) {
return;
}
Block source = event.getSourceBlock();
if ((source != null && (emergencies.isEmergencyFire(source) || construction.isIncidentDamaged(source)))
|| construction.isNearIncidentDamage(event.getBlock(), 1)) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void protectHangingDecorations(HangingBreakEvent event) {
if (event.getCause() != HangingBreakEvent.RemoveCause.PHYSICS
|| !plugin.getConfig().getBoolean("emergencies.prevent-secondary-physics-damage", true)) {
return;
}
Block block = event.getEntity().getLocation().getBlock();
if (emergencies.isInsideActiveSite(block.getLocation()) && construction.isNearIncidentDamage(block, 1)) {
event.setCancelled(true); event.setCancelled(true);
} }
} }
@@ -61,6 +161,14 @@ final class FireSafetyAndOriginListener implements Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void trackIgnition(BlockIgniteEvent event) { public void trackIgnition(BlockIgniteEvent event) {
Block fire = event.getBlock(); Block fire = event.getBlock();
Block source = event.getIgnitingBlock();
if (event.getCause() == BlockIgniteEvent.IgniteCause.SPREAD
&& source != null && emergencies.isEmergencyFire(source)
&& emergencies.isInsideActiveSite(fire.getLocation())) {
emergencies.registerEmergencyFire(fire);
return;
}
// Direct player ignition, including flint-and-steel and fire charges, // Direct player ignition, including flint-and-steel and fire charges,
// is never considered a legitimate payable fire. // is never considered a legitimate payable fire.
@@ -85,7 +193,6 @@ final class FireSafetyAndOriginListener implements Listener {
return; return;
} }
Block source = event.getIgnitingBlock();
if (source == null) { if (source == null) {
return; return;
} }
@@ -97,11 +204,6 @@ final class FireSafetyAndOriginListener implements Listener {
} }
} }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void trackFireSpread(BlockSpreadEvent event) {
rewards.propagatePlayerCreated(event.getSource(), event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void trackLavaBucket(PlayerBucketEmptyEvent event) { public void trackLavaBucket(PlayerBucketEmptyEvent event) {
if (event.getBucket() == Material.LAVA_BUCKET) { if (event.getBucket() == Material.LAVA_BUCKET) {
@@ -15,11 +15,13 @@ final class FirefighterCommand implements CommandExecutor, TabCompleter {
private final RoseFirefighterPlugin plugin; private final RoseFirefighterPlugin plugin;
private final JobsFacade jobs; private final JobsFacade jobs;
private final EmergencyManager emergencies; private final EmergencyManager emergencies;
private final FirefighterDutyManager duty;
FirefighterCommand(RoseFirefighterPlugin plugin, JobsFacade jobs, EmergencyManager emergencies) { FirefighterCommand(RoseFirefighterPlugin plugin, JobsFacade jobs, EmergencyManager emergencies, FirefighterDutyManager duty) {
this.plugin = plugin; this.plugin = plugin;
this.jobs = jobs; this.jobs = jobs;
this.emergencies = emergencies; this.emergencies = emergencies;
this.duty = duty;
} }
@Override @Override
@@ -38,6 +40,14 @@ final class FirefighterCommand implements CommandExecutor, TabCompleter {
showStatus(sender); showStatus(sender);
return true; return true;
} }
if (root.equals("onduty")) {
setDuty(sender, true);
return true;
}
if (root.equals("offduty")) {
setDuty(sender, false);
return true;
}
if (!sender.hasPermission("rosefirefighter.admin")) { if (!sender.hasPermission("rosefirefighter.admin")) {
sender.sendMessage(plugin.prefix() + plugin.color("&cYou do not have permission to manage the Fire Department.")); sender.sendMessage(plugin.prefix() + plugin.color("&cYou do not have permission to manage the Fire Department."));
@@ -53,6 +63,20 @@ final class FirefighterCommand implements CommandExecutor, TabCompleter {
return true; return true;
} }
private void setDuty(CommandSender sender, boolean onDuty) {
if (!(sender instanceof Player player)) {
sender.sendMessage(plugin.prefix() + plugin.color("&cDuty status can only be changed by a player."));
return;
}
if (!jobs.isFirefighter(player)) {
sender.sendMessage(plugin.prefix() + plugin.message("messages.joined-needed"));
return;
}
duty.setOnDuty(player, onDuty);
String key = onDuty ? "messages.on-duty" : "messages.off-duty";
player.sendMessage(plugin.prefix() + plugin.message(key));
}
private void reload(CommandSender sender) { private void reload(CommandSender sender) {
plugin.reloadRuntimeConfiguration(true); plugin.reloadRuntimeConfiguration(true);
sender.sendMessage(plugin.prefix() + plugin.color("&aRoseFirefighter configuration reloaded.")); sender.sendMessage(plugin.prefix() + plugin.color("&aRoseFirefighter configuration reloaded."));
@@ -67,7 +91,7 @@ final class FirefighterCommand implements CommandExecutor, TabCompleter {
switch (args[1].toLowerCase(Locale.ROOT)) { switch (args[1].toLowerCase(Locale.ROOT)) {
case "list" -> { case "list" -> {
if (emergencies.sites().isEmpty()) { if (emergencies.sites().isEmpty()) {
sender.sendMessage(plugin.prefix() + plugin.color("&7No response sites have been configured.")); sender.sendMessage(plugin.prefix() + plugin.color("&7No fixed response sites have been configured. Random dispatch may still be enabled."));
return; return;
} }
sender.sendMessage(plugin.prefix() + plugin.color("&eConfigured response sites:")); sender.sendMessage(plugin.prefix() + plugin.color("&eConfigured response sites:"));
@@ -101,7 +125,7 @@ final class FirefighterCommand implements CommandExecutor, TabCompleter {
} }
if (!emergencies.addSite(args[2], player.getLocation(), radius)) { 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).")); sender.sendMessage(plugin.prefix() + plugin.color("&cSite names may only contain letters, numbers, _ and - (maximum 32 characters), and random is reserved."));
return; return;
} }
sender.sendMessage(plugin.prefix() + plugin.color("&aAdded response site &f" + args[2] + "&a with radius &f" + Math.max(3, radius) + "&a.")); sender.sendMessage(plugin.prefix() + plugin.color("&aAdded response site &f" + args[2] + "&a with radius &f" + Math.max(3, radius) + "&a."));
@@ -123,7 +147,7 @@ final class FirefighterCommand implements CommandExecutor, TabCompleter {
private void handleEmergency(CommandSender sender, String[] args) { private void handleEmergency(CommandSender sender, String[] args) {
if (args.length < 2) { if (args.length < 2) {
sender.sendMessage(plugin.prefix() + plugin.color("&eUsage: /firefighter emergency <start|stop> [site]")); sender.sendMessage(plugin.prefix() + plugin.color("&eUsage: /firefighter emergency <start|stop> [site|random]"));
return; return;
} }
@@ -143,25 +167,25 @@ final class FirefighterCommand implements CommandExecutor, TabCompleter {
sender.sendMessage(plugin.prefix() + plugin.color("&cUnknown response site: &f" + site)); sender.sendMessage(plugin.prefix() + plugin.color("&cUnknown response site: &f" + site));
return; return;
} }
if (site != null && !emergencies.isSitePermitted(site)) { if (site != null && !site.equalsIgnoreCase("random") && !emergencies.isSitePermitted(site)) {
sender.sendMessage(plugin.prefix() + plugin.color("&cThat response site's world is not permitted by config.yml.")); sender.sendMessage(plugin.prefix() + plugin.color("&cThat response site's world is not permitted by config.yml."));
return; return;
} }
int required = emergencies.requiredOnlineFirefighters(); int required = emergencies.requiredOnlineFirefighters();
int online = emergencies.onlineFirefighterCount(); int online = emergencies.onlineOnDutyFirefighterCount();
if (online < required) { if (online < required) {
sender.sendMessage(plugin.prefix() + plugin.color("&cAt least &f" + required + " &cFirefighter(s) must be online before an emergency can start.")); sender.sendMessage(plugin.prefix() + plugin.color("&cAt least &f" + required + " &con-duty Firefighter(s) must be online before an emergency can start."));
return; return;
} }
if (!emergencies.startEmergency(site)) { 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.")); sender.sendMessage(plugin.prefix() + plugin.color("&cCould not start an emergency. No suitable permitted fire location was found."));
return; return;
} }
EmergencySite active = emergencies.activeSite(); EmergencySite active = emergencies.activeSite();
sender.sendMessage(plugin.prefix() + plugin.color("&aEmergency started at &f" + active.name() + "&a with &f" sender.sendMessage(plugin.prefix() + plugin.color("&aEmergency started at &f" + active.name() + "&a with &f"
+ emergencies.activeFireCount() + " &afires.")); + emergencies.activeFireCount() + " &afires near &f" + active.x() + ", " + active.y() + ", " + active.z() + "&a."));
} }
case "stop" -> { case "stop" -> {
if (!emergencies.hasActiveEmergency()) { if (!emergencies.hasActiveEmergency()) {
@@ -169,40 +193,47 @@ final class FirefighterCommand implements CommandExecutor, TabCompleter {
return; return;
} }
emergencies.stopEmergency(); emergencies.stopEmergency();
sender.sendMessage(plugin.prefix() + plugin.color("&aThe active emergency was stopped and remaining emergency fires were removed.")); sender.sendMessage(plugin.prefix() + plugin.color("&aThe active emergency was stopped, remaining fire removed, and incident damage restored."));
} }
default -> sender.sendMessage(plugin.prefix() + plugin.color("&eUsage: /firefighter emergency <start|stop> [site]")); default -> sender.sendMessage(plugin.prefix() + plugin.color("&eUsage: /firefighter emergency <start|stop> [site|random]"));
} }
} }
private void showStatus(CommandSender sender) { private void showStatus(CommandSender sender) {
String jobName = plugin.getConfig().getString("job-name", "Firefighter"); String jobName = plugin.getConfig().getString("job-name", "Firefighter");
sender.sendMessage(plugin.prefix() + plugin.color("&fJob: &c" + jobName + " &8| &fFirefighters online: &c" + emergencies.onlineFirefighterCount())); sender.sendMessage(plugin.prefix() + plugin.color("&fJob: &c" + jobName
+ " &8| &fFirefighters online: &c" + emergencies.onlineFirefighterCount()
+ " &8| &fOn duty: &c" + emergencies.onlineOnDutyFirefighterCount()));
if (sender instanceof Player player) { if (sender instanceof Player player) {
sender.sendMessage(plugin.prefix() + (jobs.isFirefighter(player) if (jobs.isFirefighter(player)) {
? plugin.color("&aYou are currently on duty as a Firefighter.") String dutyText = duty.isOnDuty(player) ? "&aON DUTY" : "&7OFF DUTY";
: plugin.color("&7You are not currently working as a Firefighter."))); sender.sendMessage(plugin.prefix() + plugin.color("&aYou are currently working as a Firefighter. &8| " + dutyText));
} else {
sender.sendMessage(plugin.prefix() + plugin.color("&7You are not currently working as a Firefighter."));
}
} }
EmergencySite site = emergencies.activeSite(); EmergencySite site = emergencies.activeSite();
if (site == null) { if (site == null) {
sender.sendMessage(plugin.prefix() + plugin.color("&7No active fire emergency.")); sender.sendMessage(plugin.prefix() + plugin.color("&7No active fire emergency."));
} else { } else {
sender.sendMessage(plugin.prefix() + plugin.color("&cACTIVE: &f" + site.name() + " &7near " + site.x() + ", " + site.y() + ", " + site.z() sender.sendMessage(plugin.prefix() + plugin.color("&cACTIVE: &f" + site.name() + " &7in " + site.worldName() + " near "
+ " &8| &e" + emergencies.activeFireCount() + " fires remaining")); + site.x() + ", " + site.y() + ", " + site.z() + " &8| &e" + emergencies.activeFireCount() + " fires remaining"));
} }
} }
private void showHelp(CommandSender sender) { private void showHelp(CommandSender sender) {
sender.sendMessage(plugin.prefix() + plugin.color("&c&lRoseFirefighter &7commands")); sender.sendMessage(plugin.prefix() + plugin.color("&c&lRoseFirefighter &7commands"));
sender.sendMessage(plugin.color("&e/firefighter status &8- &7Show current response status")); sender.sendMessage(plugin.color("&e/firefighter status &8- &7Show current response and duty status"));
sender.sendMessage(plugin.color("&e/firefighter onduty &8- &7Enable Fire Department dispatch alerts"));
sender.sendMessage(plugin.color("&e/firefighter offduty &8- &7Silence your Fire Department dispatch alerts"));
if (sender.hasPermission("rosefirefighter.admin")) { 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 add <name> [radius] &8- &7Add a fixed response site"));
sender.sendMessage(plugin.color("&e/firefighter site remove <name> &8- &7Remove a response site")); sender.sendMessage(plugin.color("&e/firefighter site remove <name> &8- &7Remove a fixed response site"));
sender.sendMessage(plugin.color("&e/firefighter site list &8- &7List response sites")); sender.sendMessage(plugin.color("&e/firefighter site list &8- &7List fixed response sites"));
sender.sendMessage(plugin.color("&e/firefighter emergency start [site] &8- &7Start a controlled fire")); sender.sendMessage(plugin.color("&e/firefighter emergency start [site|random] &8- &7Start a fire response"));
sender.sendMessage(plugin.color("&e/firefighter emergency stop &8- &7Stop the active fire")); sender.sendMessage(plugin.color("&e/firefighter emergency stop &8- &7Stop the active fire and restore damage"));
sender.sendMessage(plugin.color("&e/firefighter reload &8- &7Reload the addon config")); sender.sendMessage(plugin.color("&e/firefighter reload &8- &7Reload the addon config"));
} }
} }
@@ -213,6 +244,8 @@ final class FirefighterCommand implements CommandExecutor, TabCompleter {
if (args.length == 1) { if (args.length == 1) {
choices.add("status"); choices.add("status");
choices.add("help"); choices.add("help");
choices.add("onduty");
choices.add("offduty");
if (sender.hasPermission("rosefirefighter.admin")) { if (sender.hasPermission("rosefirefighter.admin")) {
choices.add("site"); choices.add("site");
choices.add("emergency"); choices.add("emergency");
@@ -228,6 +261,9 @@ final class FirefighterCommand implements CommandExecutor, TabCompleter {
} else if (sender.hasPermission("rosefirefighter.admin") && args.length == 3 } else if (sender.hasPermission("rosefirefighter.admin") && args.length == 3
&& ((args[0].equalsIgnoreCase("site") && args[1].equalsIgnoreCase("remove")) && ((args[0].equalsIgnoreCase("site") && args[1].equalsIgnoreCase("remove"))
|| (args[0].equalsIgnoreCase("emergency") && args[1].equalsIgnoreCase("start")))) { || (args[0].equalsIgnoreCase("emergency") && args[1].equalsIgnoreCase("start")))) {
if (args[0].equalsIgnoreCase("emergency")) {
choices.add("random");
}
for (EmergencySite site : emergencies.sites()) { for (EmergencySite site : emergencies.sites()) {
choices.add(site.name()); choices.add(site.name());
} }
@@ -0,0 +1,69 @@
package net.therosegarden.firefighter;
import java.io.File;
import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
final class FirefighterDutyManager {
private final RoseFirefighterPlugin plugin;
private final JobsFacade jobs;
private final File file;
private final Set<UUID> offDuty = new HashSet<>();
FirefighterDutyManager(RoseFirefighterPlugin plugin, JobsFacade jobs) {
this.plugin = plugin;
this.jobs = jobs;
this.file = new File(plugin.getDataFolder(), "duty-status.yml");
load();
}
boolean isOnDuty(Player player) {
return player != null && jobs.isFirefighter(player) && !offDuty.contains(player.getUniqueId());
}
boolean setOnDuty(Player player, boolean onDuty) {
if (player == null || !jobs.isFirefighter(player)) {
return false;
}
if (onDuty) {
offDuty.remove(player.getUniqueId());
} else {
offDuty.add(player.getUniqueId());
}
save();
return true;
}
private void load() {
offDuty.clear();
if (!file.isFile()) {
return;
}
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file);
for (String value : yaml.getStringList("off-duty")) {
try {
offDuty.add(UUID.fromString(value));
} catch (IllegalArgumentException ignored) {
plugin.getLogger().warning("Ignoring invalid off-duty UUID: " + value);
}
}
}
void save() {
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("off-duty", offDuty.stream().map(UUID::toString).sorted().toList());
try {
if (!plugin.getDataFolder().exists() && !plugin.getDataFolder().mkdirs()) {
plugin.getLogger().warning("Could not create plugin data folder for duty status.");
return;
}
yaml.save(file);
} catch (IOException ex) {
plugin.getLogger().severe("Could not save Firefighter duty status: " + ex.getMessage());
}
}
}
@@ -0,0 +1,135 @@
package net.therosegarden.firefighter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Random;
import java.util.UUID;
import java.util.function.Predicate;
import me.ryanhamshire.GriefPrevention.Claim;
import me.ryanhamshire.GriefPrevention.GriefPrevention;
import org.bukkit.Bukkit;
import org.bukkit.HeightMap;
import org.bukkit.Location;
import org.bukkit.OfflinePlayer;
import org.bukkit.World;
final class GriefPreventionLocationProvider implements EmergencyLocationProvider {
@Override
public EmergencySite randomClaimSite(Random random, Predicate<World> worldAllowed, int maxRadius, int minClaimArea) {
List<Claim> eligible = new ArrayList<>();
for (Claim claim : GriefPrevention.instance.dataStore.getClaims()) {
if (claim == null || claim.parent != null || claim.getOwnerID() == null || claim.getArea() < minClaimArea) {
continue;
}
Location lesser = claim.getLesserBoundaryCorner();
Location greater = claim.getGreaterBoundaryCorner();
World world = lesser == null ? null : lesser.getWorld();
if (world == null || greater == null || greater.getWorld() == null || !worldAllowed.test(world)) {
continue;
}
eligible.add(claim);
}
if (eligible.isEmpty()) {
return null;
}
Claim claim = eligible.get(random.nextInt(eligible.size()));
Location lesser = claim.getLesserBoundaryCorner();
Location greater = claim.getGreaterBoundaryCorner();
World world = lesser.getWorld();
if (world == null) {
return null;
}
int minX = Math.min(lesser.getBlockX(), greater.getBlockX());
int maxX = Math.max(lesser.getBlockX(), greater.getBlockX());
int minZ = Math.min(lesser.getBlockZ(), greater.getBlockZ());
int maxZ = Math.max(lesser.getBlockZ(), greater.getBlockZ());
int centerX = minX + ((maxX - minX) / 2);
int centerZ = minZ + ((maxZ - minZ) / 2);
int y = world.getHighestBlockAt(centerX, centerZ, HeightMap.MOTION_BLOCKING).getY() + 1;
int halfWidth = Math.max(2, (maxX - minX) / 2);
int halfDepth = Math.max(2, (maxZ - minZ) / 2);
int radius = Math.max(2, Math.min(Math.max(2, maxRadius), Math.min(halfWidth, halfDepth)));
String owner = ownerName(claim);
return new EmergencySite(owner + "'s home", world.getName(), centerX, y, centerZ, radius);
}
@Override
public String nearbyClaims(Location location, int radius, int maxResults) {
if (location == null || location.getWorld() == null) {
return "none";
}
List<NearbyClaim> nearby = new ArrayList<>();
for (Claim claim : GriefPrevention.instance.dataStore.getClaims()) {
if (claim == null || claim.parent != null) {
continue;
}
Location lesser = claim.getLesserBoundaryCorner();
Location greater = claim.getGreaterBoundaryCorner();
if (lesser == null || greater == null || lesser.getWorld() == null
|| !lesser.getWorld().equals(location.getWorld())) {
continue;
}
double distance = distanceToClaim(location, lesser, greater);
if (distance <= radius) {
nearby.add(new NearbyClaim(ownerName(claim), distance));
}
}
nearby.sort(Comparator.comparingDouble(NearbyClaim::distance));
if (nearby.isEmpty()) {
return "none within " + radius + " blocks";
}
int limit = Math.max(1, maxResults);
List<String> descriptions = new ArrayList<>();
for (int i = 0; i < nearby.size() && i < limit; i++) {
NearbyClaim entry = nearby.get(i);
String distance = entry.distance() < 1.0 ? "inside" : Math.round(entry.distance()) + "m";
descriptions.add(entry.owner() + " (" + distance + ")");
}
if (nearby.size() > limit) {
descriptions.add("+" + (nearby.size() - limit) + " more");
}
return String.join(", ", descriptions);
}
private String ownerName(Claim claim) {
UUID ownerId = claim.getOwnerID();
if (ownerId == null) {
return "Admin claim";
}
OfflinePlayer offline = Bukkit.getOfflinePlayer(ownerId);
String name = offline.getName();
if (name == null || name.isBlank()) {
name = claim.getOwnerName();
}
if (name == null || name.isBlank()) {
name = ownerId.toString().substring(0, 8).toLowerCase(Locale.ROOT);
}
return name;
}
private double distanceToClaim(Location location, Location lesser, Location greater) {
int minX = Math.min(lesser.getBlockX(), greater.getBlockX());
int maxX = Math.max(lesser.getBlockX(), greater.getBlockX());
int minZ = Math.min(lesser.getBlockZ(), greater.getBlockZ());
int maxZ = Math.max(lesser.getBlockZ(), greater.getBlockZ());
double x = location.getX();
double z = location.getZ();
double dx = x < minX ? minX - x : (x > maxX ? x - maxX : 0.0);
double dz = z < minZ ? minZ - z : (z > maxZ ? z - maxZ : 0.0);
return Math.sqrt((dx * dx) + (dz * dz));
}
private record NearbyClaim(String owner, double distance) {}
}
@@ -19,6 +19,7 @@ public final class RoseFirefighterPlugin extends JavaPlugin {
private FireSafetyAndOriginListener safetyListener; private FireSafetyAndOriginListener safetyListener;
private EmergencyPermissionManager emergencyPermissions; private EmergencyPermissionManager emergencyPermissions;
private EmergencyConstructionManager emergencyConstruction; private EmergencyConstructionManager emergencyConstruction;
private FirefighterDutyManager duty;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -28,9 +29,11 @@ public final class RoseFirefighterPlugin extends JavaPlugin {
rewards = new FirefighterRewardService(this, jobs); rewards = new FirefighterRewardService(this, jobs);
emergencies = new EmergencyManager(this, jobs); emergencies = new EmergencyManager(this, jobs);
rewards.setEmergencyManager(emergencies); rewards.setEmergencyManager(emergencies);
safetyListener = new FireSafetyAndOriginListener(this, rewards, emergencies);
emergencyPermissions = new EmergencyPermissionManager(this, jobs); emergencyPermissions = new EmergencyPermissionManager(this, jobs);
emergencyConstruction = new EmergencyConstructionManager(this, jobs, emergencyPermissions); emergencyConstruction = new EmergencyConstructionManager(this, jobs, emergencyPermissions);
duty = new FirefighterDutyManager(this, jobs);
emergencyPermissions.setDutyManager(duty);
safetyListener = new FireSafetyAndOriginListener(this, rewards, emergencies, emergencyConstruction);
emergencies.setEmergencyServices(emergencyPermissions, emergencyConstruction); emergencies.setEmergencyServices(emergencyPermissions, emergencyConstruction);
Bukkit.getPluginManager().registerEvents(new FireInteractionListener(this, rewards, emergencies), this); Bukkit.getPluginManager().registerEvents(new FireInteractionListener(this, rewards, emergencies), this);
@@ -38,16 +41,19 @@ public final class RoseFirefighterPlugin extends JavaPlugin {
Bukkit.getPluginManager().registerEvents(emergencyPermissions, this); Bukkit.getPluginManager().registerEvents(emergencyPermissions, this);
Bukkit.getPluginManager().registerEvents(emergencyConstruction, this); Bukkit.getPluginManager().registerEvents(emergencyConstruction, this);
EmergencyLocationProvider locationProvider = null;
Plugin griefPrevention = Bukkit.getPluginManager().getPlugin("GriefPrevention"); Plugin griefPrevention = Bukkit.getPluginManager().getPlugin("GriefPrevention");
if (griefPrevention != null && griefPrevention.isEnabled()) { if (griefPrevention != null && griefPrevention.isEnabled()) {
locationProvider = new GriefPreventionLocationProvider();
Bukkit.getPluginManager().registerEvents( Bukkit.getPluginManager().registerEvents(
new GriefPreventionBridge(emergencies, emergencyPermissions, emergencyConstruction), this); new GriefPreventionBridge(emergencies, emergencyPermissions, emergencyConstruction), this);
getLogger().info("GriefPrevention detected. Emergency-site build overrides are enabled."); getLogger().info("GriefPrevention detected. Protected response, random claim dispatch, and claim context are enabled.");
} else { } else {
getLogger().info("GriefPrevention not detected. Emergency build permission will remain internal to RoseFirefighter."); getLogger().info("GriefPrevention not detected. Random dispatch will use loaded-world locations and configured sites.");
} }
emergencies.setDispatchServices(duty, locationProvider);
FirefighterCommand commandHandler = new FirefighterCommand(this, jobs, emergencies); FirefighterCommand commandHandler = new FirefighterCommand(this, jobs, emergencies, duty);
PluginCommand command = Objects.requireNonNull(getCommand("firefighter"), "firefighter command missing from plugin.yml"); PluginCommand command = Objects.requireNonNull(getCommand("firefighter"), "firefighter command missing from plugin.yml");
command.setExecutor(commandHandler); command.setExecutor(commandHandler);
command.setTabCompleter(commandHandler); command.setTabCompleter(commandHandler);
@@ -65,7 +71,7 @@ public final class RoseFirefighterPlugin extends JavaPlugin {
getLogger().warning("Copy jobs/Firefighter.yml from the RoseFirefighter package into plugins/Jobs/jobs/ and run /jobs reload."); 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."); getLogger().info("RoseFirefighter enabled. Emergency spread, rollback, dispatch, and response controls are active.");
} }
@Override @Override
@@ -73,6 +79,9 @@ public final class RoseFirefighterPlugin extends JavaPlugin {
if (emergencies != null) { if (emergencies != null) {
emergencies.shutdown(); emergencies.shutdown();
} }
if (duty != null) {
duty.save();
}
if (rewards != null) { if (rewards != null) {
rewards.clear(); rewards.clear();
} }
+48 -13
View File
@@ -28,9 +28,6 @@ rewards:
# are clamped to 8 to avoid an excessive per-bucket scan. # are clamped to 8 to avoid an excessive per-bucket scan.
water-bucket-scan-radius: 4 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: normal-fire:
enabled: true enabled: true
action-count: 1 action-count: 1
@@ -48,16 +45,50 @@ emergencies:
enabled: true enabled: true
automatic: true automatic: true
# Let incident fire behave naturally inside the response boundary. Every spread
# fire is registered as part of the emergency, and spread outside the boundary
# is blocked.
fire-spread: true
fire-damage: true
# Snapshot the first pre-incident state of blocks damaged by emergency fire and
# restore them instantly when the incident clears, expires, is stopped, or the
# plugin shuts down. Tile/container contents are preserved as part of rollback.
restore-fire-damage: true
# Fire-triggered TNT is suppressed because explosion chains can escape the fire
# boundary and cause damage that is not attributable to normal fire events.
prevent-fire-tnt: true
# Prevent support-dependent secondary damage such as falling blocks or hanging
# decorations popping off after a support block burns.
prevent-secondary-physics-damage: true
protected-site-building: true protected-site-building: true
cleanup-firefighter-blocks: true cleanup-firefighter-blocks: true
# Allow on-duty Firefighters to make temporary forced entry through protected # Allow Firefighters to make temporary forced entry through protected doors,
# doors, windows, walls, etc. inside the active response site. Broken blocks # windows, walls, etc. inside the active response site. Broken property gives
# produce no drops/XP and are restored when the incident ends. # no drops/XP and is restored by the same incident rollback ledger.
forced-entry: true forced-entry: true
# Applies to both automatic and manual emergency starts. Set false if admins # Automatic and `/firefighter emergency start random` dispatches use random
# should be able to start/test an emergency with no Firefighters online. # incident locations. With GriefPrevention installed, player-owned claims are
# preferred so dispatches can identify the property owner.
random-locations:
enabled: true
prefer-griefprevention-claims: true
min-claim-area: 25
claim-radius-max: 18
attempts: 12
loaded-world-fallback: true
configured-sites-fallback: true
nearby-claims-radius: 64
nearby-claims-max: 5
# Automatic/manual incidents require this many ON-DUTY Firefighters. Players
# working the JobsReborn Firefighter job are on duty by default and can silence
# dispatch notifications with /firefighter offduty.
require-firefighters-online: true require-firefighters-online: true
min-firefighters-online: 1 min-firefighters-online: 1
@@ -84,13 +115,17 @@ emergencies:
messages: messages:
prefix: '&8[&cFire Dept&8] &r' 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!' alert: '&c&l🔥 FIRE ALERT! &eFire reported at &f%site% &ein &f%world% &enear &fX:%x% Y:%y% Z:%z%&e. Firefighters are needed immediately!'
cleared: '&aFire at &f%site% &ahas been brought under control.' dispatch-details: '&6Dispatch details: &eCoordinates &f%world% %x%, %y%, %z% &8| &eNearby claims: &f%nearby_claims%'
expired: '&eThe fire response at &f%site% &ehas ended.' cleared: '&aFire at &f%site% &ahas been brought under control. Incident damage has been restored.'
joined-needed: '&7You must be working as a &cFirefighter &7to receive firefighting pay.' expired: '&eThe fire response at &f%site% &ehas ended. Incident damage has been restored.'
joined-needed: '&7You must be working as a &cFirefighter &7to use Firefighter duty controls or receive firefighting pay.'
on-duty: '&aYou are now ON DUTY. Fire dispatch alerts are enabled.'
off-duty: '&7You are now OFF DUTY. Fire dispatch alerts are silenced until you use &f/firefighter onduty&7.'
temporary-build-enabled: '&eEmergency build access is active inside the response site.' temporary-build-enabled: '&eEmergency build access is active inside the response site.'
temporary-build-disabled: '&7Emergency build access has ended.' temporary-build-disabled: '&7Emergency build access has ended.'
returned-blocks: '&aReturned &f%count% &atemporary response block item(s) from the completed incident.' returned-blocks: '&aReturned &f%count% &atemporary response block item(s) from the completed incident.'
# Managed with /firefighter site add <name> [radius] # Optional fixed sites managed with /firefighter site add <name> [radius]. Random
# locations can operate without any fixed sites when enabled above.
sites: {} sites: {}