diff --git a/README.md b/README.md index 1ebf075..354f974 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Creative and spectator players do not receive rewards by default. Creative earni - Java 25 for compilation/server runtime - JobsReborn 5.2.6.x - CMILib, as required by JobsReborn +- GriefPrevention 16.18.6+ (optional; enables protected-claim emergency building) - A JobsReborn-compatible economy if monetary payouts are desired ## Build @@ -110,7 +111,7 @@ Other management commands: 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: +Emergency fire generation and response behavior: - Uses admin-defined named sites and radii. - Can be limited to configured world names with `emergencies.allowed-worlds`. @@ -119,7 +120,13 @@ Emergency fire generation: - 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. +- Grants on-duty Firefighters the temporary `rosefirefighter.emergency.build` runtime permission while an incident is active. +- When GriefPrevention is installed, uses its claim-permission API to allow block placement and water-bucket response only inside the active site. Emergency fire and tracked temporary response blocks can also be broken there. +- 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: @@ -128,6 +135,22 @@ Example alert: 🔥 FIRE ALERT! A fire has been reported at RoseGarden near X:123 Y:64 Z:-245. Firefighters are needed immediately! ``` +## Protected-site integration + +`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. + +With GriefPrevention installed, RoseFirefighter listens to `ClaimPermissionCheckEvent` and clears a denied `Build` check only when all of the following are true: + +- An emergency is active. +- 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 placing a block, emptying a bucket, breaking an emergency fire, or breaking a tracked temporary Firefighter block. + +Normal protected structures cannot be broken merely because an emergency is active. + +`emergencies.cleanup-firefighter-blocks` controls whether placements made by Firefighters inside the active site are added to the temporary response-construction ledger. + ## Configuration `src/main/resources/config.yml` includes controls for: @@ -144,6 +167,7 @@ Example alert: - Emergency reward multiplier. - Announcement behavior. - Permitted emergency worlds. +- Protected-site emergency building and temporary Firefighter block cleanup. - Saved emergency sites. ## Development notes @@ -160,4 +184,10 @@ JobsReborn is referenced through its documented JitPack coordinate: 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. +GriefPrevention is an optional compile-time API dependency and a runtime soft dependency: + +```kotlin +compileOnly("com.github.GriefPrevention:GriefPrevention:16.18.6") +``` + +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. diff --git a/build.gradle.kts b/build.gradle.kts index ea4d313..1a90e47 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,6 +18,9 @@ dependencies { compileOnly("com.github.Zrips:Jobs:v5.2.6.2") { isTransitive = false } + compileOnly("com.github.GriefPrevention:GriefPrevention:16.18.6") { + isTransitive = false + } } java { diff --git a/src/main/java/net/therosegarden/firefighter/EmergencyConstructionManager.java b/src/main/java/net/therosegarden/firefighter/EmergencyConstructionManager.java new file mode 100644 index 0000000..08377dc --- /dev/null +++ b/src/main/java/net/therosegarden/firefighter/EmergencyConstructionManager.java @@ -0,0 +1,219 @@ +package net.therosegarden.firefighter; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockMultiPlaceEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.inventory.ItemStack; + +final class EmergencyConstructionManager implements Listener { + private final RoseFirefighterPlugin plugin; + private final JobsFacade jobs; + private final EmergencyPermissionManager permissions; + private final Map placements = new HashMap<>(); + private final Map byBlock = new HashMap<>(); + private final Map> pendingReturns = new HashMap<>(); + private final File pendingFile; + private EmergencySite activeSite; + + EmergencyConstructionManager(RoseFirefighterPlugin plugin, JobsFacade jobs, EmergencyPermissionManager permissions) { + this.plugin = plugin; + this.jobs = jobs; + this.permissions = permissions; + this.pendingFile = new File(plugin.getDataFolder(), "pending-returns.yml"); + loadPendingReturns(); + } + + void begin(EmergencySite site) { + activeSite = site; + placements.clear(); + byBlock.clear(); + } + + boolean isTemporaryBlock(Block block) { + return block != null && byBlock.containsKey(BlockKey.of(block)); + } + + void cleanupAndReturn() { + if (placements.isEmpty()) { + activeSite = null; + return; + } + + Map> returns = new HashMap<>(); + for (Placement placement : new ArrayList<>(placements.values())) { + boolean restoredAny = false; + for (PlacedBlock placed : placement.blocks()) { + Block current = placed.key().block(); + if (current != null && current.getType() == placed.placedType()) { + placed.replacedState().update(true, false); + restoredAny = true; + } + } + if (restoredAny) { + returns.computeIfAbsent(placement.owner(), ignored -> new ArrayList<>()).add(placement.returnItem().clone()); + } + } + + placements.clear(); + byBlock.clear(); + activeSite = null; + + for (Map.Entry> entry : returns.entrySet()) { + returnItems(entry.getKey(), entry.getValue()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlace(BlockPlaceEvent event) { + if (activeSite == null || !plugin.getConfig().getBoolean("emergencies.cleanup-firefighter-blocks", true)) { + return; + } + Player player = event.getPlayer(); + if (!jobs.isFirefighter(player) || !permissions.hasBuildAccess(player) || !activeSite.contains(event.getBlock().getLocation())) { + return; + } + + List replacedStates = new ArrayList<>(); + if (event instanceof BlockMultiPlaceEvent multi) { + replacedStates.addAll(multi.getReplacedBlockStates()); + } else { + replacedStates.add(event.getBlockReplacedState()); + } + + List blocks = new ArrayList<>(); + for (BlockState replaced : replacedStates) { + Block current = replaced.getBlock(); + if (!activeSite.contains(current.getLocation())) { + continue; + } + blocks.add(new PlacedBlock(BlockKey.of(current), current.getType(), replaced)); + } + if (blocks.isEmpty()) { + return; + } + + ItemStack returned = event.getItemInHand().clone(); + returned.setAmount(1); + UUID placementId = UUID.randomUUID(); + Placement placement = new Placement(placementId, player.getUniqueId(), returned, List.copyOf(blocks)); + placements.put(placementId, placement); + for (PlacedBlock block : blocks) { + byBlock.put(block.key(), placementId); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBreak(BlockBreakEvent event) { + UUID placementId = byBlock.get(BlockKey.of(event.getBlock())); + if (placementId == null) { + return; + } + Placement placement = placements.remove(placementId); + if (placement == null) { + return; + } + for (PlacedBlock block : placement.blocks()) { + byBlock.remove(block.key()); + } + } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + List items = pendingReturns.remove(event.getPlayer().getUniqueId()); + if (items == null || items.isEmpty()) { + return; + } + giveItems(event.getPlayer(), items); + savePendingReturns(); + } + + private void returnItems(UUID owner, List items) { + Player player = Bukkit.getPlayer(owner); + if (player != null && player.isOnline()) { + giveItems(player, items); + return; + } + pendingReturns.computeIfAbsent(owner, ignored -> new ArrayList<>()).addAll(items); + savePendingReturns(); + } + + private void giveItems(Player player, List items) { + int count = 0; + for (ItemStack item : items) { + if (item == null || item.getType() == Material.AIR || item.getAmount() <= 0) { + continue; + } + count += item.getAmount(); + Map overflow = player.getInventory().addItem(item.clone()); + for (ItemStack extra : overflow.values()) { + player.getWorld().dropItemNaturally(player.getLocation(), extra); + } + } + if (count > 0) { + String message = plugin.message("messages.returned-blocks").replace("%count%", Integer.toString(count)); + if (!message.isEmpty()) { + player.sendMessage(plugin.prefix() + message); + } + } + } + + private void loadPendingReturns() { + if (!pendingFile.isFile()) { + return; + } + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(pendingFile); + for (String key : yaml.getKeys(false)) { + try { + UUID uuid = UUID.fromString(key); + List items = new ArrayList<>(); + for (Object value : yaml.getList(key, List.of())) { + if (value instanceof ItemStack item) { + items.add(item); + } + } + if (!items.isEmpty()) { + pendingReturns.put(uuid, items); + } + } catch (IllegalArgumentException ignored) { + plugin.getLogger().warning("Ignoring invalid pending-return UUID: " + key); + } + } + } + + private void savePendingReturns() { + YamlConfiguration yaml = new YamlConfiguration(); + for (Map.Entry> entry : pendingReturns.entrySet()) { + yaml.set(entry.getKey().toString(), entry.getValue()); + } + try { + if (!plugin.getDataFolder().exists() && !plugin.getDataFolder().mkdirs()) { + plugin.getLogger().warning("Could not create plugin data folder for pending returns."); + return; + } + yaml.save(pendingFile); + } catch (IOException ex) { + plugin.getLogger().severe("Could not save pending firefighter block returns: " + ex.getMessage()); + } + } + + private record Placement(UUID id, UUID owner, ItemStack returnItem, List blocks) {} + private record PlacedBlock(BlockKey key, Material placedType, BlockState replacedState) {} +} + diff --git a/src/main/java/net/therosegarden/firefighter/EmergencyManager.java b/src/main/java/net/therosegarden/firefighter/EmergencyManager.java index 302ab3d..67c01cc 100644 --- a/src/main/java/net/therosegarden/firefighter/EmergencyManager.java +++ b/src/main/java/net/therosegarden/firefighter/EmergencyManager.java @@ -27,6 +27,8 @@ final class EmergencyManager { private final Random random = new Random(); private final Map sites = new LinkedHashMap<>(); private final Set activeFires = new HashSet<>(); + private EmergencyPermissionManager permissions; + private EmergencyConstructionManager construction; private EmergencySite activeSite; private BukkitTask expiryTask; @@ -38,6 +40,11 @@ final class EmergencyManager { this.jobs = jobs; } + void setEmergencyServices(EmergencyPermissionManager permissions, EmergencyConstructionManager construction) { + this.permissions = permissions; + this.construction = construction; + } + void reload() { loadSites(); scheduleNextAutomaticEmergency(); @@ -48,6 +55,12 @@ final class EmergencyManager { cancelExpiryTask(); cancelIntegrityTask(); clearEmergencyFireBlocks(); + if (construction != null) { + construction.cleanupAndReturn(); + } + if (permissions != null) { + permissions.deactivate(); + } activeFires.clear(); activeSite = null; } @@ -216,6 +229,12 @@ final class EmergencyManager { activeSite = site; activeFires.addAll(spawned); + if (construction != null) { + construction.begin(site); + } + if (permissions != null) { + permissions.activate(site); + } int durationMinutes = Math.max(1, plugin.getConfig().getInt("emergencies.duration-minutes", 10)); expiryTask = Bukkit.getScheduler().runTaskLater(plugin, () -> endEmergency(false), durationMinutes * 60L * 20L); @@ -317,6 +336,10 @@ final class EmergencyManager { return; } + if (permissions != null) { + permissions.syncResponders(); + } + activeFires.removeIf(key -> { Block block = key.block(); return block == null || (block.getType() != Material.FIRE && block.getType() != Material.SOUL_FIRE); @@ -338,6 +361,12 @@ final class EmergencyManager { cancelIntegrityTask(); clearEmergencyFireBlocks(); + if (construction != null) { + construction.cleanupAndReturn(); + } + if (permissions != null) { + permissions.deactivate(); + } activeFires.clear(); activeSite = null; diff --git a/src/main/java/net/therosegarden/firefighter/EmergencyPermissionManager.java b/src/main/java/net/therosegarden/firefighter/EmergencyPermissionManager.java new file mode 100644 index 0000000..bfe5296 --- /dev/null +++ b/src/main/java/net/therosegarden/firefighter/EmergencyPermissionManager.java @@ -0,0 +1,104 @@ +package net.therosegarden.firefighter; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.permissions.PermissionAttachment; + +final class EmergencyPermissionManager implements Listener { + static final String BUILD_PERMISSION = "rosefirefighter.emergency.build"; + + private final RoseFirefighterPlugin plugin; + private final JobsFacade jobs; + private final Map attachments = new HashMap<>(); + private EmergencySite activeSite; + + EmergencyPermissionManager(RoseFirefighterPlugin plugin, JobsFacade jobs) { + this.plugin = plugin; + this.jobs = jobs; + } + + void activate(EmergencySite site) { + activeSite = site; + syncResponders(); + } + + void syncResponders() { + if (activeSite == null || !plugin.getConfig().getBoolean("emergencies.protected-site-building", true)) { + revokeAll(true); + return; + } + for (Player player : Bukkit.getOnlinePlayers()) { + if (jobs.isFirefighter(player)) { + grant(player); + } else { + revoke(player, true); + } + } + } + + void deactivate() { + activeSite = null; + revokeAll(true); + } + + boolean hasBuildAccess(Player player) { + return activeSite != null && player != null && player.hasPermission(BUILD_PERMISSION) && jobs.isFirefighter(player); + } + + boolean isInsideActiveSite(org.bukkit.Location location) { + return activeSite != null && activeSite.contains(location); + } + + private void grant(Player player) { + if (attachments.containsKey(player.getUniqueId())) { + return; + } + PermissionAttachment attachment = player.addAttachment(plugin); + attachment.setPermission(BUILD_PERMISSION, true); + attachments.put(player.getUniqueId(), attachment); + String message = plugin.message("messages.temporary-build-enabled"); + if (!message.isEmpty()) { + player.sendMessage(plugin.prefix() + message); + } + } + + private void revoke(Player player, boolean notify) { + PermissionAttachment attachment = attachments.remove(player.getUniqueId()); + if (attachment == null) { + return; + } + player.removeAttachment(attachment); + if (notify) { + String message = plugin.message("messages.temporary-build-disabled"); + if (!message.isEmpty()) { + player.sendMessage(plugin.prefix() + message); + } + } + } + + private void revokeAll(boolean notify) { + for (Player player : Bukkit.getOnlinePlayers()) { + revoke(player, notify); + } + attachments.clear(); + } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + if (activeSite != null) { + Bukkit.getScheduler().runTask(plugin, this::syncResponders); + } + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + revoke(event.getPlayer(), false); + } +} diff --git a/src/main/java/net/therosegarden/firefighter/EmergencySite.java b/src/main/java/net/therosegarden/firefighter/EmergencySite.java index d7cc725..fb3eff6 100644 --- a/src/main/java/net/therosegarden/firefighter/EmergencySite.java +++ b/src/main/java/net/therosegarden/firefighter/EmergencySite.java @@ -9,4 +9,13 @@ record EmergencySite(String name, String worldName, int x, int y, int z, int rad World world = Bukkit.getWorld(worldName); return world == null ? null : new Location(world, x + 0.5, y, z + 0.5); } + + boolean contains(Location location) { + if (location == null || location.getWorld() == null || !location.getWorld().getName().equals(worldName)) { + return false; + } + long dx = (long) location.getBlockX() - x; + long dz = (long) location.getBlockZ() - z; + return (dx * dx) + (dz * dz) <= (long) radius * radius; + } } diff --git a/src/main/java/net/therosegarden/firefighter/GriefPreventionBridge.java b/src/main/java/net/therosegarden/firefighter/GriefPreventionBridge.java new file mode 100644 index 0000000..f8f79a4 --- /dev/null +++ b/src/main/java/net/therosegarden/firefighter/GriefPreventionBridge.java @@ -0,0 +1,64 @@ +package net.therosegarden.firefighter; + +import me.ryanhamshire.GriefPrevention.ClaimPermission; +import me.ryanhamshire.GriefPrevention.events.ClaimPermissionCheckEvent; +import org.bukkit.Location; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +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.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.player.PlayerBucketEmptyEvent; + +final class GriefPreventionBridge implements Listener { + private final EmergencyManager emergencies; + private final EmergencyPermissionManager permissions; + private final EmergencyConstructionManager construction; + + GriefPreventionBridge(EmergencyManager emergencies, EmergencyPermissionManager permissions, + EmergencyConstructionManager construction) { + this.emergencies = emergencies; + this.permissions = permissions; + this.construction = construction; + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onClaimPermissionCheck(ClaimPermissionCheckEvent event) { + if (event.getRequiredPermission() != ClaimPermission.Build || event.getDenialReason() == null) { + return; + } + + Player player = event.getCheckedPlayer(); + if (player == null || !permissions.hasBuildAccess(player)) { + return; + } + + Event trigger = event.getTriggeringEvent(); + if (trigger instanceof BlockPlaceEvent placeEvent) { + allowIfInside(event, placeEvent.getBlock().getLocation()); + return; + } + + if (trigger instanceof PlayerBucketEmptyEvent bucketEvent) { + allowIfInside(event, bucketEvent.getBlock().getLocation()); + return; + } + + if (trigger instanceof BlockBreakEvent breakEvent) { + Block block = breakEvent.getBlock(); + if ((emergencies.isEmergencyFire(block) || construction.isTemporaryBlock(block)) + && permissions.isInsideActiveSite(block.getLocation())) { + event.setDenialReason(null); + } + } + } + + private void allowIfInside(ClaimPermissionCheckEvent event, Location location) { + if (permissions.isInsideActiveSite(location)) { + event.setDenialReason(null); + } + } +} diff --git a/src/main/java/net/therosegarden/firefighter/RoseFirefighterPlugin.java b/src/main/java/net/therosegarden/firefighter/RoseFirefighterPlugin.java index 4029bfb..54c02bc 100644 --- a/src/main/java/net/therosegarden/firefighter/RoseFirefighterPlugin.java +++ b/src/main/java/net/therosegarden/firefighter/RoseFirefighterPlugin.java @@ -6,6 +6,7 @@ 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.Plugin; import org.bukkit.plugin.java.JavaPlugin; public final class RoseFirefighterPlugin extends JavaPlugin { @@ -16,6 +17,8 @@ public final class RoseFirefighterPlugin extends JavaPlugin { private FirefighterRewardService rewards; private EmergencyManager emergencies; private FireSafetyAndOriginListener safetyListener; + private EmergencyPermissionManager emergencyPermissions; + private EmergencyConstructionManager emergencyConstruction; @Override public void onEnable() { @@ -26,9 +29,23 @@ public final class RoseFirefighterPlugin extends JavaPlugin { emergencies = new EmergencyManager(this, jobs); rewards.setEmergencyManager(emergencies); safetyListener = new FireSafetyAndOriginListener(this, rewards, emergencies); + emergencyPermissions = new EmergencyPermissionManager(this, jobs); + emergencyConstruction = new EmergencyConstructionManager(this, jobs, emergencyPermissions); + emergencies.setEmergencyServices(emergencyPermissions, emergencyConstruction); Bukkit.getPluginManager().registerEvents(new FireInteractionListener(this, rewards, emergencies), this); Bukkit.getPluginManager().registerEvents(safetyListener, this); + Bukkit.getPluginManager().registerEvents(emergencyPermissions, this); + Bukkit.getPluginManager().registerEvents(emergencyConstruction, this); + + Plugin griefPrevention = Bukkit.getPluginManager().getPlugin("GriefPrevention"); + if (griefPrevention != null && griefPrevention.isEnabled()) { + Bukkit.getPluginManager().registerEvents( + new GriefPreventionBridge(emergencies, emergencyPermissions, emergencyConstruction), this); + getLogger().info("GriefPrevention detected. Emergency-site build overrides are enabled."); + } else { + getLogger().info("GriefPrevention not detected. Emergency build permission will remain internal to RoseFirefighter."); + } FirefighterCommand commandHandler = new FirefighterCommand(this, jobs, emergencies); PluginCommand command = Objects.requireNonNull(getCommand("firefighter"), "firefighter command missing from plugin.yml"); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 36b9573..5111ddd 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -48,6 +48,9 @@ emergencies: enabled: true automatic: true + protected-site-building: true + cleanup-firefighter-blocks: 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 @@ -80,6 +83,9 @@ messages: 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.' + temporary-build-enabled: '&eEmergency build access is active inside the response site.' + temporary-build-disabled: '&7Emergency build access has ended.' + returned-blocks: '&aReturned &f%count% &atemporary response block item(s) from the completed incident.' # Managed with /firefighter site add [radius] sites: {} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 06c865a..72373aa 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,6 +3,7 @@ version: '${version}' main: net.therosegarden.firefighter.RoseFirefighterPlugin api-version: '26.2' depend: [Jobs] +softdepend: [GriefPrevention] description: Firefighter profession companion and controlled fire emergencies for JobsReborn. commands: firefighter: @@ -13,3 +14,6 @@ permissions: rosefirefighter.admin: description: Manage Firefighter sites and emergencies. default: op + rosefirefighter.emergency.build: + description: Temporary runtime permission granted to on-duty Firefighters at an active emergency site. + default: false