Skip to content
Merged
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
27 changes: 20 additions & 7 deletions docs/features/graveyard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<player>-<HH:mm:ss>`, using the server timezone. The internal UUID remains the durable unique identity.
Expand Down Expand Up @@ -45,7 +45,7 @@ Grave identifiers contain colons and are accepted directly without quotes, for e
| `/grave admin deliver <id>` | `.admin.deliver` | Delivers to the owner or queues durable delivery when the owner is offline. |
| `/grave admin expire <id>` | `.admin.expire` | Expires a recoverable grave while retaining its payload. |
| `/grave admin restore <id>` | `.admin.restore` | Restores an expired or orphaned grave with a fresh lifetime. |
| `/grave admin purge <id> confirm` | `.admin.purge` | Permanently purges a grave through a guarded action. |
| `/grave admin purge confirm <id>` | `.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:
Expand Down Expand Up @@ -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. |
Expand All @@ -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. |
Expand All @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -162,7 +175,7 @@ The readable `<player>-<HH:mm:ss>` 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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}.");
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -334,7 +334,6 @@ private static List<ItemStack> cloneItems(List<ItemStack> items) {
.toList();
}


private record PreparedCapture(CaptureJournalRecord record, GravePayload payload) {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
}
Expand All @@ -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());
}
Expand All @@ -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());
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,13 @@ List<EntityData<?>> block(
List<EntityData<?>> 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);
Expand All @@ -73,7 +73,7 @@ List<EntityData<?>> text(
return metadata;
}

List<EntityData<?>> timer(World world, Component title, String timer) {
List<EntityData<?>> timer(World world, Component title, Component timer) {
Integer index = textComponentIndex;
if (index == null) {
TextDisplay template = world.createEntity(templateLocation(world), TextDisplay.class);
Expand All @@ -84,7 +84,7 @@ List<EntityData<?>> 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)
));
}

Expand Down
Loading