Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ repositories {
name = "minebench"
url = "https://repo.minebench.de/"
}
maven {
name = "democracycraft"
url = "https://maven.democracycraft.net/releases"
}
}

dependencies {
Expand All @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/net/democracycraft/vault/VaultStoragePlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -44,6 +46,7 @@ public final class VaultStoragePlugin extends JavaPlugin {
private VaultPlacementService placementService;
private VaultInventoryService inventoryService;
private VaultScanService scanService;
private AutoVaultService autoVaultService;


// Integration services
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<RegionKey, BukkitTask> 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<UUID> 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<DisplacedContainer> 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<DisplacedContainer> 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<UUID> allowed, @NotNull UUID initiator,
@NotNull Runnable onVaulted) {
VaultCaptureService captureService = plugin.getCaptureService();
BoltService bolt = plugin.getBoltService();

List<int[]> 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 <T> void forEachChunkPaced(@NotNull UUID worldId, @NotNull List<T> items,
@NotNull Function<T, int[]> chunkOf, @NotNull ChunkWork<T> 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<T> {
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();
}
}
Loading