Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -43,6 +44,7 @@ public final class Graveyard extends BukkitBaseFeature<Meta> {
private LastSafeLocationTracker safeLocationTracker;
private GravePlacementService placementService;
private GraveManager manager;
private GraveExpiryNotifier expiryNotifier;
private GraveInteractionPacketListener packetListener;
private ServerActiveClock activeClock;

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public String getFeatureName() {

@Override
public String getFeatureVersion() {
return "1.2.0";
return "1.3.0";
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UUID, GraveStatus> previousStatuses = new HashMap<>();
private final Set<UUID> deliveredWarnings = new HashSet<>();
private final Set<UUID> pendingExpiryNotifications = new HashSet<>();

EnumSet<Notification> 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<Notification> 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<UUID> 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
}
}
Original file line number Diff line number Diff line change
@@ -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<GraveSnapshot> snapshots = manager.allRuntimeGraves();
Set<UUID> observedGraves = new HashSet<>(snapshots.size());
for (GraveSnapshot snapshot : snapshots) {
observedGraves.add(snapshot.graveId());
EnumSet<GraveExpiryNotificationTracker.Notification> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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
);
}
}