diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/Graveyard.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/Graveyard.java
index 74c8b3a8..796898a5 100644
--- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/Graveyard.java
+++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/Graveyard.java
@@ -25,6 +25,7 @@
import nl.hauntedmc.serverfeatures.features.graveyard.persistence.GraveRepository;
import nl.hauntedmc.serverfeatures.features.graveyard.placement.GravePlacementService;
import nl.hauntedmc.serverfeatures.features.graveyard.placement.LastSafeLocationTracker;
+import nl.hauntedmc.serverfeatures.features.graveyard.runtime.GraveExpiryNotifier;
import nl.hauntedmc.serverfeatures.features.graveyard.runtime.GraveManager;
import nl.hauntedmc.serverfeatures.framework.time.ServerActiveClock;
@@ -43,6 +44,7 @@ public final class Graveyard extends BukkitBaseFeature {
private LastSafeLocationTracker safeLocationTracker;
private GravePlacementService placementService;
private GraveManager manager;
+ private GraveExpiryNotifier expiryNotifier;
private GraveInteractionPacketListener packetListener;
private ServerActiveClock activeClock;
@@ -119,6 +121,11 @@ public MessageMap getDefaultMessages() {
"graveyard.claim_recovery_pending",
"&eDe claim van graf {grave_id} is opgeslagen en wordt veilig afgerond."
);
+ messages.add(
+ "graveyard.expiry_warning",
+ "&eJe graf &b{grave_id}&e verdwijnt binnen &f{seconds} seconden&e."
+ );
+ messages.add("graveyard.expired", "&cJe graf &b{grave_id}&c is verlopen en verdwenen.");
messages.add("graveyard.keep_inventory", "&aJe inventory is behouden; er is geen graf gemaakt.");
messages.add("graveyard.claimed", "&aGraf {grave_id} is volledig teruggehaald.");
messages.add(
@@ -256,6 +263,8 @@ public void initialize() {
packetListener = new GraveInteractionPacketListener(this, manager);
PacketEvents.getAPI().getEventManager().registerListener(packetListener);
manager.initialize();
+ expiryNotifier = new GraveExpiryNotifier(this, manager);
+ expiryNotifier.start();
}
@Override
diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/meta/Meta.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/meta/Meta.java
index 5ea8545a..78dbfa02 100644
--- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/meta/Meta.java
+++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/meta/Meta.java
@@ -12,7 +12,7 @@ public String getFeatureName() {
@Override
public String getFeatureVersion() {
- return "1.2.0";
+ return "1.3.0";
}
@Override
diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveExpiryNotificationTracker.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveExpiryNotificationTracker.java
new file mode 100644
index 00000000..86c9f44e
--- /dev/null
+++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveExpiryNotificationTracker.java
@@ -0,0 +1,81 @@
+package nl.hauntedmc.serverfeatures.features.graveyard.runtime;
+
+import nl.hauntedmc.serverfeatures.api.graveyard.GraveSnapshot;
+import nl.hauntedmc.serverfeatures.api.graveyard.GraveStatus;
+
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Tracks one-shot player notifications across Graveyard lifecycle transitions.
+ */
+final class GraveExpiryNotificationTracker {
+ static final long WARNING_THRESHOLD_MILLIS = 60_000L;
+
+ private final Map previousStatuses = new HashMap<>();
+ private final Set deliveredWarnings = new HashSet<>();
+ private final Set pendingExpiryNotifications = new HashSet<>();
+
+ EnumSet observe(GraveSnapshot snapshot) {
+ UUID graveId = snapshot.graveId();
+ GraveStatus currentStatus = snapshot.status();
+ GraveStatus previousStatus = previousStatuses.put(graveId, currentStatus);
+
+ if (previousStatus == GraveStatus.EXPIRED && currentStatus != GraveStatus.EXPIRED) {
+ deliveredWarnings.remove(graveId);
+ pendingExpiryNotifications.remove(graveId);
+ }
+ if (currentStatus == GraveStatus.EXPIRED && isExpirable(previousStatus)) {
+ pendingExpiryNotifications.add(graveId);
+ }
+ if (isTerminalWithoutExpiryNotification(currentStatus)) {
+ deliveredWarnings.remove(graveId);
+ pendingExpiryNotifications.remove(graveId);
+ }
+
+ EnumSet notifications = EnumSet.noneOf(Notification.class);
+ if (isExpirable(currentStatus)
+ && snapshot.remainingActiveMillis() > 0L
+ && snapshot.remainingActiveMillis() <= WARNING_THRESHOLD_MILLIS
+ && !deliveredWarnings.contains(graveId)) {
+ notifications.add(Notification.WARNING);
+ }
+ if (currentStatus == GraveStatus.EXPIRED && pendingExpiryNotifications.contains(graveId)) {
+ notifications.add(Notification.EXPIRED);
+ }
+ return notifications;
+ }
+
+ void markDelivered(UUID graveId, Notification notification) {
+ switch (notification) {
+ case WARNING -> deliveredWarnings.add(graveId);
+ case EXPIRED -> pendingExpiryNotifications.remove(graveId);
+ }
+ }
+
+ void retainOnly(Set graveIds) {
+ previousStatuses.keySet().retainAll(graveIds);
+ deliveredWarnings.retainAll(graveIds);
+ pendingExpiryNotifications.retainAll(graveIds);
+ }
+
+ private static boolean isExpirable(GraveStatus status) {
+ return status == GraveStatus.ACTIVE || status == GraveStatus.PARTIAL;
+ }
+
+ private static boolean isTerminalWithoutExpiryNotification(GraveStatus status) {
+ return status == GraveStatus.CLAIMED
+ || status == GraveStatus.CORRUPT
+ || status == GraveStatus.ADMIN_RECOVERED
+ || status == GraveStatus.PURGED;
+ }
+
+ enum Notification {
+ WARNING,
+ EXPIRED
+ }
+}
diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveExpiryNotifier.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveExpiryNotifier.java
new file mode 100644
index 00000000..a304ed6e
--- /dev/null
+++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveExpiryNotifier.java
@@ -0,0 +1,77 @@
+package nl.hauntedmc.serverfeatures.features.graveyard.runtime;
+
+import nl.hauntedmc.serverfeatures.api.graveyard.GraveSnapshot;
+import nl.hauntedmc.serverfeatures.api.util.BukkitTime;
+import nl.hauntedmc.serverfeatures.features.graveyard.Graveyard;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Delivers localized warning and expiry messages to grave owners.
+ */
+public final class GraveExpiryNotifier {
+ private final Graveyard feature;
+ private final GraveManager manager;
+ private final GraveExpiryNotificationTracker tracker = new GraveExpiryNotificationTracker();
+
+ public GraveExpiryNotifier(Graveyard feature, GraveManager manager) {
+ this.feature = feature;
+ this.manager = manager;
+ }
+
+ public void start() {
+ long intervalTicks = Math.max(1L, feature.getSettings().reconciliationTicks());
+ feature.getLifecycleManager().getTaskManager().scheduleRepeatingTask(
+ this::tick,
+ BukkitTime.ticks(intervalTicks),
+ BukkitTime.ticks(intervalTicks)
+ );
+ }
+
+ private void tick() {
+ List snapshots = manager.allRuntimeGraves();
+ Set observedGraves = new HashSet<>(snapshots.size());
+ for (GraveSnapshot snapshot : snapshots) {
+ observedGraves.add(snapshot.graveId());
+ EnumSet notifications = tracker.observe(snapshot);
+ for (GraveExpiryNotificationTracker.Notification notification : notifications) {
+ deliver(snapshot, notification);
+ }
+ }
+ tracker.retainOnly(observedGraves);
+ }
+
+ private void deliver(
+ GraveSnapshot snapshot,
+ GraveExpiryNotificationTracker.Notification notification
+ ) {
+ Player owner = Bukkit.getPlayer(snapshot.ownerUuid());
+ if (owner == null || !owner.isOnline()) {
+ return;
+ }
+
+ switch (notification) {
+ case WARNING -> owner.sendMessage(feature.getLocalizationHandler()
+ .getMessage("graveyard.expiry_warning")
+ .with("grave_id", snapshot.shortId())
+ .with(
+ "seconds",
+ Long.toString(GraveExpiryNotificationTracker.WARNING_THRESHOLD_MILLIS / 1_000L)
+ )
+ .forAudience(owner)
+ .build());
+ case EXPIRED -> owner.sendMessage(feature.getLocalizationHandler()
+ .getMessage("graveyard.expired")
+ .with("grave_id", snapshot.shortId())
+ .forAudience(owner)
+ .build());
+ }
+ tracker.markDelivered(snapshot.graveId(), notification);
+ }
+}
diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveExpiryNotificationTrackerTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveExpiryNotificationTrackerTest.java
new file mode 100644
index 00000000..22082b34
--- /dev/null
+++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveExpiryNotificationTrackerTest.java
@@ -0,0 +1,104 @@
+package nl.hauntedmc.serverfeatures.features.graveyard.runtime;
+
+import nl.hauntedmc.serverfeatures.api.graveyard.GraveSnapshot;
+import nl.hauntedmc.serverfeatures.api.graveyard.GraveStatus;
+import org.junit.jupiter.api.Test;
+
+import java.util.EnumSet;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class GraveExpiryNotificationTrackerTest {
+ @Test
+ void warningRemainsPendingUntilItIsDelivered() {
+ GraveExpiryNotificationTracker tracker = new GraveExpiryNotificationTracker();
+ UUID graveId = UUID.randomUUID();
+
+ assertTrue(tracker.observe(snapshot(graveId, GraveStatus.ACTIVE, 60_001L)).isEmpty());
+ assertEquals(
+ EnumSet.of(GraveExpiryNotificationTracker.Notification.WARNING),
+ tracker.observe(snapshot(graveId, GraveStatus.ACTIVE, 60_000L))
+ );
+ assertEquals(
+ EnumSet.of(GraveExpiryNotificationTracker.Notification.WARNING),
+ tracker.observe(snapshot(graveId, GraveStatus.ACTIVE, 59_000L))
+ );
+
+ tracker.markDelivered(graveId, GraveExpiryNotificationTracker.Notification.WARNING);
+
+ assertTrue(tracker.observe(snapshot(graveId, GraveStatus.ACTIVE, 58_000L)).isEmpty());
+ }
+
+ @Test
+ void expiryRemainsPendingUntilItIsDelivered() {
+ GraveExpiryNotificationTracker tracker = new GraveExpiryNotificationTracker();
+ UUID graveId = UUID.randomUUID();
+
+ tracker.observe(snapshot(graveId, GraveStatus.ACTIVE, 61_000L));
+ assertEquals(
+ EnumSet.of(GraveExpiryNotificationTracker.Notification.EXPIRED),
+ tracker.observe(snapshot(graveId, GraveStatus.EXPIRED, 0L))
+ );
+ assertEquals(
+ EnumSet.of(GraveExpiryNotificationTracker.Notification.EXPIRED),
+ tracker.observe(snapshot(graveId, GraveStatus.EXPIRED, 0L))
+ );
+
+ tracker.markDelivered(graveId, GraveExpiryNotificationTracker.Notification.EXPIRED);
+
+ assertTrue(tracker.observe(snapshot(graveId, GraveStatus.EXPIRED, 0L)).isEmpty());
+ }
+
+ @Test
+ void alreadyExpiredRecordsDoNotProduceStaleNotifications() {
+ GraveExpiryNotificationTracker tracker = new GraveExpiryNotificationTracker();
+ UUID graveId = UUID.randomUUID();
+
+ assertTrue(tracker.observe(snapshot(graveId, GraveStatus.EXPIRED, 0L)).isEmpty());
+ }
+
+ @Test
+ void restoredGraveStartsANewNotificationCycle() {
+ GraveExpiryNotificationTracker tracker = new GraveExpiryNotificationTracker();
+ UUID graveId = UUID.randomUUID();
+
+ assertEquals(
+ EnumSet.of(GraveExpiryNotificationTracker.Notification.WARNING),
+ tracker.observe(snapshot(graveId, GraveStatus.ACTIVE, 30_000L))
+ );
+ tracker.markDelivered(graveId, GraveExpiryNotificationTracker.Notification.WARNING);
+ assertEquals(
+ EnumSet.of(GraveExpiryNotificationTracker.Notification.EXPIRED),
+ tracker.observe(snapshot(graveId, GraveStatus.EXPIRED, 0L))
+ );
+ tracker.markDelivered(graveId, GraveExpiryNotificationTracker.Notification.EXPIRED);
+
+ assertEquals(
+ EnumSet.of(GraveExpiryNotificationTracker.Notification.WARNING),
+ tracker.observe(snapshot(graveId, GraveStatus.ACTIVE, 30_000L))
+ );
+ }
+
+ private static GraveSnapshot snapshot(UUID graveId, GraveStatus status, long remainingMillis) {
+ return new GraveSnapshot(
+ graveId,
+ "Player-12:34:56",
+ UUID.randomUUID(),
+ "Player",
+ "survival",
+ "survival",
+ UUID.randomUUID(),
+ "minecraft:overworld",
+ 0.0,
+ 64.0,
+ 0.0,
+ status,
+ false,
+ remainingMillis,
+ 1,
+ 0
+ );
+ }
+}