diff --git a/build.gradle b/build.gradle index a5e89f7..bae52a1 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,10 @@ repositories { name = "minebench" url = "https://repo.minebench.de/" } + maven { + name = "democracycraft" + url = "https://maven.democracycraft.net/releases" + } } dependencies { @@ -49,6 +53,7 @@ dependencies { compileOnly("net.essentialsx:EssentialsX:2.21.2") compileOnly("org.geysermc.floodgate:api:2.2.4-SNAPSHOT") compileOnly("com.acrobot.chestshop:chestshop:3.12.2") + compileOnly("io.github.md5sha256:realty-paper-api:1.4.0") { transitive = false } } tasks { diff --git a/src/main/java/net/democracycraft/vault/VaultStoragePlugin.java b/src/main/java/net/democracycraft/vault/VaultStoragePlugin.java index 8cb9c08..021190b 100644 --- a/src/main/java/net/democracycraft/vault/VaultStoragePlugin.java +++ b/src/main/java/net/democracycraft/vault/VaultStoragePlugin.java @@ -10,7 +10,9 @@ import net.democracycraft.vault.internal.database.MySQLManager; import net.democracycraft.vault.internal.database.dao.VaultDAOImpl; import net.democracycraft.vault.internal.database.entity.WorldEntity; +import net.democracycraft.vault.internal.listener.RealtyOccupantChangeListener; import net.democracycraft.vault.internal.service.*; +import net.democracycraft.vault.internal.util.config.ConfigPaths; import net.democracycraft.vault.internal.session.BedrockUniqueIdentifierRetriever; import net.democracycraft.vault.internal.session.VaultSessionManager; import net.democracycraft.vault.internal.util.config.ConfigInitializer; @@ -44,6 +46,7 @@ public final class VaultStoragePlugin extends JavaPlugin { private VaultPlacementService placementService; private VaultInventoryService inventoryService; private VaultScanService scanService; + private AutoVaultService autoVaultService; // Integration services @@ -161,6 +164,14 @@ public void onEnable() { // Validate defined permissions vs plugin.yml validatePermissionsMapping(); + + // Realty soft dependency: auto-vault containers when a region's owner/tenant changes. + if (getServer().getPluginManager().getPlugin("Realty") != null + && getConfig().getBoolean(ConfigPaths.AUTOVAULT_ENABLED.getPath(), true)) { + this.autoVaultService = new AutoVaultService(this); + getServer().getPluginManager().registerEvents(new RealtyOccupantChangeListener(autoVaultService), this); + getLogger().info("Realty detected: auto-vault on region occupant change enabled."); + } } private void validatePermissionsMapping() { @@ -203,6 +214,7 @@ private void bootstrapWorlds() { @Override public void onDisable() { + if (this.autoVaultService != null) this.autoVaultService.shutdown(); if (this.mysql != null) this.mysql.disconnect(); } diff --git a/src/main/java/net/democracycraft/vault/internal/listener/RealtyOccupantChangeListener.java b/src/main/java/net/democracycraft/vault/internal/listener/RealtyOccupantChangeListener.java new file mode 100644 index 0000000..7a964a2 --- /dev/null +++ b/src/main/java/net/democracycraft/vault/internal/listener/RealtyOccupantChangeListener.java @@ -0,0 +1,56 @@ +package net.democracycraft.vault.internal.listener; + +import io.github.md5sha256.realty.api.event.RealtyRegionEvent; +import io.github.md5sha256.realty.api.event.RegionBoughtEvent; +import io.github.md5sha256.realty.api.event.RegionRentedEvent; +import io.github.md5sha256.realty.api.event.TenantSetEvent; +import io.github.md5sha256.realty.api.event.TitleTransferredEvent; +import net.democracycraft.vault.internal.service.AutoVaultService; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; + +/** + * Listens for Realty owner/tenant changes and triggers an {@link AutoVaultService} sweep for the new + * occupant. References Realty API types, so it is only instantiated when the Realty plugin is present. + */ +public final class RealtyOccupantChangeListener implements Listener { + + private final AutoVaultService autoVaultService; + + public RealtyOccupantChangeListener(@NotNull AutoVaultService autoVaultService) { + this.autoVaultService = autoVaultService; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTitleTransferred(TitleTransferredEvent event) { + submit(event, event.getNewTitleHolderId()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onRegionBought(RegionBoughtEvent event) { + submit(event, event.getBuyerId()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onRegionRented(RegionRentedEvent event) { + submit(event, event.getTenantId()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTenantSet(TenantSetEvent event) { + // A null new tenant means the tenancy was cleared (out of scope); skip. + submit(event, event.getNewTenantId()); + } + + private void submit(@NotNull RealtyRegionEvent event, @Nullable UUID newOccupant) { + if (newOccupant == null) { + return; + } + autoVaultService.submit(event.getWorldId(), event.getRegionId(), newOccupant); + } +} diff --git a/src/main/java/net/democracycraft/vault/internal/service/AutoVaultService.java b/src/main/java/net/democracycraft/vault/internal/service/AutoVaultService.java new file mode 100644 index 0000000..ddcafb9 --- /dev/null +++ b/src/main/java/net/democracycraft/vault/internal/service/AutoVaultService.java @@ -0,0 +1,229 @@ +package net.democracycraft.vault.internal.service; + +import net.democracycraft.vault.VaultStoragePlugin; +import net.democracycraft.vault.api.region.VaultRegion; +import net.democracycraft.vault.api.service.BoltService; +import net.democracycraft.vault.api.service.WorldGuardService; +import net.democracycraft.vault.internal.util.config.ConfigPaths; +import net.democracycraft.vault.internal.util.minimessage.MiniMessageUtil; +import net.democracycraft.vault.internal.util.region.RegionKey; +import net.democracycraft.vault.internal.util.scan.OfflineRegionScanner; +import net.democracycraft.vault.internal.util.scan.OfflineRegionScanner.DisplacedContainer; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Hanging; +import org.bukkit.entity.ItemFrame; +import org.bukkit.entity.Painting; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitRunnable; +import org.bukkit.scheduler.BukkitTask; +import org.bukkit.util.BoundingBox; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; + +/** + * Schedules and runs the delayed, batched auto-vaulting of Bolt-locked containers (and, when enabled, + * item frames and paintings) when a region's owner/tenant changes. A lock is vaulted when its Bolt owner + * is not in the allowed set ({new occupant} plus the region's WorldGuard owners/members). Runs on the + * main thread; chunk loads are async. + */ +public class AutoVaultService { + + private final VaultStoragePlugin plugin; + // Main-thread only: populated from the Realty event handler and the scheduled sweep task. + private final Map pending = new HashMap<>(); + + public AutoVaultService(@NotNull VaultStoragePlugin plugin) { + this.plugin = plugin; + } + + /** + * Schedules an auto-vault sweep of {@code regionId} after the configured grace delay. + * Cancels any pending sweep already queued for the same region+world. + * + * @param worldId world the region lives in + * @param regionId WorldGuard region id + * @param initiator the new occupant (owner/tenant); recorded as vault creator and exempt from vaulting + */ + public void submit(@NotNull UUID worldId, @NotNull String regionId, @NotNull UUID initiator) { + if (!plugin.getConfig().getBoolean(ConfigPaths.AUTOVAULT_ENABLED.getPath(), true)) { + return; + } + RegionKey key = new RegionKey(worldId, regionId); + long delayTicks = Math.max(0L, plugin.getConfig().getLong(ConfigPaths.AUTOVAULT_DELAY_TICKS.getPath(), 1200L)); + + BukkitTask task = new BukkitRunnable() { + @Override public void run() { + // A fired task is always the current pending entry (a resubmit cancels the prior one). + pending.remove(key); + runSweep(key, initiator); + } + }.runTaskLater(plugin, delayTicks); + BukkitTask previous = pending.put(key, task); + if (previous != null) { + previous.cancel(); + } + } + + /** + * Builds the callback the capture service runs whenever it actually vaults one of the previous occupant's + * locks during this sweep. The first such call (and only the first) tells the new occupant, if online and + * notifications are enabled, that those items have been moved to vault storage. Thread-safe and may be + * invoked from async capture tasks; the message send is hopped back onto the main thread. + */ + private Runnable vaultedNotifier(@NotNull UUID initiator) { + if (!plugin.getConfig().getBoolean(ConfigPaths.AUTOVAULT_NOTIFY_OCCUPANT.getPath(), true)) { + return () -> {}; + } + AtomicBoolean sent = new AtomicBoolean(false); + return () -> { + if (!sent.compareAndSet(false, true)) { + return; + } + Bukkit.getScheduler().runTask(plugin, () -> { + Player occupant = Bukkit.getPlayer(initiator); + if (occupant == null) { + return; + } + String template = plugin.getConfig().getString(ConfigPaths.AUTOVAULT_NOTIFY_MESSAGE.getPath()); + if (template == null || template.isBlank()) { + return; + } + occupant.sendMessage(MiniMessageUtil.parseOrPlain(template)); + }); + }; + } + + private void runSweep(@NotNull RegionKey key, @NotNull UUID initiator) { + World world = Bukkit.getWorld(key.worldId()); + if (world == null) { + return; + } + WorldGuardService wgs = plugin.getWorldGuardService(); + if (wgs == null) { + return; + } + VaultRegion region = wgs.getRegionById(key.regionId(), world); + if (region == null) { + // Region was deleted during the delay window; nothing to sweep. + return; + } + + Set allowed = new HashSet<>(); + allowed.addAll(region.owners()); + allowed.addAll(region.members()); + allowed.add(initiator); + + // Shared across both passes so the occupant is messaged at most once per sweep. + Runnable onVaulted = vaultedNotifier(initiator); + + List displaced = OfflineRegionScanner.scan(key.worldId(), region.boundingBox(), allowed); + if (displaced != null && !displaced.isEmpty()) { + vaultBlocks(key.worldId(), displaced, initiator, onVaulted); + } + if (plugin.getConfig().getBoolean(ConfigPaths.AUTOVAULT_INCLUDE_HANGINGS.getPath(), true)) { + vaultHangings(key.worldId(), region.boundingBox(), allowed, initiator, onVaulted); + } + } + + /** Vaults each displaced container, loading its chunk asynchronously. */ + private void vaultBlocks(@NotNull UUID worldId, @NotNull List displaced, @NotNull UUID initiator, + @NotNull Runnable onVaulted) { + VaultCaptureService captureService = plugin.getCaptureService(); + forEachChunkPaced(worldId, displaced, + dc -> new int[]{dc.x() >> 4, dc.z() >> 4}, + (world, chunk, dc) -> + // Tolerant of blocks changed since the scan (non-containers just drop their protection). + captureService.captureOfflineAsync(world.getBlockAt(dc.x(), dc.y(), dc.z()), initiator, dc.owner(), onVaulted)); + } + + /** + * Vaults displaced item frames and paintings across the region. Entity protections are not indexed + * by coordinate, so every chunk in the region's bounds is loaded and its hanging entities inspected. + */ + private void vaultHangings(@NotNull UUID worldId, @NotNull BoundingBox box, @NotNull Set allowed, @NotNull UUID initiator, + @NotNull Runnable onVaulted) { + VaultCaptureService captureService = plugin.getCaptureService(); + BoltService bolt = plugin.getBoltService(); + + List chunks = new ArrayList<>(); + int minChunkX = (int) Math.floor(box.getMinX()) >> 4; + int minChunkZ = (int) Math.floor(box.getMinZ()) >> 4; + int maxChunkX = (int) Math.floor(box.getMaxX()) >> 4; + int maxChunkZ = (int) Math.floor(box.getMaxZ()) >> 4; + for (int cx = minChunkX; cx <= maxChunkX; cx++) { + for (int cz = minChunkZ; cz <= maxChunkZ; cz++) { + chunks.add(new int[]{cx, cz}); + } + } + + forEachChunkPaced(worldId, chunks, + coords -> coords, + (world, chunk, coords) -> { + for (Entity entity : chunk.getEntities()) { + if (!(entity instanceof ItemFrame) && !(entity instanceof Painting)) continue; + UUID owner = bolt.getOwner(entity); + if (owner == null || allowed.contains(owner)) continue; + captureService.captureHangingOfflineAsync((Hanging) entity, owner, initiator, onVaulted); + } + }); + } + + /** + * Runs {@code work} for each item, loading the chunk given by {@code chunkOf} asynchronously and keeping + * at most {@code scan.batch-size} loads in flight. {@code work} runs on the main thread once that chunk is + * loaded. Stops if the world unloads. + */ + private void forEachChunkPaced(@NotNull UUID worldId, @NotNull List items, + @NotNull Function chunkOf, @NotNull ChunkWork work) { + final int maxInFlight = Math.max(1, plugin.getConfig().getInt(ConfigPaths.SCAN_BATCH_SIZE.getPath(), 50)); + + new BukkitRunnable() { + int index = 0; + int inFlight = 0; // pending async chunk loads; mutated only on the main thread + + @Override public void run() { + World world = Bukkit.getWorld(worldId); + if (world == null) { + this.cancel(); + return; + } + while (index < items.size() && inFlight < maxInFlight) { + T item = items.get(index); + index++; + int[] chunkCoords = chunkOf.apply(item); + inFlight++; + world.getChunkAtAsync(chunkCoords[0], chunkCoords[1]).thenAccept(chunk -> { + inFlight--; + work.run(world, chunk, item); + }); + } + if (index >= items.size() && inFlight == 0) { + this.cancel(); + } + } + }.runTaskTimer(plugin, 1L, 1L); + } + + @FunctionalInterface + private interface ChunkWork { + void run(World world, Chunk chunk, T item); + } + + /** Cancels all pending sweeps. Call from {@code onDisable}. */ + public void shutdown() { + pending.values().forEach(BukkitTask::cancel); + pending.clear(); + } +} diff --git a/src/main/java/net/democracycraft/vault/internal/service/VaultCaptureService.java b/src/main/java/net/democracycraft/vault/internal/service/VaultCaptureService.java index c4b26cc..a506445 100644 --- a/src/main/java/net/democracycraft/vault/internal/service/VaultCaptureService.java +++ b/src/main/java/net/democracycraft/vault/internal/service/VaultCaptureService.java @@ -33,6 +33,7 @@ import org.bukkit.scheduler.BukkitTask; import org.bukkit.block.data.type.WallSign; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.time.Instant; import java.util.*; @@ -136,6 +137,11 @@ public boolean isContainerEmpty(Block block) { *

This method must be called on the main thread.

*/ public VaultImp captureFromBlock(Player actor, Block block) { + return captureFromBlock(actor.getUniqueId(), block); + } + + /** Player-free variant of {@link #captureFromBlock(Player, Block)}; the vault is owned by {@code vaultOwnerUuid}. */ + public VaultImp captureFromBlock(UUID vaultOwnerUuid, Block block) { if (!(block.getState() instanceof Container container)) { throw new IllegalArgumentException("Target block is not a container."); } @@ -154,8 +160,7 @@ public VaultImp captureFromBlock(Player actor, Block block) { block.setType(Material.AIR); UUID vaultId = UUID.randomUUID(); - UUID owner = actor.getUniqueId(); - return new VaultImp(owner, vaultId, stacks, material, location, when, blockDataString); + return new VaultImp(vaultOwnerUuid, vaultId, stacks, material, location, when, blockDataString); } /** @@ -179,8 +184,18 @@ public record CaptureOutcome(boolean empty, boolean protectionRemoved, VaultImp * */ public CaptureOutcome captureWithDoubleChestSupport(Player actor, @NonNull Block block, UUID originalOwner) { + return captureWithDoubleChestSupport(block, originalOwner, actor.getUniqueId(), actor::sendMessage); + } + + /** + * Player-free variant of {@link #captureWithDoubleChestSupport(Player, Block, UUID)}. The vault owner is + * {@code originalOwner} when non-null, else {@code finalOwnerFallback}; {@code warn} may be null to suppress notices. + */ + public CaptureOutcome captureWithDoubleChestSupport(@NonNull Block block, UUID originalOwner, + @NonNull UUID finalOwnerFallback, + Consumer warn) { BoltService bolt = VaultStoragePlugin.getInstance().getBoltService(); - UUID finalOwner = originalOwner != null ? originalOwner : actor.getUniqueId(); + UUID finalOwner = originalOwner != null ? originalOwner : finalOwnerFallback; // 1. Non-container path: treat as empty capture (remove protection) if (!(block.getState() instanceof Container container)) { @@ -201,7 +216,9 @@ public CaptureOutcome captureWithDoubleChestSupport(Player actor, @NonNull Block // If the target half is empty but the other half contains items, we abort the operation // to prevent accidental unprotection of the full chest. The user is guided to vault the non-empty side. if (targetIsEmpty && !otherHalfIsEmpty) { - actor.sendMessage(Component.text("[Vault Capture Service] You should vault the non-empty side first.").color(NamedTextColor.YELLOW)); + if (warn != null) { + warn.accept(Component.text("[Vault Capture Service] You should vault the non-empty side first.").color(NamedTextColor.YELLOW)); + } // Return empty=true so no vault is made, but protectionRemoved=false so we don't spam "Protection Removed". return new CaptureOutcome(true, false, null, originalOwner, finalOwner, List.of()); } @@ -244,8 +261,8 @@ public CaptureOutcome captureWithDoubleChestSupport(Player actor, @NonNull Block } } - // Capture block contents into vault (removes block) - VaultImp vault = captureFromBlock(actor, block); + // VaultImp owner carries the capturing identity; persistence uses CaptureOutcome.finalOwner(). + VaultImp vault = captureFromBlock(finalOwnerFallback, block); return new CaptureOutcome(false, true, vault, originalOwner, finalOwner, List.copyOf(reProtected)); } @@ -523,18 +540,7 @@ public void run() { "[VaultCaptureService] Vault created successfully: ID=" + newId + " Owner=" + validatedOwner + " for player " + actor.getName() ); } - List items = vault.contents(); - List batch = new ArrayList<>(items.size()); - for (int idx = 0; idx < items.size(); idx++) { - ItemStack itemStack = items.get(idx); - if (itemStack == null) continue; - VaultItemEntity vie = new VaultItemEntity(); - vie.vaultUuid = newId; - vie.slot = idx; - vie.amount = itemStack.getAmount(); - vie.item = ItemSerialization.toBytes(itemStack); - batch.add(vie); - } + List batch = toItemBatch(newId, vault.contents()); if (!batch.isEmpty()) { vaultService.putItems(newId, batch); } @@ -612,45 +618,7 @@ public void onInteractEntity(PlayerInteractEntityEvent event) { new BukkitRunnable() { @Override public void run() { var plugin = VaultStoragePlugin.getInstance(); - VaultService vaultService = plugin.getVaultService(); - int needed = stacks.size(); - var targetOpt = HangingVaultSupport.findFirstVaultWithSpace(vaultService, validatedOwner, needed); - UUID vaultUuid; - int startSlot; - if (targetOpt.isPresent()) { - var t = targetOpt.get(); - vaultUuid = t.vaultUuid(); - startSlot = t.startSlot(); - } else { - // Note: persisted material must be a block; placement uses Block#setType (item names are invalid). - var created = vaultService.createVault( - supporting.getWorld().getUID(), - actor.getUniqueId(), - supporting.getX(), - supporting.getY(), - supporting.getZ(), - validatedOwner, - Material.CHEST.name(), - null - ); - vaultUuid = created.uuid; - startSlot = 0; - plugin.getLogger().info( - "[VaultCaptureService] Hanging capture created vault ID=" + vaultUuid + " owner=" + validatedOwner - ); - } - - List batch = new ArrayList<>(stacks.size()); - int slot = startSlot; - for (ItemStack stack : stacks) { - VaultItemEntity vie = new VaultItemEntity(); - vie.vaultUuid = vaultUuid; - vie.slot = slot++; - vie.amount = stack.getAmount(); - vie.item = ItemSerialization.toBytes(stack); - batch.add(vie); - } - vaultService.putItems(vaultUuid, batch); + UUID vaultUuid = persistHangingStacks(stacks, validatedOwner, actor.getUniqueId(), supporting); new BukkitRunnable() { @Override public void run() { @@ -776,8 +744,8 @@ private static boolean isSignBlock(@NotNull Block block) { return block.getState() instanceof Sign; } - /** Unlocks and removes the given shop signs, notifying ChestShop. Must run on the main thread. */ - private void removeAttachedChestShopSigns(@NotNull Player actor, @NotNull List shopSigns) { + /** Unlocks and removes the given shop signs, notifying ChestShop. {@code actor} may be null for automated captures. Must run on the main thread. */ + private void removeAttachedChestShopSigns(@Nullable Player actor, @NotNull List shopSigns) { ChestShopService chestShop = VaultStoragePlugin.getInstance().getChestShopService(); BoltService bolt = VaultStoragePlugin.getInstance().getBoltService(); for (Block signBlock : shopSigns) { @@ -866,18 +834,7 @@ public void captureDirectAsync(@NotNull Player actor, @NotNull Block block, @Not "[VaultCaptureService] Direct vault created: ID=" + newId + " Owner=" + validatedOwner + " for player " + actor.getName() ); } - List items = vault.contents(); - List batch = new ArrayList<>(items.size()); - for (int idx = 0; idx < items.size(); idx++) { - ItemStack itemStack = items.get(idx); - if (itemStack == null) continue; - VaultItemEntity vie = new VaultItemEntity(); - vie.vaultUuid = newId; - vie.slot = idx; - vie.amount = itemStack.getAmount(); - vie.item = ItemSerialization.toBytes(itemStack); - batch.add(vie); - } + List batch = toItemBatch(newId, vault.contents()); if (!batch.isEmpty()) vaultService.putItems(newId, batch); new BukkitRunnable(){ @@ -895,4 +852,134 @@ public void captureDirectAsync(@NotNull Player actor, @NotNull Block block, @Not }.runTaskAsynchronously(plugin); }); } + + /** + * Offline-safe capture: no live {@link Player} and no policy evaluation; the caller decides what to vault. + * The vault is owned by {@code boltOwner} (falls back to {@code initiatorUuid} when null) and created by + * {@code initiatorUuid}. Must be called on the main thread; persistence runs async (fire-and-forget). + */ + public void captureOfflineAsync(@NotNull Block block, + @NotNull UUID initiatorUuid, + UUID boltOwner, + @NotNull Runnable onVaulted) { + var plugin = VaultStoragePlugin.getInstance(); + + CaptureOutcome outcome = captureWithDoubleChestSupport(block, boltOwner, initiatorUuid, null); + if (outcome.empty()) { + return; + } + + // Drop any ChestShop sign left on the now-captured container. + ChestShopService chestShop = plugin.getChestShopService(); + if (chestShop != null && chestShop.isAvailable()) { + List shopSigns = chestShop.findShopSigns(block); + if (!shopSigns.isEmpty()) { + removeAttachedChestShopSigns(null, shopSigns); + } + } + + UUID finalOwner = outcome.finalOwner(); + if (!UniqueIdentifierResolver.isValidUUID(finalOwner)) { + plugin.getLogger().warning( + "[VaultCaptureService] Offline capture aborted at " + block.getWorld().getName() + ":" + + block.getX() + "," + block.getY() + "," + block.getZ() + + " - invalid vault owner UUID: " + finalOwner); + return; + } + + VaultImp vault = outcome.vault(); + new BukkitRunnable() { + @Override public void run() { + VaultService vaultService = plugin.getVaultService(); + UUID worldId = block.getWorld().getUID(); + var created = vaultService.createVault(worldId, initiatorUuid, block.getX(), block.getY(), block.getZ(), finalOwner, + vault.blockMaterial() == null ? null : vault.blockMaterial().name(), + vault.blockDataString()); + UUID newId = created.uuid; + plugin.getLogger().info( + "[VaultCaptureService] Offline vault created: ID=" + newId + " owner=" + finalOwner + " initiator=" + initiatorUuid); + + List batch = toItemBatch(newId, vault.contents()); + if (!batch.isEmpty()) vaultService.putItems(newId, batch); + // No PlayerVaultEvent: this is an automated capture. Signal the occupant notifier that a vault was made. + onVaulted.run(); + } + }.runTaskAsynchronously(plugin); + } + + /** + * Offline-safe capture of a Bolt-locked hanging entity (item frame or painting) with no live + * {@link Player} and no policy evaluation. Its contents (the frame/painting plus any displayed item) + * are stored in a vault owned by {@code boltOwner}, merged into an existing vault when one has room, + * and the entity is removed (which also disposes its Bolt protection). + *

Must be called on the main thread with the entity loaded; persistence runs async.

+ */ + public void captureHangingOfflineAsync(@NotNull Hanging hang, @NotNull UUID boltOwner, @NotNull UUID initiatorUuid, + @NotNull Runnable onVaulted) { + List stacks = HangingVaultSupport.itemStacksFrom(hang); + if (stacks.isEmpty()) { + return; + } + + var plugin = VaultStoragePlugin.getInstance(); + Block supporting = HangingVaultSupport.resolveSupportingBlock(hang); + new BukkitRunnable() { + @Override public void run() { + persistHangingStacks(stacks, boltOwner, initiatorUuid, supporting); + onVaulted.run(); + new BukkitRunnable() { + @Override public void run() { + if (hang.isValid()) hang.remove(); + } + }.runTask(plugin); + } + }.runTaskAsynchronously(plugin); + } + + /** + * Stores hanging stacks into an existing vault owned by {@code boltOwner} that has room, or a new vault + * anchored at {@code supporting} and created by {@code creatorUuid}. Runs on the async persistence thread; + * returns the target vault id. + */ + private UUID persistHangingStacks(@NotNull List stacks, @NotNull UUID boltOwner, @NotNull UUID creatorUuid, @NotNull Block supporting) { + var plugin = VaultStoragePlugin.getInstance(); + VaultService vaultService = plugin.getVaultService(); + var target = HangingVaultSupport.findFirstVaultWithSpace(vaultService, boltOwner, stacks.size()); + UUID vaultUuid; + int startSlot; + if (target.isPresent()) { + vaultUuid = target.get().vaultUuid(); + startSlot = target.get().startSlot(); + } else { + // Persisted material must be a block; placement uses Block#setType (item names are invalid). + var created = vaultService.createVault(supporting.getWorld().getUID(), creatorUuid, + supporting.getX(), supporting.getY(), supporting.getZ(), boltOwner, Material.CHEST.name(), null); + vaultUuid = created.uuid; + startSlot = 0; + plugin.getLogger().info("[VaultCaptureService] Hanging capture created vault ID=" + vaultUuid + " owner=" + boltOwner); + } + vaultService.putItems(vaultUuid, toItemBatch(vaultUuid, stacks, startSlot)); + return vaultUuid; + } + + /** Serializes a vault's contents into persistable item rows starting at slot 0, skipping empty slots. */ + private static List toItemBatch(UUID vaultId, List items) { + return toItemBatch(vaultId, items, 0); + } + + /** Serializes items into persistable rows assigned consecutive slots from {@code startSlot}, skipping empty slots. */ + private static List toItemBatch(UUID vaultId, List items, int startSlot) { + List batch = new ArrayList<>(items.size()); + int slot = startSlot; + for (ItemStack itemStack : items) { + if (itemStack == null) continue; + VaultItemEntity vie = new VaultItemEntity(); + vie.vaultUuid = vaultId; + vie.slot = slot++; + vie.amount = itemStack.getAmount(); + vie.item = ItemSerialization.toBytes(itemStack); + batch.add(vie); + } + return batch; + } } \ No newline at end of file diff --git a/src/main/java/net/democracycraft/vault/internal/util/config/ConfigPaths.java b/src/main/java/net/democracycraft/vault/internal/util/config/ConfigPaths.java index 7f1e146..7e9833d 100644 --- a/src/main/java/net/democracycraft/vault/internal/util/config/ConfigPaths.java +++ b/src/main/java/net/democracycraft/vault/internal/util/config/ConfigPaths.java @@ -9,7 +9,12 @@ public enum ConfigPaths { MYSQL_USE_SSL("mysql.useSSL"), SCAN_BATCH_SIZE("scan.batch-size"), SCAN_CACHE_TTL_SECONDS("scan.cache-ttl-seconds"), - SCAN_COOLDOWN_SECONDS("scan.cooldown-seconds"); + SCAN_COOLDOWN_SECONDS("scan.cooldown-seconds"), + AUTOVAULT_ENABLED("auto-vault.enabled"), + AUTOVAULT_DELAY_TICKS("auto-vault.delay-ticks"), + AUTOVAULT_INCLUDE_HANGINGS("auto-vault.include-hangings"), + AUTOVAULT_NOTIFY_OCCUPANT("auto-vault.notify-occupant"), + AUTOVAULT_NOTIFY_MESSAGE("auto-vault.notify-message"); private final String path; diff --git a/src/main/java/net/democracycraft/vault/internal/util/region/RegionKey.java b/src/main/java/net/democracycraft/vault/internal/util/region/RegionKey.java new file mode 100644 index 0000000..5ca6911 --- /dev/null +++ b/src/main/java/net/democracycraft/vault/internal/util/region/RegionKey.java @@ -0,0 +1,17 @@ +package net.democracycraft.vault.internal.util.region; + +import org.jetbrains.annotations.NotNull; + +import java.util.Locale; +import java.util.UUID; + +/** + * Identity of a region within a world, used to dedupe and cancel pending auto-vault sweeps. + * The region id is normalized to lower-case to match WorldGuard's id convention. + */ +public record RegionKey(@NotNull UUID worldId, @NotNull String regionId) { + + public RegionKey { + regionId = regionId.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/net/democracycraft/vault/internal/util/scan/OfflineRegionScanner.java b/src/main/java/net/democracycraft/vault/internal/util/scan/OfflineRegionScanner.java new file mode 100644 index 0000000..b124880 --- /dev/null +++ b/src/main/java/net/democracycraft/vault/internal/util/scan/OfflineRegionScanner.java @@ -0,0 +1,50 @@ +package net.democracycraft.vault.internal.util.scan; + +import net.democracycraft.vault.VaultStoragePlugin; +import net.democracycraft.vault.api.service.BoltService; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.util.BoundingBox; +import org.popcraft.bolt.protection.BlockProtection; +import org.popcraft.bolt.protection.Protection; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * In-memory filter over a region's Bolt protections. Returns the locked blocks whose owner is non-null + * and not in {@code allowed}, using only Bolt's stored coordinates/owner — no block reads or chunk loads, + * so it never blocks the main thread. Callers load chunks asynchronously when vaulting the results. + */ +public final class OfflineRegionScanner { + + private OfflineRegionScanner() {} + + /** A locked container flagged for vaulting: its coordinates and Bolt owner. */ + public record DisplacedContainer(int x, int y, int z, UUID owner) {} + + /** Filters the region's Bolt protections to displaced containers, or {@code null} if services are unavailable. */ + public static List scan(UUID worldId, BoundingBox boundingBox, Set allowed) { + World world = Bukkit.getWorld(worldId); + BoltService boltService = VaultStoragePlugin.getInstance().getBoltService(); + if (world == null || boltService == null) { + return null; + } + + Collection protections = boltService.getProtections(boundingBox, world); + List displaced = new ArrayList<>(); + if (protections != null) { + for (Protection protection : protections) { + if (!(protection instanceof BlockProtection bp)) continue; + if (bp.getY() < world.getMinHeight() || bp.getY() >= world.getMaxHeight()) continue; + UUID owner = bp.getOwner(); + if (owner == null || allowed.contains(owner)) continue; + displaced.add(new DisplacedContainer(bp.getX(), bp.getY(), bp.getZ(), owner)); + } + } + return displaced; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 05f52ce..3f1406b 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -9,3 +9,17 @@ mysql: password: "" useSSL: false +# Automatically vault Bolt-locked containers when a Realty region changes occupant +# (bought/transferred or rented). A container is vaulted when its Bolt owner is not the +# new occupant and is not a WorldGuard owner/member of the region. Requires the Realty plugin. +auto-vault: + enabled: true + # Grace period (in server ticks, 20 = 1 second) between the occupant change and the sweep. + delay-ticks: 1200 + # Vaukt Bolt-locked item frames and paintings. + include-hangings: true + # When a sweep actually vaults the previous occupant's locked items, tell the new occupant + # (if online). Sent once per sweep, only when something was vaulted. Accepts MiniMessage. + notify-occupant: true + notify-message: "Locked containers and items left here by the previous occupant have been cleared into their vault storage." + diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index fc8145b..e0be6ff 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -4,7 +4,7 @@ main: net.democracycraft.vault.VaultStoragePlugin api-version: '1.21' authors: [ Alepando ] depend: [ Bolt , WorldGuard, WorldEdit, Essentials ] -softdepend: [ ChestShop ] +softdepend: [ ChestShop, Realty ] commands: vault: