Skip to content

Commit 6f157a9

Browse files
authored
Merge pull request #7 from MCCitiesNetwork/feat/auto-vault
feat: auto-vaulting integration with Realty for new titleholders/tenants
2 parents abbe14d + c0d8484 commit 6f157a9

10 files changed

Lines changed: 548 additions & 73 deletions

File tree

build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ repositories {
3636
name = "minebench"
3737
url = "https://repo.minebench.de/"
3838
}
39+
maven {
40+
name = "democracycraft"
41+
url = "https://maven.democracycraft.net/releases"
42+
}
3943
}
4044

4145
dependencies {
@@ -49,6 +53,7 @@ dependencies {
4953
compileOnly("net.essentialsx:EssentialsX:2.21.2")
5054
compileOnly("org.geysermc.floodgate:api:2.2.4-SNAPSHOT")
5155
compileOnly("com.acrobot.chestshop:chestshop:3.12.2")
56+
compileOnly("io.github.md5sha256:realty-paper-api:1.4.0") { transitive = false }
5257
}
5358

5459
tasks {

src/main/java/net/democracycraft/vault/VaultStoragePlugin.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
import net.democracycraft.vault.internal.database.MySQLManager;
1111
import net.democracycraft.vault.internal.database.dao.VaultDAOImpl;
1212
import net.democracycraft.vault.internal.database.entity.WorldEntity;
13+
import net.democracycraft.vault.internal.listener.RealtyOccupantChangeListener;
1314
import net.democracycraft.vault.internal.service.*;
15+
import net.democracycraft.vault.internal.util.config.ConfigPaths;
1416
import net.democracycraft.vault.internal.session.BedrockUniqueIdentifierRetriever;
1517
import net.democracycraft.vault.internal.session.VaultSessionManager;
1618
import net.democracycraft.vault.internal.util.config.ConfigInitializer;
@@ -44,6 +46,7 @@ public final class VaultStoragePlugin extends JavaPlugin {
4446
private VaultPlacementService placementService;
4547
private VaultInventoryService inventoryService;
4648
private VaultScanService scanService;
49+
private AutoVaultService autoVaultService;
4750

4851

4952
// Integration services
@@ -161,6 +164,14 @@ public void onEnable() {
161164

162165
// Validate defined permissions vs plugin.yml
163166
validatePermissionsMapping();
167+
168+
// Realty soft dependency: auto-vault containers when a region's owner/tenant changes.
169+
if (getServer().getPluginManager().getPlugin("Realty") != null
170+
&& getConfig().getBoolean(ConfigPaths.AUTOVAULT_ENABLED.getPath(), true)) {
171+
this.autoVaultService = new AutoVaultService(this);
172+
getServer().getPluginManager().registerEvents(new RealtyOccupantChangeListener(autoVaultService), this);
173+
getLogger().info("Realty detected: auto-vault on region occupant change enabled.");
174+
}
164175
}
165176

166177
private void validatePermissionsMapping() {
@@ -203,6 +214,7 @@ private void bootstrapWorlds() {
203214

204215
@Override
205216
public void onDisable() {
217+
if (this.autoVaultService != null) this.autoVaultService.shutdown();
206218
if (this.mysql != null) this.mysql.disconnect();
207219
}
208220

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package net.democracycraft.vault.internal.listener;
2+
3+
import io.github.md5sha256.realty.api.event.RealtyRegionEvent;
4+
import io.github.md5sha256.realty.api.event.RegionBoughtEvent;
5+
import io.github.md5sha256.realty.api.event.RegionRentedEvent;
6+
import io.github.md5sha256.realty.api.event.TenantSetEvent;
7+
import io.github.md5sha256.realty.api.event.TitleTransferredEvent;
8+
import net.democracycraft.vault.internal.service.AutoVaultService;
9+
import org.bukkit.event.EventHandler;
10+
import org.bukkit.event.EventPriority;
11+
import org.bukkit.event.Listener;
12+
import org.jetbrains.annotations.NotNull;
13+
import org.jetbrains.annotations.Nullable;
14+
15+
import java.util.UUID;
16+
17+
/**
18+
* Listens for Realty owner/tenant changes and triggers an {@link AutoVaultService} sweep for the new
19+
* occupant. References Realty API types, so it is only instantiated when the Realty plugin is present.
20+
*/
21+
public final class RealtyOccupantChangeListener implements Listener {
22+
23+
private final AutoVaultService autoVaultService;
24+
25+
public RealtyOccupantChangeListener(@NotNull AutoVaultService autoVaultService) {
26+
this.autoVaultService = autoVaultService;
27+
}
28+
29+
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
30+
public void onTitleTransferred(TitleTransferredEvent event) {
31+
submit(event, event.getNewTitleHolderId());
32+
}
33+
34+
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
35+
public void onRegionBought(RegionBoughtEvent event) {
36+
submit(event, event.getBuyerId());
37+
}
38+
39+
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
40+
public void onRegionRented(RegionRentedEvent event) {
41+
submit(event, event.getTenantId());
42+
}
43+
44+
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
45+
public void onTenantSet(TenantSetEvent event) {
46+
// A null new tenant means the tenancy was cleared (out of scope); skip.
47+
submit(event, event.getNewTenantId());
48+
}
49+
50+
private void submit(@NotNull RealtyRegionEvent event, @Nullable UUID newOccupant) {
51+
if (newOccupant == null) {
52+
return;
53+
}
54+
autoVaultService.submit(event.getWorldId(), event.getRegionId(), newOccupant);
55+
}
56+
}
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
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

Comments
 (0)