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 + ); + } +}