From 750cbdfd3459a50fac4c9a87be9242b6eefa792d Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 4 Aug 2026 15:40:50 +0200 Subject: [PATCH 01/12] Add Graveyard experience recovery modes --- .../config/ExperienceRecoveryMode.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/config/ExperienceRecoveryMode.java diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/config/ExperienceRecoveryMode.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/config/ExperienceRecoveryMode.java new file mode 100644 index 00000000..00e8efff --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/config/ExperienceRecoveryMode.java @@ -0,0 +1,26 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.config; + +/** + * Selects how much of the final Minecraft death-event experience is stored in a grave. + */ +public enum ExperienceRecoveryMode { + /** Store exactly the amount Minecraft and other plugins left in PlayerDeathEvent#getDroppedExp(). */ + NATIVE { + @Override + public int capturedExperience(int droppedExperience, int recoveryPercentage) { + return Math.max(0, droppedExperience); + } + }, + + /** Store a configured percentage of the final dropped experience. */ + PERCENTAGE { + @Override + public int capturedExperience(int droppedExperience, int recoveryPercentage) { + int dropped = Math.max(0, droppedExperience); + int percentage = Math.max(0, Math.min(100, recoveryPercentage)); + return (int) (((long) dropped * percentage) / 100L); + } + }; + + public abstract int capturedExperience(int droppedExperience, int recoveryPercentage); +} From 9882d2f98224fcbf09ef17b9c64f11819025e03b Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 4 Aug 2026 15:41:13 +0200 Subject: [PATCH 02/12] Localize Graveyard runtime display text --- .../graveyard/text/GraveyardText.java | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/text/GraveyardText.java diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/text/GraveyardText.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/text/GraveyardText.java new file mode 100644 index 00000000..a54ee649 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/text/GraveyardText.java @@ -0,0 +1,112 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.text; + +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; +import nl.hauntedmc.serverfeatures.api.graveyard.GraveStatus; +import nl.hauntedmc.serverfeatures.features.graveyard.Graveyard; +import nl.hauntedmc.serverfeatures.features.graveyard.model.Grave; +import org.bukkit.entity.Player; + +import java.util.Locale; +import java.util.Objects; + +/** + * Builds every player-facing runtime text used by Graveyard through the localization handler. + */ +public final class GraveyardText { + private final Graveyard feature; + + public GraveyardText(Graveyard feature) { + this.feature = Objects.requireNonNull(feature, "feature"); + } + + public Component hologramTitle(Grave grave, Player viewer) { + return feature.getLocalizationHandler() + .getMessage("graveyard.hologram.title") + .with("player", grave.ownerName()) + .forAudience(viewer) + .build(); + } + + public Component timer(Grave grave, Player viewer, long remainingMillis) { + return switch (grave.status()) { + case DELIVERY_PENDING -> feature.getLocalizationHandler() + .getMessage("graveyard.timer.delivery_pending") + .forAudience(viewer) + .build(); + case ORPHANED_WORLD -> feature.getLocalizationHandler() + .getMessage("graveyard.timer.remote_recovery") + .forAudience(viewer) + .build(); + default -> feature.getLocalizationHandler() + .getMessage("graveyard.timer.remaining") + .with("remaining", duration(remainingMillis, viewer)) + .forAudience(viewer) + .build(); + }; + } + + public Component tracking( + Grave grave, + Player viewer, + Integer distance, + long remainingMillis + ) { + var builder = feature.getLocalizationHandler() + .getMessage(distance == null + ? "graveyard.tracking.other_world" + : "graveyard.tracking.same_world") + .with("grave_id", grave.shortId()) + .with("timer", timer(grave, viewer, remainingMillis)) + .forAudience(viewer); + if (distance == null) { + builder.with("world", grave.location().worldKey()); + } else { + builder.with("distance", distance); + } + return builder.build(); + } + + public Component duration(long millis, Audience audience) { + DurationParts parts = durationParts(millis); + if (parts.hours() > 0L) { + return feature.getLocalizationHandler() + .getMessage("graveyard.duration.hours_minutes") + .with("hours", parts.hours()) + .with("minutes", parts.minutes()) + .forAudience(audience) + .build(); + } + return feature.getLocalizationHandler() + .getMessage("graveyard.duration.minutes_seconds") + .with("minutes", parts.minutes()) + .with("seconds", parts.seconds()) + .forAudience(audience) + .build(); + } + + public Component status(GraveStatus status, Audience audience) { + return feature.getLocalizationHandler() + .getMessage("graveyard.status." + status.name().toLowerCase(Locale.ROOT)) + .forAudience(audience) + .build(); + } + + public String timerFingerprint(Grave grave, long remainingMillis) { + return switch (grave.status()) { + case DELIVERY_PENDING, ORPHANED_WORLD -> grave.status().name(); + default -> "REMAINING:" + durationParts(remainingMillis).totalSeconds(); + }; + } + + public static DurationParts durationParts(long millis) { + long totalSeconds = Math.max(0L, (millis + 999L) / 1_000L); + long hours = totalSeconds / 3_600L; + long minutes = totalSeconds % 3_600L / 60L; + long seconds = totalSeconds % 60L; + return new DurationParts(totalSeconds, hours, minutes, seconds); + } + + public record DurationParts(long totalSeconds, long hours, long minutes, long seconds) { + } +} From 33bc49f09b2ed0a1e8935f789a45731e22bf0902 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 4 Aug 2026 15:42:11 +0200 Subject: [PATCH 03/12] Configure Graveyard experience recovery mode --- .../graveyard/config/GraveyardSettings.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/config/GraveyardSettings.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/config/GraveyardSettings.java index 21a30041..1edf7756 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/config/GraveyardSettings.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/config/GraveyardSettings.java @@ -32,6 +32,7 @@ public final class GraveyardSettings { private final long claimedRetentionMillis; private final long purgeIntervalMillis; private final int purgeBatchSize; + private final ExperienceRecoveryMode experienceMode; private final int experiencePercentage; private final int horizontalSearchRadius; private final int verticalSearchBelow; @@ -101,7 +102,17 @@ private GraveyardSettings(Graveyard feature) { 1, 1_000 ); - experiencePercentage = clamp(feature.getConfigHandler().get("experience.recovery_percentage", Integer.class, 50), 0, 100); + experienceMode = enumSetting( + feature, + "experience.mode", + ExperienceRecoveryMode.class, + ExperienceRecoveryMode.NATIVE + ); + experiencePercentage = clamp( + feature.getConfigHandler().get("experience.recovery_percentage", Integer.class, 50), + 0, + 100 + ); horizontalSearchRadius = clamp(feature.getConfigHandler().get("placement.horizontal_search_radius", Integer.class, 8), 0, 32); verticalSearchBelow = clamp(feature.getConfigHandler().get("placement.vertical_search_below", Integer.class, 4), 0, 16); verticalSearchAbove = clamp(feature.getConfigHandler().get("placement.vertical_search_above", Integer.class, 6), 0, 16); @@ -151,6 +162,7 @@ public static GraveyardSettings load(Graveyard feature) { public long claimedRetentionMillis() { return claimedRetentionMillis; } public long purgeIntervalMillis() { return purgeIntervalMillis; } public int purgeBatchSize() { return purgeBatchSize; } + public ExperienceRecoveryMode experienceMode() { return experienceMode; } public int experiencePercentage() { return experiencePercentage; } public int horizontalSearchRadius() { return horizontalSearchRadius; } public int verticalSearchBelow() { return verticalSearchBelow; } From a955230260a8ae2c57e2bd0999f27bed28b44b40 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 4 Aug 2026 15:42:59 +0200 Subject: [PATCH 04/12] Apply configured Graveyard XP recovery mode --- .../features/graveyard/capture/GraveCaptureService.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/GraveCaptureService.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/GraveCaptureService.java index b77ab807..bbbc16f3 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/GraveCaptureService.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/GraveCaptureService.java @@ -123,9 +123,9 @@ private PreparedCapture prepare( allocation.item() )); } - int experience = Math.max( - 0, - (int) Math.floor(event.getDroppedExp() * (settings.experiencePercentage() / 100.0)) + int experience = settings.experienceMode().capturedExperience( + event.getDroppedExp(), + settings.experiencePercentage() ); if (entries.isEmpty() && experience == 0) { return null; @@ -334,7 +334,6 @@ private static List cloneItems(List items) { .toList(); } - private record PreparedCapture(CaptureJournalRecord record, GravePayload payload) { } From 4969a99b0455d169ae490f7c2437870751431165 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 4 Aug 2026 15:43:58 +0200 Subject: [PATCH 05/12] Add Graveyard XP defaults and localized display texts --- .../features/graveyard/Graveyard.java | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) 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 76d8c6bb..77d751c4 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 @@ -68,6 +68,7 @@ public ConfigMap getDefaultConfig() { defaults.put("lifetime.duration", "10m"); defaults.put("eligibility.disabled_worlds", List.of()); defaults.put("eligibility.disabled_gamemodes", List.of("CREATIVE", "SPECTATOR")); + defaults.put("experience.mode", "NATIVE"); defaults.put("experience.recovery_percentage", 50); defaults.put("placement.horizontal_search_radius", 8); defaults.put("placement.vertical_search_below", 4); @@ -135,12 +136,12 @@ public MessageMap getDefaultMessages() { messages.add("graveyard.admin_list_empty", "&7Geen bekende herstelbare graven voor deze speler."); messages.add( "graveyard.list_entry", - "&b{grave_id} &7- {state} - &f{world} {x}, {y}, {z} &7- {remaining}" + "&b{grave_id} &7- {state} &7- &f{world} {x}, {y}, {z} &7- {remaining}" ); messages.add( "graveyard.info", - "&b{grave_id}&7: eigenaar=&f{player}&7, status=&f{state}&7, wereld=&f{world}&7, " - + "locatie=&f{x}, {y}, {z}&7, items=&f{items}&7, xp=&f{xp}&7, tijd=&f{remaining}" + "&b{grave_id}&7: eigenaar=&f{player}&7, status={state}&7, wereld=&f{world}&7, " + + "locatie=&f{x}, {y}, {z}&7, items=&f{items}&7, xp=&f{xp}&7, tijd={remaining}" ); messages.add("graveyard.locate", "&7Graf &b{grave_id}&7 staat op &f{world} {x}, {y}, {z}&7."); messages.add("graveyard.track_started", "&aJe volgt nu graf {grave_id}."); @@ -150,6 +151,31 @@ public MessageMap getDefaultMessages() { messages.add("graveyard.admin_failed", "&cGraveyard actie kon niet worden uitgevoerd voor {grave_id}."); messages.add("graveyard.admin_diagnostics", "&7Graveyard diagnostics: &f{details}"); messages.add("graveyard.usage", "&7Gebruik: /grave [list|info|locate|track|claim|admin]"); + + messages.add("graveyard.hologram.title", "&7Graf van &f{player}"); + messages.add("graveyard.timer.remaining", "&7Verdwijnt over {remaining}"); + messages.add("graveyard.timer.delivery_pending", "&eBezorging in afwachting"); + messages.add("graveyard.timer.remote_recovery", "&eHerstel op afstand beschikbaar"); + messages.add( + "graveyard.tracking.same_world", + "&bGraf {grave_id} &8· &b{distance}m &8· {timer}" + ); + messages.add( + "graveyard.tracking.other_world", + "&bGraf {grave_id} &8· &b{world} &8· {timer}" + ); + messages.add("graveyard.duration.hours_minutes", "{hours}u {minutes}m"); + messages.add("graveyard.duration.minutes_seconds", "{minutes}m {seconds}s"); + + messages.add("graveyard.status.active", "&aactief"); + messages.add("graveyard.status.partial", "&egegedeeltelijk"); + messages.add("graveyard.status.orphaned_world", "&eoffline wereld"); + messages.add("graveyard.status.delivery_pending", "&ebezorging in afwachting"); + messages.add("graveyard.status.claimed", "&ageclaimd"); + messages.add("graveyard.status.expired", "&cverlopen"); + messages.add("graveyard.status.corrupt", "&cbeschadigd"); + messages.add("graveyard.status.admin_recovered", "&adoor beheer hersteld"); + messages.add("graveyard.status.purged", "&8verwijderd"); return messages; } From 554856fa6ca00975b47d318d6e8aaadf9aa3dbf4 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 4 Aug 2026 15:44:21 +0200 Subject: [PATCH 06/12] Render localized Graveyard display components --- .../graveyard/packet/GraveDisplayMetadataFactory.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GraveDisplayMetadataFactory.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GraveDisplayMetadataFactory.java index baaec228..fd6d67ef 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GraveDisplayMetadataFactory.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GraveDisplayMetadataFactory.java @@ -50,13 +50,13 @@ List> block( List> text( World world, Component title, - String timer, + Component timer, float verticalOffset, int glowRgb ) { TextDisplay display = world.createEntity(templateLocation(world), TextDisplay.class); configureDisplay(display, glowRgb, 2.5f, verticalOffset + 0.5f); - display.text(title.append(Component.newline()).append(Component.text(timer))); + display.text(title.append(Component.newline()).append(timer)); display.setBillboard(Display.Billboard.CENTER); display.setSeeThrough(true); display.setShadowed(true); @@ -73,7 +73,7 @@ List> text( return metadata; } - List> timer(World world, Component title, String timer) { + List> timer(World world, Component title, Component timer) { Integer index = textComponentIndex; if (index == null) { TextDisplay template = world.createEntity(templateLocation(world), TextDisplay.class); @@ -84,7 +84,7 @@ List> timer(World world, Component title, String timer) { return List.of(new EntityData<>( index, EntityDataTypes.ADV_COMPONENT, - title.append(Component.newline()).append(Component.text(timer)) + title.append(Component.newline()).append(timer) )); } From 4b78b280c2cf85c289f9ddd5b4f4b98ed00f18a8 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 4 Aug 2026 15:44:50 +0200 Subject: [PATCH 07/12] Localize Graveyard hologram text per viewer --- .../graveyard/packet/GravePacketRenderer.java | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GravePacketRenderer.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GravePacketRenderer.java index a5bd9dd5..fb7dc8b4 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GravePacketRenderer.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GravePacketRenderer.java @@ -10,9 +10,10 @@ import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSpawnEntity; import io.github.retrooper.packetevents.util.SpigotConversionUtil; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; +import nl.hauntedmc.serverfeatures.features.graveyard.Graveyard; import nl.hauntedmc.serverfeatures.features.graveyard.config.GraveyardSettings; import nl.hauntedmc.serverfeatures.features.graveyard.model.Grave; +import nl.hauntedmc.serverfeatures.features.graveyard.text.GraveyardText; import org.bukkit.Material; import org.bukkit.World; import org.bukkit.entity.Player; @@ -26,11 +27,13 @@ */ public final class GravePacketRenderer { private final GraveyardSettings settings; + private final GraveyardText text; private final GraveDisplayMetadataFactory metadataFactory = new GraveDisplayMetadataFactory(); private final Logger logger; - public GravePacketRenderer(GraveyardSettings settings, Logger logger) { + public GravePacketRenderer(Graveyard feature, GraveyardSettings settings, Logger logger) { this.settings = settings; + this.text = new GraveyardText(feature); this.logger = logger; } @@ -38,14 +41,15 @@ public void spawn( Grave grave, GravePacketIdentity identity, Player viewer, - String timer, + long remainingMillis, int glowRgb ) { org.bukkit.Location bukkitLocation = grave.location().resolve() .orElseThrow(() -> new IllegalStateException("Cannot render a grave in an unavailable world")); World world = bukkitLocation.getWorld(); Location packetLocation = SpigotConversionUtil.fromBukkitLocation(bukkitLocation); - Component title = Component.text(grave.ownerName() + "'s Grave", NamedTextColor.GRAY); + Component title = text.hologramTitle(grave, viewer); + Component timer = text.timer(grave, viewer, remainingMillis); try { spawnBlock( @@ -116,11 +120,12 @@ public void updateTimer( Grave grave, GravePacketIdentity identity, Player viewer, - String timer + long remainingMillis ) { org.bukkit.Location location = grave.location().resolve() .orElseThrow(() -> new IllegalStateException("Cannot update a grave in an unavailable world")); - Component title = Component.text(grave.ownerName() + "'s Grave", NamedTextColor.GRAY); + Component title = text.hologramTitle(grave, viewer); + Component timer = text.timer(grave, viewer, remainingMillis); sendMetadata( viewer, identity.textEntityId(), From 06bf2b8159f5ce027d377a1d63284b9b1cfa8160 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 4 Aug 2026 15:46:10 +0200 Subject: [PATCH 08/12] Localize Graveyard command statuses and durations --- .../graveyard/command/GraveCommand.java | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/command/GraveCommand.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/command/GraveCommand.java index 5ea1be59..2e6ce412 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/command/GraveCommand.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/command/GraveCommand.java @@ -16,6 +16,7 @@ import nl.hauntedmc.serverfeatures.features.graveyard.Graveyard; import nl.hauntedmc.serverfeatures.features.graveyard.model.Grave; import nl.hauntedmc.serverfeatures.features.graveyard.runtime.GraveManager; +import nl.hauntedmc.serverfeatures.features.graveyard.text.GraveyardText; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; @@ -47,10 +48,12 @@ public final class GraveCommand implements BrigadierCommand { private final Graveyard feature; private final GraveManager manager; + private final GraveyardText text; public GraveCommand(Graveyard feature, GraveManager manager) { this.feature = feature; this.manager = manager; + this.text = new GraveyardText(feature); } @Override @@ -195,12 +198,12 @@ private int showOwnGraves(CommandSender sender, boolean listAll) { sender.sendMessage(feature.getLocalizationHandler() .getMessage("graveyard.list_entry") .with("grave_id", grave.shortId()) - .with("state", grave.status().name()) + .with("state", text.status(grave.status(), sender)) .with("world", grave.worldKey()) .with("x", coordinate(grave.x())) .with("y", coordinate(grave.y())) .with("z", coordinate(grave.z())) - .with("remaining", formatDuration(grave.remainingActiveMillis())) + .with("remaining", text.duration(grave.remainingActiveMillis(), sender)) .forAudience(sender) .build()); } @@ -222,12 +225,12 @@ private int listPlayerGraves(CommandSender sender, String playerIdentifier) { sender.sendMessage(feature.getLocalizationHandler() .getMessage("graveyard.list_entry") .with("grave_id", grave.shortId()) - .with("state", grave.status().name()) + .with("state", text.status(grave.status(), sender)) .with("world", grave.worldKey()) .with("x", coordinate(grave.x())) .with("y", coordinate(grave.y())) .with("z", coordinate(grave.z())) - .with("remaining", formatDuration(grave.remainingActiveMillis())) + .with("remaining", text.duration(grave.remainingActiveMillis(), sender)) .forAudience(sender) .build()); } @@ -251,14 +254,14 @@ private void showSnapshot(CommandSender sender, GraveSnapshot grave) { .getMessage("graveyard.info") .with("grave_id", grave.shortId()) .with("player", grave.ownerName()) - .with("state", grave.status().name()) + .with("state", text.status(grave.status(), sender)) .with("world", grave.worldKey()) .with("x", coordinate(grave.x())) .with("y", coordinate(grave.y())) .with("z", coordinate(grave.z())) .with("items", grave.itemEntryCount()) .with("xp", grave.remainingExperience()) - .with("remaining", formatDuration(grave.remainingActiveMillis())) + .with("remaining", text.duration(grave.remainingActiveMillis(), sender)) .forAudience(sender) .build()); } @@ -502,17 +505,6 @@ private static String coordinate(double value) { return Integer.toString((int) Math.floor(value)); } - private static String formatDuration(long millis) { - long seconds = Math.max(0L, (millis + 999L) / 1_000L); - long hours = seconds / 3_600L; - long minutes = seconds % 3_600L / 60L; - long remainder = seconds % 60L; - if (hours > 0L) { - return hours + "h " + minutes + "m"; - } - return minutes + "m " + remainder + "s"; - } - private enum SuggestionScope { OWNER_ACTIVE { @Override From 9e3bc035192febd1f464854517eaf677135e58b0 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 4 Aug 2026 15:50:40 +0200 Subject: [PATCH 09/12] Localize Graveyard timers and tracking text --- .../graveyard/runtime/GraveManager.java | 52 ++++++------------- 1 file changed, 16 insertions(+), 36 deletions(-) diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveManager.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveManager.java index 625c0dcf..723754b7 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveManager.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveManager.java @@ -1,7 +1,5 @@ package nl.hauntedmc.serverfeatures.features.graveyard.runtime; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import nl.hauntedmc.serverfeatures.api.graveyard.ClaimReason; import nl.hauntedmc.serverfeatures.api.graveyard.GraveClaimOutcome; import nl.hauntedmc.serverfeatures.api.graveyard.GraveClaimResult; @@ -31,6 +29,7 @@ import nl.hauntedmc.serverfeatures.features.graveyard.persistence.GravePayloadCodec; import nl.hauntedmc.serverfeatures.features.graveyard.persistence.GraveRepository; import nl.hauntedmc.serverfeatures.features.graveyard.placement.GravePlacementService; +import nl.hauntedmc.serverfeatures.features.graveyard.text.GraveyardText; import org.bukkit.Bukkit; import org.bukkit.Chunk; import org.bukkit.Location; @@ -96,6 +95,7 @@ public final class GraveManager implements GraveyardService { private final GravePayloadCodec payloadCodec; private final GraveClaimPlanner claimPlanner; private final GravePlacementService placementService; + private final GraveyardText text; private final GravePacketRenderer renderer; private final GraveSpatialIndex spatialIndex = new GraveSpatialIndex(); @@ -147,7 +147,8 @@ public GraveManager( this.payloadCodec = payloadCodec; this.claimPlanner = new GraveClaimPlanner(payloadCodec, settings.partialClaims()); this.placementService = placementService; - this.renderer = new GravePacketRenderer(settings, feature.getPlugin().getLogger()); + this.text = new GraveyardText(feature); + this.renderer = new GravePacketRenderer(feature, settings, feature.getPlugin().getLogger()); this.leaseScopeKey = settings.serverId() + ":" + settings.inventoryScope(); } @@ -1475,14 +1476,15 @@ private void ensureShown(Grave grave, Player viewer) { viewer.getUniqueId(), ignored -> new GraveViewerState() ); - String timer = timerText(grave); + long remainingMillis = remainingMillis(grave); + String timerFingerprint = text.timerFingerprint(grave, remainingMillis); if (!state.spawned() || state.generation() != identity.generation()) { if (state.spawned()) { renderer.destroy(identity, viewer); } try { - renderer.spawn(grave, identity, viewer, timer, glowColor(grave, viewer)); - state.markSpawned(identity.generation(), timer); + renderer.spawn(grave, identity, viewer, remainingMillis, glowColor(grave, viewer)); + state.markSpawned(identity.generation(), timerFingerprint); } catch (RuntimeException exception) { state.markHidden(); feature.getLogger().warning( @@ -1492,10 +1494,10 @@ private void ensureShown(Grave grave, Player viewer) { } return; } - if (!timer.equals(state.renderedTimer())) { + if (!timerFingerprint.equals(state.renderedTimer())) { try { - renderer.updateTimer(grave, identity, viewer, timer); - state.setRenderedTimer(timer); + renderer.updateTimer(grave, identity, viewer, remainingMillis); + state.setRenderedTimer(timerFingerprint); } catch (RuntimeException exception) { feature.getLogger().warning( "Could not update grave timer " + grave.graveId() + " for " + viewer.getName() @@ -2033,15 +2035,8 @@ private int glowColor(Grave grave, Player viewer) { return settings.otherGlowRgb(); } - private String timerText(Grave grave) { - long remaining = grave.remainingActiveMillis(feature.getPlugin().getServerActiveClock().nowMillis()); - if (grave.status() == GraveStatus.DELIVERY_PENDING) { - return "Delivery pending"; - } - if (grave.status() == GraveStatus.ORPHANED_WORLD) { - return "Remote recovery available"; - } - return "Disappears in " + formatDuration(remaining); + private long remainingMillis(Grave grave) { + return grave.remainingActiveMillis(feature.getPlugin().getServerActiveClock().nowMillis()); } private void updateTracking(Player player) { @@ -2054,20 +2049,14 @@ private void updateTracking(Player player) { trackedGraves.remove(player.getUniqueId()); return; } + long remainingMillis = remainingMillis(grave); Optional location = grave.location().resolve(); if (location.isEmpty() || !player.getWorld().equals(location.get().getWorld())) { - player.sendActionBar(Component.text( - "Grave " + grave.shortId() + " · " + grave.location().worldKey() - + " · " + timerText(grave), - NamedTextColor.AQUA - )); + player.sendActionBar(text.tracking(grave, player, null, remainingMillis)); return; } int distance = (int) Math.round(player.getLocation().distance(location.get())); - player.sendActionBar(Component.text( - "Grave " + grave.shortId() + " · " + distance + "m · " + timerText(grave), - NamedTextColor.AQUA - )); + player.sendActionBar(text.tracking(grave, player, distance, remainingMillis)); } private void playEffect(Grave grave, Particle particle, Sound sound, int count) { @@ -2100,15 +2089,6 @@ private double distanceSquared(Player player, Grave grave) { .orElse(Double.MAX_VALUE); } - static String formatDuration(long millis) { - long seconds = Math.max(0L, (millis + 999L) / 1_000L); - if (seconds >= 3_600L) { - long roundedMinutes = (seconds + 59L) / 60L; - return roundedMinutes / 60L + "h " + roundedMinutes % 60L + "m"; - } - return seconds / 60L + "m " + seconds % 60L + "s"; - } - private static String normalizeShortId(String shortId) { return shortId == null ? "" : shortId.trim().toUpperCase(Locale.ROOT); } From 4e526ec71f68c45ea8af3c2cbce5a6d421f86895 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 4 Aug 2026 15:50:51 +0200 Subject: [PATCH 10/12] Test Graveyard experience recovery modes --- .../config/ExperienceRecoveryModeTest.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/config/ExperienceRecoveryModeTest.java diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/config/ExperienceRecoveryModeTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/config/ExperienceRecoveryModeTest.java new file mode 100644 index 00000000..b7d3ea4e --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/config/ExperienceRecoveryModeTest.java @@ -0,0 +1,22 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.config; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ExperienceRecoveryModeTest { + @Test + void nativeModePreservesTheFinalMinecraftDrop() { + assertEquals(0, ExperienceRecoveryMode.NATIVE.capturedExperience(-1, 50)); + assertEquals(0, ExperienceRecoveryMode.NATIVE.capturedExperience(0, 50)); + assertEquals(37, ExperienceRecoveryMode.NATIVE.capturedExperience(37, 0)); + } + + @Test + void percentageModeUsesAClampedFloorPercentage() { + assertEquals(0, ExperienceRecoveryMode.PERCENTAGE.capturedExperience(37, -10)); + assertEquals(18, ExperienceRecoveryMode.PERCENTAGE.capturedExperience(37, 50)); + assertEquals(37, ExperienceRecoveryMode.PERCENTAGE.capturedExperience(37, 100)); + assertEquals(37, ExperienceRecoveryMode.PERCENTAGE.capturedExperience(37, 150)); + } +} From ab0041ce3562b2c63d3fdfc5923ae84ed222dd45 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 4 Aug 2026 15:51:02 +0200 Subject: [PATCH 11/12] Test localized Graveyard duration precision --- .../runtime/GraveTimerFormattingTest.java | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveTimerFormattingTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveTimerFormattingTest.java index b4d2bf31..86a12e87 100644 --- a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveTimerFormattingTest.java +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveTimerFormattingTest.java @@ -1,5 +1,6 @@ package nl.hauntedmc.serverfeatures.features.graveyard.runtime; +import nl.hauntedmc.serverfeatures.features.graveyard.text.GraveyardText; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -7,9 +8,21 @@ class GraveTimerFormattingTest { @Test void keepsSecondPrecisionAcrossTheWholeDefaultLifetime() { - assertEquals("10m 0s", GraveManager.formatDuration(600_000L)); - assertEquals("9m 59s", GraveManager.formatDuration(599_000L)); - assertEquals("5m 1s", GraveManager.formatDuration(301_000L)); - assertEquals("5m 0s", GraveManager.formatDuration(300_000L)); + assertEquals( + new GraveyardText.DurationParts(600L, 0L, 10L, 0L), + GraveyardText.durationParts(600_000L) + ); + assertEquals( + new GraveyardText.DurationParts(599L, 0L, 9L, 59L), + GraveyardText.durationParts(599_000L) + ); + assertEquals( + new GraveyardText.DurationParts(301L, 0L, 5L, 1L), + GraveyardText.durationParts(301_000L) + ); + assertEquals( + new GraveyardText.DurationParts(300L, 0L, 5L, 0L), + GraveyardText.durationParts(300_000L) + ); } } From 01e7c6715b6a105b14e8a329c53ca649e36a731a Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 4 Aug 2026 15:52:09 +0200 Subject: [PATCH 12/12] Document Graveyard XP recovery and localization --- docs/features/graveyard.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/features/graveyard.md b/docs/features/graveyard.md index 7a2c4d1f..ece8cd4a 100644 --- a/docs/features/graveyard.md +++ b/docs/features/graveyard.md @@ -2,14 +2,14 @@ > Paper · Feature name `Graveyard` · feature package `features.graveyard` · disabled by default -Graveyard replaces physical death chests with durable, packet-only graves. A successful capture transfers the final death-event drops and a configurable share of dropped experience into a checksummed payload. Nearby clients receive virtual block displays, a text display and an invisible interaction entity; no block or Bukkit entity is placed in the world. +Graveyard replaces physical death chests with durable, packet-only graves. A successful capture transfers the final death-event drops and stores experience according to `experience.mode`: the exact final Minecraft drop by default, or a configurable percentage. Nearby clients receive virtual block displays, a localized text display and an invisible interaction entity; no block or Bukkit entity is placed in the world. The implementation is fail-safe. Death drops are changed only after a local `PREPARED` journal record has been synchronously forced to disk. Claims use a second journal plus a receipt persisted in playerdata, so a restart between inventory delivery and database finalization cannot deliver the same entries twice. ## Player flow 1. `PlayerDeathEvent` is snapshotted at `LOWEST` and evaluated at `HIGHEST` after other plugins have adjusted drops, retained items, XP or keep-inventory. -2. The final drops are matched deterministically back to preferred inventory, armour and offhand slots. +2. The final drops are matched deterministically back to preferred inventory, armour and offhand slots. Grave XP is calculated from the final `PlayerDeathEvent#getDroppedExp()` value using the configured recovery mode. 3. A reachable virtual-grave location is selected without loading or generating chunks. A recent safe location is preferred when the death position is hazardous; otherwise the grave becomes remote-only. 4. The payload and `PREPARED` capture journal are forced to disk. Only then are normal drops suppressed and the intended post-death inventory saved to playerdata. 5. New graves receive a readable identifier in the exact form `-`, using the server timezone. The internal UUID remains the durable unique identity. @@ -45,7 +45,7 @@ Grave identifiers contain colons and are accepted directly without quotes, for e | `/grave admin deliver ` | `.admin.deliver` | Delivers to the owner or queues durable delivery when the owner is offline. | | `/grave admin expire ` | `.admin.expire` | Expires a recoverable grave while retaining its payload. | | `/grave admin restore ` | `.admin.restore` | Restores an expired or orphaned grave with a fresh lifetime. | -| `/grave admin purge confirm` | `.admin.purge` | Permanently purges a grave through a guarded action. | +| `/grave admin purge confirm ` | `.admin.purge` | Permanently purges a grave through a guarded action. | | `/grave admin diagnostics` | `.admin.diagnostics` | Shows lease, runtime cache, journal backlog, pending claims and rendered-pair counts. | Permission nodes: @@ -95,7 +95,8 @@ File: `plugins/ServerFeatures/features/Graveyard/config.yml`. | `lifetime.duration` | `10m` | Grave lifetime measured by the plugin active-server clock, not wall time. | | `eligibility.disabled_worlds` | empty | Case-insensitive world names or namespaced keys excluded from Graveyard. | | `eligibility.disabled_gamemodes` | `CREATIVE`, `SPECTATOR` | Game modes that keep normal death behaviour. | -| `experience.recovery_percentage` | `50` | Percentage of final dropped XP captured, clamped to `0..100`. | +| `experience.mode` | `NATIVE` | `NATIVE` stores exactly the final XP Minecraft and other death listeners would drop; `PERCENTAGE` applies `experience.recovery_percentage` to that final value. | +| `experience.recovery_percentage` | `50` | Percentage used only in `PERCENTAGE` mode, clamped to `0..100` and rounded down. | | `placement.horizontal_search_radius` | `8` | Loaded-block horizontal candidate radius. | | `placement.vertical_search_below` | `4` | Candidate search below the death position. | | `placement.vertical_search_above` | `6` | Candidate search above the death position. | @@ -105,7 +106,7 @@ File: `plugins/ServerFeatures/features/Graveyard/config.yml`. | `render.reconciliation_interval_ticks` | `20` | Central viewer and timer refresh interval. The default is once per second. | | `render.spawn_settle_delay_ticks` | `2` | Delay before rebuilding a viewer after world or teleport transitions. | | `render.max_rendered_per_viewer` | `64` | Visual cap; own and nearest graves are prioritized. | -| `render.base.material` | `DARK_OAK_SLAB` | Virtual grave-bed material. | +| `render.base.material` | `POLISHED_BLACKSTONE_BRICK_SLAB` | Virtual grave-bed material. | | `render.headstone.material` | `DARK_OAK_PLANKS` | Virtual memorial and cross material. | | `render.glow.owner_rgb` | `55FFFF` | Owner glow override. | | `render.glow.staff_rgb` | `FFD700` | Staff glow override. | @@ -122,6 +123,18 @@ Durations accept positive raw milliseconds or `ms`, `s`, `m`, `h` and `d` suffix Generated defaults apply to new or missing keys. An existing explicit `lifetime.duration` remains authoritative until it is changed to `10m`. Claimed and expired retention are capped at `1h` even when an existing configuration contains a larger value. +## Localization + +All direct player-facing Graveyard text is resolved through the feature localization handler. This includes chat messages, command status names, formatted durations, the tracking action bar, the hologram title and every hologram timer state. Language files can override the corresponding `graveyard.*` message keys without changing renderer configuration or code. + +The default localization keys additionally include: + +- `graveyard.hologram.title`; +- `graveyard.timer.*`; +- `graveyard.tracking.*`; +- `graveyard.duration.*`; +- `graveyard.status.*`. + ## Rendering and timer lifecycle A visual generation contains globally allocated packet entity IDs and random UUIDs for: @@ -134,7 +147,7 @@ A visual generation contains globally allocated packet entity IDs and random UUI Every visual is packet-only. Partial spawn failure triggers best-effort destruction, and all five packet entities are removed together on claim, expiry, relocation, reload, viewer transition and shutdown. -Timer metadata is derived from active-server time and only sent when the displayed value changes. With the default 20-tick reconciliation interval, the complete ten-minute countdown updates once per second rather than rounding to ten-second steps. +Hologram text is built separately for each viewer, so its title, timer and duration format follow that viewer's selected language. Timer metadata is derived from active-server time and only sent when the displayed second changes. With the default 20-tick reconciliation interval, the complete ten-minute countdown updates once per second rather than rounding to ten-second steps. ## Active-server clock @@ -162,7 +175,7 @@ The readable `-` identifier is stable across relocation. The i Capture protocol: -1. Encode the final drops and XP. +1. Encode the final drops and configured XP amount. 2. Force a `PREPARED` capture record to disk. 3. Persist a player receipt. 4. Apply the intended post-death inventory and suppress normal drops.