|
| 1 | +package net.democracycraft.vault.internal.service; |
| 2 | + |
| 3 | +import net.democracycraft.vault.VaultStoragePlugin; |
| 4 | +import net.democracycraft.vault.api.region.VaultRegion; |
| 5 | +import net.democracycraft.vault.api.service.BoltService; |
| 6 | +import net.democracycraft.vault.api.service.WorldGuardService; |
| 7 | +import net.democracycraft.vault.internal.util.config.ConfigPaths; |
| 8 | +import net.democracycraft.vault.internal.util.minimessage.MiniMessageUtil; |
| 9 | +import net.democracycraft.vault.internal.util.region.RegionKey; |
| 10 | +import net.democracycraft.vault.internal.util.scan.OfflineRegionScanner; |
| 11 | +import net.democracycraft.vault.internal.util.scan.OfflineRegionScanner.DisplacedContainer; |
| 12 | +import org.bukkit.Bukkit; |
| 13 | +import org.bukkit.Chunk; |
| 14 | +import org.bukkit.World; |
| 15 | +import org.bukkit.entity.Entity; |
| 16 | +import org.bukkit.entity.Hanging; |
| 17 | +import org.bukkit.entity.ItemFrame; |
| 18 | +import org.bukkit.entity.Painting; |
| 19 | +import org.bukkit.entity.Player; |
| 20 | +import org.bukkit.scheduler.BukkitRunnable; |
| 21 | +import org.bukkit.scheduler.BukkitTask; |
| 22 | +import org.bukkit.util.BoundingBox; |
| 23 | +import org.jetbrains.annotations.NotNull; |
| 24 | + |
| 25 | +import java.util.ArrayList; |
| 26 | +import java.util.HashMap; |
| 27 | +import java.util.HashSet; |
| 28 | +import java.util.List; |
| 29 | +import java.util.Map; |
| 30 | +import java.util.Set; |
| 31 | +import java.util.UUID; |
| 32 | +import java.util.concurrent.atomic.AtomicBoolean; |
| 33 | +import java.util.function.Function; |
| 34 | + |
| 35 | +/** |
| 36 | + * Schedules and runs the delayed, batched auto-vaulting of Bolt-locked containers (and, when enabled, |
| 37 | + * item frames and paintings) when a region's owner/tenant changes. A lock is vaulted when its Bolt owner |
| 38 | + * is not in the allowed set ({new occupant} plus the region's WorldGuard owners/members). Runs on the |
| 39 | + * main thread; chunk loads are async. |
| 40 | + */ |
| 41 | +public class AutoVaultService { |
| 42 | + |
| 43 | + private final VaultStoragePlugin plugin; |
| 44 | + // Main-thread only: populated from the Realty event handler and the scheduled sweep task. |
| 45 | + private final Map<RegionKey, BukkitTask> pending = new HashMap<>(); |
| 46 | + |
| 47 | + public AutoVaultService(@NotNull VaultStoragePlugin plugin) { |
| 48 | + this.plugin = plugin; |
| 49 | + } |
| 50 | + |
| 51 | + /** |
| 52 | + * Schedules an auto-vault sweep of {@code regionId} after the configured grace delay. |
| 53 | + * Cancels any pending sweep already queued for the same region+world. |
| 54 | + * |
| 55 | + * @param worldId world the region lives in |
| 56 | + * @param regionId WorldGuard region id |
| 57 | + * @param initiator the new occupant (owner/tenant); recorded as vault creator and exempt from vaulting |
| 58 | + */ |
| 59 | + public void submit(@NotNull UUID worldId, @NotNull String regionId, @NotNull UUID initiator) { |
| 60 | + if (!plugin.getConfig().getBoolean(ConfigPaths.AUTOVAULT_ENABLED.getPath(), true)) { |
| 61 | + return; |
| 62 | + } |
| 63 | + RegionKey key = new RegionKey(worldId, regionId); |
| 64 | + long delayTicks = Math.max(0L, plugin.getConfig().getLong(ConfigPaths.AUTOVAULT_DELAY_TICKS.getPath(), 1200L)); |
| 65 | + |
| 66 | + BukkitTask task = new BukkitRunnable() { |
| 67 | + @Override public void run() { |
| 68 | + // A fired task is always the current pending entry (a resubmit cancels the prior one). |
| 69 | + pending.remove(key); |
| 70 | + runSweep(key, initiator); |
| 71 | + } |
| 72 | + }.runTaskLater(plugin, delayTicks); |
| 73 | + BukkitTask previous = pending.put(key, task); |
| 74 | + if (previous != null) { |
| 75 | + previous.cancel(); |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + /** |
| 80 | + * Builds the callback the capture service runs whenever it actually vaults one of the previous occupant's |
| 81 | + * locks during this sweep. The first such call (and only the first) tells the new occupant, if online and |
| 82 | + * notifications are enabled, that those items have been moved to vault storage. Thread-safe and may be |
| 83 | + * invoked from async capture tasks; the message send is hopped back onto the main thread. |
| 84 | + */ |
| 85 | + private Runnable vaultedNotifier(@NotNull UUID initiator) { |
| 86 | + if (!plugin.getConfig().getBoolean(ConfigPaths.AUTOVAULT_NOTIFY_OCCUPANT.getPath(), true)) { |
| 87 | + return () -> {}; |
| 88 | + } |
| 89 | + AtomicBoolean sent = new AtomicBoolean(false); |
| 90 | + return () -> { |
| 91 | + if (!sent.compareAndSet(false, true)) { |
| 92 | + return; |
| 93 | + } |
| 94 | + Bukkit.getScheduler().runTask(plugin, () -> { |
| 95 | + Player occupant = Bukkit.getPlayer(initiator); |
| 96 | + if (occupant == null) { |
| 97 | + return; |
| 98 | + } |
| 99 | + String template = plugin.getConfig().getString(ConfigPaths.AUTOVAULT_NOTIFY_MESSAGE.getPath()); |
| 100 | + if (template == null || template.isBlank()) { |
| 101 | + return; |
| 102 | + } |
| 103 | + occupant.sendMessage(MiniMessageUtil.parseOrPlain(template)); |
| 104 | + }); |
| 105 | + }; |
| 106 | + } |
| 107 | + |
| 108 | + private void runSweep(@NotNull RegionKey key, @NotNull UUID initiator) { |
| 109 | + World world = Bukkit.getWorld(key.worldId()); |
| 110 | + if (world == null) { |
| 111 | + return; |
| 112 | + } |
| 113 | + WorldGuardService wgs = plugin.getWorldGuardService(); |
| 114 | + if (wgs == null) { |
| 115 | + return; |
| 116 | + } |
| 117 | + VaultRegion region = wgs.getRegionById(key.regionId(), world); |
| 118 | + if (region == null) { |
| 119 | + // Region was deleted during the delay window; nothing to sweep. |
| 120 | + return; |
| 121 | + } |
| 122 | + |
| 123 | + Set<UUID> allowed = new HashSet<>(); |
| 124 | + allowed.addAll(region.owners()); |
| 125 | + allowed.addAll(region.members()); |
| 126 | + allowed.add(initiator); |
| 127 | + |
| 128 | + // Shared across both passes so the occupant is messaged at most once per sweep. |
| 129 | + Runnable onVaulted = vaultedNotifier(initiator); |
| 130 | + |
| 131 | + List<DisplacedContainer> displaced = OfflineRegionScanner.scan(key.worldId(), region.boundingBox(), allowed); |
| 132 | + if (displaced != null && !displaced.isEmpty()) { |
| 133 | + vaultBlocks(key.worldId(), displaced, initiator, onVaulted); |
| 134 | + } |
| 135 | + if (plugin.getConfig().getBoolean(ConfigPaths.AUTOVAULT_INCLUDE_HANGINGS.getPath(), true)) { |
| 136 | + vaultHangings(key.worldId(), region.boundingBox(), allowed, initiator, onVaulted); |
| 137 | + } |
| 138 | + } |
| 139 | + |
| 140 | + /** Vaults each displaced container, loading its chunk asynchronously. */ |
| 141 | + private void vaultBlocks(@NotNull UUID worldId, @NotNull List<DisplacedContainer> displaced, @NotNull UUID initiator, |
| 142 | + @NotNull Runnable onVaulted) { |
| 143 | + VaultCaptureService captureService = plugin.getCaptureService(); |
| 144 | + forEachChunkPaced(worldId, displaced, |
| 145 | + dc -> new int[]{dc.x() >> 4, dc.z() >> 4}, |
| 146 | + (world, chunk, dc) -> |
| 147 | + // Tolerant of blocks changed since the scan (non-containers just drop their protection). |
| 148 | + captureService.captureOfflineAsync(world.getBlockAt(dc.x(), dc.y(), dc.z()), initiator, dc.owner(), onVaulted)); |
| 149 | + } |
| 150 | + |
| 151 | + /** |
| 152 | + * Vaults displaced item frames and paintings across the region. Entity protections are not indexed |
| 153 | + * by coordinate, so every chunk in the region's bounds is loaded and its hanging entities inspected. |
| 154 | + */ |
| 155 | + private void vaultHangings(@NotNull UUID worldId, @NotNull BoundingBox box, @NotNull Set<UUID> allowed, @NotNull UUID initiator, |
| 156 | + @NotNull Runnable onVaulted) { |
| 157 | + VaultCaptureService captureService = plugin.getCaptureService(); |
| 158 | + BoltService bolt = plugin.getBoltService(); |
| 159 | + |
| 160 | + List<int[]> chunks = new ArrayList<>(); |
| 161 | + int minChunkX = (int) Math.floor(box.getMinX()) >> 4; |
| 162 | + int minChunkZ = (int) Math.floor(box.getMinZ()) >> 4; |
| 163 | + int maxChunkX = (int) Math.floor(box.getMaxX()) >> 4; |
| 164 | + int maxChunkZ = (int) Math.floor(box.getMaxZ()) >> 4; |
| 165 | + for (int cx = minChunkX; cx <= maxChunkX; cx++) { |
| 166 | + for (int cz = minChunkZ; cz <= maxChunkZ; cz++) { |
| 167 | + chunks.add(new int[]{cx, cz}); |
| 168 | + } |
| 169 | + } |
| 170 | + |
| 171 | + forEachChunkPaced(worldId, chunks, |
| 172 | + coords -> coords, |
| 173 | + (world, chunk, coords) -> { |
| 174 | + for (Entity entity : chunk.getEntities()) { |
| 175 | + if (!(entity instanceof ItemFrame) && !(entity instanceof Painting)) continue; |
| 176 | + UUID owner = bolt.getOwner(entity); |
| 177 | + if (owner == null || allowed.contains(owner)) continue; |
| 178 | + captureService.captureHangingOfflineAsync((Hanging) entity, owner, initiator, onVaulted); |
| 179 | + } |
| 180 | + }); |
| 181 | + } |
| 182 | + |
| 183 | + /** |
| 184 | + * Runs {@code work} for each item, loading the chunk given by {@code chunkOf} asynchronously and keeping |
| 185 | + * at most {@code scan.batch-size} loads in flight. {@code work} runs on the main thread once that chunk is |
| 186 | + * loaded. Stops if the world unloads. |
| 187 | + */ |
| 188 | + private <T> void forEachChunkPaced(@NotNull UUID worldId, @NotNull List<T> items, |
| 189 | + @NotNull Function<T, int[]> chunkOf, @NotNull ChunkWork<T> work) { |
| 190 | + final int maxInFlight = Math.max(1, plugin.getConfig().getInt(ConfigPaths.SCAN_BATCH_SIZE.getPath(), 50)); |
| 191 | + |
| 192 | + new BukkitRunnable() { |
| 193 | + int index = 0; |
| 194 | + int inFlight = 0; // pending async chunk loads; mutated only on the main thread |
| 195 | + |
| 196 | + @Override public void run() { |
| 197 | + World world = Bukkit.getWorld(worldId); |
| 198 | + if (world == null) { |
| 199 | + this.cancel(); |
| 200 | + return; |
| 201 | + } |
| 202 | + while (index < items.size() && inFlight < maxInFlight) { |
| 203 | + T item = items.get(index); |
| 204 | + index++; |
| 205 | + int[] chunkCoords = chunkOf.apply(item); |
| 206 | + inFlight++; |
| 207 | + world.getChunkAtAsync(chunkCoords[0], chunkCoords[1]).thenAccept(chunk -> { |
| 208 | + inFlight--; |
| 209 | + work.run(world, chunk, item); |
| 210 | + }); |
| 211 | + } |
| 212 | + if (index >= items.size() && inFlight == 0) { |
| 213 | + this.cancel(); |
| 214 | + } |
| 215 | + } |
| 216 | + }.runTaskTimer(plugin, 1L, 1L); |
| 217 | + } |
| 218 | + |
| 219 | + @FunctionalInterface |
| 220 | + private interface ChunkWork<T> { |
| 221 | + void run(World world, Chunk chunk, T item); |
| 222 | + } |
| 223 | + |
| 224 | + /** Cancels all pending sweeps. Call from {@code onDisable}. */ |
| 225 | + public void shutdown() { |
| 226 | + pending.values().forEach(BukkitTask::cancel); |
| 227 | + pending.clear(); |
| 228 | + } |
| 229 | +} |
0 commit comments