diff --git a/docs/features/README.md b/docs/features/README.md index 474ade36..43b671d2 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -53,6 +53,7 @@ This directory documents all **58** feature modules currently present in ServerF - [DurabilityAlert](durabilityalert.md) — Warns players when equipped or used items cross configured durability thresholds. - [VillagerOptimizer](villageroptimizer.md) — Reduces villager processing cost by applying configurable optimization rules without changing intended gameplay more than necessary. - [WorldEditVisualizer](worldeditvisualizer.md) — Visualizes WorldEdit selections for authorized builders with temporary particles or display markers. +- [Graveyard](graveyard.md) — Stores death contents durably and renders claimable packet-only graves with active-server-time expiry and crash recovery. ## Moderation and administration diff --git a/docs/features/graveyard.md b/docs/features/graveyard.md new file mode 100644 index 00000000..ca4351fe --- /dev/null +++ b/docs/features/graveyard.md @@ -0,0 +1,254 @@ +# Graveyard + +> 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, one text display, and an invisible interaction entity; no block or Bukkit entity is placed in the world. + +The implementation is deliberately 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 can be resolved without delivering the same entries twice. MySQL stores grave metadata, payloads, audit records, and the single-writer server lease; the local journal remains the immediate authority while database projection is unavailable. + +## 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. +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. The grave receives a stable readable command identifier in the form + `--`, is projected asynchronously to MySQL, and is rendered + to nearby viewers. The timestamp uses the server timezone and millisecond precision, so normal + command use never exposes the internal UUID. +6. The owner right- or left-clicks the virtual interaction entity. Items restore to preferred slots where safe, merge into compatible stacks, then use empty slots. Nothing is overwritten or dropped. +7. If only part fits, the remaining entries stay in the grave and its active-server-time expiry continues. +8. A fully claimed or expired grave disappears with the configured effect. Expired payloads remain available for the configured support window, after which a lease-owned bounded retention sweep removes payload and metadata while preserving the audit record. Corrupt graves are never automatically purged. + +## Commands and permissions + +Root commands: `/grave` and `/graves`. + +All player commands require `serverfeatures.feature.graveyard.use`. Subcommands additionally require their dedicated permission. + +| Syntax | Permission | Behaviour | +|---|---|---| +| `/grave` | `.use` | Shows the newest grave owned by the player. | +| `/grave list` | `.command.list` | Lists the player's active, partial, remote, orphaned, or pending-delivery graves. | +| `/grave info ` | `.command.info` | Shows owner, server, world, coordinates, status, remaining active time, item-entry count, and XP. | +| `/grave locate ` | `.command.locate` | Shows the grave location. Physical graves still require a valid nearby interaction to claim. | +| `/grave track ` | `.command.track` | Selects a grave for periodic actionbar distance and remaining-time feedback. | +| `/grave track off` | `.command.track` | Stops grave tracking. | +| `/grave claim ` | `.command.remoteclaim` | Claims graves marked `REMOTE_ONLY`, `ORPHANED_WORLD`, or `DELIVERY_PENDING`. | +| `/grave admin list ` | `.admin.list` | Lists known graves for an online or resolvable player. | +| `/grave admin info ` | `.admin.inspect` | Shows detailed grave diagnostics. | +| `/grave admin teleport ` | `.admin.teleport` | Teleports a player sender to the grave's resolved world location. | +| `/grave admin relocate ` | `.admin.relocate` | Relocates the grave to the executing player's safe location and rotates packet identity. | +| `/grave admin deliver ` | `.admin.deliver` | Delivers to the owner. If the owner is offline, marks durable pending delivery; staff never receive the payload. | +| `/grave admin expire ` | `.admin.expire` | Expires a claimable grave while retaining its payload. | +| `/grave admin restore ` | `.admin.restore` | Restores an expired/orphaned grave with a fresh configured lifetime. | +| `/grave admin purge confirm` | `.admin.purge` | Permanently purges the grave through a guarded administrative action. | +| `/grave admin diagnostics` | `.admin.diagnostics` | Shows lease, runtime cache, journal backlog, pending claim, and rendered-pair counts. | + +Permission nodes: + +```text +serverfeatures.feature.graveyard.use +serverfeatures.feature.graveyard.command.list +serverfeatures.feature.graveyard.command.info +serverfeatures.feature.graveyard.command.locate +serverfeatures.feature.graveyard.command.track +serverfeatures.feature.graveyard.command.remoteclaim +serverfeatures.feature.graveyard.keepinventory +serverfeatures.feature.graveyard.admin +serverfeatures.feature.graveyard.admin.list +serverfeatures.feature.graveyard.admin.inspect +serverfeatures.feature.graveyard.admin.teleport +serverfeatures.feature.graveyard.admin.relocate +serverfeatures.feature.graveyard.admin.deliver +serverfeatures.feature.graveyard.admin.expire +serverfeatures.feature.graveyard.admin.restore +serverfeatures.feature.graveyard.admin.purge +serverfeatures.feature.graveyard.admin.diagnostics +``` + +`serverfeatures.feature.graveyard.admin` implies every administrative child command. `keepinventory` is checked independently and is not implied by the administrator node. It retains inventory and level, creates no grave, and suppresses normal drops. + +## Configuration reference + +File: `plugins/ServerFeatures/features/Graveyard/config.yml`. + +| Key | Default | Behaviour | +|---|---:|---| +| `enabled` | `false` | Enables feature initialization. | +| `mode` | `ACTIVE` | `ACTIVE` mutates deaths; `OBSERVE` validates and logs captures without changing the event or creating graves. | +| `identity.server_id` | global `server_name` | Stable backend identity used in persistence and the writer lease. | +| `identity.inventory_scope` | server name | Prevents delivery into an unrelated gamemode inventory. | +| `identity.lease_heartbeat` | `5s` | Single-writer lease renewal interval. | +| `identity.lease_timeout` | `20s` | Lease freshness window. Loss switches mutation paths to fail-closed/read-only behaviour. | +| `storage.journal.maximum_record_bytes` | `12000000` | Maximum local capture or claim record size. | +| `storage.payload.maximum_entries` | `64` | Defensive payload item-entry limit. | +| `storage.payload.maximum_item_bytes` | `2097152` | Maximum serialized bytes for one item entry. | +| `storage.payload.maximum_total_bytes` | `8388608` | Maximum encoded payload size. | +| `storage.retention.expired` | `7d` | Support window for expired payloads before automatic permanent cleanup. | +| `storage.retention.claimed` | `24h` | Retention for claimed, administratively recovered, or already-purged metadata before row cleanup. | +| `storage.retention.purge_interval` | `10m` | Interval between bounded retention sweeps; minimum one minute. | +| `storage.retention.purge_batch_size` | `100` | Maximum records removed per transaction; full batches are drained gradually. | +| `lifetime.duration` | `30m` | Lifetime measured by the plugin-scoped active-server clock, not wall time. | +| `eligibility.disabled_worlds` | empty | Case-insensitive world names or namespaced world keys excluded from Graveyard. | +| `eligibility.disabled_gamemodes` | `CREATIVE`, `SPECTATOR` | Game modes that retain normal death behaviour. | +| `experience.recovery_percentage` | `50` | Percentage of the final event dropped-XP value captured, clamped to `0..100`. | +| `placement.horizontal_search_radius` | `8` | Loaded-block horizontal candidate radius. | +| `placement.vertical_search_below` | `4` | Candidate search below death position. | +| `placement.vertical_search_above` | `6` | Candidate search above death position. | +| `placement.last_safe_location_max_age` | `30s` | Maximum age for a tracked safe-location fallback. | +| `render.spawn_distance` | `48` | Inclusive spawn distance. | +| `render.despawn_distance` | `56` | Despawn distance; clamped not lower than spawn distance to provide hysteresis. | +| `render.reconciliation_interval_ticks` | `20` | Central viewer reconciliation period; no task is created per grave. | +| `render.spawn_settle_delay_ticks` | `2` | Delay before rebuilding a viewer after world/teleport transitions. | +| `render.max_rendered_per_viewer` | `64` | Visual cap only; own and nearest graves are prioritized and storage is unaffected. | +| `render.base.material` | `POLISHED_BLACKSTONE_BRICK_SLAB` | Virtual base block-display material. | +| `render.headstone.material` | `POLISHED_BLACKSTONE_BRICK_WALL` | Virtual headstone block-display material. | +| `render.glow.owner_rgb` | `55FFFF` | Per-viewer owner glow override. | +| `render.glow.staff_rgb` | `FFD700` | Staff glow override. | +| `render.glow.other_rgb` | `00AAAA` | Ordinary-viewer glow override. | +| `interaction.maximum_distance` | `4.5` | Server-side interaction-distance limit. | +| `interaction.require_line_of_sight` | `true` | Rechecks line of sight before a packet interaction can claim. | +| `claim.partial_claims` | `true` | Keeps overflow entries in the grave. When disabled, an insufficient inventory returns `NOTHING_FIT`. | +| `particles.claim.type` | `SCULK_SOUL` | Claim particle. Invalid names fall back independently. | +| `particles.expiry.type` | `SCULK_SOUL` | Expiry particle. | +| `sounds.claim.sound` | `BLOCK_RESPAWN_ANCHOR_CHARGE` | Claim sound resolved through the Bukkit sound registry. | +| `sounds.expiry.sound` | `PARTICLE_SOUL_ESCAPE` | Expiry sound. | + +Durations accept positive raw milliseconds or `ms`, `s`, `m`, `h`, and `d` suffixes. Invalid or non-positive values fall back to the documented default. + +## Virtual grave and viewer lifecycle + +A visual generation contains globally generated packet entity IDs and random UUIDs for: + +- base `BLOCK_DISPLAY`; +- headstone `BLOCK_DISPLAY`; +- multiline `TEXT_DISPLAY`; +- invisible `INTERACTION` entity. + +The renderer sends concrete `PacketWrapper` instances through PacketEvents. Display and +interaction metadata is generated from unspawned Bukkit entity templates and converted by +PacketEvents, avoiding protocol-version-specific metadata indexes. Partial spawn failure triggers +best-effort destruction. Every viewer state records the visual generation and rendered timer +string. Relocation or hard rebuilding rotates the generation so delayed interactions and metadata +callbacks cannot affect the replacement. + +The interaction packet listener consumes only IDs in Graveyard's active interaction index and schedules all Bukkit validation on the main thread. It rechecks connection state, generated identity, world, server-side distance, line of sight, claim state, inventory scope, ownership/staff permission, and operation reservation. Right-click `INTERACT`/`INTERACT_AT` and left-click `ATTACK` actions are accepted; duplicate packets are serialized by the grave and owner operation locks. + +The spatial index is `world UUID -> packed chunk key -> grave IDs`. Reconciliation checks only nearby indexed chunks and does not load chunks. A grave is rendered only when its world resolves by both UUID and key and Paper reports the grave chunk as sent to that viewer. + +Timer metadata is derived from active time and updated only when the displayed value changes. Text uses minute granularity above one hour, ten-second granularity from five minutes to one hour, and second granularity below five minutes. + +## Active-server clock + +`ServerActiveClock` is owned by the ServerFeatures plugin bootstrap, not by Graveyard. It uses monotonic process time plus an atomically replaced local checkpoint. The checkpoint is updated every five seconds and on plugin shutdown. + +Consequences: + +- ordinary feature reload does not reset grave time; +- server downtime does not consume grave lifetime; +- after a hard crash, at most one checkpoint interval is omitted, making a grave live slightly longer rather than expiring early; +- active clock values never depend on wall-clock corrections. + +## Persistence + +Connection: feature-owned `graveyardOrm`, MySQL, access policy `player_data_rw`. + +Retention cleanup runs only under the active writer lease and never automatically removes `CORRUPT` graves. + +Entities/tables (the feature does not read or migrate the former unprefixed development tables): + +- `player_graveyard_graves`: lightweight metadata, location, state, expiry, payload revision/checksum, and optimistic operation token; +- `player_graveyard_payloads`: versioned payload BLOB separate from render/list metadata; +- `player_graveyard_audit`: administrative and claim state transitions; +- `player_graveyard_leases`: single-writer lease for `server_id + inventory_scope`. + +The readable identifier is stored in `short_id` with sufficient room for owner, world, and the +creation timestamp. It is stable across relocation; `/grave info` and `/grave locate` report the +current coordinates separately. Existing unprefixed development tables can be removed by +operators after confirming they are no longer needed. + +The owner UUID is always stored. Canonical DataRegistry identity can be resolved independently and is not required on the synchronous death path. + +Payload format is a versioned binary envelope with stable entry UUIDs, preferred slot data, Paper raw item bytes, remaining XP, and SHA-256 checksum. Decode validates schema, counts, item sizes, total size, duplicate IDs, AIR/invalid entries, and checksum. A decode failure is not treated as an empty grave; the operation fails closed and is surfaced through diagnostics/logging. + +## Capture journal protocol + +1. Final death drops and XP are encoded. +2. `PREPARED` is written to an independent atomic journal file and forced to disk. +3. A capture receipt is placed in player PDC. +4. The intended post-death inventory/XP is applied; keep-inventory is set internally; event drops and XP are cleared. +5. `Player#saveData()` persists the ownership transfer. +6. The journal advances to `COMMITTED`. +7. MySQL projection is retried asynchronously until successful, then the journal is removed. + +If preparation fails, Graveyard leaves the event untouched. A crash with `PREPARED` is resolved on owner join: a matching persisted receipt promotes it to committed; no receipt aborts it. A `COMMITTED` journal is loaded and rendered even while MySQL remains unavailable, but database-reserved mutations remain fail-closed until projection succeeds. + +## Claim journal protocol + +1. MySQL atomically reserves the grave with an operation UUID and expected claimable state. +2. The current payload is loaded and a deterministic transfer plan is calculated. +3. A `PREPARED` claim record stores the exact transferred count, XP, and remaining encoded payload. +4. The player receipt and inventory changes are saved to playerdata. +5. The journal advances to `PLAYER_APPLIED`. +6. MySQL finalizes the remaining payload and state using operation token and payload revision preconditions. +7. The receipt and journal are removed only after finalization. + +A `PREPARED` claim is not immediately discarded at startup: the owner's playerdata receipt decides whether it was applied. This prevents the critical crash window after playerdata save but before the journal-state rewrite from duplicating items. + +## Item restoration + +The claim planner never calls APIs that may drop overflow. It works against a cloned inventory snapshot and applies the result only after durable preparation. + +Order: + +1. original preferred slot when valid and empty; +2. merge into compatible partial stacks; +3. original armour/offhand slot when valid and empty; +4. empty storage/hotbar slots; +5. retain remaining quantity in the grave. + +Existing items are never swapped or overwritten. XP is transferred once and removed from the remaining payload independently of item capacity. + +## Placement and worlds + +Candidate order is exact death position, vertical correction, expanding loaded-block rings, recent safe location, and remote-only fallback. Candidates require a solid collision surface, adequate body/headroom, a nearby standable interaction position, world-border validity, and rejection of lava, fire, cactus, magma, powder snow, portals, and void exposure. + +Graves persist both world UUID and namespaced key. A matching key with a different UUID is treated as a world reset, not as the original world. Missing/reset worlds become `ORPHANED_WORLD`, pause their remaining time, disappear from spatial rendering, and permit owner remote recovery. + +## Staff delivery + +Staff never receive another player's grave contents. `admin deliver` either performs the normal claim against the online owner or changes the grave to `DELIVERY_PENDING`. Pending delivery pauses normal interaction, is retried when the owner joins the same inventory scope, and can also be requested manually by the owner through `/grave claim ` after freeing inventory space. All state-changing staff actions append an audit row with actor, grave, old/new state, and payload counts. + +## Failure modes + +- **Journal unavailable:** death event is left unchanged. +- **Database unavailable during capture:** committed local record remains authoritative and projection retries. +- **Database unavailable during claim:** operation fails before player delivery, or a previously player-applied operation stays journal-backed for deterministic recovery. +- **Lease unavailable/lost:** new mutation paths fail closed; visuals and diagnostic reads may continue. +- **Payload invalid:** no item is delivered and the error is logged; operators retain the stored record for investigation. +- **World unavailable/reset:** grave becomes non-rendered orphaned recovery rather than appearing in a replacement world. +- **Inventory full:** claim remains unchanged or partial; no item entity is spawned. + +## Messages + +Feature messages include creation, remote creation, storage fallback, keep-inventory, claim success/partial/full, inventory full, owner/claim-state errors, location/tracking output, pending delivery, staff operations, list/info formatting, and diagnostics. Common variables are: + +```text + + +``` + +## API + +Published service: `nl.hauntedmc.serverfeatures.api.graveyard.GraveyardService`. + +It exposes immutable snapshots and asynchronous claim requests by grave UUID/short ID. It never exposes mutable item payloads, Hibernate entities, packet IDs, or journal records. + +## Shutdown + +Shutdown stops captures and interactions, destroys every visible packet entity, clears interaction mappings and viewer state, releases the lease, and then lets the feature lifecycle close tasks, listeners, command, API, and data scopes. No world cleanup is required because Graveyard never creates a real world entity or block. + +## Test coverage and acceptance + +Automated tests cover duration parsing, spatial reindexing/removal, capture and claim journal round-trips, corrupt-record quarantine/token preservation, payload checksum/schema validation, retained-item reconstruction, partial/all-or-nothing claim planning, stack limits, and paused-state resume behaviour. The production acceptance pass should additionally exercise ordinary/void/lava deaths, retained and vanishing items, full-inventory partial claims, duplicate interaction packets, restart at each journal boundary, database outage, world reset, staff online/offline delivery, vanished owners, feature reload, and absence of ghost displays. diff --git a/pom.xml b/pom.xml index a49d87a4..eb313ac0 100644 --- a/pom.xml +++ b/pom.xml @@ -62,7 +62,7 @@ - 3.1.7 + 3.1.8 UTF-8 UTF-8 2026-07-26T00:00:00Z diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/ClaimReason.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/ClaimReason.java new file mode 100644 index 00000000..6f46ce7a --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/ClaimReason.java @@ -0,0 +1,12 @@ +package nl.hauntedmc.serverfeatures.api.graveyard; + +/** + * Why a grave claim was initiated. + */ +public enum ClaimReason { + PHYSICAL_INTERACTION, + REMOTE_UNREACHABLE, + STAFF_DELIVERY, + PENDING_DELIVERY, + ADMIN_RECOVERY +} diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveClaimOutcome.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveClaimOutcome.java new file mode 100644 index 00000000..ede1855a --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveClaimOutcome.java @@ -0,0 +1,18 @@ +package nl.hauntedmc.serverfeatures.api.graveyard; + +/** + * Terminal result reported by a Graveyard claim request. + */ +public enum GraveClaimOutcome { + CLAIMED, + PARTIAL, + DELIVERY_QUEUED, + RECOVERY_PENDING, + NOTHING_FIT, + NOT_FOUND, + NOT_OWNER, + NOT_CLAIMABLE, + WRONG_INVENTORY_SCOPE, + BUSY, + FAILED +} diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveClaimResult.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveClaimResult.java new file mode 100644 index 00000000..973f5724 --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveClaimResult.java @@ -0,0 +1,16 @@ +package nl.hauntedmc.serverfeatures.api.graveyard; + +import java.util.UUID; + +/** + * Immutable result of one grave claim attempt. + */ +public record GraveClaimResult( + UUID graveId, + GraveClaimOutcome outcome, + int transferredEntries, + int remainingEntries, + int transferredExperience, + String message +) { +} diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveSnapshot.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveSnapshot.java new file mode 100644 index 00000000..b71989f7 --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveSnapshot.java @@ -0,0 +1,26 @@ +package nl.hauntedmc.serverfeatures.api.graveyard; + +import java.util.UUID; + +/** + * Read-only grave metadata exposed to other features and plugins. + */ +public record GraveSnapshot( + UUID graveId, + String shortId, + UUID ownerUuid, + String ownerName, + String serverId, + String inventoryScope, + UUID worldUuid, + String worldKey, + double x, + double y, + double z, + GraveStatus status, + boolean remoteOnly, + long remainingActiveMillis, + int itemEntryCount, + int remainingExperience +) { +} diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveStatus.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveStatus.java new file mode 100644 index 00000000..f7f64456 --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveStatus.java @@ -0,0 +1,28 @@ +package nl.hauntedmc.serverfeatures.api.graveyard; + +/** + * Durable lifecycle state of a Graveyard grave. + */ +public enum GraveStatus { + ACTIVE, + PARTIAL, + ORPHANED_WORLD, + DELIVERY_PENDING, + CLAIMED, + EXPIRED, + CORRUPT, + ADMIN_RECOVERED, + PURGED; + + public boolean isVisible() { + return this == ACTIVE || this == PARTIAL; + } + + public boolean isPlayerClaimable() { + return this == ACTIVE || this == PARTIAL || this == ORPHANED_WORLD; + } + + public boolean hasRecoverablePayload() { + return this != CLAIMED && this != ADMIN_RECOVERED && this != PURGED; + } +} diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveyardService.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveyardService.java new file mode 100644 index 00000000..1faccfae --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveyardService.java @@ -0,0 +1,23 @@ +package nl.hauntedmc.serverfeatures.api.graveyard; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletionStage; + +/** + * Public, ownership-safe access to Graveyard metadata and claim requests. + */ +public interface GraveyardService { + Optional find(UUID graveId); + + Optional findByShortId(String shortId); + + List findActiveByOwner(UUID ownerUuid); + + CompletionStage requestClaim( + UUID graveId, + UUID ownerUuid, + ClaimReason reason + ); +} diff --git a/serverfeatures-api/src/test/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveyardApiTest.java b/serverfeatures-api/src/test/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveyardApiTest.java new file mode 100644 index 00000000..20f3fa73 --- /dev/null +++ b/serverfeatures-api/src/test/java/nl/hauntedmc/serverfeatures/api/graveyard/GraveyardApiTest.java @@ -0,0 +1,99 @@ +package nl.hauntedmc.serverfeatures.api.graveyard; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GraveyardApiTest { + + @Test + void enumContractsExposeEverySupportedValue() { + assertEquals(5, ClaimReason.values().length); + assertSame(ClaimReason.PHYSICAL_INTERACTION, ClaimReason.valueOf("PHYSICAL_INTERACTION")); + + assertEquals(11, GraveClaimOutcome.values().length); + assertSame(GraveClaimOutcome.RECOVERY_PENDING, GraveClaimOutcome.valueOf("RECOVERY_PENDING")); + + assertEquals(9, GraveStatus.values().length); + assertSame(GraveStatus.DELIVERY_PENDING, GraveStatus.valueOf("DELIVERY_PENDING")); + } + + @Test + void statusCapabilitiesMatchTheLifecycleContract() { + assertTrue(GraveStatus.ACTIVE.isVisible()); + assertTrue(GraveStatus.PARTIAL.isVisible()); + assertFalse(GraveStatus.EXPIRED.isVisible()); + + assertTrue(GraveStatus.ACTIVE.isPlayerClaimable()); + assertTrue(GraveStatus.PARTIAL.isPlayerClaimable()); + assertTrue(GraveStatus.ORPHANED_WORLD.isPlayerClaimable()); + assertFalse(GraveStatus.DELIVERY_PENDING.isPlayerClaimable()); + + assertTrue(GraveStatus.CORRUPT.hasRecoverablePayload()); + assertFalse(GraveStatus.CLAIMED.hasRecoverablePayload()); + assertFalse(GraveStatus.ADMIN_RECOVERED.hasRecoverablePayload()); + assertFalse(GraveStatus.PURGED.hasRecoverablePayload()); + } + + @Test + void immutableResultAndSnapshotPreserveTheirValues() { + UUID graveId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + UUID worldId = UUID.randomUUID(); + GraveClaimResult result = new GraveClaimResult( + graveId, + GraveClaimOutcome.PARTIAL, + 3, + 2, + 17, + "partial" + ); + GraveSnapshot snapshot = new GraveSnapshot( + graveId, + "remymine-world-20260804-002600-000", + ownerId, + "remymine", + "demo", + "demo", + worldId, + "minecraft:world", + 10.5, + 64.0, + -20.5, + GraveStatus.PARTIAL, + false, + 60_000L, + 2, + 17 + ); + + assertEquals(graveId, result.graveId()); + assertEquals(GraveClaimOutcome.PARTIAL, result.outcome()); + assertEquals(3, result.transferredEntries()); + assertEquals(2, result.remainingEntries()); + assertEquals(17, result.transferredExperience()); + assertEquals("partial", result.message()); + + assertEquals(graveId, snapshot.graveId()); + assertEquals("remymine-world-20260804-002600-000", snapshot.shortId()); + assertEquals(ownerId, snapshot.ownerUuid()); + assertEquals("remymine", snapshot.ownerName()); + assertEquals("demo", snapshot.serverId()); + assertEquals("demo", snapshot.inventoryScope()); + assertEquals(worldId, snapshot.worldUuid()); + assertEquals("minecraft:world", snapshot.worldKey()); + assertEquals(10.5, snapshot.x()); + assertEquals(64.0, snapshot.y()); + assertEquals(-20.5, snapshot.z()); + assertEquals(GraveStatus.PARTIAL, snapshot.status()); + assertFalse(snapshot.remoteOnly()); + assertEquals(60_000L, snapshot.remainingActiveMillis()); + assertEquals(2, snapshot.itemEntryCount()); + assertEquals(17, snapshot.remainingExperience()); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/ServerFeatures.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/ServerFeatures.java index e12138ef..3f6dae96 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/ServerFeatures.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/ServerFeatures.java @@ -18,6 +18,7 @@ import nl.hauntedmc.serverfeatures.framework.listener.ScoreboardListener; import nl.hauntedmc.serverfeatures.framework.loader.FeatureLoadManager; import nl.hauntedmc.serverfeatures.framework.localization.LocalizationHandler; +import nl.hauntedmc.serverfeatures.framework.time.ServerActiveClock; import org.bukkit.Bukkit; import org.bukkit.plugin.PluginManager; import org.bukkit.plugin.java.JavaPlugin; @@ -32,6 +33,7 @@ public class ServerFeatures extends JavaPlugin { private ConfigService configService; private FeatureLifecycleFactory featureLifecycleFactory; private FeatureScopeFactory featureScopeFactory; + private ServerActiveClock serverActiveClock; // Keep if other parts of your framework still use dispatcher (features registering their own brig nodes). private BrigadierDispatcher brigadierDispatcher; @@ -53,6 +55,8 @@ public void onEnable() { localizationHandler, featureLifecycleFactory ); + serverActiveClock = new ServerActiveClock(this); + serverActiveClock.start(); featureLoadManager = FeatureLoadManager.create(this); // Optional: if your feature system still needs direct dispatcher access elsewhere @@ -92,6 +96,10 @@ public void onDisable() { ((PaperActionBarAPI) ActionBars.service()).shutdown(); ActionBars.shutdown(); + if (serverActiveClock != null) { + serverActiveClock.close(); + } + getLogger().info("ServerFeatures is shutting down..."); } @@ -131,6 +139,10 @@ public BrigadierDispatcher getBrigadierDispatcher() { return brigadierDispatcher; } + public ServerActiveClock getServerActiveClock() { + return serverActiveClock; + } + public Optional getDataRegistry() { return Optional.ofNullable(Bukkit.getPluginManager().getPlugin("DataRegistry")) .filter(DataRegistryApiProvider.class::isInstance) 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 new file mode 100644 index 00000000..98af45e7 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/Graveyard.java @@ -0,0 +1,270 @@ +package nl.hauntedmc.serverfeatures.features.graveyard; + +import com.github.retrooper.packetevents.PacketEvents; +import nl.hauntedmc.dataprovider.api.orm.ORMContext; +import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.serverfeatures.api.graveyard.GraveyardService; +import nl.hauntedmc.serverfeatures.api.io.config.ConfigMap; +import nl.hauntedmc.serverfeatures.api.io.localization.MessageMap; +import nl.hauntedmc.serverfeatures.features.BukkitBaseFeature; +import nl.hauntedmc.serverfeatures.features.FeatureContext; +import nl.hauntedmc.serverfeatures.features.graveyard.capture.GraveCaptureService; +import nl.hauntedmc.serverfeatures.features.graveyard.capture.GraveDeathListener; +import nl.hauntedmc.serverfeatures.features.graveyard.command.GraveCommand; +import nl.hauntedmc.serverfeatures.features.graveyard.config.GraveyardSettings; +import nl.hauntedmc.serverfeatures.features.graveyard.journal.GraveOperationJournal; +import nl.hauntedmc.serverfeatures.features.graveyard.journal.PlayerOperationReceiptService; +import nl.hauntedmc.serverfeatures.features.graveyard.listener.GravePlayerLifecycleListener; +import nl.hauntedmc.serverfeatures.features.graveyard.meta.Meta; +import nl.hauntedmc.serverfeatures.features.graveyard.packet.GraveInteractionPacketListener; +import nl.hauntedmc.serverfeatures.features.graveyard.persistence.GraveAuditEntity; +import nl.hauntedmc.serverfeatures.features.graveyard.persistence.GraveLeaseEntity; +import nl.hauntedmc.serverfeatures.features.graveyard.persistence.GraveMetadataEntity; +import nl.hauntedmc.serverfeatures.features.graveyard.persistence.GravePayloadCodec; +import nl.hauntedmc.serverfeatures.features.graveyard.persistence.GravePayloadEntity; +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.GraveManager; + +import java.io.IOException; +import java.util.List; + +public final class Graveyard extends BukkitBaseFeature { + private static final String DATABASE_IDENTIFIER = "graveyardOrm"; + + private GraveyardSettings settings; + private ORMContext ormContext; + private GraveRepository repository; + private GraveOperationJournal journal; + private GravePayloadCodec payloadCodec; + private PlayerOperationReceiptService receiptService; + private LastSafeLocationTracker safeLocationTracker; + private GravePlacementService placementService; + private GraveManager manager; + private GraveInteractionPacketListener packetListener; + + public Graveyard(FeatureContext context) { + super(context); + } + + @Override + public ConfigMap getDefaultConfig() { + ConfigMap defaults = new ConfigMap(); + defaults.put("enabled", false); + defaults.put("mode", "ACTIVE"); + defaults.put("identity.server_id", ""); + defaults.put("identity.inventory_scope", ""); + defaults.put("identity.lease_heartbeat", "5s"); + defaults.put("identity.lease_timeout", "20s"); + defaults.put("storage.journal.maximum_record_bytes", 12_000_000); + defaults.put("storage.payload.maximum_entries", 64); + defaults.put("storage.payload.maximum_item_bytes", 2_097_152); + defaults.put("storage.payload.maximum_total_bytes", 8_388_608); + defaults.put("storage.retention.expired", "7d"); + defaults.put("storage.retention.claimed", "24h"); + defaults.put("storage.retention.purge_interval", "10m"); + defaults.put("storage.retention.purge_batch_size", 100); + defaults.put("lifetime.duration", "30m"); + defaults.put("eligibility.disabled_worlds", List.of()); + defaults.put("eligibility.disabled_gamemodes", List.of("CREATIVE", "SPECTATOR")); + defaults.put("experience.recovery_percentage", 50); + defaults.put("placement.horizontal_search_radius", 8); + defaults.put("placement.vertical_search_below", 4); + defaults.put("placement.vertical_search_above", 6); + defaults.put("placement.last_safe_location_max_age", "30s"); + defaults.put("render.spawn_distance", 48.0); + defaults.put("render.despawn_distance", 56.0); + defaults.put("render.reconciliation_interval_ticks", 20L); + defaults.put("render.spawn_settle_delay_ticks", 2L); + defaults.put("render.max_rendered_per_viewer", 64); + defaults.put("render.base.material", "POLISHED_BLACKSTONE_BRICK_SLAB"); + defaults.put("render.headstone.material", "POLISHED_BLACKSTONE_BRICK_WALL"); + defaults.put("render.glow.owner_rgb", "55FFFF"); + defaults.put("render.glow.other_rgb", "00AAAA"); + defaults.put("render.glow.staff_rgb", "FFD700"); + defaults.put("interaction.maximum_distance", 4.5); + defaults.put("interaction.require_line_of_sight", true); + defaults.put("claim.partial_claims", true); + defaults.put("particles.claim.type", "SCULK_SOUL"); + defaults.put("particles.expiry.type", "SCULK_SOUL"); + defaults.put("sounds.claim.sound", "BLOCK_RESPAWN_ANCHOR_CHARGE"); + defaults.put("sounds.expiry.sound", "PARTICLE_SOUL_ESCAPE"); + return defaults; + } + + @Override + public MessageMap getDefaultMessages() { + MessageMap messages = new MessageMap(); + messages.add( + "graveyard.created", + "&7Je graf &b{grave_id}&7 staat op &f{x}, {y}, {z}&7. Gebruik &f/grave locate {grave_id}&7." + ); + messages.add( + "graveyard.created_remote", + "&7Je graf &b{grave_id}&7 kon niet veilig geplaatst worden. Gebruik &f/grave claim {grave_id}&7." + ); + messages.add( + "graveyard.storage_failed_vanilla_fallback", + "&cJe graf kon niet veilig worden opgeslagen; de normale death drops zijn gebruikt." + ); + messages.add( + "graveyard.capture_recovery_pending", + "&eGraf {grave_id} wordt veilig hersteld na een opslagfout. Je items worden niet dubbel uitgegeven." + ); + messages.add( + "graveyard.claim_recovery_pending", + "&eDe claim van graf {grave_id} is opgeslagen en wordt veilig afgerond." + ); + 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( + "graveyard.partially_claimed", + "&eGraf {grave_id} is gedeeltelijk teruggehaald; {remaining} itemstacks blijven over." + ); + messages.add("graveyard.inventory_full", "&cJe inventory heeft onvoldoende ruimte voor graf {grave_id}."); + messages.add("graveyard.not_owner", "&cDit graf is van {player}."); + messages.add( + "graveyard.staff_use_deliver", + "&eGraf {grave_id} is van {player}. Gebruik /grave admin deliver {grave_id}." + ); + messages.add("graveyard.not_found", "&cDat graf bestaat niet of is niet meer beschikbaar."); + messages.add("graveyard.no_graves", "&7Je hebt geen actieve graven."); + messages.add("graveyard.list_header", "&8--- &bJouw graven &8---"); + messages.add("graveyard.admin_list_header", "&8--- &bGraven van {player} &8---"); + 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}" + ); + 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}" + ); + 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}."); + messages.add("graveyard.track_stopped", "&7Graftracking is gestopt."); + messages.add("graveyard.remote_claim_unavailable", "&cDit graf moet op locatie worden aangeklikt."); + messages.add("graveyard.admin_success", "&aGraveyard actie uitgevoerd voor {grave_id}."); + 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]"); + return messages; + } + + @Override + public void initialize() { + settings = GraveyardSettings.load(this); + getLifecycleManager().getDataManager().initDataProvider(getFeatureName()); + getLifecycleManager().getDataManager().registerConnection( + DATABASE_IDENTIFIER, + DatabaseType.MYSQL, + "player_data_rw" + ); + ormContext = getLifecycleManager().getDataManager().createORMContext( + DATABASE_IDENTIFIER, + GraveMetadataEntity.class, + GravePayloadEntity.class, + GraveAuditEntity.class, + GraveLeaseEntity.class + ).orElseThrow(() -> new IllegalStateException("Could not initialize the Graveyard ORM context.")); + + payloadCodec = new GravePayloadCodec( + settings.maximumEntries(), + settings.maximumItemBytes(), + settings.maximumPayloadBytes() + ); + try { + int configuredJournalBytes = getConfigHandler().get( + "storage.journal.maximum_record_bytes", + Integer.class, + 12_000_000 + ); + long encodedPayloadFloor = (settings.maximumPayloadBytes() * 4L + 2L) / 3L + 65_536L; + int maximumJournalBytes = (int) Math.min( + Integer.MAX_VALUE, + Math.max(Math.max(65_536L, configuredJournalBytes), encodedPayloadFloor) + ); + journal = new GraveOperationJournal( + getPlugin().getDataFolder().toPath().resolve("features").resolve(getFeatureName()), + maximumJournalBytes + ); + } catch (IOException exception) { + throw new IllegalStateException("Could not initialize the Graveyard operation journal.", exception); + } + receiptService = new PlayerOperationReceiptService(this); + safeLocationTracker = new LastSafeLocationTracker(this, settings); + placementService = new GravePlacementService(settings, safeLocationTracker); + repository = new GraveRepository(this, ormContext); + manager = new GraveManager( + this, + settings, + repository, + journal, + receiptService, + payloadCodec, + placementService + ); + + GraveCaptureService captureService = new GraveCaptureService( + this, + settings, + manager, + journal, + receiptService, + payloadCodec, + placementService + ); + getLifecycleManager().getListenerManager().registerListener(new GraveDeathListener(captureService)); + getLifecycleManager().getListenerManager().registerListener( + new GravePlayerLifecycleListener(this, manager, safeLocationTracker) + ); + getLifecycleManager().getCommandManager().registerBrigadierCommand(new GraveCommand(this, manager)); + getLifecycleManager().getApiManager().registerService(GraveyardService.class, manager); + + packetListener = new GraveInteractionPacketListener(this, manager); + PacketEvents.getAPI().getEventManager().registerListener(packetListener); + manager.initialize(); + } + + @Override + public void disable() { + Throwable failure = null; + if (manager != null) { + try { + manager.shutdown(); + } catch (Throwable throwable) { + failure = throwable; + } + } + if (packetListener != null) { + try { + PacketEvents.getAPI().getEventManager().unregisterListener(packetListener); + } catch (Throwable throwable) { + if (failure == null) { + failure = throwable; + } else { + failure.addSuppressed(throwable); + } + } + packetListener = null; + } + if (failure != null) { + throwUnchecked(failure); + } + } + + public GraveyardSettings getSettings() { + return settings; + } + + public GraveManager getManager() { + return manager; + } + + @SuppressWarnings("unchecked") + private static void throwUnchecked(Throwable throwable) throws E { + throw (E) throwable; + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/DeathDropMatcher.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/DeathDropMatcher.java new file mode 100644 index 00000000..2aeab691 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/DeathDropMatcher.java @@ -0,0 +1,67 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.capture; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import java.util.ArrayList; +import java.util.List; + +/** + * Deterministically associates final death-drop quantities with their original inventory slots. + */ +public final class DeathDropMatcher { + public List allocate(List sourceSlots, List requestedItems) { + List sources = sourceSlots.stream().map(MutableSource::new).toList(); + List allocations = new ArrayList<>(); + + for (ItemStack requested : requestedItems) { + if (isEmpty(requested)) { + continue; + } + int remaining = requested.getAmount(); + for (MutableSource source : sources) { + if (remaining == 0) { + break; + } + if (source.remaining <= 0 || !source.item.isSimilar(requested)) { + continue; + } + int amount = Math.min(remaining, source.remaining); + ItemStack allocated = requested.clone(); + allocated.setAmount(amount); + allocations.add(new ItemAllocation(source.slot, allocated)); + source.remaining -= amount; + remaining -= amount; + } + if (remaining > 0) { + ItemStack unassigned = requested.clone(); + unassigned.setAmount(remaining); + allocations.add(new ItemAllocation(InventorySlot.UNASSIGNED, unassigned)); + } + } + return List.copyOf(allocations); + } + + private static boolean isEmpty(ItemStack item) { + if (item == null || item.getAmount() <= 0) { + return true; + } + Material material = item.getType(); + return material == null + || material == Material.AIR + || material == Material.CAVE_AIR + || material == Material.VOID_AIR; + } + + private static final class MutableSource { + private final int slot; + private final ItemStack item; + private int remaining; + + private MutableSource(SlotStack source) { + slot = source.slot(); + item = source.item(); + remaining = item.getAmount(); + } + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/DeathInventorySnapshot.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/DeathInventorySnapshot.java new file mode 100644 index 00000000..c1db26bb --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/DeathInventorySnapshot.java @@ -0,0 +1,24 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.capture; + +import org.bukkit.Location; +import org.bukkit.entity.Player; + +import java.util.UUID; + +public record DeathInventorySnapshot( + UUID playerId, + PlayerInventoryState inventory, + int totalExperience, + Location deathLocation, + long capturedAtMillis +) { + public static DeathInventorySnapshot capture(Player player) { + return new DeathInventorySnapshot( + player.getUniqueId(), + PlayerInventoryState.capture(player), + Math.max(0, player.calculateTotalExperiencePoints()), + player.getLocation().clone(), + System.currentTimeMillis() + ); + } +} 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 new file mode 100644 index 00000000..b77ab807 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/GraveCaptureService.java @@ -0,0 +1,364 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.capture; + +import nl.hauntedmc.serverfeatures.api.graveyard.GraveStatus; +import nl.hauntedmc.serverfeatures.features.graveyard.Graveyard; +import nl.hauntedmc.serverfeatures.features.graveyard.config.GraveyardMode; +import nl.hauntedmc.serverfeatures.features.graveyard.config.GraveyardSettings; +import nl.hauntedmc.serverfeatures.features.graveyard.journal.CaptureJournalRecord; +import nl.hauntedmc.serverfeatures.features.graveyard.journal.CaptureJournalState; +import nl.hauntedmc.serverfeatures.features.graveyard.journal.GraveOperationJournal; +import nl.hauntedmc.serverfeatures.features.graveyard.journal.PlayerOperationReceiptService; +import nl.hauntedmc.serverfeatures.features.graveyard.model.Grave; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GraveItemEntry; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GraveIdentifier; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GraveLocation; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GravePayload; +import nl.hauntedmc.serverfeatures.features.graveyard.persistence.EncodedGravePayload; +import nl.hauntedmc.serverfeatures.features.graveyard.persistence.GravePayloadCodec; +import nl.hauntedmc.serverfeatures.features.graveyard.placement.GravePlacementResult; +import nl.hauntedmc.serverfeatures.features.graveyard.placement.GravePlacementService; +import nl.hauntedmc.serverfeatures.features.graveyard.runtime.GraveManager; +import nl.hauntedmc.serverfeatures.features.vanish.internal.VanishAPI; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.inventory.ItemStack; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.logging.Level; + +/** + * Synchronous death-event ownership transfer. The event is not mutated until a complete PREPARED + * record has been fsynced to the local operation journal. + */ +public final class GraveCaptureService { + private static final String KEEP_INVENTORY_PERMISSION = + "serverfeatures.feature.graveyard.keepinventory"; + + private final Graveyard feature; + private final GraveyardSettings settings; + private final GraveManager manager; + private final GraveOperationJournal journal; + private final PlayerOperationReceiptService receipts; + private final GravePayloadCodec payloadCodec; + private final GravePlacementService placementService; + private final DeathDropMatcher dropMatcher = new DeathDropMatcher(); + private final PostDeathInventoryBuilder postDeathInventoryBuilder = new PostDeathInventoryBuilder(); + + public GraveCaptureService( + Graveyard feature, + GraveyardSettings settings, + GraveManager manager, + GraveOperationJournal journal, + PlayerOperationReceiptService receipts, + GravePayloadCodec payloadCodec, + GravePlacementService placementService + ) { + this.feature = feature; + this.settings = settings; + this.manager = manager; + this.journal = journal; + this.receipts = receipts; + this.payloadCodec = payloadCodec; + this.placementService = placementService; + } + + public void handleDeath(DeathInventorySnapshot snapshot, PlayerDeathEvent event) { + Player player = event.getPlayer(); + if (!eligible(player, event)) { + return; + } + + try { + PreparedCapture prepared = prepare(snapshot, event); + if (prepared == null) { + return; + } + if (settings.mode() == GraveyardMode.OBSERVE) { + feature.getLogger().info( + "Graveyard observe: validated death capture for " + player.getName() + + " with " + prepared.payload().entries().size() + " entries." + ); + return; + } + commit(snapshot, event, prepared); + } catch (IOException exception) { + feature.getLogger().log( + Level.SEVERE, + "Graveyard capture preparation failed for " + player.getName() + + "; vanilla death handling was preserved.", + exception + ); + player.sendMessage(feature.getLocalizationHandler() + .getMessage("graveyard.storage_failed_vanilla_fallback") + .forAudience(player) + .build()); + } catch (RuntimeException exception) { + feature.getLogger().log( + Level.SEVERE, + "Unexpected Graveyard capture failure for " + player.getName() + + "; vanilla death handling was preserved where possible.", + exception + ); + } + } + + private PreparedCapture prepare( + DeathInventorySnapshot snapshot, + PlayerDeathEvent event + ) throws IOException { + Player player = event.getPlayer(); + List allocations = dropMatcher.allocate( + snapshot.inventory().occupiedSlots(), + cloneItems(event.getDrops()) + ); + List entries = new ArrayList<>(allocations.size()); + for (ItemAllocation allocation : allocations) { + entries.add(payloadCodec.createEntry( + UUID.randomUUID(), + allocation.preferredSlot(), + allocation.item() + )); + } + int experience = Math.max( + 0, + (int) Math.floor(event.getDroppedExp() * (settings.experiencePercentage() / 100.0)) + ); + if (entries.isEmpty() && experience == 0) { + return null; + } + + UUID graveId = UUID.randomUUID(); + UUID operationToken = UUID.randomUUID(); + GravePlacementResult placement = placementService.place(player, snapshot.deathLocation()); + long createdWallMillis = System.currentTimeMillis(); + String shortId = GraveIdentifier.create( + player.getName(), + snapshot.deathLocation().getWorld().getName(), + createdWallMillis + ); + long activeNow = feature.getPlugin().getServerActiveClock().nowMillis(); + GravePayload payload = new GravePayload(0L, entries, experience); + EncodedGravePayload encoded = payloadCodec.encode(payload); + GraveLocation deathLocation = GraveLocation.from(snapshot.deathLocation()); + boolean vanished = feature.getLifecycleManager().getApiManager() + .findService(VanishAPI.class) + .map(api -> api.isVanished(player.getUniqueId())) + .orElse(false); + Grave grave = new Grave( + graveId, + shortId, + player.getUniqueId(), + player.getName(), + settings.serverId(), + settings.inventoryScope(), + deathLocation, + placement.location(), + placement.type(), + GraveStatus.ACTIVE, + createdWallMillis, + activeNow, + Math.addExact(activeNow, settings.lifetimeMillis()), + null, + entries.size(), + experience, + payload.revision(), + encoded.checksum(), + event.getDamageSource().getDamageType().getKey().asString(), + vanished + ); + CaptureJournalRecord record = new CaptureJournalRecord( + operationToken, + CaptureJournalState.PREPARED, + grave, + encoded + ); + return new PreparedCapture(record, payload); + } + + private void commit( + DeathInventorySnapshot snapshot, + PlayerDeathEvent event, + PreparedCapture prepared + ) throws IOException { + Player player = event.getPlayer(); + EventState eventState = EventState.capture(event); + PlayerInventoryState postDeath = postDeathInventoryBuilder.build(snapshot, event.getItemsToKeep()); + CaptureJournalRecord record = prepared.record(); + journal.writeCapture(record); + boolean saveAttempted = false; + try { + receipts.putCapture(player, record.operationToken(), record.grave().graveId()); + postDeath.apply(player); + player.setExperienceLevelAndProgress(Math.max(0, event.getNewTotalExp())); + event.setKeepInventory(true); + event.getDrops().clear(); + event.setKeepLevel(true); + event.setDroppedExp(0); + saveAttempted = true; + player.saveData(); + } catch (RuntimeException exception) { + if (!saveAttempted) { + rollbackBeforeSave(snapshot, event, eventState, record, player, exception); + return; + } + retainAmbiguousCapture(record, player, exception); + return; + } + + CaptureJournalRecord committed = record.withState(CaptureJournalState.COMMITTED); + try { + journal.writeCapture(committed); + } catch (IOException exception) { + retainAmbiguousCapture(record, player, exception); + return; + } + + receipts.clearCapture(player); + saveReceiptCleanup(player); + try { + manager.acceptCommittedCapture(committed, prepared.payload()); + } catch (RuntimeException exception) { + feature.getLogger().log( + Level.SEVERE, + "Committed grave " + record.grave().graveId() + + " could not be activated immediately; journal recovery will retry it.", + exception + ); + } + player.sendMessage(feature.getLocalizationHandler() + .getMessage(record.grave().placementType().name().equals("REMOTE_ONLY") + ? "graveyard.created_remote" + : "graveyard.created") + .with("grave_id", record.grave().shortId()) + .with("x", Integer.toString((int) Math.floor(record.grave().location().x()))) + .with("y", Integer.toString((int) Math.floor(record.grave().location().y()))) + .with("z", Integer.toString((int) Math.floor(record.grave().location().z()))) + .forAudience(player) + .build()); + } + + private void retainAmbiguousCapture( + CaptureJournalRecord record, + Player player, + Throwable failure + ) { + manager.acceptAmbiguousCapture(record); + feature.getLogger().log( + Level.SEVERE, + "Graveyard capture reached the playerdata-save boundary for grave " + + record.grave().graveId() + "; the persisted receipt and PREPARED journal " + + "will resolve the operation without rolling it back.", + failure + ); + player.sendMessage(feature.getLocalizationHandler() + .getMessage("graveyard.capture_recovery_pending") + .with("grave_id", record.grave().shortId()) + .forAudience(player) + .build()); + } + + private void rollbackBeforeSave( + DeathInventorySnapshot snapshot, + PlayerDeathEvent event, + EventState eventState, + CaptureJournalRecord record, + Player player, + Throwable failure + ) { + try { + snapshot.inventory().apply(player); + player.setExperienceLevelAndProgress(snapshot.totalExperience()); + receipts.clearCapture(player); + eventState.restore(event); + } catch (RuntimeException rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + try { + journal.writeCapture(record.withState(CaptureJournalState.ABORTED)); + } catch (IOException journalFailure) { + failure.addSuppressed(journalFailure); + } + feature.getLogger().log( + Level.SEVERE, + "Graveyard capture rolled back before playerdata save for " + player.getName(), + failure + ); + } + + private boolean eligible(Player player, PlayerDeathEvent event) { + if (event.getKeepInventory()) { + return false; + } + String worldName = player.getWorld().getName().toLowerCase(Locale.ROOT); + String worldKey = player.getWorld().getKey().asString().toLowerCase(Locale.ROOT); + if (settings.disabledWorlds().contains(worldName) + || settings.disabledWorlds().contains(worldKey) + || settings.disabledGameModes().contains(player.getGameMode())) { + return false; + } + if (settings.mode() == GraveyardMode.OBSERVE) { + return true; + } + if (player.hasPermission(KEEP_INVENTORY_PERMISSION)) { + event.setKeepInventory(true); + event.getDrops().clear(); + event.setKeepLevel(true); + event.setDroppedExp(0); + player.sendMessage(feature.getLocalizationHandler() + .getMessage("graveyard.keep_inventory") + .forAudience(player) + .build()); + return false; + } + return manager.canMutate(); + } + + private void saveReceiptCleanup(Player player) { + try { + player.saveData(); + } catch (RuntimeException exception) { + feature.getLogger().warning( + "Could not immediately persist Graveyard capture receipt cleanup for " + player.getName() + ); + } + } + + private static List cloneItems(List items) { + return items.stream() + .filter(item -> item != null && !item.getType().isAir() && item.getAmount() > 0) + .map(ItemStack::clone) + .toList(); + } + + + private record PreparedCapture(CaptureJournalRecord record, GravePayload payload) { + } + + private record EventState( + boolean keepInventory, + boolean keepLevel, + int droppedExperience, + List drops + ) { + static EventState capture(PlayerDeathEvent event) { + return new EventState( + event.getKeepInventory(), + event.getKeepLevel(), + event.getDroppedExp(), + cloneItems(event.getDrops()) + ); + } + + void restore(PlayerDeathEvent event) { + event.setKeepInventory(keepInventory); + event.setKeepLevel(keepLevel); + event.setDroppedExp(droppedExperience); + event.getDrops().clear(); + event.getDrops().addAll(cloneItems(drops)); + } + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/GraveDeathListener.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/GraveDeathListener.java new file mode 100644 index 00000000..1c006c4c --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/GraveDeathListener.java @@ -0,0 +1,38 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.capture; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class GraveDeathListener implements Listener { + private final GraveCaptureService captureService; + private final Map snapshots = new ConcurrentHashMap<>(); + + public GraveDeathListener(GraveCaptureService captureService) { + this.captureService = captureService; + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onDeathSnapshot(PlayerDeathEvent event) { + snapshots.put(event.getPlayer().getUniqueId(), DeathInventorySnapshot.capture(event.getPlayer())); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onDeathFinalize(PlayerDeathEvent event) { + DeathInventorySnapshot snapshot = snapshots.remove(event.getPlayer().getUniqueId()); + if (snapshot != null) { + captureService.handleDeath(snapshot, event); + } + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + snapshots.remove(event.getPlayer().getUniqueId()); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/InventorySlot.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/InventorySlot.java new file mode 100644 index 00000000..49b9e097 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/InventorySlot.java @@ -0,0 +1,17 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.capture; + +public final class InventorySlot { + public static final int BOOTS = 36; + public static final int LEGGINGS = 37; + public static final int CHESTPLATE = 38; + public static final int HELMET = 39; + public static final int OFF_HAND = 40; + public static final int UNASSIGNED = -1; + + private InventorySlot() { + } + + public static boolean isStorage(int slot) { + return slot >= 0 && slot < 36; + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/ItemAllocation.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/ItemAllocation.java new file mode 100644 index 00000000..496fb20d --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/ItemAllocation.java @@ -0,0 +1,17 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.capture; + +import org.bukkit.inventory.ItemStack; + +import java.util.Objects; + +public record ItemAllocation(int preferredSlot, ItemStack item) { + public ItemAllocation { + Objects.requireNonNull(item, "item"); + item = item.clone(); + } + + @Override + public ItemStack item() { + return item.clone(); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/PlayerInventoryState.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/PlayerInventoryState.java new file mode 100644 index 00000000..788bc6e0 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/PlayerInventoryState.java @@ -0,0 +1,147 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.capture; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; + +import java.util.Arrays; + +public final class PlayerInventoryState { + private final ItemStack[] storage; + private ItemStack boots; + private ItemStack leggings; + private ItemStack chestplate; + private ItemStack helmet; + private ItemStack offHand; + + public PlayerInventoryState() { + storage = new ItemStack[36]; + } + + private PlayerInventoryState( + ItemStack[] storage, + ItemStack boots, + ItemStack leggings, + ItemStack chestplate, + ItemStack helmet, + ItemStack offHand + ) { + this.storage = cloneArray(storage); + this.boots = cloneOrNull(boots); + this.leggings = cloneOrNull(leggings); + this.chestplate = cloneOrNull(chestplate); + this.helmet = cloneOrNull(helmet); + this.offHand = cloneOrNull(offHand); + } + + public static PlayerInventoryState capture(Player player) { + PlayerInventory inventory = player.getInventory(); + return new PlayerInventoryState( + inventory.getStorageContents(), + inventory.getBoots(), + inventory.getLeggings(), + inventory.getChestplate(), + inventory.getHelmet(), + inventory.getItemInOffHand() + ); + } + + public ItemStack get(int slot) { + if (InventorySlot.isStorage(slot)) { + return cloneOrNull(storage[slot]); + } + return cloneOrNull(switch (slot) { + case InventorySlot.BOOTS -> boots; + case InventorySlot.LEGGINGS -> leggings; + case InventorySlot.CHESTPLATE -> chestplate; + case InventorySlot.HELMET -> helmet; + case InventorySlot.OFF_HAND -> offHand; + default -> null; + }); + } + + public void set(int slot, ItemStack item) { + if (InventorySlot.isStorage(slot)) { + storage[slot] = cloneOrNull(item); + return; + } + switch (slot) { + case InventorySlot.BOOTS -> boots = cloneOrNull(item); + case InventorySlot.LEGGINGS -> leggings = cloneOrNull(item); + case InventorySlot.CHESTPLATE -> chestplate = cloneOrNull(item); + case InventorySlot.HELMET -> helmet = cloneOrNull(item); + case InventorySlot.OFF_HAND -> offHand = cloneOrNull(item); + default -> throw new IllegalArgumentException("Unknown player inventory slot " + slot); + } + } + + public int firstEmptyStorageSlot() { + for (int slot = 0; slot < storage.length; slot++) { + if (isEmpty(storage[slot])) { + return slot; + } + } + return -1; + } + + public ItemStack[] storageContents() { + return cloneArray(storage); + } + + public void apply(Player player) { + PlayerInventory inventory = player.getInventory(); + inventory.setStorageContents(storageContents()); + inventory.setBoots(cloneOrNull(boots)); + inventory.setLeggings(cloneOrNull(leggings)); + inventory.setChestplate(cloneOrNull(chestplate)); + inventory.setHelmet(cloneOrNull(helmet)); + inventory.setItemInOffHand(offHand == null ? ItemStack.empty() : offHand.clone()); + } + + public java.util.List occupiedSlots() { + java.util.List slots = new java.util.ArrayList<>(); + for (int slot = 0; slot < storage.length; slot++) { + add(slots, slot, storage[slot]); + } + add(slots, InventorySlot.BOOTS, boots); + add(slots, InventorySlot.LEGGINGS, leggings); + add(slots, InventorySlot.CHESTPLATE, chestplate); + add(slots, InventorySlot.HELMET, helmet); + add(slots, InventorySlot.OFF_HAND, offHand); + return java.util.List.copyOf(slots); + } + + public PlayerInventoryState copy() { + return new PlayerInventoryState(storage, boots, leggings, chestplate, helmet, offHand); + } + + private static void add(java.util.List target, int slot, ItemStack item) { + if (!isEmpty(item)) { + target.add(new SlotStack(slot, item)); + } + } + + private static ItemStack[] cloneArray(ItemStack[] source) { + ItemStack[] copy = Arrays.copyOf(source, 36); + for (int index = 0; index < copy.length; index++) { + copy[index] = cloneOrNull(copy[index]); + } + return copy; + } + + private static ItemStack cloneOrNull(ItemStack item) { + return isEmpty(item) ? null : item.clone(); + } + + private static boolean isEmpty(ItemStack item) { + if (item == null || item.getAmount() <= 0) { + return true; + } + Material material = item.getType(); + return material == null + || material == Material.AIR + || material == Material.CAVE_AIR + || material == Material.VOID_AIR; + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/PostDeathInventoryBuilder.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/PostDeathInventoryBuilder.java new file mode 100644 index 00000000..1f82e30f --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/PostDeathInventoryBuilder.java @@ -0,0 +1,96 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.capture; + +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; + +import java.util.List; + +/** + * Reconstructs the exact retained post-death inventory without overwriting slots or silently losing + * plugin-added retained items. If the final retained set cannot be represented safely, capture is + * abandoned and Bukkit keeps control of the original death event. + */ +public final class PostDeathInventoryBuilder { + private final DeathDropMatcher matcher = new DeathDropMatcher(); + + public PlayerInventoryState build(DeathInventorySnapshot snapshot, List itemsToKeep) { + PlayerInventoryState result = new PlayerInventoryState(); + List allocations = matcher.allocate(snapshot.inventory().occupiedSlots(), itemsToKeep); + for (ItemAllocation allocation : allocations) { + ItemStack remainder = allocation.item().clone(); + placePreferred(result, allocation.preferredSlot(), remainder); + mergeExistingStorage(result, remainder); + fillEmptyStorage(result, remainder); + if (remainder.getAmount() > 0) { + throw new IllegalStateException("Retained death items do not fit in the player inventory"); + } + } + return result; + } + + private static void placePreferred(PlayerInventoryState state, int slot, ItemStack remainder) { + if (remainder.getAmount() <= 0 || !isLegalPreferredSlot(slot, remainder)) { + return; + } + ItemStack current = state.get(slot); + if (current == null) { + int moved = Math.min(remainder.getAmount(), remainder.getMaxStackSize()); + ItemStack inserted = remainder.clone(); + inserted.setAmount(moved); + state.set(slot, inserted); + remainder.setAmount(remainder.getAmount() - moved); + return; + } + merge(current, remainder); + state.set(slot, current); + } + + private static void mergeExistingStorage(PlayerInventoryState state, ItemStack remainder) { + for (int slot = 0; slot < 36 && remainder.getAmount() > 0; slot++) { + ItemStack current = state.get(slot); + if (current == null || !current.isSimilar(remainder)) { + continue; + } + merge(current, remainder); + state.set(slot, current); + } + } + + private static void fillEmptyStorage(PlayerInventoryState state, ItemStack remainder) { + while (remainder.getAmount() > 0) { + int empty = state.firstEmptyStorageSlot(); + if (empty < 0) { + return; + } + int moved = Math.min(remainder.getAmount(), remainder.getMaxStackSize()); + ItemStack inserted = remainder.clone(); + inserted.setAmount(moved); + state.set(empty, inserted); + remainder.setAmount(remainder.getAmount() - moved); + } + } + + private static void merge(ItemStack target, ItemStack remainder) { + if (!target.isSimilar(remainder)) { + return; + } + int capacity = Math.max(0, target.getMaxStackSize() - target.getAmount()); + int moved = Math.min(capacity, remainder.getAmount()); + target.setAmount(target.getAmount() + moved); + remainder.setAmount(remainder.getAmount() - moved); + } + + private static boolean isLegalPreferredSlot(int slot, ItemStack item) { + if (InventorySlot.isStorage(slot) || slot == InventorySlot.OFF_HAND) { + return true; + } + EquipmentSlot required = switch (slot) { + case InventorySlot.BOOTS -> EquipmentSlot.FEET; + case InventorySlot.LEGGINGS -> EquipmentSlot.LEGS; + case InventorySlot.CHESTPLATE -> EquipmentSlot.CHEST; + case InventorySlot.HELMET -> EquipmentSlot.HEAD; + default -> null; + }; + return required != null && item.getType().getEquipmentSlot() == required; + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/SlotStack.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/SlotStack.java new file mode 100644 index 00000000..fc0dfd84 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/SlotStack.java @@ -0,0 +1,17 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.capture; + +import org.bukkit.inventory.ItemStack; + +import java.util.Objects; + +public record SlotStack(int slot, ItemStack item) { + public SlotStack { + Objects.requireNonNull(item, "item"); + item = item.clone(); + } + + @Override + public ItemStack item() { + return item.clone(); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/claim/ClaimTransferPlan.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/claim/ClaimTransferPlan.java new file mode 100644 index 00000000..60049f47 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/claim/ClaimTransferPlan.java @@ -0,0 +1,13 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.claim; + +import nl.hauntedmc.serverfeatures.features.graveyard.capture.PlayerInventoryState; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GravePayload; + +public record ClaimTransferPlan( + PlayerInventoryState resultingInventory, + GravePayload remainingPayload, + int transferredEntries, + int transferredExperience, + boolean changed +) { +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/claim/GraveClaimPlanner.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/claim/GraveClaimPlanner.java new file mode 100644 index 00000000..0114f336 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/claim/GraveClaimPlanner.java @@ -0,0 +1,141 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.claim; + +import nl.hauntedmc.serverfeatures.features.graveyard.capture.InventorySlot; +import nl.hauntedmc.serverfeatures.features.graveyard.capture.PlayerInventoryState; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GraveItemEntry; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GravePayload; +import nl.hauntedmc.serverfeatures.features.graveyard.persistence.GravePayloadCodec; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Pure deterministic inventory transfer planning. It never calls Bukkit inventory insertion methods, + * because those methods may drop overflow according to vanilla behaviour. + */ +public final class GraveClaimPlanner { + private final GravePayloadCodec codec; + private final boolean partialClaims; + + public GraveClaimPlanner(GravePayloadCodec codec, boolean partialClaims) { + this.codec = codec; + this.partialClaims = partialClaims; + } + + public ClaimTransferPlan plan(PlayerInventoryState current, GravePayload payload) throws IOException { + PlayerInventoryState result = current.copy(); + List remainingEntries = new ArrayList<>(); + int transferredEntries = 0; + + for (GraveItemEntry entry : payload.entries()) { + ItemStack original = codec.decodeItem(entry); + ItemStack remainder = original.clone(); + int before = remainder.getAmount(); + + mergeIntoSlot(result, entry.preferredSlot(), remainder); + mergeExistingStorage(result, remainder); + fillEmptyStorage(result, remainder); + + int transferred = before - remainder.getAmount(); + if (transferred > 0) { + transferredEntries++; + } + if (remainder.getAmount() > 0) { + remainingEntries.add(codec.createEntry(entry.entryId(), entry.preferredSlot(), remainder)); + } + } + + int transferredExperience = payload.remainingExperience(); + GravePayload remainingPayload = payload.next(remainingEntries, 0); + boolean changed = transferredEntries > 0 || transferredExperience > 0; + + if (!partialClaims && !remainingEntries.isEmpty()) { + return new ClaimTransferPlan( + current.copy(), + payload, + 0, + 0, + false + ); + } + return new ClaimTransferPlan( + result, + remainingPayload, + transferredEntries, + transferredExperience, + changed + ); + } + + private static void mergeIntoSlot(PlayerInventoryState state, int slot, ItemStack remainder) { + if (remainder.getAmount() <= 0 || !isLegalPreferredSlot(slot, remainder)) { + return; + } + ItemStack current = state.get(slot); + if (current == null) { + int moved = Math.min(remainder.getAmount(), remainder.getMaxStackSize()); + ItemStack inserted = remainder.clone(); + inserted.setAmount(moved); + state.set(slot, inserted); + remainder.setAmount(remainder.getAmount() - moved); + return; + } + merge(current, remainder); + state.set(slot, current); + } + + private static void mergeExistingStorage(PlayerInventoryState state, ItemStack remainder) { + if (remainder.getAmount() <= 0) { + return; + } + for (int slot = 0; slot < 36 && remainder.getAmount() > 0; slot++) { + ItemStack current = state.get(slot); + if (current == null || !current.isSimilar(remainder)) { + continue; + } + merge(current, remainder); + state.set(slot, current); + } + } + + private static void fillEmptyStorage(PlayerInventoryState state, ItemStack remainder) { + while (remainder.getAmount() > 0) { + int empty = state.firstEmptyStorageSlot(); + if (empty < 0) { + return; + } + int amount = Math.min(remainder.getAmount(), remainder.getMaxStackSize()); + ItemStack inserted = remainder.clone(); + inserted.setAmount(amount); + state.set(empty, inserted); + remainder.setAmount(remainder.getAmount() - amount); + } + } + + private static void merge(ItemStack target, ItemStack remainder) { + if (!target.isSimilar(remainder)) { + return; + } + int capacity = Math.max(0, target.getMaxStackSize() - target.getAmount()); + int moved = Math.min(capacity, remainder.getAmount()); + target.setAmount(target.getAmount() + moved); + remainder.setAmount(remainder.getAmount() - moved); + } + + private static boolean isLegalPreferredSlot(int slot, ItemStack item) { + if (InventorySlot.isStorage(slot) || slot == InventorySlot.OFF_HAND) { + return true; + } + EquipmentSlot required = switch (slot) { + case InventorySlot.BOOTS -> EquipmentSlot.FEET; + case InventorySlot.LEGGINGS -> EquipmentSlot.LEGS; + case InventorySlot.CHESTPLATE -> EquipmentSlot.CHEST; + case InventorySlot.HELMET -> EquipmentSlot.HEAD; + default -> null; + }; + return required != null && item.getType().getEquipmentSlot() == required; + } +} 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 new file mode 100644 index 00000000..a16a37f8 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/command/GraveCommand.java @@ -0,0 +1,561 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.command; + +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import com.mojang.brigadier.tree.LiteralCommandNode; +import io.papermc.paper.command.brigadier.CommandSourceStack; +import io.papermc.paper.command.brigadier.Commands; +import nl.hauntedmc.serverfeatures.api.command.brigadier.BrigadierCommand; +import nl.hauntedmc.serverfeatures.api.graveyard.ClaimReason; +import nl.hauntedmc.serverfeatures.api.graveyard.GraveClaimOutcome; +import nl.hauntedmc.serverfeatures.api.graveyard.GraveSnapshot; +import nl.hauntedmc.serverfeatures.api.graveyard.GraveStatus; +import nl.hauntedmc.serverfeatures.features.graveyard.Graveyard; +import nl.hauntedmc.serverfeatures.features.graveyard.model.Grave; +import nl.hauntedmc.serverfeatures.features.graveyard.runtime.GraveManager; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +/** + * Player and staff command tree for grave discovery, tracking and recovery. + */ +public final class GraveCommand implements BrigadierCommand { + private static final String BASE = "serverfeatures.feature.graveyard.use"; + private static final String P_LIST = "serverfeatures.feature.graveyard.command.list"; + private static final String P_INFO = "serverfeatures.feature.graveyard.command.info"; + private static final String P_LOCATE = "serverfeatures.feature.graveyard.command.locate"; + private static final String P_TRACK = "serverfeatures.feature.graveyard.command.track"; + private static final String P_REMOTE_CLAIM = "serverfeatures.feature.graveyard.command.remoteclaim"; + private static final String P_ADMIN = "serverfeatures.feature.graveyard.admin"; + private static final String P_ADMIN_LIST = P_ADMIN + ".list"; + private static final String P_INSPECT = P_ADMIN + ".inspect"; + private static final String P_TELEPORT = P_ADMIN + ".teleport"; + private static final String P_RELOCATE = P_ADMIN + ".relocate"; + private static final String P_DELIVER = P_ADMIN + ".deliver"; + private static final String P_EXPIRE = P_ADMIN + ".expire"; + private static final String P_RESTORE = P_ADMIN + ".restore"; + private static final String P_PURGE = P_ADMIN + ".purge"; + private static final String P_DIAGNOSTICS = P_ADMIN + ".diagnostics"; + + private final Graveyard feature; + private final GraveManager manager; + + public GraveCommand(Graveyard feature, GraveManager manager) { + this.feature = feature; + this.manager = manager; + } + + @Override + public @NotNull String name() { + return "grave"; + } + + @Override + public List aliases() { + return List.of("graves"); + } + + @Override + public String description() { + return "Find and recover virtual death graves."; + } + + @Override + public @NotNull LiteralCommandNode buildTree() { + LiteralArgumentBuilder root = Commands.literal(name()) + .requires(source -> hasRootAccess(source.getSender())) + .executes(context -> showOwnGraves(context.getSource().getSender(), false)); + + root.then(Commands.literal("list") + .requires(source -> source.getSender().hasPermission(P_LIST)) + .executes(context -> showOwnGraves(context.getSource().getSender(), true))); + + root.then(Commands.literal("info") + .requires(source -> source.getSender().hasPermission(P_INFO)) + .then(graveArgument(SuggestionScope.OWNER_ACTIVE).executes(context -> showInfo( + context.getSource().getSender(), + StringArgumentType.getString(context, "grave_id") + )))); + + root.then(Commands.literal("locate") + .requires(source -> source.getSender().hasPermission(P_LOCATE)) + .then(graveArgument(SuggestionScope.OWNER_ACTIVE).executes(context -> locate( + context.getSource().getSender(), + StringArgumentType.getString(context, "grave_id") + )))); + + root.then(Commands.literal("track") + .requires(source -> source.getSender().hasPermission(P_TRACK)) + .then(Commands.literal("off").executes(context -> stopTracking( + context.getSource().getSender() + ))) + .then(graveArgument(SuggestionScope.OWNER_ACTIVE).executes(context -> track( + context.getSource().getSender(), + StringArgumentType.getString(context, "grave_id") + )))); + + root.then(Commands.literal("claim") + .requires(source -> source.getSender().hasPermission(P_REMOTE_CLAIM)) + .then(graveArgument(SuggestionScope.OWNER_ACTIVE).executes(context -> claimRemote( + context.getSource().getSender(), + StringArgumentType.getString(context, "grave_id") + )))); + + root.then(adminTree()); + return root.build(); + } + + private LiteralArgumentBuilder adminTree() { + return Commands.literal("admin") + .requires(source -> hasAdminAccess(source.getSender())) + .then(Commands.literal("list") + .requires(source -> hasPermission(source.getSender(), P_ADMIN_LIST)) + .then(Commands.argument("player", StringArgumentType.word()) + .executes(context -> listPlayerGraves( + context.getSource().getSender(), + StringArgumentType.getString(context, "player") + )))) + .then(Commands.literal("diagnostics") + .requires(source -> hasPermission(source.getSender(), P_DIAGNOSTICS)) + .executes(context -> diagnostics(context.getSource().getSender()))) + .then(Commands.literal("info") + .requires(source -> hasPermission(source.getSender(), P_INSPECT)) + .then(graveArgument(SuggestionScope.ADMIN_INSPECTABLE).executes(context -> showInfo( + context.getSource().getSender(), + StringArgumentType.getString(context, "grave_id") + )))) + .then(Commands.literal("teleport") + .requires(source -> hasPermission(source.getSender(), P_TELEPORT)) + .then(graveArgument(SuggestionScope.ADMIN_ACTIVE).executes(context -> teleport( + context.getSource().getSender(), + StringArgumentType.getString(context, "grave_id") + )))) + .then(Commands.literal("relocate") + .requires(source -> hasPermission(source.getSender(), P_RELOCATE)) + .then(graveArgument(SuggestionScope.ADMIN_ACTIVE).executes(context -> relocate( + context.getSource().getSender(), + StringArgumentType.getString(context, "grave_id") + )))) + .then(Commands.literal("deliver") + .requires(source -> hasPermission(source.getSender(), P_DELIVER)) + .then(graveArgument(SuggestionScope.ADMIN_ACTIVE).executes(context -> deliver( + context.getSource().getSender(), + StringArgumentType.getString(context, "grave_id") + )))) + .then(Commands.literal("expire") + .requires(source -> hasPermission(source.getSender(), P_EXPIRE)) + .then(graveArgument(SuggestionScope.ADMIN_ACTIVE).executes(context -> transition( + context.getSource().getSender(), + StringArgumentType.getString(context, "grave_id"), + AdminAction.EXPIRE + )))) + .then(Commands.literal("restore") + .requires(source -> hasPermission(source.getSender(), P_RESTORE)) + .then(graveArgument(SuggestionScope.ADMIN_RESTORABLE).executes(context -> transition( + context.getSource().getSender(), + StringArgumentType.getString(context, "grave_id"), + AdminAction.RESTORE + )))) + .then(Commands.literal("purge") + .requires(source -> hasPermission(source.getSender(), P_PURGE)) + .then(graveArgument(SuggestionScope.ADMIN_PURGEABLE) + .then(Commands.literal("confirm").executes(context -> transition( + context.getSource().getSender(), + StringArgumentType.getString(context, "grave_id"), + AdminAction.PURGE + ))))); + } + + private com.mojang.brigadier.builder.RequiredArgumentBuilder graveArgument( + SuggestionScope scope + ) { + return Commands.argument("grave_id", StringArgumentType.word()) + .suggests((context, builder) -> suggestGraves(context, builder, scope)); + } + + private int showOwnGraves(CommandSender sender, boolean listAll) { + if (!(sender instanceof Player player)) { + send(sender, "general.player_command"); + return 1; + } + List graves = manager.findActiveByOwner(player.getUniqueId()); + if (graves.isEmpty()) { + send(sender, "graveyard.no_graves"); + return 1; + } + if (!listAll) { + showSnapshot(sender, graves.getFirst()); + return 1; + } + send(sender, "graveyard.list_header"); + for (GraveSnapshot grave : graves) { + sender.sendMessage(feature.getLocalizationHandler() + .getMessage("graveyard.list_entry") + .with("grave_id", grave.shortId()) + .with("state", grave.status().name()) + .with("world", grave.worldKey()) + .with("x", coordinate(grave.x())) + .with("y", coordinate(grave.y())) + .with("z", coordinate(grave.z())) + .with("remaining", formatDuration(grave.remainingActiveMillis())) + .forAudience(sender) + .build()); + } + return 1; + } + + private int listPlayerGraves(CommandSender sender, String playerIdentifier) { + List graves = manager.findByOwnerIdentifier(playerIdentifier); + if (graves.isEmpty()) { + send(sender, "graveyard.admin_list_empty"); + return 1; + } + sender.sendMessage(feature.getLocalizationHandler() + .getMessage("graveyard.admin_list_header") + .with("player", graves.getFirst().ownerName()) + .forAudience(sender) + .build()); + for (GraveSnapshot grave : graves) { + sender.sendMessage(feature.getLocalizationHandler() + .getMessage("graveyard.list_entry") + .with("grave_id", grave.shortId()) + .with("state", grave.status().name()) + .with("world", grave.worldKey()) + .with("x", coordinate(grave.x())) + .with("y", coordinate(grave.y())) + .with("z", coordinate(grave.z())) + .with("remaining", formatDuration(grave.remainingActiveMillis())) + .forAudience(sender) + .build()); + } + return 1; + } + + private int showInfo(CommandSender sender, String identifier) { + SuggestionScope scope = hasPermission(sender, P_INSPECT) + ? SuggestionScope.ADMIN_INSPECTABLE + : SuggestionScope.OWNER_ACTIVE; + Grave grave = resolveAccessible(sender, identifier, scope); + if (grave == null) { + return 1; + } + showSnapshot(sender, manager.find(grave.graveId()).orElseThrow()); + return 1; + } + + private void showSnapshot(CommandSender sender, GraveSnapshot grave) { + sender.sendMessage(feature.getLocalizationHandler() + .getMessage("graveyard.info") + .with("grave_id", grave.shortId()) + .with("player", grave.ownerName()) + .with("state", grave.status().name()) + .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())) + .forAudience(sender) + .build()); + } + + private int locate(CommandSender sender, String identifier) { + Grave grave = resolveAccessible(sender, identifier, SuggestionScope.OWNER_ACTIVE); + if (grave == null) { + return 1; + } + sender.sendMessage(feature.getLocalizationHandler() + .getMessage("graveyard.locate") + .with("grave_id", grave.shortId()) + .with("world", grave.location().worldKey()) + .with("x", coordinate(grave.location().x())) + .with("y", coordinate(grave.location().y())) + .with("z", coordinate(grave.location().z())) + .forAudience(sender) + .build()); + return 1; + } + + private int track(CommandSender sender, String identifier) { + if (!(sender instanceof Player player)) { + send(sender, "general.player_command"); + return 1; + } + Grave grave = resolveAccessible(sender, identifier, SuggestionScope.OWNER_ACTIVE); + if (grave == null || !manager.track(player, grave)) { + send(sender, "graveyard.not_found"); + return 1; + } + player.sendMessage(feature.getLocalizationHandler() + .getMessage("graveyard.track_started") + .with("grave_id", grave.shortId()) + .forAudience(player) + .build()); + return 1; + } + + private int stopTracking(CommandSender sender) { + if (!(sender instanceof Player player)) { + send(sender, "general.player_command"); + return 1; + } + manager.stopTracking(player); + send(sender, "graveyard.track_stopped"); + return 1; + } + + private int claimRemote(CommandSender sender, String identifier) { + if (!(sender instanceof Player player)) { + send(sender, "general.player_command"); + return 1; + } + Grave grave = resolveAccessible(sender, identifier, SuggestionScope.OWNER_ACTIVE); + if (grave == null) { + return 1; + } + manager.requestClaim(grave.graveId(), player.getUniqueId(), ClaimReason.REMOTE_UNREACHABLE) + .thenAccept(result -> { + if (result.outcome() == GraveClaimOutcome.NOT_CLAIMABLE) { + player.sendMessage(feature.getLocalizationHandler() + .getMessage("graveyard.remote_claim_unavailable") + .forAudience(player) + .build()); + } + }); + return 1; + } + + private int teleport(CommandSender sender, String identifier) { + if (!(sender instanceof Player player)) { + send(sender, "general.player_command"); + return 1; + } + Grave grave = resolveAccessible(sender, identifier, SuggestionScope.ADMIN_ACTIVE); + if (grave == null) { + return 1; + } + grave.location().resolve().ifPresentOrElse( + location -> player.teleportAsync(location.clone().add(0.0, 1.0, 0.0)), + () -> send(player, "graveyard.not_found") + ); + return 1; + } + + private int relocate(CommandSender sender, String identifier) { + if (!(sender instanceof Player player)) { + send(sender, "general.player_command"); + return 1; + } + Grave grave = resolveAccessible(sender, identifier, SuggestionScope.ADMIN_ACTIVE); + if (grave == null) { + return 1; + } + manager.relocate(player, grave, player.getLocation()).thenAccept(success -> + sendAdminResult(player, grave.shortId(), success) + ); + return 1; + } + + private int deliver(CommandSender sender, String identifier) { + if (!(sender instanceof Player player)) { + send(sender, "general.player_command"); + return 1; + } + Grave grave = resolveAccessible(sender, identifier, SuggestionScope.ADMIN_ACTIVE); + if (grave == null) { + return 1; + } + manager.deliver(player, grave).thenAccept(result -> sendAdminResult( + player, + grave.shortId(), + result.outcome() == GraveClaimOutcome.CLAIMED + || result.outcome() == GraveClaimOutcome.PARTIAL + || result.outcome() == GraveClaimOutcome.DELIVERY_QUEUED + || result.outcome() == GraveClaimOutcome.RECOVERY_PENDING + )); + return 1; + } + + private int transition(CommandSender sender, String identifier, AdminAction action) { + SuggestionScope scope = switch (action) { + case EXPIRE -> SuggestionScope.ADMIN_ACTIVE; + case RESTORE -> SuggestionScope.ADMIN_RESTORABLE; + case PURGE -> SuggestionScope.ADMIN_PURGEABLE; + }; + Grave grave = resolveAccessible(sender, identifier, scope); + if (grave == null) { + return 1; + } + Player actor = sender instanceof Player player ? player : null; + CompletableFuture completion = switch (action) { + case EXPIRE -> manager.expire(actor, grave).toCompletableFuture(); + case RESTORE -> manager.restore(actor, grave).toCompletableFuture(); + case PURGE -> manager.purge(actor, grave).toCompletableFuture(); + }; + completion.thenAccept(success -> sendAdminResult(sender, grave.shortId(), success)); + return 1; + } + + private int diagnostics(CommandSender sender) { + sender.sendMessage(feature.getLocalizationHandler() + .getMessage("graveyard.admin_diagnostics") + .with("details", manager.diagnostics()) + .forAudience(sender) + .build()); + return 1; + } + + private Grave resolveAccessible( + CommandSender sender, + String identifier, + SuggestionScope scope + ) { + Grave grave = manager.findRuntime(identifier) + .filter(candidate -> scope.accepts(candidate.status())) + .orElse(null); + if (grave == null) { + send(sender, "graveyard.not_found"); + return null; + } + if (scope == SuggestionScope.OWNER_ACTIVE + && (!(sender instanceof Player player) + || !grave.ownerUuid().equals(player.getUniqueId()))) { + send(sender, "graveyard.not_found"); + return null; + } + return grave; + } + + private CompletableFuture suggestGraves( + CommandContext context, + SuggestionsBuilder builder, + SuggestionScope scope + ) { + CommandSender sender = context.getSource().getSender(); + String prefix = builder.getRemaining().toUpperCase(Locale.ROOT); + List candidates = switch (scope) { + case OWNER_ACTIVE -> sender instanceof Player player + ? manager.findActiveByOwner(player.getUniqueId()) + : List.of(); + case ADMIN_INSPECTABLE -> hasAdminAccess(sender) + ? manager.allRuntimeGraves() + : List.of(); + case ADMIN_ACTIVE -> hasAdminAccess(sender) + ? manager.allActiveRuntimeGraves() + : List.of(); + case ADMIN_RESTORABLE -> hasAdminAccess(sender) + ? manager.allRestorableRuntimeGraves() + : List.of(); + case ADMIN_PURGEABLE -> hasAdminAccess(sender) + ? manager.allPurgeableRuntimeGraves() + : List.of(); + }; + for (GraveSnapshot grave : candidates) { + if (grave.shortId().toUpperCase(Locale.ROOT).startsWith(prefix)) { + builder.suggest(grave.shortId()); + } + } + return builder.buildFuture(); + } + + private static boolean hasRootAccess(CommandSender sender) { + return sender.hasPermission(BASE) || hasAdminAccess(sender); + } + + private static boolean hasPermission(CommandSender sender, String permission) { + return sender.hasPermission(P_ADMIN) || sender.hasPermission(permission); + } + + private static boolean hasAdminAccess(CommandSender sender) { + return sender.hasPermission(P_ADMIN) + || sender.hasPermission(P_ADMIN_LIST) + || sender.hasPermission(P_INSPECT) + || sender.hasPermission(P_TELEPORT) + || sender.hasPermission(P_RELOCATE) + || sender.hasPermission(P_DELIVER) + || sender.hasPermission(P_EXPIRE) + || sender.hasPermission(P_RESTORE) + || sender.hasPermission(P_PURGE) + || sender.hasPermission(P_DIAGNOSTICS); + } + + private void sendAdminResult(CommandSender sender, String graveId, boolean success) { + sender.sendMessage(feature.getLocalizationHandler() + .getMessage(success ? "graveyard.admin_success" : "graveyard.admin_failed") + .with("grave_id", graveId) + .forAudience(sender) + .build()); + } + + private void send(CommandSender sender, String key) { + sender.sendMessage(feature.getLocalizationHandler() + .getMessage(key) + .forAudience(sender) + .build()); + } + + 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 + boolean accepts(GraveStatus status) { + return GraveManager.isOwnerListable(status); + } + }, + ADMIN_INSPECTABLE { + @Override + boolean accepts(GraveStatus status) { + return status != GraveStatus.PURGED; + } + }, + ADMIN_ACTIVE { + @Override + boolean accepts(GraveStatus status) { + return GraveManager.isOwnerListable(status); + } + }, + ADMIN_RESTORABLE { + @Override + boolean accepts(GraveStatus status) { + return GraveManager.isRestorable(status); + } + }, + ADMIN_PURGEABLE { + @Override + boolean accepts(GraveStatus status) { + return GraveManager.isPurgeable(status); + } + }; + + abstract boolean accepts(GraveStatus status); + } + + private enum AdminAction { + EXPIRE, + RESTORE, + PURGE + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/config/DurationValueParser.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/config/DurationValueParser.java new file mode 100644 index 00000000..d429a5d6 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/config/DurationValueParser.java @@ -0,0 +1,44 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.config; + +import java.util.Locale; + +final class DurationValueParser { + private DurationValueParser() { + } + + static long parseMillis(Object value, long fallback) { + if (value instanceof Number number) { + return positiveOrFallback(number.longValue(), fallback); + } + if (!(value instanceof String text)) { + return fallback; + } + String normalized = text.trim().toLowerCase(Locale.ROOT); + if (normalized.isEmpty()) { + return fallback; + } + try { + if (normalized.endsWith("ms")) { + return positiveOrFallback(Long.parseLong(normalized.substring(0, normalized.length() - 2)), fallback); + } + char suffix = normalized.charAt(normalized.length() - 1); + long multiplier = switch (suffix) { + case 's' -> 1_000L; + case 'm' -> 60_000L; + case 'h' -> 3_600_000L; + case 'd' -> 86_400_000L; + default -> 1L; + }; + String number = Character.isLetter(suffix) + ? normalized.substring(0, normalized.length() - 1) + : normalized; + return positiveOrFallback(Math.multiplyExact(Long.parseLong(number), multiplier), fallback); + } catch (ArithmeticException | NumberFormatException exception) { + return fallback; + } + } + + private static long positiveOrFallback(long value, long fallback) { + return value > 0L ? value : fallback; + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/config/GraveyardMode.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/config/GraveyardMode.java new file mode 100644 index 00000000..85803ac3 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/config/GraveyardMode.java @@ -0,0 +1,6 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.config; + +public enum GraveyardMode { + OBSERVE, + ACTIVE +} 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 new file mode 100644 index 00000000..a0b7d5d0 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/config/GraveyardSettings.java @@ -0,0 +1,278 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.config; + +import nl.hauntedmc.serverfeatures.features.graveyard.Graveyard; +import org.bukkit.GameMode; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; +import org.bukkit.Particle; +import org.bukkit.Sound; + +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public final class GraveyardSettings { + private static final long DEFAULT_LIFETIME_MILLIS = 1_800_000L; + private static final long DEFAULT_LEASE_MILLIS = 20_000L; + private static final long DEFAULT_LEASE_HEARTBEAT_MILLIS = 5_000L; + private static final long DEFAULT_EXPIRED_RETENTION_MILLIS = 7L * 24L * 60L * 60L * 1_000L; + private static final long DEFAULT_CLAIMED_RETENTION_MILLIS = 24L * 60L * 60L * 1_000L; + private static final long DEFAULT_PURGE_INTERVAL_MILLIS = 10L * 60L * 1_000L; + + private final GraveyardMode mode; + private final String serverId; + private final String inventoryScope; + private final long lifetimeMillis; + private final long leaseDurationMillis; + private final long leaseHeartbeatMillis; + private final long expiredRetentionMillis; + private final long claimedRetentionMillis; + private final long purgeIntervalMillis; + private final int purgeBatchSize; + private final int experiencePercentage; + private final int horizontalSearchRadius; + private final int verticalSearchBelow; + private final int verticalSearchAbove; + private final long lastSafeMaxAgeMillis; + private final double spawnDistance; + private final double despawnDistance; + private final int maxRenderedPerViewer; + private final long reconciliationTicks; + private final long spawnSettleTicks; + private final double interactionDistance; + private final boolean requireLineOfSight; + private final boolean partialClaims; + private final int maximumEntries; + private final int maximumItemBytes; + private final int maximumPayloadBytes; + private final Set disabledWorlds; + private final Set disabledGameModes; + private final Material baseMaterial; + private final Material headstoneMaterial; + private final Particle claimParticle; + private final Particle expiryParticle; + private final Sound claimSound; + private final Sound expirySound; + private final int ownerGlowRgb; + private final int otherGlowRgb; + private final int staffGlowRgb; + + private GraveyardSettings(Graveyard feature) { + mode = enumSetting(feature, "mode", GraveyardMode.class, GraveyardMode.ACTIVE); + String globalServerId = normalize( + feature.getConfigHandler().getGlobalSetting("server_name", String.class, "server") + ); + serverId = normalizeOrFallback( + feature.getConfigHandler().get("identity.server_id", String.class, ""), + globalServerId + ); + inventoryScope = normalizeOrFallback( + feature.getConfigHandler().get("identity.inventory_scope", String.class, ""), + serverId + ); + lifetimeMillis = duration(feature, "lifetime.duration", DEFAULT_LIFETIME_MILLIS); + leaseDurationMillis = Math.max(5_000L, duration( + feature, + "identity.lease_timeout", + DEFAULT_LEASE_MILLIS + )); + leaseHeartbeatMillis = Math.max(1_000L, Math.min( + Math.max(1_000L, leaseDurationMillis / 2L), + duration(feature, "identity.lease_heartbeat", DEFAULT_LEASE_HEARTBEAT_MILLIS) + )); + expiredRetentionMillis = duration( + feature, + "storage.retention.expired", + DEFAULT_EXPIRED_RETENTION_MILLIS + ); + claimedRetentionMillis = duration( + feature, + "storage.retention.claimed", + DEFAULT_CLAIMED_RETENTION_MILLIS + ); + purgeIntervalMillis = Math.max(60_000L, duration( + feature, + "storage.retention.purge_interval", + DEFAULT_PURGE_INTERVAL_MILLIS + )); + purgeBatchSize = clamp( + feature.getConfigHandler().get("storage.retention.purge_batch_size", Integer.class, 100), + 1, + 1_000 + ); + 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); + lastSafeMaxAgeMillis = duration(feature, "placement.last_safe_location_max_age", 30_000L); + spawnDistance = positive(feature.getConfigHandler().get("render.spawn_distance", Double.class, 48.0), 48.0); + despawnDistance = Math.max(spawnDistance, positive( + feature.getConfigHandler().get("render.despawn_distance", Double.class, 56.0), + 56.0 + )); + maxRenderedPerViewer = clamp(feature.getConfigHandler().get("render.max_rendered_per_viewer", Integer.class, 64), 1, 512); + reconciliationTicks = clamp(feature.getConfigHandler().get("render.reconciliation_interval_ticks", Long.class, 20L), 1L, 200L); + spawnSettleTicks = clamp(feature.getConfigHandler().get("render.spawn_settle_delay_ticks", Long.class, 2L), 0L, 100L); + interactionDistance = positive(feature.getConfigHandler().get("interaction.maximum_distance", Double.class, 4.5), 4.5); + requireLineOfSight = feature.getConfigHandler().get("interaction.require_line_of_sight", Boolean.class, true); + partialClaims = feature.getConfigHandler().get("claim.partial_claims", Boolean.class, true); + maximumEntries = clamp(feature.getConfigHandler().get("storage.payload.maximum_entries", Integer.class, 64), 1, 256); + maximumItemBytes = clamp(feature.getConfigHandler().get("storage.payload.maximum_item_bytes", Integer.class, 2_097_152), 1_024, 8_388_608); + maximumPayloadBytes = clamp(feature.getConfigHandler().get("storage.payload.maximum_total_bytes", Integer.class, 8_388_608), maximumItemBytes, 33_554_432); + disabledWorlds = normalizeWorlds(feature.getConfigHandler().getList("eligibility.disabled_worlds", String.class, List.of())); + disabledGameModes = parseGameModes(feature.getConfigHandler().getList( + "eligibility.disabled_gamemodes", + String.class, + List.of("CREATIVE", "SPECTATOR") + )); + baseMaterial = material(feature, "render.base.material", Material.POLISHED_BLACKSTONE_BRICK_SLAB); + headstoneMaterial = material(feature, "render.headstone.material", Material.POLISHED_BLACKSTONE_BRICK_WALL); + claimParticle = particle(feature, "particles.claim.type", Particle.SCULK_SOUL); + expiryParticle = particle(feature, "particles.expiry.type", Particle.SCULK_SOUL); + claimSound = sound(feature, "sounds.claim.sound", "block.respawn_anchor.charge", Sound.BLOCK_RESPAWN_ANCHOR_CHARGE); + expirySound = sound(feature, "sounds.expiry.sound", "particle.soul_escape", Sound.PARTICLE_SOUL_ESCAPE); + ownerGlowRgb = rgb(feature.getConfigHandler().get("render.glow.owner_rgb", String.class, "55FFFF"), 0x55FFFF); + otherGlowRgb = rgb(feature.getConfigHandler().get("render.glow.other_rgb", String.class, "00AAAA"), 0x00AAAA); + staffGlowRgb = rgb(feature.getConfigHandler().get("render.glow.staff_rgb", String.class, "FFD700"), 0xFFD700); + } + + public static GraveyardSettings load(Graveyard feature) { + return new GraveyardSettings(feature); + } + + public GraveyardMode mode() { return mode; } + public String serverId() { return serverId; } + public String inventoryScope() { return inventoryScope; } + public long lifetimeMillis() { return lifetimeMillis; } + public long leaseDurationMillis() { return leaseDurationMillis; } + public long leaseHeartbeatMillis() { return leaseHeartbeatMillis; } + public long expiredRetentionMillis() { return expiredRetentionMillis; } + public long claimedRetentionMillis() { return claimedRetentionMillis; } + public long purgeIntervalMillis() { return purgeIntervalMillis; } + public int purgeBatchSize() { return purgeBatchSize; } + public int experiencePercentage() { return experiencePercentage; } + public int horizontalSearchRadius() { return horizontalSearchRadius; } + public int verticalSearchBelow() { return verticalSearchBelow; } + public int verticalSearchAbove() { return verticalSearchAbove; } + public long lastSafeMaxAgeMillis() { return lastSafeMaxAgeMillis; } + public double spawnDistance() { return spawnDistance; } + public double despawnDistance() { return despawnDistance; } + public int maxRenderedPerViewer() { return maxRenderedPerViewer; } + public long reconciliationTicks() { return reconciliationTicks; } + public long spawnSettleTicks() { return spawnSettleTicks; } + public double interactionDistance() { return interactionDistance; } + public boolean requireLineOfSight() { return requireLineOfSight; } + public boolean partialClaims() { return partialClaims; } + public int maximumEntries() { return maximumEntries; } + public int maximumItemBytes() { return maximumItemBytes; } + public int maximumPayloadBytes() { return maximumPayloadBytes; } + public Set disabledWorlds() { return disabledWorlds; } + public Set disabledGameModes() { return disabledGameModes; } + public Material baseMaterial() { return baseMaterial; } + public Material headstoneMaterial() { return headstoneMaterial; } + public Particle claimParticle() { return claimParticle; } + public Particle expiryParticle() { return expiryParticle; } + public Sound claimSound() { return claimSound; } + public Sound expirySound() { return expirySound; } + public int ownerGlowRgb() { return ownerGlowRgb; } + public int otherGlowRgb() { return otherGlowRgb; } + public int staffGlowRgb() { return staffGlowRgb; } + + private static long duration(Graveyard feature, String key, long fallback) { + return DurationValueParser.parseMillis(feature.getConfigHandler().get(key), fallback); + } + + private static Material material(Graveyard feature, String key, Material fallback) { + return enumSetting(feature, key, Material.class, fallback); + } + + private static Particle particle(Graveyard feature, String key, Particle fallback) { + return enumSetting(feature, key, Particle.class, fallback); + } + + private static Sound sound(Graveyard feature, String key, String fallbackKey, Sound fallback) { + String configured = feature.getConfigHandler().get(key, String.class, fallbackKey); + String normalized = configured.trim().toLowerCase(Locale.ROOT); + if (!normalized.contains(":")) { + normalized = "minecraft:" + normalized.replace('_', '.'); + } + NamespacedKey namespacedKey = NamespacedKey.fromString(normalized); + Sound resolved = namespacedKey == null ? null : Registry.SOUNDS.get(namespacedKey); + if (resolved == null) { + feature.getLogger().warning("Invalid Graveyard setting " + key + "=" + configured + "; using " + fallbackKey); + return fallback; + } + return resolved; + } + + private static > E enumSetting( + Graveyard feature, + String key, + Class type, + E fallback + ) { + String configured = feature.getConfigHandler().get(key, String.class, fallback.name()); + try { + return Enum.valueOf(type, configured.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + feature.getLogger().warning("Invalid Graveyard setting " + key + "=" + configured + "; using " + fallback); + return fallback; + } + } + + private static Set parseGameModes(List values) { + Set result = EnumSet.noneOf(GameMode.class); + for (String value : values) { + try { + result.add(GameMode.valueOf(value.trim().toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException ignored) { + // Invalid optional values are ignored after the typed settings object is created. + } + } + return Set.copyOf(result); + } + + private static Set normalizeWorlds(List values) { + Set result = new HashSet<>(); + for (String value : values) { + if (value != null && !value.isBlank()) { + result.add(value.trim().toLowerCase(Locale.ROOT)); + } + } + return Set.copyOf(result); + } + + private static String normalizeOrFallback(String value, String fallback) { + return value == null || value.isBlank() ? fallback : normalize(value); + } + + private static String normalize(String value) { + String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + normalized = normalized.replaceAll("[^a-z0-9_.:-]", "_").replaceAll("_+", "_"); + return normalized.isBlank() ? "server" : normalized.substring(0, Math.min(normalized.length(), 100)); + } + + private static int rgb(String value, int fallback) { + String normalized = value == null ? "" : value.trim().replace("#", ""); + try { + return Integer.parseInt(normalized, 16) & 0xFFFFFF; + } catch (NumberFormatException exception) { + return fallback; + } + } + + private static int clamp(int value, int minimum, int maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } + + private static long clamp(long value, long minimum, long maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } + + private static double positive(double value, double fallback) { + return Double.isFinite(value) && value > 0.0 ? value : fallback; + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/CaptureJournalRecord.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/CaptureJournalRecord.java new file mode 100644 index 00000000..04d66688 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/CaptureJournalRecord.java @@ -0,0 +1,25 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.journal; + +import nl.hauntedmc.serverfeatures.features.graveyard.model.Grave; +import nl.hauntedmc.serverfeatures.features.graveyard.persistence.EncodedGravePayload; + +import java.util.Objects; +import java.util.UUID; + +public record CaptureJournalRecord( + UUID operationToken, + CaptureJournalState state, + Grave grave, + EncodedGravePayload payload +) { + public CaptureJournalRecord { + Objects.requireNonNull(operationToken, "operationToken"); + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(grave, "grave"); + Objects.requireNonNull(payload, "payload"); + } + + public CaptureJournalRecord withState(CaptureJournalState next) { + return new CaptureJournalRecord(operationToken, next, grave, payload); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/CaptureJournalState.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/CaptureJournalState.java new file mode 100644 index 00000000..8a181aec --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/CaptureJournalState.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.journal; + +public enum CaptureJournalState { + PREPARED, + COMMITTED, + PROJECTED, + ABORTED +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/ClaimJournalRecord.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/ClaimJournalRecord.java new file mode 100644 index 00000000..c108db4d --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/ClaimJournalRecord.java @@ -0,0 +1,41 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.journal; + +import nl.hauntedmc.serverfeatures.features.graveyard.persistence.EncodedGravePayload; + +import java.util.Objects; +import java.util.UUID; + +public record ClaimJournalRecord( + UUID operationToken, + ClaimJournalState state, + UUID graveId, + UUID ownerUuid, + UUID actorUuid, + long previousRevision, + int transferredEntries, + int transferredExperience, + EncodedGravePayload remainingPayload +) { + public ClaimJournalRecord { + Objects.requireNonNull(operationToken, "operationToken"); + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(graveId, "graveId"); + Objects.requireNonNull(ownerUuid, "ownerUuid"); + Objects.requireNonNull(actorUuid, "actorUuid"); + Objects.requireNonNull(remainingPayload, "remainingPayload"); + } + + public ClaimJournalRecord withState(ClaimJournalState next) { + return new ClaimJournalRecord( + operationToken, + next, + graveId, + ownerUuid, + actorUuid, + previousRevision, + transferredEntries, + transferredExperience, + remainingPayload + ); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/ClaimJournalState.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/ClaimJournalState.java new file mode 100644 index 00000000..98dfc356 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/ClaimJournalState.java @@ -0,0 +1,9 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.journal; + +public enum ClaimJournalState { + PREPARED, + PLAYER_APPLIED, + GRAVE_FINALIZED, + RECOVERED, + ABORTED +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/GraveOperationJournal.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/GraveOperationJournal.java new file mode 100644 index 00000000..93042095 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/GraveOperationJournal.java @@ -0,0 +1,325 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.journal; + +import nl.hauntedmc.serverfeatures.api.graveyard.GraveStatus; +import nl.hauntedmc.serverfeatures.features.graveyard.model.Grave; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GraveLocation; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GravePlacementType; +import nl.hauntedmc.serverfeatures.features.graveyard.persistence.EncodedGravePayload; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashSet; +import java.util.List; +import java.util.Properties; +import java.util.Set; +import java.util.UUID; + +/** + * Per-operation atomic journal. Each transition rewrites one independent record, so a damaged record + * can be quarantined without making unrelated captures or claims unreadable. + */ +public final class GraveOperationJournal { + private static final String SUFFIX = ".properties"; + + private final Path captureDirectory; + private final Path claimDirectory; + private final Path corruptDirectory; + private final int maximumRecordBytes; + + public GraveOperationJournal(Path featureDirectory, int maximumRecordBytes) throws IOException { + Path root = featureDirectory.resolve("local").resolve("journal"); + captureDirectory = root.resolve("capture"); + claimDirectory = root.resolve("claim"); + corruptDirectory = root.resolve("corrupt"); + this.maximumRecordBytes = maximumRecordBytes; + Files.createDirectories(captureDirectory); + Files.createDirectories(claimDirectory); + Files.createDirectories(corruptDirectory); + } + + public synchronized void writeCapture(CaptureJournalRecord record) throws IOException { + Properties properties = common(record.operationToken(), record.state().name()); + Grave grave = record.grave(); + properties.setProperty("graveId", grave.graveId().toString()); + properties.setProperty("shortId", grave.shortId()); + properties.setProperty("ownerUuid", grave.ownerUuid().toString()); + properties.setProperty("ownerName", grave.ownerName()); + properties.setProperty("serverId", grave.serverId()); + properties.setProperty("inventoryScope", grave.inventoryScope()); + writeLocation(properties, "death", grave.deathLocation()); + writeLocation(properties, "grave", grave.location()); + properties.setProperty("placementType", grave.placementType().name()); + properties.setProperty("graveStatus", grave.status().name()); + properties.setProperty("createdWallMillis", Long.toString(grave.createdWallMillis())); + properties.setProperty("createdActiveMillis", Long.toString(grave.createdActiveMillis())); + properties.setProperty("expiresActiveMillis", Long.toString(grave.expiresActiveMillis())); + if (grave.pausedRemainingMillis() != null) { + properties.setProperty("pausedRemainingMillis", grave.pausedRemainingMillis().toString()); + } + properties.setProperty("itemEntryCount", Integer.toString(grave.itemEntryCount())); + properties.setProperty("remainingExperience", Integer.toString(grave.remainingExperience())); + properties.setProperty("payloadRevision", Long.toString(grave.payloadRevision())); + properties.setProperty("payloadChecksum", grave.payloadChecksum()); + properties.setProperty("ownerWasVanished", Boolean.toString(grave.ownerWasVanished())); + if (grave.deathCause() != null) { + properties.setProperty("deathCause", grave.deathCause()); + } + properties.setProperty("payload", Base64.getEncoder().encodeToString(record.payload().bytes())); + properties.setProperty("encodedChecksum", record.payload().checksum()); + writeAtomic(capturePath(grave.graveId()), properties); + } + + public synchronized void writeClaim(ClaimJournalRecord record) throws IOException { + Properties properties = common(record.operationToken(), record.state().name()); + properties.setProperty("graveId", record.graveId().toString()); + properties.setProperty("ownerUuid", record.ownerUuid().toString()); + properties.setProperty("actorUuid", record.actorUuid().toString()); + properties.setProperty("previousRevision", Long.toString(record.previousRevision())); + properties.setProperty("transferredEntries", Integer.toString(record.transferredEntries())); + properties.setProperty("transferredExperience", Integer.toString(record.transferredExperience())); + properties.setProperty("payload", Base64.getEncoder().encodeToString(record.remainingPayload().bytes())); + properties.setProperty("encodedChecksum", record.remainingPayload().checksum()); + writeAtomic(claimPath(record.operationToken()), properties); + } + + public synchronized List loadCaptures() throws IOException { + List records = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(captureDirectory, "*" + SUFFIX)) { + for (Path path : stream) { + try { + records.add(readCapture(path)); + } catch (RuntimeException | IOException exception) { + quarantine(path); + } + } + } + return List.copyOf(records); + } + + public synchronized List loadClaims() throws IOException { + List records = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(claimDirectory, "*" + SUFFIX)) { + for (Path path : stream) { + try { + records.add(readClaim(path)); + } catch (RuntimeException | IOException exception) { + quarantine(path); + } + } + } + return List.copyOf(records); + } + + public synchronized void deleteCapture(UUID graveId) throws IOException { + if (Files.deleteIfExists(capturePath(graveId))) { + forceDirectory(captureDirectory); + } + } + + public synchronized void deleteClaim(UUID operationToken) throws IOException { + if (Files.deleteIfExists(claimPath(operationToken))) { + forceDirectory(claimDirectory); + } + } + + /** + * Returns operation tokens whose claim journals were quarantined. These tokens must remain + * reserved in the database: clearing one without knowing whether playerdata was applied could + * make the same payload claimable again. + */ + public synchronized Set quarantinedClaimTokens() throws IOException { + Set tokens = new HashSet<>(); + try (DirectoryStream stream = Files.newDirectoryStream(corruptDirectory)) { + for (Path path : stream) { + String name = path.getFileName().toString(); + int suffix = name.indexOf(SUFFIX); + if (suffix <= 0) { + continue; + } + try { + tokens.add(UUID.fromString(name.substring(0, suffix))); + } catch (IllegalArgumentException ignored) { + // Capture journals use grave ids; preserving those as operation tokens is safe + // but unnecessary, so malformed or non-token filenames are ignored. + } + } + } + return Set.copyOf(tokens); + } + + private CaptureJournalRecord readCapture(Path path) throws IOException { + Properties properties = read(path); + try { + UUID operationToken = UUID.fromString(required(properties, "operationToken")); + CaptureJournalState state = CaptureJournalState.valueOf(required(properties, "state")); + GraveLocation death = readLocation(properties, "death"); + GraveLocation location = readLocation(properties, "grave"); + Long paused = properties.containsKey("pausedRemainingMillis") + ? Long.parseLong(properties.getProperty("pausedRemainingMillis")) + : null; + Grave grave = new Grave( + UUID.fromString(required(properties, "graveId")), + required(properties, "shortId"), + UUID.fromString(required(properties, "ownerUuid")), + required(properties, "ownerName"), + required(properties, "serverId"), + required(properties, "inventoryScope"), + death, + location, + GravePlacementType.valueOf(required(properties, "placementType")), + GraveStatus.valueOf(required(properties, "graveStatus")), + Long.parseLong(required(properties, "createdWallMillis")), + Long.parseLong(required(properties, "createdActiveMillis")), + Long.parseLong(required(properties, "expiresActiveMillis")), + paused, + Integer.parseInt(required(properties, "itemEntryCount")), + Integer.parseInt(required(properties, "remainingExperience")), + Long.parseLong(required(properties, "payloadRevision")), + required(properties, "payloadChecksum"), + properties.getProperty("deathCause"), + Boolean.parseBoolean(required(properties, "ownerWasVanished")) + ); + EncodedGravePayload payload = encodedPayload(properties); + return new CaptureJournalRecord(operationToken, state, grave, payload); + } catch (IllegalArgumentException exception) { + throw new IOException("Invalid Graveyard capture journal record: " + path, exception); + } + } + + private ClaimJournalRecord readClaim(Path path) throws IOException { + Properties properties = read(path); + try { + return new ClaimJournalRecord( + UUID.fromString(required(properties, "operationToken")), + ClaimJournalState.valueOf(required(properties, "state")), + UUID.fromString(required(properties, "graveId")), + UUID.fromString(required(properties, "ownerUuid")), + UUID.fromString(required(properties, "actorUuid")), + Long.parseLong(required(properties, "previousRevision")), + Integer.parseInt(required(properties, "transferredEntries")), + Integer.parseInt(required(properties, "transferredExperience")), + encodedPayload(properties) + ); + } catch (IllegalArgumentException exception) { + throw new IOException("Invalid Graveyard claim journal record: " + path, exception); + } + } + + private Properties read(Path path) throws IOException { + long size = Files.size(path); + if (size <= 0L || size > maximumRecordBytes) { + throw new IOException("Invalid Graveyard journal size " + size); + } + Properties properties = new Properties(); + properties.load(new ByteArrayInputStream(Files.readAllBytes(path))); + return properties; + } + + private void writeAtomic(Path target, Properties properties) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + properties.store(bytes, "ServerFeatures Graveyard operation journal"); + if (bytes.size() > maximumRecordBytes) { + throw new IOException("Graveyard journal record exceeds configured limit: " + bytes.size()); + } + Files.createDirectories(target.getParent()); + Path temporary = target.resolveSibling(target.getFileName() + ".tmp"); + try (FileChannel channel = FileChannel.open( + temporary, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE + )) { + channel.write(ByteBuffer.wrap(bytes.toByteArray())); + channel.force(true); + } + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + forceDirectory(target.getParent()); + } + + private static void forceDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } catch (UnsupportedOperationException ignored) { + // Some filesystems do not expose directory fsync through FileChannel. + } + } + + private void quarantine(Path path) throws IOException { + Path target = corruptDirectory.resolve(path.getFileName() + "." + System.currentTimeMillis()); + Files.move(path, target, StandardCopyOption.REPLACE_EXISTING); + forceDirectory(path.getParent()); + forceDirectory(corruptDirectory); + } + + private Path capturePath(UUID graveId) { + return captureDirectory.resolve(graveId + SUFFIX); + } + + private Path claimPath(UUID operationToken) { + return claimDirectory.resolve(operationToken + SUFFIX); + } + + private static Properties common(UUID operationToken, String state) { + Properties properties = new Properties(); + properties.setProperty("operationToken", operationToken.toString()); + properties.setProperty("state", state); + return properties; + } + + private static void writeLocation(Properties properties, String prefix, GraveLocation location) { + properties.setProperty(prefix + "WorldUuid", location.worldUuid().toString()); + properties.setProperty(prefix + "WorldKey", location.worldKey()); + properties.setProperty(prefix + "X", Double.toString(location.x())); + properties.setProperty(prefix + "Y", Double.toString(location.y())); + properties.setProperty(prefix + "Z", Double.toString(location.z())); + properties.setProperty(prefix + "Yaw", Float.toString(location.yaw())); + } + + private static GraveLocation readLocation(Properties properties, String prefix) { + try { + return new GraveLocation( + UUID.fromString(required(properties, prefix + "WorldUuid")), + required(properties, prefix + "WorldKey"), + Double.parseDouble(required(properties, prefix + "X")), + Double.parseDouble(required(properties, prefix + "Y")), + Double.parseDouble(required(properties, prefix + "Z")), + Float.parseFloat(required(properties, prefix + "Yaw")) + ); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException( + "Invalid numeric Graveyard journal location for prefix " + prefix, + exception + ); + } + } + + private static EncodedGravePayload encodedPayload(Properties properties) { + return new EncodedGravePayload( + Base64.getDecoder().decode(required(properties, "payload")), + required(properties, "encodedChecksum") + ); + } + + private static String required(Properties properties, String key) { + String value = properties.getProperty(key); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing Graveyard journal property " + key); + } + return value; + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/PlayerOperationReceiptService.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/PlayerOperationReceiptService.java new file mode 100644 index 00000000..f9fcb956 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/PlayerOperationReceiptService.java @@ -0,0 +1,70 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.journal; + +import nl.hauntedmc.serverfeatures.features.graveyard.Graveyard; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.persistence.PersistentDataType; + +import java.util.Optional; +import java.util.UUID; + +public final class PlayerOperationReceiptService { + private final NamespacedKey captureKey; + private final NamespacedKey claimKey; + + public PlayerOperationReceiptService(Graveyard feature) { + captureKey = new NamespacedKey(feature.getPlugin(), "graveyard_capture_receipt"); + claimKey = new NamespacedKey(feature.getPlugin(), "graveyard_claim_receipt"); + } + + public void putCapture(Player player, UUID operationToken, UUID graveId) { + player.getPersistentDataContainer().set( + captureKey, + PersistentDataType.STRING, + operationToken + ":" + graveId + ); + } + + public void putClaim(Player player, UUID operationToken, UUID graveId) { + player.getPersistentDataContainer().set( + claimKey, + PersistentDataType.STRING, + operationToken + ":" + graveId + ); + } + + public Optional capture(Player player) { + return read(player, captureKey); + } + + public Optional claim(Player player) { + return read(player, claimKey); + } + + public void clearCapture(Player player) { + player.getPersistentDataContainer().remove(captureKey); + } + + public void clearClaim(Player player) { + player.getPersistentDataContainer().remove(claimKey); + } + + private Optional read(Player player, NamespacedKey key) { + String value = player.getPersistentDataContainer().get(key, PersistentDataType.STRING); + if (value == null) { + return Optional.empty(); + } + String[] parts = value.split(":", 2); + if (parts.length != 2) { + return Optional.empty(); + } + try { + return Optional.of(new Receipt(UUID.fromString(parts[0]), UUID.fromString(parts[1]))); + } catch (IllegalArgumentException exception) { + return Optional.empty(); + } + } + + public record Receipt(UUID operationToken, UUID graveId) { + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/listener/GravePlayerLifecycleListener.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/listener/GravePlayerLifecycleListener.java new file mode 100644 index 00000000..a7de5abf --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/listener/GravePlayerLifecycleListener.java @@ -0,0 +1,76 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.listener; + +import nl.hauntedmc.serverfeatures.features.graveyard.Graveyard; +import nl.hauntedmc.serverfeatures.features.graveyard.placement.LastSafeLocationTracker; +import nl.hauntedmc.serverfeatures.features.graveyard.runtime.GraveManager; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.event.world.WorldLoadEvent; +import org.bukkit.event.world.WorldUnloadEvent; + +/** + * Bridges player/world lifecycle changes into the location-anchored grave runtime. + */ +public final class GravePlayerLifecycleListener implements Listener { + private final Graveyard feature; + private final GraveManager manager; + private final LastSafeLocationTracker safeLocations; + + public GravePlayerLifecycleListener( + Graveyard feature, + GraveManager manager, + LastSafeLocationTracker safeLocations + ) { + this.feature = feature; + this.manager = manager; + this.safeLocations = safeLocations; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + manager.onPlayerJoin(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + manager.onPlayerQuit(event.getPlayer()); + safeLocations.remove(event.getPlayer().getUniqueId()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTeleport(PlayerTeleportEvent event) { + if (event.getFrom().getWorld() != event.getTo().getWorld() + || event.getFrom().distanceSquared(event.getTo()) >= 64.0 * 64.0) { + manager.onPlayerTransition(event.getPlayer()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onChangedWorld(PlayerChangedWorldEvent event) { + manager.onPlayerTransition(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onRespawn(PlayerRespawnEvent event) { + manager.onPlayerTransition(event.getPlayer()); + manager.onPlayerRespawn(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onWorldUnload(WorldUnloadEvent event) { + manager.onWorldUnload(event.getWorld()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onWorldLoad(WorldLoadEvent event) { + feature.getLifecycleManager().getTaskManager().scheduleOneTimeTask( + () -> manager.onWorldLoad(event.getWorld()) + ); + } +} 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 new file mode 100644 index 00000000..0396f6e7 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/meta/Meta.java @@ -0,0 +1,22 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.meta; + +import nl.hauntedmc.serverfeatures.api.feature.meta.BaseMeta; + +import java.util.List; + +public final class Meta implements BaseMeta { + @Override + public String getFeatureName() { + return "Graveyard"; + } + + @Override + public String getFeatureVersion() { + return "1.0.0"; + } + + @Override + public List getPluginDependencies() { + return List.of(PACKET_EVENTS, DATA_PROVIDER, DATA_REGISTRY); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/Grave.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/Grave.java new file mode 100644 index 00000000..ae8c2b2c --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/Grave.java @@ -0,0 +1,245 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.model; + +import nl.hauntedmc.serverfeatures.api.graveyard.GraveSnapshot; +import nl.hauntedmc.serverfeatures.api.graveyard.GraveStatus; + +import java.util.Objects; +import java.util.UUID; + +public final class Grave { + private final UUID graveId; + private final String shortId; + private final UUID ownerUuid; + private final String ownerName; + private final String serverId; + private final String inventoryScope; + private final GraveLocation deathLocation; + private volatile GraveLocation location; + private volatile GravePlacementType placementType; + private volatile GraveStatus status; + private final long createdWallMillis; + private final long createdActiveMillis; + private volatile long expiresActiveMillis; + private volatile Long pausedRemainingMillis; + private volatile int itemEntryCount; + private volatile int remainingExperience; + private volatile long payloadRevision; + private volatile String payloadChecksum; + private final String deathCause; + private final boolean ownerWasVanished; + private volatile long visualGeneration; + + public Grave( + UUID graveId, + String shortId, + UUID ownerUuid, + String ownerName, + String serverId, + String inventoryScope, + GraveLocation deathLocation, + GraveLocation location, + GravePlacementType placementType, + GraveStatus status, + long createdWallMillis, + long createdActiveMillis, + long expiresActiveMillis, + Long pausedRemainingMillis, + int itemEntryCount, + int remainingExperience, + long payloadRevision, + String payloadChecksum, + String deathCause, + boolean ownerWasVanished + ) { + this.graveId = Objects.requireNonNull(graveId, "graveId"); + this.shortId = requireText(shortId, "shortId"); + this.ownerUuid = Objects.requireNonNull(ownerUuid, "ownerUuid"); + this.ownerName = requireText(ownerName, "ownerName"); + this.serverId = requireText(serverId, "serverId"); + this.inventoryScope = requireText(inventoryScope, "inventoryScope"); + this.deathLocation = Objects.requireNonNull(deathLocation, "deathLocation"); + this.location = Objects.requireNonNull(location, "location"); + this.placementType = Objects.requireNonNull(placementType, "placementType"); + this.status = Objects.requireNonNull(status, "status"); + this.createdWallMillis = createdWallMillis; + this.createdActiveMillis = createdActiveMillis; + this.expiresActiveMillis = expiresActiveMillis; + this.pausedRemainingMillis = pausedRemainingMillis; + this.itemEntryCount = itemEntryCount; + this.remainingExperience = remainingExperience; + this.payloadRevision = payloadRevision; + this.payloadChecksum = payloadChecksum; + this.deathCause = deathCause; + this.ownerWasVanished = ownerWasVanished; + } + + public UUID graveId() { + return graveId; + } + + public String shortId() { + return shortId; + } + + public UUID ownerUuid() { + return ownerUuid; + } + + public String ownerName() { + return ownerName; + } + + public String serverId() { + return serverId; + } + + public String inventoryScope() { + return inventoryScope; + } + + public GraveLocation deathLocation() { + return deathLocation; + } + + public GraveLocation location() { + return location; + } + + public GravePlacementType placementType() { + return placementType; + } + + public GraveStatus status() { + return status; + } + + public long createdWallMillis() { + return createdWallMillis; + } + + public long createdActiveMillis() { + return createdActiveMillis; + } + + public long expiresActiveMillis() { + return expiresActiveMillis; + } + + public Long pausedRemainingMillis() { + return pausedRemainingMillis; + } + + public int itemEntryCount() { + return itemEntryCount; + } + + public int remainingExperience() { + return remainingExperience; + } + + public long payloadRevision() { + return payloadRevision; + } + + public String payloadChecksum() { + return payloadChecksum; + } + + public String deathCause() { + return deathCause; + } + + public boolean ownerWasVanished() { + return ownerWasVanished; + } + + public long visualGeneration() { + return visualGeneration; + } + + public synchronized long rotateVisualGeneration() { + return ++visualGeneration; + } + + public synchronized void relocate(GraveLocation next, GravePlacementType nextType) { + location = Objects.requireNonNull(next, "next"); + placementType = Objects.requireNonNull(nextType, "nextType"); + rotateVisualGeneration(); + } + + public synchronized void updatePayload(GravePayload payload, String checksum, GraveStatus nextStatus) { + itemEntryCount = payload.entries().size(); + remainingExperience = payload.remainingExperience(); + payloadRevision = payload.revision(); + payloadChecksum = checksum; + status = Objects.requireNonNull(nextStatus, "nextStatus"); + } + + public synchronized void setStatus(GraveStatus nextStatus) { + status = Objects.requireNonNull(nextStatus, "nextStatus"); + } + + public synchronized void pause(long remainingMillis, GraveStatus nextStatus) { + pausedRemainingMillis = Math.max(0L, remainingMillis); + status = Objects.requireNonNull(nextStatus, "nextStatus"); + } + + public synchronized void restore(long activeNow, long lifetimeMillis) { + pausedRemainingMillis = null; + expiresActiveMillis = Math.addExact(activeNow, lifetimeMillis); + status = payloadRevision > 0L ? GraveStatus.PARTIAL : GraveStatus.ACTIVE; + } + + public synchronized void resume(long activeNow) { + GraveStatus resumedStatus = payloadRevision > 0L ? GraveStatus.PARTIAL : GraveStatus.ACTIVE; + resume(activeNow, resumedStatus); + } + + public synchronized void resume(long activeNow, GraveStatus resumedStatus) { + long remaining = pausedRemainingMillis == null ? 0L : pausedRemainingMillis; + pausedRemainingMillis = null; + expiresActiveMillis = Math.addExact(activeNow, remaining); + status = itemEntryCount == 0 && remainingExperience == 0 + ? GraveStatus.CLAIMED + : Objects.requireNonNull(resumedStatus, "resumedStatus"); + } + + public synchronized void clearPause() { + pausedRemainingMillis = null; + } + + public long remainingActiveMillis(long activeNow) { + Long paused = pausedRemainingMillis; + return paused == null ? Math.max(0L, expiresActiveMillis - activeNow) : Math.max(0L, paused); + } + + public GraveSnapshot snapshot(long activeNow) { + GraveLocation current = location; + return new GraveSnapshot( + graveId, + shortId, + ownerUuid, + ownerName, + serverId, + inventoryScope, + current.worldUuid(), + current.worldKey(), + current.x(), + current.y(), + current.z(), + status, + placementType == GravePlacementType.REMOTE_ONLY, + remainingActiveMillis(activeNow), + itemEntryCount, + remainingExperience + ); + } + + private static String requireText(String value, String name) { + Objects.requireNonNull(value, name); + if (value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GraveIdentifier.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GraveIdentifier.java new file mode 100644 index 00000000..1a162374 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GraveIdentifier.java @@ -0,0 +1,59 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.model; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.Objects; + +/** + * Builds stable, human-readable command identifiers for graves. + */ +public final class GraveIdentifier { + public static final int MAXIMUM_LENGTH = 160; + private static final int MAXIMUM_OWNER_LENGTH = 16; + private static final int MAXIMUM_WORLD_LENGTH = 100; + private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter + .ofPattern("yyyyMMdd-HHmmss-SSS", Locale.ROOT); + + private GraveIdentifier() { + } + + public static String create(String ownerName, String worldName, long createdWallMillis) { + return create(ownerName, worldName, createdWallMillis, ZoneId.systemDefault()); + } + + static String create( + String ownerName, + String worldName, + long createdWallMillis, + ZoneId zoneId + ) { + String owner = slug(ownerName, "player", MAXIMUM_OWNER_LENGTH); + String world = slug(worldName, "world", MAXIMUM_WORLD_LENGTH); + String time = TIMESTAMP.withZone(Objects.requireNonNull(zoneId, "zoneId")) + .format(Instant.ofEpochMilli(createdWallMillis)); + String identifier = owner + "-" + world + "-" + time; + if (identifier.length() <= MAXIMUM_LENGTH) { + return identifier; + } + int maximumWorldLength = Math.max( + 1, + MAXIMUM_LENGTH - owner.length() - time.length() - 2 + ); + return owner + "-" + world.substring(0, Math.min(world.length(), maximumWorldLength)) + "-" + time; + } + + private static String slug(String value, String fallback, int maximumLength) { + String normalized = Objects.requireNonNullElse(value, "") + .trim() + .toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9_-]+", "-") + .replaceAll("-+", "-") + .replaceAll("^[-_]+|[-_]+$", ""); + if (normalized.isEmpty()) { + normalized = fallback; + } + return normalized.substring(0, Math.min(normalized.length(), maximumLength)); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GraveItemEntry.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GraveItemEntry.java new file mode 100644 index 00000000..36374a2a --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GraveItemEntry.java @@ -0,0 +1,18 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.model; + +import java.util.Arrays; +import java.util.Objects; +import java.util.UUID; + +public record GraveItemEntry(UUID entryId, int preferredSlot, byte[] serializedItem) { + public GraveItemEntry { + Objects.requireNonNull(entryId, "entryId"); + Objects.requireNonNull(serializedItem, "serializedItem"); + serializedItem = Arrays.copyOf(serializedItem, serializedItem.length); + } + + @Override + public byte[] serializedItem() { + return Arrays.copyOf(serializedItem, serializedItem.length); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GraveLocation.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GraveLocation.java new file mode 100644 index 00000000..87cb19ee --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GraveLocation.java @@ -0,0 +1,48 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.model; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; + +import java.util.Optional; +import java.util.UUID; + +public record GraveLocation( + UUID worldUuid, + String worldKey, + double x, + double y, + double z, + float yaw +) { + public static GraveLocation from(Location location) { + World world = location.getWorld(); + if (world == null) { + throw new IllegalArgumentException("Grave location must have a world"); + } + return new GraveLocation( + world.getUID(), + world.getKey().asString(), + location.getX(), + location.getY(), + location.getZ(), + location.getYaw() + ); + } + + public Optional resolve() { + World world = Bukkit.getWorld(worldUuid); + if (world == null || !world.getKey().asString().equals(worldKey)) { + return Optional.empty(); + } + return Optional.of(new Location(world, x, y, z, yaw, 0.0f)); + } + + public int chunkX() { + return ((int) Math.floor(x)) >> 4; + } + + public int chunkZ() { + return ((int) Math.floor(z)) >> 4; + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GravePayload.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GravePayload.java new file mode 100644 index 00000000..e6b862b5 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GravePayload.java @@ -0,0 +1,25 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.model; + +import java.util.List; +import java.util.Objects; + +public record GravePayload(long revision, List entries, int remainingExperience) { + public GravePayload { + if (revision < 0L) { + throw new IllegalArgumentException("revision must be non-negative"); + } + Objects.requireNonNull(entries, "entries"); + entries = List.copyOf(entries); + if (remainingExperience < 0) { + throw new IllegalArgumentException("remainingExperience must be non-negative"); + } + } + + public boolean isEmpty() { + return entries.isEmpty() && remainingExperience == 0; + } + + public GravePayload next(List remainingEntries, int remainingXp) { + return new GravePayload(revision + 1L, remainingEntries, remainingXp); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GravePlacementType.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GravePlacementType.java new file mode 100644 index 00000000..53b176b9 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GravePlacementType.java @@ -0,0 +1,9 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.model; + +public enum GravePlacementType { + DEATH_LOCATION, + NEARBY, + LAST_SAFE, + SURFACE, + REMOTE_ONLY +} 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 new file mode 100644 index 00000000..a9eebe0f --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GraveDisplayMetadataFactory.java @@ -0,0 +1,129 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.packet; + +import com.github.retrooper.packetevents.protocol.entity.data.EntityData; +import com.github.retrooper.packetevents.protocol.entity.data.EntityDataTypes; +import io.github.retrooper.packetevents.util.SpigotConversionUtil; +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.Color; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.entity.BlockDisplay; +import org.bukkit.entity.Display; +import org.bukkit.entity.Interaction; +import org.bukkit.entity.TextDisplay; +import org.bukkit.util.Transformation; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import java.util.List; + +/** + * Creates version-correct display metadata from unspawned Bukkit entity templates. + * + *

Metadata indexes are protocol-version-specific. Letting Paper and PacketEvents translate an + * unspawned template keeps the grave renderer aligned with the exact runtime version instead of + * hard-coding indexes that silently stop rendering after protocol changes.

+ */ +final class GraveDisplayMetadataFactory { + private volatile Integer textComponentIndex; + + List> block( + World world, + Material material, + com.github.retrooper.packetevents.util.Vector3f translation, + com.github.retrooper.packetevents.util.Vector3f scale, + int glowRgb + ) { + BlockDisplay display = world.createEntity(templateLocation(world), BlockDisplay.class); + configureDisplay(display, glowRgb, Math.max(scale.getX(), scale.getZ()), scale.getY()); + display.setBlock(Bukkit.createBlockData(material)); + display.setTransformation(new Transformation( + new Vector3f(translation.getX(), translation.getY(), translation.getZ()), + new Quaternionf(), + new Vector3f(scale.getX(), scale.getY(), scale.getZ()), + new Quaternionf() + )); + return List.copyOf(SpigotConversionUtil.getEntityMetadata(display)); + } + + List> text( + World world, + Component title, + String timer, + int glowRgb + ) { + TextDisplay display = world.createEntity(templateLocation(world), TextDisplay.class); + configureDisplay(display, glowRgb, 2.5f, 1.5f); + display.text(title.append(Component.newline()).append(Component.text(timer))); + display.setBillboard(Display.Billboard.CENTER); + display.setSeeThrough(true); + display.setShadowed(true); + display.setBackgroundColor(Color.fromARGB(112, 0, 0, 0)); + display.setLineWidth(180); + display.setTransformation(new Transformation( + new Vector3f(0.0f, 1.55f, 0.0f), + new Quaternionf(), + new Vector3f(1.0f, 1.0f, 1.0f), + new Quaternionf() + )); + List> metadata = List.copyOf(SpigotConversionUtil.getEntityMetadata(display)); + textComponentIndex = findTextComponentIndex(metadata); + return metadata; + } + + List> timer(World world, Component title, String timer) { + Integer index = textComponentIndex; + if (index == null) { + TextDisplay template = world.createEntity(templateLocation(world), TextDisplay.class); + template.text(Component.empty()); + index = findTextComponentIndex(SpigotConversionUtil.getEntityMetadata(template)); + textComponentIndex = index; + } + return List.of(new EntityData<>( + index, + EntityDataTypes.ADV_COMPONENT, + title.append(Component.newline()).append(Component.text(timer)) + )); + } + + List> interaction(World world, float width, float height) { + Interaction interaction = world.createEntity(templateLocation(world), Interaction.class); + interaction.setInteractionWidth(width); + interaction.setInteractionHeight(height); + interaction.setResponsive(true); + return List.copyOf(SpigotConversionUtil.getEntityMetadata(interaction)); + } + + private static int findTextComponentIndex(List> metadata) { + return metadata.stream() + .filter(data -> EntityDataTypes.ADV_COMPONENT.equals(data.getType())) + .mapToInt(EntityData::getIndex) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Paper did not expose TextDisplay text metadata")); + } + + private static void configureDisplay( + Display display, + int glowRgb, + float displayWidth, + float displayHeight + ) { + display.setBrightness(new Display.Brightness(15, 15)); + display.setGlowing(true); + display.setGlowColorOverride(Color.fromRGB(glowRgb & 0xFFFFFF)); + display.setInterpolationDelay(0); + display.setInterpolationDuration(0); + display.setTeleportDuration(0); + display.setViewRange(1.25f); + display.setDisplayWidth(Math.max(1.0f, displayWidth)); + display.setDisplayHeight(Math.max(1.0f, displayHeight)); + display.setShadowRadius(0.0f); + display.setShadowStrength(0.0f); + } + + private static org.bukkit.Location templateLocation(World world) { + double y = world.getMinHeight() + ((world.getMaxHeight() - world.getMinHeight()) / 2.0); + return new org.bukkit.Location(world, 0.0, y, 0.0); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GraveInteractionPacketListener.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GraveInteractionPacketListener.java new file mode 100644 index 00000000..701f1dfb --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GraveInteractionPacketListener.java @@ -0,0 +1,56 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.packet; + +import com.github.retrooper.packetevents.event.PacketListenerAbstract; +import com.github.retrooper.packetevents.event.PacketListenerPriority; +import com.github.retrooper.packetevents.event.PacketReceiveEvent; +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientInteractEntity; +import nl.hauntedmc.serverfeatures.features.graveyard.Graveyard; +import nl.hauntedmc.serverfeatures.features.graveyard.runtime.GraveManager; +import org.bukkit.entity.Player; + +/** + * Converts client interaction packets for virtual grave hitboxes into main-thread claim attempts. + */ +public final class GraveInteractionPacketListener extends PacketListenerAbstract { + private final Graveyard feature; + private final GraveManager manager; + + public GraveInteractionPacketListener(Graveyard feature, GraveManager manager) { + super(PacketListenerPriority.HIGHEST); + this.feature = feature; + this.manager = manager; + } + + @Override + public void onPacketReceive(PacketReceiveEvent event) { + if (event.getPacketType() != PacketType.Play.Client.INTERACT_ENTITY) { + return; + } + WrapperPlayClientInteractEntity wrapper = new WrapperPlayClientInteractEntity(event); + int entityId = wrapper.getEntityId(); + if (!manager.isInteractionEntity(entityId)) { + return; + } + + // Vanilla cannot resolve a packet-only entity. Consume every packet targeting one of our + // interaction IDs, then perform all authoritative validation on the Bukkit main thread. + event.setCancelled(true); + if (!isClaimInteraction(wrapper.getAction())) { + return; + } + Object rawPlayer = event.getPlayer(); + if (!(rawPlayer instanceof Player player)) { + return; + } + feature.getLifecycleManager().getTaskManager().scheduleOneTimeTask( + () -> manager.handleInteraction(player, entityId) + ); + } + + static boolean isClaimInteraction(WrapperPlayClientInteractEntity.InteractAction action) { + return action == WrapperPlayClientInteractEntity.InteractAction.INTERACT + || action == WrapperPlayClientInteractEntity.InteractAction.INTERACT_AT + || action == WrapperPlayClientInteractEntity.InteractAction.ATTACK; + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GravePacketIdentity.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GravePacketIdentity.java new file mode 100644 index 00000000..bff5b149 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GravePacketIdentity.java @@ -0,0 +1,43 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.packet; + +import io.github.retrooper.packetevents.util.SpigotReflectionUtil; +import org.bukkit.World; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +public record GravePacketIdentity( + long generation, + int baseEntityId, + UUID baseEntityUuid, + int headstoneEntityId, + UUID headstoneEntityUuid, + int textEntityId, + UUID textEntityUuid, + int interactionEntityId, + UUID interactionEntityUuid +) { + public static GravePacketIdentity create(long generation, World world) { + Objects.requireNonNull(world, "world"); + return new GravePacketIdentity( + generation, + SpigotReflectionUtil.generateEntityId(world), + UUID.randomUUID(), + SpigotReflectionUtil.generateEntityId(world), + UUID.randomUUID(), + SpigotReflectionUtil.generateEntityId(world), + UUID.randomUUID(), + SpigotReflectionUtil.generateEntityId(world), + UUID.randomUUID() + ); + } + + public int[] entityIds() { + return new int[] {baseEntityId, headstoneEntityId, textEntityId, interactionEntityId}; + } + + public List entityIdList() { + return List.of(baseEntityId, headstoneEntityId, textEntityId, interactionEntityId); + } +} 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 new file mode 100644 index 00000000..581a47a0 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GravePacketRenderer.java @@ -0,0 +1,162 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.packet; + +import com.github.retrooper.packetevents.PacketEvents; +import com.github.retrooper.packetevents.protocol.entity.type.EntityType; +import com.github.retrooper.packetevents.protocol.entity.type.EntityTypes; +import com.github.retrooper.packetevents.protocol.world.Location; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerDestroyEntities; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityMetadata; +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.config.GraveyardSettings; +import nl.hauntedmc.serverfeatures.features.graveyard.model.Grave; +import org.bukkit.World; +import org.bukkit.entity.Player; + +import java.util.List; +import java.util.logging.Logger; + +/** + * Emits one coherent client-side grave composition. + */ +public final class GravePacketRenderer { + private final GraveyardSettings settings; + private final GraveDisplayMetadataFactory metadataFactory = new GraveDisplayMetadataFactory(); + private final Logger logger; + + public GravePacketRenderer(GraveyardSettings settings, Logger logger) { + this.settings = settings; + this.logger = logger; + } + + public void spawn( + Grave grave, + GravePacketIdentity identity, + Player viewer, + String timer, + 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); + + try { + sendSpawn(viewer, identity.baseEntityId(), identity.baseEntityUuid(), EntityTypes.BLOCK_DISPLAY, packetLocation); + sendMetadata(viewer, identity.baseEntityId(), metadataFactory.block( + world, + settings.baseMaterial(), + new com.github.retrooper.packetevents.util.Vector3f(-0.75f, 0.0f, -0.45f), + new com.github.retrooper.packetevents.util.Vector3f(1.5f, 0.5f, 0.9f), + glowRgb + )); + sendSpawn( + viewer, + identity.headstoneEntityId(), + identity.headstoneEntityUuid(), + EntityTypes.BLOCK_DISPLAY, + packetLocation + ); + sendMetadata(viewer, identity.headstoneEntityId(), metadataFactory.block( + world, + settings.headstoneMaterial(), + new com.github.retrooper.packetevents.util.Vector3f(-0.25f, 0.15f, 0.25f), + new com.github.retrooper.packetevents.util.Vector3f(0.5f, 1.1f, 0.25f), + glowRgb + )); + sendSpawn(viewer, identity.textEntityId(), identity.textEntityUuid(), EntityTypes.TEXT_DISPLAY, packetLocation); + sendMetadata(viewer, identity.textEntityId(), metadataFactory.text(world, title, timer, glowRgb)); + sendSpawn( + viewer, + identity.interactionEntityId(), + identity.interactionEntityUuid(), + EntityTypes.INTERACTION, + packetLocation + ); + sendMetadata( + viewer, + identity.interactionEntityId(), + metadataFactory.interaction(world, 1.6f, 1.4f) + ); + } catch (RuntimeException exception) { + try { + destroy(identity, viewer); + } catch (RuntimeException cleanupFailure) { + exception.addSuppressed(cleanupFailure); + } + throw exception; + } + } + + public void updateTimer( + Grave grave, + GravePacketIdentity identity, + Player viewer, + String timer + ) { + 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); + sendMetadata( + viewer, + identity.textEntityId(), + metadataFactory.timer(location.getWorld(), title, timer) + ); + } + + public void destroy(GravePacketIdentity identity, Player viewer) { + if (viewer == null || !viewer.isOnline()) { + return; + } + try { + send(viewer, new WrapperPlayServerDestroyEntities(identity.entityIds())); + } catch (RuntimeException exception) { + logger.warning( + "Failed to destroy Graveyard packet entities for " + viewer.getName() + + ": " + rootMessage(exception) + ); + } + } + + private void sendSpawn( + Player viewer, + int entityId, + java.util.UUID entityUuid, + EntityType entityType, + Location location + ) { + send(viewer, new WrapperPlayServerSpawnEntity( + entityId, + entityUuid, + entityType, + location, + location.getYaw(), + 0, + null + )); + } + + private void sendMetadata( + Player viewer, + int entityId, + List> metadata + ) { + send(viewer, new WrapperPlayServerEntityMetadata(entityId, metadata)); + } + + private static void send(Player viewer, PacketWrapper packet) { + PacketEvents.getAPI().getPlayerManager().sendPacket(viewer, packet); + } + + private static String rootMessage(Throwable throwable) { + Throwable current = throwable; + while (current.getCause() != null && current.getCause() != current) { + current = current.getCause(); + } + return current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage(); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GraveViewerState.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GraveViewerState.java new file mode 100644 index 00000000..122939ac --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GraveViewerState.java @@ -0,0 +1,35 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.packet; + +public final class GraveViewerState { + private long generation; + private boolean spawned; + private String renderedTimer = ""; + + public long generation() { + return generation; + } + + public boolean spawned() { + return spawned; + } + + public String renderedTimer() { + return renderedTimer; + } + + public void markSpawned(long nextGeneration, String timer) { + generation = nextGeneration; + spawned = true; + renderedTimer = timer; + } + + public void markHidden() { + generation++; + spawned = false; + renderedTimer = ""; + } + + public void setRenderedTimer(String timer) { + renderedTimer = timer; + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/EncodedGravePayload.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/EncodedGravePayload.java new file mode 100644 index 00000000..784d289c --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/EncodedGravePayload.java @@ -0,0 +1,14 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.persistence; + +import java.util.Arrays; + +public record EncodedGravePayload(byte[] bytes, String checksum) { + public EncodedGravePayload { + bytes = Arrays.copyOf(bytes, bytes.length); + } + + @Override + public byte[] bytes() { + return Arrays.copyOf(bytes, bytes.length); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GraveAuditEntity.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GraveAuditEntity.java new file mode 100644 index 00000000..268072b5 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GraveAuditEntity.java @@ -0,0 +1,90 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.persistence; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +@Entity +@Table( + name = "player_graveyard_audit", + indexes = { + @Index(name = "idx_player_grave_audit_grave", columnList = "grave_id, created_at"), + @Index(name = "idx_player_grave_audit_actor", columnList = "actor_uuid, created_at") + } +) +public class GraveAuditEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "grave_id", length = 36, nullable = false) + private String graveId; + + @Column(name = "operation_token", length = 36) + private String operationToken; + + @Column(name = "action", length = 64, nullable = false) + private String action; + + @Column(name = "actor_uuid", length = 36) + private String actorUuid; + + @Column(name = "old_state", length = 32) + private String oldState; + + @Column(name = "new_state", length = 32) + private String newState; + + @Column(name = "item_count_before", nullable = false) + private int itemCountBefore; + + @Column(name = "item_count_after", nullable = false) + private int itemCountAfter; + + @Column(name = "xp_before", nullable = false) + private int experienceBefore; + + @Column(name = "xp_after", nullable = false) + private int experienceAfter; + + @Column(name = "server_id", length = 100, nullable = false) + private String serverId; + + @Column(name = "details", length = 1000) + private String details; + + @Column(name = "created_at", nullable = false) + private long createdAt; + + public Long getId() { return id; } + public String getGraveId() { return graveId; } + public void setGraveId(String graveId) { this.graveId = graveId; } + public String getOperationToken() { return operationToken; } + public void setOperationToken(String operationToken) { this.operationToken = operationToken; } + public String getAction() { return action; } + public void setAction(String action) { this.action = action; } + public String getActorUuid() { return actorUuid; } + public void setActorUuid(String actorUuid) { this.actorUuid = actorUuid; } + public String getOldState() { return oldState; } + public void setOldState(String oldState) { this.oldState = oldState; } + public String getNewState() { return newState; } + public void setNewState(String newState) { this.newState = newState; } + public int getItemCountBefore() { return itemCountBefore; } + public void setItemCountBefore(int itemCountBefore) { this.itemCountBefore = itemCountBefore; } + public int getItemCountAfter() { return itemCountAfter; } + public void setItemCountAfter(int itemCountAfter) { this.itemCountAfter = itemCountAfter; } + public int getExperienceBefore() { return experienceBefore; } + public void setExperienceBefore(int experienceBefore) { this.experienceBefore = experienceBefore; } + public int getExperienceAfter() { return experienceAfter; } + public void setExperienceAfter(int experienceAfter) { this.experienceAfter = experienceAfter; } + public String getServerId() { return serverId; } + public void setServerId(String serverId) { this.serverId = serverId; } + public String getDetails() { return details; } + public void setDetails(String details) { this.details = details; } + public long getCreatedAt() { return createdAt; } + public void setCreatedAt(long createdAt) { this.createdAt = createdAt; } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GraveLeaseEntity.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GraveLeaseEntity.java new file mode 100644 index 00000000..20ae5182 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GraveLeaseEntity.java @@ -0,0 +1,38 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.persistence; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +@Entity +@Table(name = "player_graveyard_leases") +public class GraveLeaseEntity { + @Id + @Column(name = "scope_key", length = 220, nullable = false) + private String scopeKey; + + @Column(name = "owner_token", length = 36, nullable = false) + private String ownerToken; + + @Column(name = "expires_at", nullable = false) + private long expiresAt; + + @Column(name = "updated_at", nullable = false) + private long updatedAt; + + @Version + @Column(name = "row_version", nullable = false) + private long rowVersion; + + public String getScopeKey() { return scopeKey; } + public void setScopeKey(String scopeKey) { this.scopeKey = scopeKey; } + public String getOwnerToken() { return ownerToken; } + public void setOwnerToken(String ownerToken) { this.ownerToken = ownerToken; } + public long getExpiresAt() { return expiresAt; } + public void setExpiresAt(long expiresAt) { this.expiresAt = expiresAt; } + public long getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(long updatedAt) { this.updatedAt = updatedAt; } + public long getRowVersion() { return rowVersion; } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GraveMetadataEntity.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GraveMetadataEntity.java new file mode 100644 index 00000000..28a42147 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GraveMetadataEntity.java @@ -0,0 +1,192 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.persistence; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +@Entity +@Table( + name = "player_graveyard_graves", + indexes = { + @Index(name = "idx_player_grave_owner_state", columnList = "owner_uuid, state"), + @Index(name = "idx_player_grave_server_state", columnList = "server_id, state"), + @Index(name = "idx_player_grave_world_state", columnList = "server_id, world_uuid, state"), + @Index(name = "idx_player_grave_expiry", columnList = "server_id, expires_active_ms, state"), + @Index( + name = "uq_player_grave_scope_identifier", + columnList = "server_id, inventory_scope, short_id", + unique = true + ) + } +) +public class GraveMetadataEntity { + @Id + @Column(name = "grave_id", length = 36, nullable = false) + private String graveId; + + @Column(name = "short_id", length = 160, nullable = false) + private String shortId; + + @Column(name = "owner_uuid", length = 36, nullable = false) + private String ownerUuid; + + @Column(name = "owner_player_id") + private Long ownerPlayerId; + + @Column(name = "owner_name", length = 64, nullable = false) + private String ownerName; + + @Column(name = "server_id", length = 100, nullable = false) + private String serverId; + + @Column(name = "inventory_scope", length = 100, nullable = false) + private String inventoryScope; + + @Column(name = "world_uuid", length = 36, nullable = false) + private String worldUuid; + + @Column(name = "world_key", length = 160, nullable = false) + private String worldKey; + + @Column(name = "death_x", nullable = false) + private double deathX; + + @Column(name = "death_y", nullable = false) + private double deathY; + + @Column(name = "death_z", nullable = false) + private double deathZ; + + @Column(name = "grave_x", nullable = false) + private double graveX; + + @Column(name = "grave_y", nullable = false) + private double graveY; + + @Column(name = "grave_z", nullable = false) + private double graveZ; + + @Column(name = "grave_yaw", nullable = false) + private float graveYaw; + + @Column(name = "placement_type", length = 32, nullable = false) + private String placementType; + + @Column(name = "state", length = 32, nullable = false) + private String state; + + @Column(name = "created_wall_ms", nullable = false) + private long createdWallMillis; + + @Column(name = "created_active_ms", nullable = false) + private long createdActiveMillis; + + @Column(name = "expires_active_ms", nullable = false) + private long expiresActiveMillis; + + @Column(name = "paused_remaining_ms") + private Long pausedRemainingMillis; + + @Column(name = "item_entry_count", nullable = false) + private int itemEntryCount; + + @Column(name = "remaining_xp", nullable = false) + private int remainingExperience; + + @Column(name = "payload_revision", nullable = false) + private long payloadRevision; + + @Column(name = "payload_checksum", length = 64, nullable = false) + private String payloadChecksum; + + @Column(name = "operation_token", length = 36) + private String operationToken; + + @Column(name = "operation_started_ms") + private Long operationStartedMillis; + + @Column(name = "owner_was_vanished", nullable = false) + private boolean ownerWasVanished; + + @Column(name = "death_cause", length = 160) + private String deathCause; + + @Column(name = "updated_at", nullable = false) + private long updatedAt; + + @Column(name = "completed_at") + private Long completedAt; + + @Version + @Column(name = "row_version", nullable = false) + private long rowVersion; + + public String getGraveId() { return graveId; } + public void setGraveId(String graveId) { this.graveId = graveId; } + public String getShortId() { return shortId; } + public void setShortId(String shortId) { this.shortId = shortId; } + public String getOwnerUuid() { return ownerUuid; } + public void setOwnerUuid(String ownerUuid) { this.ownerUuid = ownerUuid; } + public Long getOwnerPlayerId() { return ownerPlayerId; } + public void setOwnerPlayerId(Long ownerPlayerId) { this.ownerPlayerId = ownerPlayerId; } + public String getOwnerName() { return ownerName; } + public void setOwnerName(String ownerName) { this.ownerName = ownerName; } + public String getServerId() { return serverId; } + public void setServerId(String serverId) { this.serverId = serverId; } + public String getInventoryScope() { return inventoryScope; } + public void setInventoryScope(String inventoryScope) { this.inventoryScope = inventoryScope; } + public String getWorldUuid() { return worldUuid; } + public void setWorldUuid(String worldUuid) { this.worldUuid = worldUuid; } + public String getWorldKey() { return worldKey; } + public void setWorldKey(String worldKey) { this.worldKey = worldKey; } + public double getDeathX() { return deathX; } + public void setDeathX(double deathX) { this.deathX = deathX; } + public double getDeathY() { return deathY; } + public void setDeathY(double deathY) { this.deathY = deathY; } + public double getDeathZ() { return deathZ; } + public void setDeathZ(double deathZ) { this.deathZ = deathZ; } + public double getGraveX() { return graveX; } + public void setGraveX(double graveX) { this.graveX = graveX; } + public double getGraveY() { return graveY; } + public void setGraveY(double graveY) { this.graveY = graveY; } + public double getGraveZ() { return graveZ; } + public void setGraveZ(double graveZ) { this.graveZ = graveZ; } + public float getGraveYaw() { return graveYaw; } + public void setGraveYaw(float graveYaw) { this.graveYaw = graveYaw; } + public String getPlacementType() { return placementType; } + public void setPlacementType(String placementType) { this.placementType = placementType; } + public String getState() { return state; } + public void setState(String state) { this.state = state; } + public long getCreatedWallMillis() { return createdWallMillis; } + public void setCreatedWallMillis(long createdWallMillis) { this.createdWallMillis = createdWallMillis; } + public long getCreatedActiveMillis() { return createdActiveMillis; } + public void setCreatedActiveMillis(long createdActiveMillis) { this.createdActiveMillis = createdActiveMillis; } + public long getExpiresActiveMillis() { return expiresActiveMillis; } + public void setExpiresActiveMillis(long expiresActiveMillis) { this.expiresActiveMillis = expiresActiveMillis; } + public Long getPausedRemainingMillis() { return pausedRemainingMillis; } + public void setPausedRemainingMillis(Long pausedRemainingMillis) { this.pausedRemainingMillis = pausedRemainingMillis; } + public int getItemEntryCount() { return itemEntryCount; } + public void setItemEntryCount(int itemEntryCount) { this.itemEntryCount = itemEntryCount; } + public int getRemainingExperience() { return remainingExperience; } + public void setRemainingExperience(int remainingExperience) { this.remainingExperience = remainingExperience; } + public long getPayloadRevision() { return payloadRevision; } + public void setPayloadRevision(long payloadRevision) { this.payloadRevision = payloadRevision; } + public String getPayloadChecksum() { return payloadChecksum; } + public void setPayloadChecksum(String payloadChecksum) { this.payloadChecksum = payloadChecksum; } + public String getOperationToken() { return operationToken; } + public void setOperationToken(String operationToken) { this.operationToken = operationToken; } + public Long getOperationStartedMillis() { return operationStartedMillis; } + public void setOperationStartedMillis(Long operationStartedMillis) { this.operationStartedMillis = operationStartedMillis; } + public boolean isOwnerWasVanished() { return ownerWasVanished; } + public void setOwnerWasVanished(boolean ownerWasVanished) { this.ownerWasVanished = ownerWasVanished; } + public String getDeathCause() { return deathCause; } + public void setDeathCause(String deathCause) { this.deathCause = deathCause; } + public long getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(long updatedAt) { this.updatedAt = updatedAt; } + public Long getCompletedAt() { return completedAt; } + public void setCompletedAt(Long completedAt) { this.completedAt = completedAt; } + public long getRowVersion() { return rowVersion; } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GravePayloadCodec.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GravePayloadCodec.java new file mode 100644 index 00000000..887bc979 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GravePayloadCodec.java @@ -0,0 +1,224 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.persistence; + +import nl.hauntedmc.serverfeatures.features.graveyard.model.GraveItemEntry; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GravePayload; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +public final class GravePayloadCodec { + public static final int CODEC_VERSION = 1; + private static final int MAGIC = 0x47525631; + private static final ItemStackBinaryCodec PAPER_ITEM_CODEC = new ItemStackBinaryCodec() { + @Override + public byte[] serialize(ItemStack item) { + return item.serializeAsBytes(); + } + + @Override + public ItemStack deserialize(byte[] bytes) { + return ItemStack.deserializeBytes(bytes); + } + }; + + private final int maximumEntries; + private final int maximumItemBytes; + private final int maximumPayloadBytes; + private final ItemStackBinaryCodec itemCodec; + + public GravePayloadCodec(int maximumEntries, int maximumItemBytes, int maximumPayloadBytes) { + this(maximumEntries, maximumItemBytes, maximumPayloadBytes, PAPER_ITEM_CODEC); + } + + public GravePayloadCodec( + int maximumEntries, + int maximumItemBytes, + int maximumPayloadBytes, + ItemStackBinaryCodec itemCodec + ) { + this.maximumEntries = maximumEntries; + this.maximumItemBytes = maximumItemBytes; + this.maximumPayloadBytes = maximumPayloadBytes; + this.itemCodec = Objects.requireNonNull(itemCodec, "itemCodec"); + } + + public GraveItemEntry createEntry(UUID entryId, int preferredSlot, ItemStack item) throws IOException { + if (entryId == null || isEmpty(item)) { + throw new IOException("Grave item entry is empty or incomplete"); + } + validatePreferredSlot(preferredSlot); + byte[] serialized; + try { + serialized = itemCodec.serialize(item); + } catch (RuntimeException exception) { + throw new IOException("Could not serialize grave item " + entryId, exception); + } + if (serialized == null) { + throw new IOException("Grave item serializer returned no data for " + entryId); + } + validateItemSize(serialized.length); + return new GraveItemEntry(entryId, preferredSlot, serialized); + } + + public ItemStack decodeItem(GraveItemEntry entry) throws IOException { + if (entry == null) { + throw new IOException("Grave item entry is missing"); + } + validateItemSize(entry.serializedItem().length); + try { + ItemStack item = itemCodec.deserialize(entry.serializedItem()); + if (isEmpty(item)) { + throw new IOException("Decoded grave item is empty"); + } + return item; + } catch (IOException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new IOException("Could not decode grave item " + entry.entryId(), exception); + } + } + + public EncodedGravePayload encode(GravePayload payload) throws IOException { + if (payload == null) { + throw new IOException("Grave payload is missing"); + } + if (payload.entries().size() > maximumEntries) { + throw new IOException("Grave payload contains too many entries: " + payload.entries().size()); + } + Set entryIds = new HashSet<>(payload.entries().size()); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + output.writeInt(MAGIC); + output.writeInt(CODEC_VERSION); + output.writeLong(payload.revision()); + output.writeInt(payload.entries().size()); + for (GraveItemEntry entry : payload.entries()) { + if (entry == null || !entryIds.add(entry.entryId())) { + throw new IOException("Grave payload contains a missing or duplicate entry id"); + } + validatePreferredSlot(entry.preferredSlot()); + output.writeLong(entry.entryId().getMostSignificantBits()); + output.writeLong(entry.entryId().getLeastSignificantBits()); + output.writeInt(entry.preferredSlot()); + byte[] item = entry.serializedItem(); + validateItemSize(item.length); + decodeItem(entry); + output.writeInt(item.length); + output.write(item); + } + output.writeInt(payload.remainingExperience()); + } + byte[] encoded = bytes.toByteArray(); + if (encoded.length > maximumPayloadBytes) { + throw new IOException("Grave payload exceeds maximum size: " + encoded.length); + } + return new EncodedGravePayload(encoded, checksum(encoded)); + } + + public GravePayload decode(byte[] encoded, String expectedChecksum) throws IOException { + if (encoded == null || encoded.length == 0) { + throw new IOException("Grave payload is empty"); + } + if (encoded.length > maximumPayloadBytes) { + throw new IOException("Grave payload exceeds maximum size: " + encoded.length); + } + String actualChecksum = checksum(encoded); + if (expectedChecksum == null || !MessageDigest.isEqual( + actualChecksum.getBytes(StandardCharsets.US_ASCII), + expectedChecksum.getBytes(StandardCharsets.US_ASCII) + )) { + throw new IOException("Grave payload checksum mismatch"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded))) { + if (input.readInt() != MAGIC) { + throw new IOException("Unknown Graveyard payload magic"); + } + int version = input.readInt(); + if (version != CODEC_VERSION) { + throw new IOException("Unsupported Graveyard payload codec " + version); + } + long revision = input.readLong(); + int count = input.readInt(); + if (count < 0 || count > maximumEntries) { + throw new IOException("Invalid Graveyard payload entry count " + count); + } + List entries = new ArrayList<>(count); + Set entryIds = new HashSet<>(count); + for (int index = 0; index < count; index++) { + UUID entryId = new UUID(input.readLong(), input.readLong()); + if (!entryIds.add(entryId)) { + throw new IOException("Duplicate Graveyard payload entry id " + entryId); + } + int preferredSlot = input.readInt(); + validatePreferredSlot(preferredSlot); + int itemLength = input.readInt(); + validateItemSize(itemLength); + GraveItemEntry entry = new GraveItemEntry(entryId, preferredSlot, input.readNBytes(itemLength)); + if (entry.serializedItem().length != itemLength) { + throw new IOException("Truncated Graveyard item payload"); + } + decodeItem(entry); + entries.add(entry); + } + int experience = input.readInt(); + if (experience < 0 || input.available() != 0) { + throw new IOException("Invalid trailing Graveyard payload data"); + } + return new GravePayload(revision, entries, experience); + } catch (RuntimeException exception) { + throw new IOException("Could not decode Graveyard payload", exception); + } + } + + public String checksum(byte[] bytes) throws IOException { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException exception) { + throw new IOException("SHA-256 is unavailable", exception); + } + } + + private static boolean isEmpty(ItemStack item) { + if (item == null || item.getAmount() <= 0) { + return true; + } + Material material = item.getType(); + return material == null + || material == Material.AIR + || material == Material.CAVE_AIR + || material == Material.VOID_AIR; + } + + private static void validatePreferredSlot(int preferredSlot) throws IOException { + if (preferredSlot < -1 || preferredSlot > 40) { + throw new IOException("Invalid grave preferred slot " + preferredSlot); + } + } + + private void validateItemSize(int size) throws IOException { + if (size <= 0 || size > maximumItemBytes) { + throw new IOException("Invalid grave item size " + size); + } + } + + public interface ItemStackBinaryCodec { + byte[] serialize(ItemStack item); + + ItemStack deserialize(byte[] bytes); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GravePayloadEntity.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GravePayloadEntity.java new file mode 100644 index 00000000..433dd9af --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GravePayloadEntity.java @@ -0,0 +1,58 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.persistence; + +import jakarta.persistence.Basic; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +@Entity +@Table(name = "player_graveyard_payloads") +public class GravePayloadEntity { + @Id + @Column(name = "grave_id", length = 36, nullable = false) + private String graveId; + + @Column(name = "payload_revision", nullable = false) + private long payloadRevision; + + @Column(name = "payload_codec", nullable = false) + private int payloadCodec; + + @Column(name = "compressed", nullable = false) + private boolean compressed; + + @Lob + @Basic(fetch = FetchType.LAZY) + @Column(name = "payload", nullable = false, columnDefinition = "LONGBLOB") + private byte[] payload; + + @Column(name = "payload_checksum", length = 64, nullable = false) + private String payloadChecksum; + + @Column(name = "updated_at", nullable = false) + private long updatedAt; + + @Version + @Column(name = "row_version", nullable = false) + private long rowVersion; + + public String getGraveId() { return graveId; } + public void setGraveId(String graveId) { this.graveId = graveId; } + public long getPayloadRevision() { return payloadRevision; } + public void setPayloadRevision(long payloadRevision) { this.payloadRevision = payloadRevision; } + public int getPayloadCodec() { return payloadCodec; } + public void setPayloadCodec(int payloadCodec) { this.payloadCodec = payloadCodec; } + public boolean isCompressed() { return compressed; } + public void setCompressed(boolean compressed) { this.compressed = compressed; } + public byte[] getPayload() { return payload; } + public void setPayload(byte[] payload) { this.payload = payload; } + public String getPayloadChecksum() { return payloadChecksum; } + public void setPayloadChecksum(String payloadChecksum) { this.payloadChecksum = payloadChecksum; } + public long getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(long updatedAt) { this.updatedAt = updatedAt; } + public long getRowVersion() { return rowVersion; } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GraveRepository.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GraveRepository.java new file mode 100644 index 00000000..b4fa1dc9 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GraveRepository.java @@ -0,0 +1,922 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.persistence; + +import jakarta.persistence.LockModeType; +import nl.hauntedmc.dataprovider.api.orm.ORMContext; +import nl.hauntedmc.serverfeatures.api.graveyard.GraveStatus; +import nl.hauntedmc.serverfeatures.features.graveyard.Graveyard; +import nl.hauntedmc.serverfeatures.features.graveyard.model.Grave; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GraveLocation; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GravePayload; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GravePlacementType; +import nl.hauntedmc.serverfeatures.framework.persistence.PlayerIdentityResolver; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletionStage; + +/** + * Asynchronous persistence boundary for Graveyard. No caller should invoke ORM work on the Paper + * thread; every public method schedules its transaction through the feature task manager. + */ +public final class GraveRepository { + private static final Set LOADABLE_STATES = EnumSet.of( + GraveStatus.ACTIVE, + GraveStatus.PARTIAL, + GraveStatus.ORPHANED_WORLD, + GraveStatus.DELIVERY_PENDING, + GraveStatus.CORRUPT, + GraveStatus.EXPIRED, + GraveStatus.ADMIN_RECOVERED + ); + + private final Graveyard feature; + private final ORMContext orm; + private final PlayerIdentityResolver playerIdentityResolver; + + public GraveRepository(Graveyard feature, ORMContext orm) { + this.feature = feature; + this.orm = orm; + this.playerIdentityResolver = new PlayerIdentityResolver( + feature.getPlugin().getDataRegistry() + .orElseThrow(() -> new IllegalStateException("DataRegistry is required for Graveyard.")) + ); + } + + public CompletionStage> loadRuntimeGraves(String serverId, String inventoryScope) { + return async(() -> orm.runInTransaction(session -> { + List entities = session.createSelectionQuery( + "FROM GraveMetadataEntity g " + + "WHERE g.serverId = :serverId " + + "AND g.inventoryScope = :scope " + + "AND g.state IN :states", + GraveMetadataEntity.class + ) + .setParameter("serverId", serverId) + .setParameter("scope", inventoryScope) + .setParameter("states", LOADABLE_STATES.stream().map(Enum::name).toList()) + .getResultList(); + List graves = new ArrayList<>(entities.size()); + for (GraveMetadataEntity entity : entities) { + try { + graves.add(toGrave(entity)); + } catch (RuntimeException exception) { + feature.getLogger().warning( + "Skipping invalid Graveyard metadata row " + entity.getGraveId() + + ": " + exception.getMessage() + ); + } + } + return List.copyOf(graves); + })); + } + + public CompletionStage> loadPayload(Grave grave) { + return async(() -> orm.runInTransaction(session -> { + GravePayloadEntity entity = session.find(GravePayloadEntity.class, grave.graveId().toString()); + if (entity == null) { + return Optional.empty(); + } + validatePayloadEncoding(entity); + if (entity.getPayloadRevision() != grave.payloadRevision() + || !Objects.equals(entity.getPayloadChecksum(), grave.payloadChecksum())) { + throw new IllegalStateException("Graveyard payload metadata mismatch for " + grave.graveId()); + } + return Optional.of(new EncodedGravePayload(entity.getPayload(), entity.getPayloadChecksum())); + })); + } + + /** + * Loads the current durable payload without comparing it to the in-memory metadata revision. + * Recovery uses this after an ambiguous database finalization, where the database may already + * contain the next revision while the runtime grave still reflects the previous revision. + */ + public CompletionStage> loadPayloadForRecovery(UUID graveId) { + return async(() -> orm.runInTransaction(session -> { + GravePayloadEntity entity = session.find(GravePayloadEntity.class, graveId.toString()); + if (entity == null) { + return Optional.empty(); + } + validatePayloadEncoding(entity); + return Optional.of(new EncodedGravePayload(entity.getPayload(), entity.getPayloadChecksum())); + })); + } + + public CompletionStage saveCaptured(Grave grave, EncodedGravePayload payload) { + CompletionStage> playerId = playerIdentityResolver.findByUuid(grave.ownerUuid()) + .handle((identity, failure) -> failure == null + ? identity.map(value -> value.playerId()) + : Optional.empty()); + return playerId.thenCompose(resolvedPlayerId -> async(() -> { + orm.runInTransaction(session -> { + String graveId = grave.graveId().toString(); + GraveMetadataEntity existing = session.find( + GraveMetadataEntity.class, + graveId, + LockModeType.PESSIMISTIC_WRITE + ); + if (existing != null) { + if (!sameCaptureIdentity(existing, grave)) { + throw new IllegalStateException("Grave id collision for " + grave.graveId()); + } + GravePayloadEntity existingPayload = session.find( + GravePayloadEntity.class, + graveId, + LockModeType.PESSIMISTIC_WRITE + ); + if (existingPayload == null + && GraveStatus.valueOf(existing.getState()) != GraveStatus.PURGED) { + throw new IllegalStateException("Grave metadata exists without payload for " + grave.graveId()); + } + return null; + } + GravePayloadEntity existingPayload = session.find( + GravePayloadEntity.class, + graveId, + LockModeType.PESSIMISTIC_WRITE + ); + if (existingPayload != null) { + throw new IllegalStateException("Payload exists without metadata for " + grave.graveId()); + } + GraveMetadataEntity metadata = toEntity(grave, resolvedPlayerId.orElse(null)); + GravePayloadEntity payloadEntity = toPayloadEntity(grave.graveId(), grave.payloadRevision(), payload); + session.persist(metadata); + session.persist(payloadEntity); + persistAudit( + session, + grave, + null, + "CAPTURED", + null, + null, + grave.status(), + 0, + grave.itemEntryCount(), + 0, + grave.remainingExperience(), + null + ); + return null; + }); + return null; + })); + } + + public CompletionStage reserveOperation( + UUID graveId, + UUID operationToken, + Set allowedStates + ) { + return async(() -> orm.runInTransaction(session -> { + GraveMetadataEntity metadata = session.find( + GraveMetadataEntity.class, + graveId.toString(), + LockModeType.PESSIMISTIC_WRITE + ); + if (metadata == null || metadata.getOperationToken() != null) { + return false; + } + GraveStatus current = GraveStatus.valueOf(metadata.getState()); + if (!allowedStates.contains(current)) { + return false; + } + metadata.setOperationToken(operationToken.toString()); + metadata.setOperationStartedMillis(System.currentTimeMillis()); + metadata.setUpdatedAt(System.currentTimeMillis()); + return true; + })); + } + + public CompletionStage releaseOperation(UUID graveId, UUID operationToken) { + return async(() -> orm.runInTransaction(session -> { + GraveMetadataEntity metadata = session.find( + GraveMetadataEntity.class, + graveId.toString(), + LockModeType.PESSIMISTIC_WRITE + ); + if (metadata == null) { + return false; + } + if (metadata.getOperationToken() == null) { + return true; + } + if (!ownsOperation(metadata, operationToken)) { + return false; + } + metadata.setOperationToken(null); + metadata.setOperationStartedMillis(null); + metadata.setUpdatedAt(System.currentTimeMillis()); + return true; + })); + } + + public CompletionStage finalizeClaim( + Grave grave, + UUID operationToken, + UUID actorUuid, + GravePayload previous, + GravePayload remaining, + EncodedGravePayload encodedRemaining, + GraveStatus finalStatus, + int transferredEntries, + int transferredExperience + ) { + return async(() -> orm.runInTransaction(session -> { + GraveMetadataEntity metadata = session.find( + GraveMetadataEntity.class, + grave.graveId().toString(), + LockModeType.PESSIMISTIC_WRITE + ); + GravePayloadEntity payloadEntity = session.find( + GravePayloadEntity.class, + grave.graveId().toString(), + LockModeType.PESSIMISTIC_WRITE + ); + if (isFinalizedClaim(metadata, payloadEntity, remaining, encodedRemaining, finalStatus)) { + return true; + } + if (!ownsOperation(metadata, operationToken) + || metadata.getPayloadRevision() != previous.revision() + || payloadEntity == null + || payloadEntity.getPayloadRevision() != previous.revision()) { + return false; + } + + payloadEntity.setPayloadRevision(remaining.revision()); + payloadEntity.setPayloadCodec(GravePayloadCodec.CODEC_VERSION); + payloadEntity.setCompressed(false); + payloadEntity.setPayload(encodedRemaining.bytes()); + payloadEntity.setPayloadChecksum(encodedRemaining.checksum()); + payloadEntity.setUpdatedAt(System.currentTimeMillis()); + + GraveStatus oldStatus = GraveStatus.valueOf(metadata.getState()); + metadata.setState(finalStatus.name()); + metadata.setItemEntryCount(remaining.entries().size()); + metadata.setRemainingExperience(remaining.remainingExperience()); + metadata.setPayloadRevision(remaining.revision()); + metadata.setPayloadChecksum(encodedRemaining.checksum()); + metadata.setOperationToken(null); + metadata.setOperationStartedMillis(null); + if (finalStatus == GraveStatus.ORPHANED_WORLD + || finalStatus == GraveStatus.DELIVERY_PENDING) { + metadata.setPausedRemainingMillis(grave.pausedRemainingMillis()); + } else { + metadata.setPausedRemainingMillis(null); + } + metadata.setUpdatedAt(System.currentTimeMillis()); + if (finalStatus == GraveStatus.CLAIMED) { + metadata.setCompletedAt(System.currentTimeMillis()); + } + + persistAudit( + session, + grave, + operationToken, + finalStatus == GraveStatus.CLAIMED ? "CLAIMED" : "PARTIAL_CLAIM", + actorUuid, + oldStatus, + finalStatus, + previous.entries().size(), + remaining.entries().size(), + previous.remainingExperience(), + remaining.remainingExperience(), + "transferredEntries=" + transferredEntries + + ", transferredExperience=" + transferredExperience + ); + return true; + })); + } + + public CompletionStage restore( + Grave grave, + UUID actorUuid, + long activeNow, + long lifetimeMillis, + boolean worldAvailable + ) { + return async(() -> orm.runInTransaction(session -> { + GraveMetadataEntity metadata = session.find( + GraveMetadataEntity.class, + grave.graveId().toString(), + LockModeType.PESSIMISTIC_WRITE + ); + if (metadata == null || metadata.getOperationToken() != null) { + return false; + } + GraveStatus oldStatus = GraveStatus.valueOf(metadata.getState()); + if (oldStatus != GraveStatus.EXPIRED && oldStatus != GraveStatus.ORPHANED_WORLD) { + return false; + } + GraveStatus target = worldAvailable + ? metadata.getPayloadRevision() > 0L ? GraveStatus.PARTIAL : GraveStatus.ACTIVE + : GraveStatus.ORPHANED_WORLD; + metadata.setState(target.name()); + if (worldAvailable) { + metadata.setExpiresActiveMillis(Math.addExact(activeNow, lifetimeMillis)); + metadata.setPausedRemainingMillis(null); + } else { + metadata.setPausedRemainingMillis(lifetimeMillis); + } + metadata.setCompletedAt(null); + metadata.setUpdatedAt(System.currentTimeMillis()); + persistAudit( + session, + grave, + null, + "RESTORED", + actorUuid, + oldStatus, + target, + grave.itemEntryCount(), + grave.itemEntryCount(), + grave.remainingExperience(), + grave.remainingExperience(), + null + ); + return true; + })); + } + + public CompletionStage transitionState( + Grave grave, + Set expectedStates, + GraveStatus target, + UUID actorUuid, + String action, + String details + ) { + return async(() -> orm.runInTransaction(session -> { + GraveMetadataEntity metadata = session.find( + GraveMetadataEntity.class, + grave.graveId().toString(), + LockModeType.PESSIMISTIC_WRITE + ); + if (metadata == null || metadata.getOperationToken() != null) { + return false; + } + GraveStatus oldStatus = GraveStatus.valueOf(metadata.getState()); + if (!expectedStates.contains(oldStatus)) { + return false; + } + metadata.setState(target.name()); + metadata.setUpdatedAt(System.currentTimeMillis()); + if (target == GraveStatus.EXPIRED + || target == GraveStatus.ADMIN_RECOVERED + || target == GraveStatus.PURGED) { + metadata.setCompletedAt(System.currentTimeMillis()); + } + persistAudit( + session, + grave, + null, + action, + actorUuid, + oldStatus, + target, + grave.itemEntryCount(), + grave.itemEntryCount(), + grave.remainingExperience(), + grave.remainingExperience(), + details + ); + return true; + })); + } + + public CompletionStage purge(Grave grave, UUID actorUuid) { + return async(() -> orm.runInTransaction(session -> { + GraveMetadataEntity metadata = session.find( + GraveMetadataEntity.class, + grave.graveId().toString(), + LockModeType.PESSIMISTIC_WRITE + ); + if (metadata == null || metadata.getOperationToken() != null) { + return false; + } + GraveStatus oldStatus = GraveStatus.valueOf(metadata.getState()); + if (!EnumSet.of( + GraveStatus.EXPIRED, + GraveStatus.CORRUPT, + GraveStatus.ADMIN_RECOVERED + ).contains(oldStatus)) { + return false; + } + + GravePayloadEntity payload = session.find( + GravePayloadEntity.class, + grave.graveId().toString(), + LockModeType.PESSIMISTIC_WRITE + ); + if (payload != null) { + session.remove(payload); + } + + int itemsBefore = metadata.getItemEntryCount(); + int experienceBefore = metadata.getRemainingExperience(); + long now = System.currentTimeMillis(); + metadata.setState(GraveStatus.PURGED.name()); + metadata.setItemEntryCount(0); + metadata.setRemainingExperience(0); + metadata.setPayloadRevision(Math.addExact(metadata.getPayloadRevision(), 1L)); + metadata.setPayloadChecksum("PURGED"); + metadata.setCompletedAt(now); + metadata.setUpdatedAt(now); + persistAudit( + session, + grave, + null, + "PURGED", + actorUuid, + oldStatus, + GraveStatus.PURGED, + itemsBefore, + 0, + experienceBefore, + 0, + "payloadDeleted=true" + ); + return true; + })); + } + + /** + * Permanently removes completed grave rows after their configured support window. Corrupt + * graves are deliberately excluded and require an explicit administrative purge. + */ + public CompletionStage> purgeRetained( + String serverId, + String inventoryScope, + long expiredCutoffMillis, + long claimedCutoffMillis, + int limit + ) { + return async(() -> orm.runInTransaction(session -> { + List candidates = session.createSelectionQuery( + "FROM GraveMetadataEntity g " + + "WHERE g.serverId = :serverId " + + "AND g.inventoryScope = :scope " + + "AND g.operationToken IS NULL " + + "AND g.completedAt IS NOT NULL " + + "AND ((g.state = :expiredState AND g.completedAt <= :expiredCutoff) " + + "OR (g.state IN :completedStates AND g.completedAt <= :claimedCutoff)) " + + "ORDER BY g.completedAt ASC", + GraveMetadataEntity.class + ) + .setParameter("serverId", serverId) + .setParameter("scope", inventoryScope) + .setParameter("expiredState", GraveStatus.EXPIRED.name()) + .setParameter("expiredCutoff", expiredCutoffMillis) + .setParameter("completedStates", List.of( + GraveStatus.CLAIMED.name(), + GraveStatus.ADMIN_RECOVERED.name(), + GraveStatus.PURGED.name() + )) + .setParameter("claimedCutoff", claimedCutoffMillis) + .setLockMode(LockModeType.PESSIMISTIC_WRITE) + .setMaxResults(Math.max(1, limit)) + .getResultList(); + List purged = new ArrayList<>(candidates.size()); + for (GraveMetadataEntity metadata : candidates) { + GraveStatus oldStatus = GraveStatus.valueOf(metadata.getState()); + Grave grave = toGrave(metadata); + GravePayloadEntity payload = session.find( + GravePayloadEntity.class, + metadata.getGraveId(), + LockModeType.PESSIMISTIC_WRITE + ); + if (payload != null) { + session.remove(payload); + } + persistAudit( + session, + grave, + null, + "RETENTION_PURGED", + null, + oldStatus, + GraveStatus.PURGED, + metadata.getItemEntryCount(), + 0, + metadata.getRemainingExperience(), + 0, + "metadataDeleted=true,payloadDeleted=" + (payload != null) + ); + session.remove(metadata); + purged.add(grave.graveId()); + } + return List.copyOf(purged); + })); + } + + public CompletionStage pauseForState( + Grave grave, + Set expectedStates, + GraveStatus target, + long remainingMillis, + UUID actorUuid, + String action + ) { + return async(() -> orm.runInTransaction(session -> { + GraveMetadataEntity metadata = session.find( + GraveMetadataEntity.class, + grave.graveId().toString(), + LockModeType.PESSIMISTIC_WRITE + ); + if (metadata == null || metadata.getOperationToken() != null) { + return false; + } + GraveStatus oldStatus = GraveStatus.valueOf(metadata.getState()); + if (!expectedStates.contains(oldStatus)) { + return false; + } + metadata.setState(target.name()); + metadata.setPausedRemainingMillis(Math.max(0L, remainingMillis)); + metadata.setUpdatedAt(System.currentTimeMillis()); + persistAudit( + session, + grave, + null, + action, + actorUuid, + oldStatus, + target, + grave.itemEntryCount(), + grave.itemEntryCount(), + grave.remainingExperience(), + grave.remainingExperience(), + "remainingActiveMillis=" + Math.max(0L, remainingMillis) + ); + return true; + })); + } + + public CompletionStage resumeOrphaned(Grave grave, long activeNow) { + return async(() -> orm.runInTransaction(session -> { + GraveMetadataEntity metadata = session.find( + GraveMetadataEntity.class, + grave.graveId().toString(), + LockModeType.PESSIMISTIC_WRITE + ); + if (metadata == null + || metadata.getOperationToken() != null + || GraveStatus.valueOf(metadata.getState()) != GraveStatus.ORPHANED_WORLD) { + return false; + } + long remaining = metadata.getPausedRemainingMillis() == null + ? 0L + : Math.max(0L, metadata.getPausedRemainingMillis()); + GraveStatus target = metadata.getItemEntryCount() == 0 && metadata.getRemainingExperience() == 0 + ? GraveStatus.CLAIMED + : metadata.getPayloadRevision() > 0L ? GraveStatus.PARTIAL : GraveStatus.ACTIVE; + metadata.setState(target.name()); + metadata.setExpiresActiveMillis(Math.addExact(activeNow, remaining)); + metadata.setPausedRemainingMillis(null); + metadata.setUpdatedAt(System.currentTimeMillis()); + persistAudit( + session, + grave, + null, + "WORLD_RESTORED", + null, + GraveStatus.ORPHANED_WORLD, + target, + grave.itemEntryCount(), + grave.itemEntryCount(), + grave.remainingExperience(), + grave.remainingExperience(), + null + ); + return true; + })); + } + + public CompletionStage relocate( + Grave grave, + GraveLocation location, + GravePlacementType type, + UUID actorUuid + ) { + return async(() -> orm.runInTransaction(session -> { + GraveMetadataEntity metadata = session.find( + GraveMetadataEntity.class, + grave.graveId().toString(), + LockModeType.PESSIMISTIC_WRITE + ); + if (metadata == null || metadata.getOperationToken() != null) { + return false; + } + metadata.setWorldUuid(location.worldUuid().toString()); + metadata.setWorldKey(location.worldKey()); + metadata.setGraveX(location.x()); + metadata.setGraveY(location.y()); + metadata.setGraveZ(location.z()); + metadata.setGraveYaw(location.yaw()); + metadata.setPlacementType(type.name()); + metadata.setUpdatedAt(System.currentTimeMillis()); + GraveStatus state = GraveStatus.valueOf(metadata.getState()); + persistAudit( + session, + grave, + null, + "RELOCATED", + actorUuid, + state, + state, + grave.itemEntryCount(), + grave.itemEntryCount(), + grave.remainingExperience(), + grave.remainingExperience(), + "from=" + grave.location() + ", to=" + location + ", type=" + type + ); + return true; + })); + } + + public CompletionStage clearAbandonedOperations( + String serverId, + String inventoryScope, + long startedBeforeMillis, + Set preservedTokens + ) { + Set preserved = preservedTokens.stream().map(UUID::toString).collect(java.util.stream.Collectors.toSet()); + return async(() -> orm.runInTransaction(session -> { + List candidates = session.createSelectionQuery( + "FROM GraveMetadataEntity g WHERE g.serverId = :serverId " + + "AND g.inventoryScope = :scope AND g.operationToken IS NOT NULL " + + "AND (g.operationStartedMillis IS NULL OR g.operationStartedMillis < :cutoff)", + GraveMetadataEntity.class + ) + .setParameter("serverId", serverId) + .setParameter("scope", inventoryScope) + .setParameter("cutoff", startedBeforeMillis) + .setLockMode(LockModeType.PESSIMISTIC_WRITE) + .getResultList(); + int cleared = 0; + long now = System.currentTimeMillis(); + for (GraveMetadataEntity metadata : candidates) { + if (preserved.contains(metadata.getOperationToken())) { + continue; + } + metadata.setOperationToken(null); + metadata.setOperationStartedMillis(null); + metadata.setUpdatedAt(now); + cleared++; + } + return cleared; + })); + } + + public CompletionStage acquireLease( + String scopeKey, + UUID ownerToken, + long leaseDurationMillis + ) { + return async(() -> orm.runInTransaction(session -> { + long now = System.currentTimeMillis(); + GraveLeaseEntity lease = session.find( + GraveLeaseEntity.class, + scopeKey, + LockModeType.PESSIMISTIC_WRITE + ); + if (lease == null) { + lease = new GraveLeaseEntity(); + lease.setScopeKey(scopeKey); + lease.setOwnerToken(ownerToken.toString()); + lease.setExpiresAt(now + leaseDurationMillis); + lease.setUpdatedAt(now); + session.persist(lease); + return true; + } + if (lease.getExpiresAt() > now && !ownerToken.toString().equals(lease.getOwnerToken())) { + return false; + } + lease.setOwnerToken(ownerToken.toString()); + lease.setExpiresAt(now + leaseDurationMillis); + lease.setUpdatedAt(now); + return true; + })); + } + + public CompletionStage renewLease( + String scopeKey, + UUID ownerToken, + long leaseDurationMillis + ) { + return async(() -> orm.runInTransaction(session -> { + long now = System.currentTimeMillis(); + GraveLeaseEntity lease = session.find( + GraveLeaseEntity.class, + scopeKey, + LockModeType.PESSIMISTIC_WRITE + ); + if (lease == null || !ownerToken.toString().equals(lease.getOwnerToken())) { + return false; + } + lease.setExpiresAt(now + leaseDurationMillis); + lease.setUpdatedAt(now); + return true; + })); + } + + public CompletionStage releaseLease(String scopeKey, UUID ownerToken) { + return async(() -> { + orm.runInTransaction(session -> { + GraveLeaseEntity lease = session.find( + GraveLeaseEntity.class, + scopeKey, + LockModeType.PESSIMISTIC_WRITE + ); + if (lease != null && ownerToken.toString().equals(lease.getOwnerToken())) { + lease.setExpiresAt(0L); + lease.setUpdatedAt(System.currentTimeMillis()); + } + return null; + }); + return null; + }); + } + + private CompletionStage async(java.util.function.Supplier operation) { + return feature.getLifecycleManager().getTaskManager().supplyAsync(operation); + } + + private static boolean sameCaptureIdentity(GraveMetadataEntity metadata, Grave grave) { + return grave.ownerUuid().toString().equals(metadata.getOwnerUuid()) + && grave.serverId().equals(metadata.getServerId()) + && grave.inventoryScope().equals(metadata.getInventoryScope()) + && grave.shortId().equals(metadata.getShortId()) + && grave.createdWallMillis() == metadata.getCreatedWallMillis() + && grave.createdActiveMillis() == metadata.getCreatedActiveMillis() + && grave.payloadRevision() == metadata.getPayloadRevision() + && Objects.equals(grave.payloadChecksum(), metadata.getPayloadChecksum()); + } + + private static void validatePayloadEncoding(GravePayloadEntity entity) { + if (entity.getPayloadCodec() != GravePayloadCodec.CODEC_VERSION) { + throw new IllegalStateException("Unsupported Graveyard payload codec " + entity.getPayloadCodec()); + } + if (entity.isCompressed()) { + throw new IllegalStateException("Compressed Graveyard payloads are not supported"); + } + } + + private static boolean isFinalizedClaim( + GraveMetadataEntity metadata, + GravePayloadEntity payload, + GravePayload remaining, + EncodedGravePayload encodedRemaining, + GraveStatus finalStatus + ) { + return metadata != null + && payload != null + && metadata.getOperationToken() == null + && finalStatus.name().equals(metadata.getState()) + && metadata.getPayloadRevision() == remaining.revision() + && metadata.getItemEntryCount() == remaining.entries().size() + && metadata.getRemainingExperience() == remaining.remainingExperience() + && Objects.equals(metadata.getPayloadChecksum(), encodedRemaining.checksum()) + && pausedStateMatches(metadata, finalStatus) + && payload.getPayloadRevision() == remaining.revision() + && Objects.equals(payload.getPayloadChecksum(), encodedRemaining.checksum()) + && Arrays.equals(payload.getPayload(), encodedRemaining.bytes()); + } + + private static boolean pausedStateMatches(GraveMetadataEntity metadata, GraveStatus finalStatus) { + if (finalStatus == GraveStatus.ORPHANED_WORLD || finalStatus == GraveStatus.DELIVERY_PENDING) { + return metadata.getPausedRemainingMillis() != null; + } + return metadata.getPausedRemainingMillis() == null; + } + + private boolean ownsOperation(GraveMetadataEntity metadata, UUID operationToken) { + return metadata != null && operationToken.toString().equals(metadata.getOperationToken()); + } + + private GraveMetadataEntity toEntity(Grave grave, Long ownerPlayerId) { + GraveMetadataEntity entity = new GraveMetadataEntity(); + entity.setGraveId(grave.graveId().toString()); + entity.setShortId(grave.shortId()); + entity.setOwnerUuid(grave.ownerUuid().toString()); + entity.setOwnerPlayerId(ownerPlayerId); + entity.setOwnerName(grave.ownerName()); + entity.setServerId(grave.serverId()); + entity.setInventoryScope(grave.inventoryScope()); + entity.setWorldUuid(grave.location().worldUuid().toString()); + entity.setWorldKey(grave.location().worldKey()); + entity.setDeathX(grave.deathLocation().x()); + entity.setDeathY(grave.deathLocation().y()); + entity.setDeathZ(grave.deathLocation().z()); + entity.setGraveX(grave.location().x()); + entity.setGraveY(grave.location().y()); + entity.setGraveZ(grave.location().z()); + entity.setGraveYaw(grave.location().yaw()); + entity.setPlacementType(grave.placementType().name()); + entity.setState(grave.status().name()); + entity.setCreatedWallMillis(grave.createdWallMillis()); + entity.setCreatedActiveMillis(grave.createdActiveMillis()); + entity.setExpiresActiveMillis(grave.expiresActiveMillis()); + entity.setPausedRemainingMillis(grave.pausedRemainingMillis()); + entity.setItemEntryCount(grave.itemEntryCount()); + entity.setRemainingExperience(grave.remainingExperience()); + entity.setPayloadRevision(grave.payloadRevision()); + entity.setPayloadChecksum(grave.payloadChecksum()); + entity.setOwnerWasVanished(grave.ownerWasVanished()); + entity.setDeathCause(grave.deathCause()); + entity.setUpdatedAt(System.currentTimeMillis()); + return entity; + } + + private GravePayloadEntity toPayloadEntity( + UUID graveId, + long revision, + EncodedGravePayload encoded + ) { + GravePayloadEntity entity = new GravePayloadEntity(); + entity.setGraveId(graveId.toString()); + entity.setPayloadRevision(revision); + entity.setPayloadCodec(GravePayloadCodec.CODEC_VERSION); + entity.setCompressed(false); + entity.setPayload(encoded.bytes()); + entity.setPayloadChecksum(encoded.checksum()); + entity.setUpdatedAt(System.currentTimeMillis()); + return entity; + } + + private Grave toGrave(GraveMetadataEntity entity) { + UUID worldUuid = UUID.fromString(entity.getWorldUuid()); + GraveLocation death = new GraveLocation( + worldUuid, + entity.getWorldKey(), + entity.getDeathX(), + entity.getDeathY(), + entity.getDeathZ(), + entity.getGraveYaw() + ); + GraveLocation location = new GraveLocation( + worldUuid, + entity.getWorldKey(), + entity.getGraveX(), + entity.getGraveY(), + entity.getGraveZ(), + entity.getGraveYaw() + ); + return new Grave( + UUID.fromString(entity.getGraveId()), + entity.getShortId(), + UUID.fromString(entity.getOwnerUuid()), + entity.getOwnerName(), + entity.getServerId(), + entity.getInventoryScope(), + death, + location, + GravePlacementType.valueOf(entity.getPlacementType()), + GraveStatus.valueOf(entity.getState()), + entity.getCreatedWallMillis(), + entity.getCreatedActiveMillis(), + entity.getExpiresActiveMillis(), + entity.getPausedRemainingMillis(), + entity.getItemEntryCount(), + entity.getRemainingExperience(), + entity.getPayloadRevision(), + entity.getPayloadChecksum(), + entity.getDeathCause(), + entity.isOwnerWasVanished() + ); + } + + private void persistAudit( + org.hibernate.Session session, + Grave grave, + UUID operationToken, + String action, + UUID actorUuid, + GraveStatus oldStatus, + GraveStatus newStatus, + int itemsBefore, + int itemsAfter, + int experienceBefore, + int experienceAfter, + String details + ) { + GraveAuditEntity audit = new GraveAuditEntity(); + audit.setGraveId(grave.graveId().toString()); + audit.setOperationToken(operationToken == null ? null : operationToken.toString()); + audit.setAction(action); + audit.setActorUuid(actorUuid == null ? null : actorUuid.toString()); + audit.setOldState(oldStatus == null ? null : oldStatus.name()); + audit.setNewState(newStatus == null ? null : newStatus.name()); + audit.setItemCountBefore(itemsBefore); + audit.setItemCountAfter(itemsAfter); + audit.setExperienceBefore(experienceBefore); + audit.setExperienceAfter(experienceAfter); + audit.setServerId(grave.serverId()); + audit.setDetails(details); + audit.setCreatedAt(System.currentTimeMillis()); + session.persist(audit); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/placement/GravePlacementResult.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/placement/GravePlacementResult.java new file mode 100644 index 00000000..5ad44204 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/placement/GravePlacementResult.java @@ -0,0 +1,7 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.placement; + +import nl.hauntedmc.serverfeatures.features.graveyard.model.GraveLocation; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GravePlacementType; + +public record GravePlacementResult(GraveLocation location, GravePlacementType type) { +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/placement/GravePlacementService.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/placement/GravePlacementService.java new file mode 100644 index 00000000..40325ebe --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/placement/GravePlacementService.java @@ -0,0 +1,172 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.placement; + +import nl.hauntedmc.serverfeatures.features.graveyard.config.GraveyardSettings; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GraveLocation; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GravePlacementType; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +/** + * Searches only already-loaded chunks and validates a practical nearby interaction position. + */ +public final class GravePlacementService { + private final GraveyardSettings settings; + private final LastSafeLocationTracker safeLocationTracker; + + public GravePlacementService( + GraveyardSettings settings, + LastSafeLocationTracker safeLocationTracker + ) { + this.settings = settings; + this.safeLocationTracker = safeLocationTracker; + } + + public GravePlacementResult place(Player player, Location deathLocation) { + Optional exact = validate(normalize(deathLocation)); + if (exact.isPresent()) { + return result(exact.get(), GravePlacementType.DEATH_LOCATION); + } + + for (Location candidate : nearbyCandidates(deathLocation)) { + Optional validated = validate(candidate); + if (validated.isPresent()) { + return result(validated.get(), GravePlacementType.NEARBY); + } + } + + Optional lastSafe = safeLocationTracker.find(player).flatMap(this::validate); + if (lastSafe.isPresent()) { + return result(lastSafe.get(), GravePlacementType.LAST_SAFE); + } + + Optional surface = surfaceCandidate(deathLocation).flatMap(this::validate); + if (surface.isPresent()) { + return result(surface.get(), GravePlacementType.SURFACE); + } + + return result(normalize(deathLocation), GravePlacementType.REMOTE_ONLY); + } + + public boolean isReachable(GraveLocation graveLocation) { + return graveLocation.resolve().flatMap(this::validate).isPresent(); + } + + public Optional validateRelocation(Location requested) { + return validate(normalize(requested)) + .map(location -> result(location, GravePlacementType.NEARBY)); + } + + private List nearbyCandidates(Location origin) { + List candidates = new ArrayList<>(); + for (int radius = 0; radius <= settings.horizontalSearchRadius(); radius++) { + for (int x = -radius; x <= radius; x++) { + for (int z = -radius; z <= radius; z++) { + if (Math.max(Math.abs(x), Math.abs(z)) != radius) { + continue; + } + for (int y = -settings.verticalSearchBelow(); y <= settings.verticalSearchAbove(); y++) { + candidates.add(normalize(origin.clone().add(x, y, z))); + } + } + } + } + candidates.sort(Comparator.comparingDouble(origin::distanceSquared)); + return candidates; + } + + private Optional surfaceCandidate(Location origin) { + World world = origin.getWorld(); + if (world == null) { + return Optional.empty(); + } + int blockX = origin.getBlockX(); + int blockZ = origin.getBlockZ(); + if (!world.isChunkLoaded(blockX >> 4, blockZ >> 4)) { + return Optional.empty(); + } + Block highest = world.getHighestBlockAt(blockX, blockZ); + return Optional.of(new Location( + world, + blockX + 0.5, + highest.getY() + 1.0, + blockZ + 0.5, + origin.getYaw(), + 0.0f + )); + } + + private Optional validate(Location candidate) { + World world = candidate.getWorld(); + if (world == null + || !world.isChunkLoaded(candidate.getBlockX() >> 4, candidate.getBlockZ() >> 4) + || candidate.getY() <= world.getMinHeight() + || candidate.getY() + 2.0 >= world.getMaxHeight() + || !world.getWorldBorder().isInside(candidate)) { + return Optional.empty(); + } + Block feet = candidate.getBlock(); + Block head = candidate.clone().add(0.0, 1.0, 0.0).getBlock(); + Block support = candidate.clone().add(0.0, -0.1, 0.0).getBlock(); + if (!feet.isPassable() + || !head.isPassable() + || !support.getType().isSolid() + || LastSafeLocationTracker.isHazard(feet.getType()) + || LastSafeLocationTracker.isHazard(head.getType()) + || LastSafeLocationTracker.isHazard(support.getType())) { + return Optional.empty(); + } + if (!hasInteractionPosition(candidate)) { + return Optional.empty(); + } + return Optional.of(candidate.clone()); + } + + private boolean hasInteractionPosition(Location grave) { + for (int x = -2; x <= 2; x++) { + for (int z = -2; z <= 2; z++) { + if (x == 0 && z == 0) { + continue; + } + Location stand = grave.clone().add(x, 0.0, z); + World world = stand.getWorld(); + if (world == null || !world.isChunkLoaded(stand.getBlockX() >> 4, stand.getBlockZ() >> 4)) { + continue; + } + Block feet = stand.getBlock(); + Block head = stand.clone().add(0.0, 1.0, 0.0).getBlock(); + Block support = stand.clone().add(0.0, -0.1, 0.0).getBlock(); + if (feet.isPassable() + && head.isPassable() + && support.getType().isSolid() + && !LastSafeLocationTracker.isHazard(feet.getType()) + && !LastSafeLocationTracker.isHazard(head.getType()) + && !LastSafeLocationTracker.isHazard(support.getType())) { + return true; + } + } + } + return false; + } + + private GravePlacementResult result(Location location, GravePlacementType type) { + return new GravePlacementResult(GraveLocation.from(location), type); + } + + private static Location normalize(Location source) { + return new Location( + source.getWorld(), + source.getBlockX() + 0.5, + Math.floor(source.getY()), + source.getBlockZ() + 0.5, + source.getYaw(), + 0.0f + ); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/placement/LastSafeLocationTracker.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/placement/LastSafeLocationTracker.java new file mode 100644 index 00000000..66a2236e --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/placement/LastSafeLocationTracker.java @@ -0,0 +1,95 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.placement; + +import nl.hauntedmc.serverfeatures.api.util.BukkitTime; +import nl.hauntedmc.serverfeatures.features.graveyard.Graveyard; +import nl.hauntedmc.serverfeatures.features.graveyard.config.GraveyardSettings; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class LastSafeLocationTracker { + private final GraveyardSettings settings; + private final Map locations = new ConcurrentHashMap<>(); + + public LastSafeLocationTracker(Graveyard feature, GraveyardSettings settings) { + this.settings = settings; + feature.getLifecycleManager().getTaskManager().scheduleRepeatingTask( + this::sampleOnlinePlayers, + BukkitTime.ticks(20L), + BukkitTime.ticks(20L) + ); + } + + public Optional find(Player player) { + SafeLocation safe = locations.get(player.getUniqueId()); + if (safe == null || System.currentTimeMillis() - safe.capturedAtMillis() > settings.lastSafeMaxAgeMillis()) { + return Optional.empty(); + } + Location location = safe.location(); + if (location.getWorld() == null || !location.getWorld().equals(player.getWorld())) { + return Optional.empty(); + } + return Optional.of(location.clone()); + } + + public void remove(UUID playerId) { + locations.remove(playerId); + } + + private void sampleOnlinePlayers() { + for (Player player : Bukkit.getOnlinePlayers()) { + if (isSafe(player)) { + locations.put( + player.getUniqueId(), + new SafeLocation(player.getLocation().clone(), System.currentTimeMillis()) + ); + } + } + } + + private boolean isSafe(Player player) { + if (player.isDead() + || player.isFlying() + || player.isGliding() + || player.isInsideVehicle() + || player.isSwimming()) { + return false; + } + Location feet = player.getLocation(); + Block feetBlock = feet.getBlock(); + Block headBlock = feet.clone().add(0.0, 1.0, 0.0).getBlock(); + Block support = feet.clone().add(0.0, -0.1, 0.0).getBlock(); + return feetBlock.isPassable() + && headBlock.isPassable() + && support.getType().isSolid() + && !isHazard(feetBlock.getType()) + && !isHazard(headBlock.getType()) + && !isHazard(support.getType()); + } + + static boolean isHazard(Material material) { + return switch (material) { + case LAVA, FIRE, SOUL_FIRE, CACTUS, MAGMA_BLOCK, POWDER_SNOW, + NETHER_PORTAL, END_PORTAL, END_GATEWAY -> true; + default -> false; + }; + } + + private record SafeLocation(Location location, long capturedAtMillis) { + private SafeLocation { + location = location.clone(); + } + + @Override + public Location location() { + return location.clone(); + } + } +} 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 new file mode 100644 index 00000000..723064f1 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveManager.java @@ -0,0 +1,2167 @@ +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; +import nl.hauntedmc.serverfeatures.api.graveyard.GraveSnapshot; +import nl.hauntedmc.serverfeatures.api.graveyard.GraveStatus; +import nl.hauntedmc.serverfeatures.api.graveyard.GraveyardService; +import nl.hauntedmc.serverfeatures.api.util.BukkitTime; +import nl.hauntedmc.serverfeatures.features.graveyard.Graveyard; +import nl.hauntedmc.serverfeatures.features.graveyard.capture.PlayerInventoryState; +import nl.hauntedmc.serverfeatures.features.graveyard.claim.ClaimTransferPlan; +import nl.hauntedmc.serverfeatures.features.graveyard.claim.GraveClaimPlanner; +import nl.hauntedmc.serverfeatures.features.graveyard.config.GraveyardSettings; +import nl.hauntedmc.serverfeatures.features.graveyard.journal.CaptureJournalRecord; +import nl.hauntedmc.serverfeatures.features.graveyard.journal.CaptureJournalState; +import nl.hauntedmc.serverfeatures.features.graveyard.journal.ClaimJournalRecord; +import nl.hauntedmc.serverfeatures.features.graveyard.journal.ClaimJournalState; +import nl.hauntedmc.serverfeatures.features.graveyard.journal.GraveOperationJournal; +import nl.hauntedmc.serverfeatures.features.graveyard.journal.PlayerOperationReceiptService; +import nl.hauntedmc.serverfeatures.features.graveyard.model.Grave; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GraveLocation; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GravePayload; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GravePlacementType; +import nl.hauntedmc.serverfeatures.features.graveyard.packet.GravePacketIdentity; +import nl.hauntedmc.serverfeatures.features.graveyard.packet.GravePacketRenderer; +import nl.hauntedmc.serverfeatures.features.graveyard.packet.GraveViewerState; +import nl.hauntedmc.serverfeatures.features.graveyard.persistence.EncodedGravePayload; +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 org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.World; +import org.bukkit.entity.Player; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; + +/** + * Runtime authority for grave indexing, visibility, expiry and effectively-once claims. + */ +public final class GraveManager implements GraveyardService { + public static final String ADMIN_PERMISSION = "serverfeatures.feature.graveyard.admin"; + public static final String ADMIN_DELIVER_PERMISSION = + "serverfeatures.feature.graveyard.admin.deliver"; + public static final String INSPECT_PERMISSION = + "serverfeatures.feature.graveyard.admin.inspect"; + + private static final Set CLAIMABLE_STATES = EnumSet.of( + GraveStatus.ACTIVE, + GraveStatus.PARTIAL, + GraveStatus.ORPHANED_WORLD, + GraveStatus.DELIVERY_PENDING + ); + private static final Set COMMAND_VISIBLE_STATES = EnumSet.copyOf(CLAIMABLE_STATES); + private static final Set EXPIRABLE_STATES = EnumSet.of( + GraveStatus.ACTIVE, + GraveStatus.PARTIAL + ); + private static final Set RESTORABLE_STATES = EnumSet.of( + GraveStatus.EXPIRED, + GraveStatus.ORPHANED_WORLD + ); + private static final Set PURGEABLE_STATES = EnumSet.of( + GraveStatus.EXPIRED, + GraveStatus.CORRUPT, + GraveStatus.ADMIN_RECOVERED + ); + + private final Graveyard feature; + private final GraveyardSettings settings; + private final GraveRepository repository; + private final GraveOperationJournal journal; + private final PlayerOperationReceiptService receipts; + private final GravePayloadCodec payloadCodec; + private final GraveClaimPlanner claimPlanner; + private final GravePlacementService placementService; + private final GravePacketRenderer renderer; + private final GraveSpatialIndex spatialIndex = new GraveSpatialIndex(); + + private final Map graves = new ConcurrentHashMap<>(); + private final Map shortIds = new ConcurrentHashMap<>(); + private final Map> ownerIndex = new ConcurrentHashMap<>(); + private final Map payloadCache = new ConcurrentHashMap<>(); + private final Map unprojectedCaptures = new ConcurrentHashMap<>(); + private final Map unresolvedCaptures = new ConcurrentHashMap<>(); + private final Map pendingClaims = new ConcurrentHashMap<>(); + private final Map unresolvedClaims = new ConcurrentHashMap<>(); + + private final Map packetIdentities = new HashMap<>(); + private final Map> viewerStates = new HashMap<>(); + private final Map interactionEntities = new ConcurrentHashMap<>(); + private final Set activeOperations = ConcurrentHashMap.newKeySet(); + private final Set activeOwnerOperations = ConcurrentHashMap.newKeySet(); + private final Map pendingOperationReleases = new ConcurrentHashMap<>(); + private final Map pendingWorldPauses = new ConcurrentHashMap<>(); + private final Set projectionInFlight = ConcurrentHashMap.newKeySet(); + private final Map trackedGraves = new ConcurrentHashMap<>(); + private final Map unauthorizedMessageTimes = new ConcurrentHashMap<>(); + + private final UUID leaseOwnerToken = UUID.randomUUID(); + private final String leaseScopeKey; + private final AtomicBoolean mutable = new AtomicBoolean(); + private final AtomicBoolean leaseRequestInFlight = new AtomicBoolean(); + private final AtomicBoolean runtimeLoadInFlight = new AtomicBoolean(); + private final AtomicBoolean runtimeLoadComplete = new AtomicBoolean(); + private final AtomicBoolean retentionSweepInFlight = new AtomicBoolean(); + private final AtomicBoolean shuttingDown = new AtomicBoolean(); + private volatile long nextRuntimeLoadAttemptMillis; + private volatile long nextRetentionSweepMillis; + + public GraveManager( + Graveyard feature, + GraveyardSettings settings, + GraveRepository repository, + GraveOperationJournal journal, + PlayerOperationReceiptService receipts, + GravePayloadCodec payloadCodec, + GravePlacementService placementService + ) { + this.feature = feature; + this.settings = settings; + this.repository = repository; + this.journal = journal; + this.receipts = receipts; + this.payloadCodec = payloadCodec; + this.claimPlanner = new GraveClaimPlanner(payloadCodec, settings.partialClaims()); + this.placementService = placementService; + this.renderer = new GravePacketRenderer(settings, feature.getPlugin().getLogger()); + this.leaseScopeKey = settings.serverId() + ":" + settings.inventoryScope(); + } + + public void initialize() { + recoverLocalJournals(); + loadPersistedGraves(); + initializeOnlinePlayers(); + maintainLease(); + feature.getLifecycleManager().getTaskManager().scheduleRepeatingTask( + this::tick, + BukkitTime.ticks(settings.reconciliationTicks()), + BukkitTime.ticks(settings.reconciliationTicks()) + ); + long heartbeatTicks = Math.max(20L, (settings.leaseHeartbeatMillis() + 49L) / 50L); + feature.getLifecycleManager().getTaskManager().scheduleAsyncRepeatingTask( + this::maintainLease, + BukkitTime.ticks(heartbeatTicks), + BukkitTime.ticks(heartbeatTicks) + ); + } + + public boolean canMutate() { + return mutable.get() && !shuttingDown.get(); + } + + public boolean isInteractionEntity(int entityId) { + return interactionEntities.containsKey(entityId); + } + + public void acceptAmbiguousCapture(CaptureJournalRecord record) { + unresolvedCaptures.put(record.operationToken(), record); + } + + public void acceptCommittedCapture(CaptureJournalRecord record, GravePayload payload) { + payloadCache.put(record.grave().graveId(), payload); + unprojectedCaptures.put(record.grave().graveId(), record); + activate(record.grave()); + projectCapture(record); + } + + public void handleInteraction(Player player, int entityId) { + InteractionHandle handle = interactionEntities.get(entityId); + if (handle == null || !player.isOnline()) { + return; + } + Grave grave = graves.get(handle.graveId()); + GravePacketIdentity identity = packetIdentities.get(handle.graveId()); + GraveViewerState state = viewerStates.getOrDefault(handle.graveId(), Map.of()) + .get(player.getUniqueId()); + if (grave == null + || identity == null + || state == null + || !state.spawned() + || handle.generation() != identity.generation() + || state.generation() != identity.generation()) { + return; + } + if (!validatePhysicalInteraction(player, grave)) { + return; + } + if (grave.ownerUuid().equals(player.getUniqueId())) { + requestClaimForPlayer(grave, player, player, ClaimReason.PHYSICAL_INTERACTION, null); + return; + } + if (hasAdminPermission(player, ADMIN_DELIVER_PERMISSION)) { + player.sendMessage(feature.getLocalizationHandler() + .getMessage("graveyard.staff_use_deliver") + .with("grave_id", grave.shortId()) + .with("player", grave.ownerName()) + .forAudience(player) + .build()); + return; + } + long now = System.currentTimeMillis(); + long previous = unauthorizedMessageTimes.getOrDefault(player.getUniqueId(), 0L); + if (now - previous >= 3_000L) { + unauthorizedMessageTimes.put(player.getUniqueId(), now); + player.sendMessage(feature.getLocalizationHandler() + .getMessage("graveyard.not_owner") + .with("player", grave.ownerName()) + .forAudience(player) + .build()); + } + } + + @Override + public Optional find(UUID graveId) { + Grave grave = graves.get(graveId); + return grave == null + ? Optional.empty() + : Optional.of(grave.snapshot(feature.getPlugin().getServerActiveClock().nowMillis())); + } + + @Override + public Optional findByShortId(String shortId) { + UUID graveId = shortIds.get(normalizeShortId(shortId)); + return graveId == null ? Optional.empty() : find(graveId); + } + + public Optional findRuntime(String identifier) { + if (identifier == null || identifier.isBlank()) { + return Optional.empty(); + } + UUID byShort = shortIds.get(normalizeShortId(identifier)); + if (byShort != null) { + return Optional.ofNullable(graves.get(byShort)); + } + try { + return Optional.ofNullable(graves.get(UUID.fromString(identifier))); + } catch (IllegalArgumentException ignored) { + return Optional.empty(); + } + } + + @Override + public List findActiveByOwner(UUID ownerUuid) { + long activeNow = feature.getPlugin().getServerActiveClock().nowMillis(); + return ownerIndex.getOrDefault(ownerUuid, Set.of()).stream() + .map(graves::get) + .filter(java.util.Objects::nonNull) + .filter(grave -> isOwnerListable(grave.status())) + .sorted(Comparator.comparingLong(Grave::createdWallMillis).reversed()) + .map(grave -> grave.snapshot(activeNow)) + .toList(); + } + + public List findByOwnerIdentifier(String identifier) { + if (identifier == null || identifier.isBlank()) { + return List.of(); + } + String normalized = identifier.trim(); + UUID ownerUuid = null; + try { + ownerUuid = UUID.fromString(normalized); + } catch (IllegalArgumentException ignored) { + // Fall back to the last known owner name stored with the grave. + } + long activeNow = feature.getPlugin().getServerActiveClock().nowMillis(); + UUID resolvedUuid = ownerUuid; + return graves.values().stream() + .filter(grave -> isOwnerListable(grave.status())) + .filter(grave -> resolvedUuid == null + ? grave.ownerName().equalsIgnoreCase(normalized) + : grave.ownerUuid().equals(resolvedUuid)) + .sorted(Comparator.comparingLong(Grave::createdWallMillis).reversed()) + .map(grave -> grave.snapshot(activeNow)) + .toList(); + } + + public List allRuntimeGraves() { + long activeNow = feature.getPlugin().getServerActiveClock().nowMillis(); + return graves.values().stream() + .filter(grave -> grave.status() != GraveStatus.PURGED) + .sorted(Comparator.comparingLong(Grave::createdWallMillis).reversed()) + .map(grave -> grave.snapshot(activeNow)) + .toList(); + } + + public List allActiveRuntimeGraves() { + return snapshotsForStates(COMMAND_VISIBLE_STATES); + } + + public List allRestorableRuntimeGraves() { + return snapshotsForStates(RESTORABLE_STATES); + } + + public List allPurgeableRuntimeGraves() { + return snapshotsForStates(PURGEABLE_STATES); + } + + public Optional findActiveRuntime(String identifier) { + return findRuntime(identifier).filter(grave -> isOwnerListable(grave.status())); + } + + private List snapshotsForStates(Set states) { + long activeNow = feature.getPlugin().getServerActiveClock().nowMillis(); + return graves.values().stream() + .filter(grave -> states.contains(grave.status())) + .sorted(Comparator.comparingLong(Grave::createdWallMillis).reversed()) + .map(grave -> grave.snapshot(activeNow)) + .toList(); + } + + @Override + public CompletionStage requestClaim( + UUID graveId, + UUID ownerUuid, + ClaimReason reason + ) { + CompletableFuture result = new CompletableFuture<>(); + runMain(() -> { + Grave grave = graves.get(graveId); + Player owner = Bukkit.getPlayer(ownerUuid); + if (grave == null || owner == null || !owner.isOnline()) { + result.complete(result(graveId, GraveClaimOutcome.NOT_FOUND, "Owner or grave is unavailable")); + return; + } + if (!grave.ownerUuid().equals(ownerUuid)) { + result.complete(result(graveId, GraveClaimOutcome.NOT_OWNER, "Owner mismatch")); + return; + } + if (reason == null) { + result.complete(result(graveId, GraveClaimOutcome.NOT_CLAIMABLE, "Claim reason is required")); + return; + } + if (reason == ClaimReason.PHYSICAL_INTERACTION && !validatePhysicalInteraction(owner, grave)) { + result.complete(result(graveId, GraveClaimOutcome.NOT_CLAIMABLE, "Physical interaction is invalid")); + return; + } + if (reason == ClaimReason.REMOTE_UNREACHABLE + && grave.placementType() != GravePlacementType.REMOTE_ONLY + && grave.status() != GraveStatus.ORPHANED_WORLD + && grave.status() != GraveStatus.DELIVERY_PENDING) { + result.complete(result( + graveId, + GraveClaimOutcome.NOT_CLAIMABLE, + "This grave must be claimed at its location" + )); + return; + } + if (reason != ClaimReason.PHYSICAL_INTERACTION && reason != ClaimReason.REMOTE_UNREACHABLE) { + result.complete(result(graveId, GraveClaimOutcome.NOT_CLAIMABLE, "Internal claim reason")); + return; + } + requestClaimForPlayer(grave, owner, owner, reason, result); + }); + return result; + } + + public CompletionStage deliver(Player actor, Grave grave) { + CompletableFuture result = new CompletableFuture<>(); + runMain(() -> { + if (!hasAdminPermission(actor, ADMIN_DELIVER_PERMISSION)) { + result.complete(result(grave.graveId(), GraveClaimOutcome.NOT_OWNER, "No permission")); + return; + } + if (!canMutate()) { + result.complete(result(grave.graveId(), GraveClaimOutcome.FAILED, "Graveyard is read-only")); + return; + } + Player owner = Bukkit.getPlayer(grave.ownerUuid()); + if (owner != null && owner.isOnline()) { + requestClaimForPlayer(grave, owner, actor, ClaimReason.STAFF_DELIVERY, result); + return; + } + queuePendingDelivery(actor, grave, result); + }); + return result; + } + + public CompletionStage expire(Player actor, Grave grave) { + return transitionAndApply( + actor, + grave, + EnumSet.of( + GraveStatus.ACTIVE, + GraveStatus.PARTIAL, + GraveStatus.ORPHANED_WORLD, + GraveStatus.DELIVERY_PENDING + ), + GraveStatus.EXPIRED, + "ADMIN_EXPIRED" + ); + } + + public CompletionStage restore(Player actor, Grave grave) { + CompletableFuture result = new CompletableFuture<>(); + if (!beginAdministrativeOperation(grave, result)) { + return result; + } + long activeNow = feature.getPlugin().getServerActiveClock().nowMillis(); + boolean worldAvailable = grave.location().resolve().isPresent(); + repository.restore( + grave, + actor == null ? null : actor.getUniqueId(), + activeNow, + settings.lifetimeMillis(), + worldAvailable + ).whenComplete((success, failure) -> runMain(() -> { + activeOperations.remove(grave.graveId()); + if (failure != null || !Boolean.TRUE.equals(success)) { + result.complete(false); + return; + } + if (worldAvailable) { + grave.restore(activeNow, settings.lifetimeMillis()); + } else { + grave.pause(settings.lifetimeMillis(), GraveStatus.ORPHANED_WORLD); + } + activate(grave); + result.complete(true); + })); + return result; + } + + public CompletionStage purge(Player actor, Grave grave) { + CompletableFuture result = new CompletableFuture<>(); + if (!beginAdministrativeOperation(grave, result)) { + return result; + } + repository.purge(grave, actor == null ? null : actor.getUniqueId()) + .whenComplete((success, failure) -> runMain(() -> { + activeOperations.remove(grave.graveId()); + if (failure != null || !Boolean.TRUE.equals(success)) { + if (failure != null) { + feature.getLogger().log( + Level.SEVERE, + "Could not permanently purge grave " + grave.graveId(), + failure + ); + } + result.complete(false); + return; + } + grave.setStatus(GraveStatus.PURGED); + removeRuntimeGrave(grave); + result.complete(true); + })); + return result; + } + + public boolean track(Player player, Grave grave) { + if (!isOwnerListable(grave.status())) { + return false; + } + if (!grave.ownerUuid().equals(player.getUniqueId()) + && !hasAdminPermission(player, INSPECT_PERMISSION)) { + return false; + } + trackedGraves.put(player.getUniqueId(), grave.graveId()); + return true; + } + + public void stopTracking(Player player) { + trackedGraves.remove(player.getUniqueId()); + } + + public void onPlayerJoin(Player player) { + recoverCaptureReceipt(player); + recoverClaimReceipt(player); + tryNextPendingDelivery(player); + reconcileViewer(player); + } + + public void onPlayerRespawn(Player player) { + feature.getLifecycleManager().getTaskManager().scheduleDelayedTask(() -> { + if (!player.isOnline()) { + return; + } + recoverCaptureReceipt(player); + recoverClaimReceipt(player); + tryNextPendingDelivery(player); + reconcileViewer(player); + }, BukkitTime.ticks(1L)); + } + + public void onPlayerQuit(Player player) { + forgetViewer(player.getUniqueId()); + trackedGraves.remove(player.getUniqueId()); + unauthorizedMessageTimes.remove(player.getUniqueId()); + } + + public void onPlayerTransition(Player player) { + hideViewer(player); + feature.getLifecycleManager().getTaskManager().scheduleDelayedTask( + () -> reconcileViewer(player), + BukkitTime.ticks(settings.spawnSettleTicks()) + ); + } + + public void onWorldUnload(World world) { + for (Grave grave : graves.values()) { + if (grave.location().worldUuid().equals(world.getUID())) { + pauseForOrphanedWorld(grave); + } + } + } + + public void onWorldLoad(World world) { + if (!canMutate()) { + return; + } + long activeNow = feature.getPlugin().getServerActiveClock().nowMillis(); + for (Grave grave : graves.values()) { + if (!grave.location().worldUuid().equals(world.getUID()) + || !grave.location().worldKey().equals(world.getKey().asString())) { + continue; + } + if (grave.status() == GraveStatus.ORPHANED_WORLD + && grave.pausedRemainingMillis() != null + && !pendingWorldPauses.containsKey(grave.graveId())) { + resumeAvailableOrphan(grave, activeNow); + } else if (grave.status().isVisible()) { + spatialIndex.put(grave); + reconcileNearby(grave); + } + } + } + + public CompletionStage relocate(Player actor, Grave grave, Location requested) { + CompletableFuture result = new CompletableFuture<>(); + if (!beginAdministrativeOperation(grave, result)) { + return result; + } + Optional placement = + placementService.validateRelocation(requested); + if (placement.isEmpty()) { + activeOperations.remove(grave.graveId()); + result.complete(false); + return result; + } + GraveLocation oldLocation = grave.location(); + var next = placement.get(); + repository.relocate(grave, next.location(), next.type(), actor.getUniqueId()) + .whenComplete((success, failure) -> runMain(() -> { + activeOperations.remove(grave.graveId()); + if (failure != null || !Boolean.TRUE.equals(success)) { + result.complete(false); + return; + } + spatialIndex.remove(grave.graveId()); + hideGrave(grave.graveId()); + grave.relocate(next.location(), next.type()); + if (grave.status().isVisible()) { + spatialIndex.put(grave); + reconcileNearby(grave); + } + feature.getLogger().info( + "Relocated grave " + grave.graveId() + " from " + oldLocation + " to " + next.location() + + " by " + actor.getName() + ); + result.complete(true); + })); + return result; + } + + public void shutdown() { + if (!shuttingDown.compareAndSet(false, true)) { + return; + } + mutable.set(false); + destroyAllPacketEntities(); + spatialIndex.clear(); + repository.releaseLease(leaseScopeKey, leaseOwnerToken); + } + + public String diagnostics() { + return "mutable=" + mutable.get() + + ", graves=" + graves.size() + + ", visiblePairs=" + visiblePairCount() + + ", unprojected=" + unprojectedCaptures.size() + + ", unresolvedCaptures=" + unresolvedCaptures.size() + + ", pendingClaims=" + pendingClaims.size() + + ", unresolvedClaims=" + unresolvedClaims.size() + + ", pendingReleases=" + pendingOperationReleases.size() + + ", pendingWorldPauses=" + pendingWorldPauses.size() + + ", projections=" + projectionInFlight.size() + + ", retentionSweep=" + retentionSweepInFlight.get() + + ", activeOperations=" + activeOperations.size(); + } + + private void requestClaimForPlayer( + Grave grave, + Player owner, + Player actor, + ClaimReason reason, + CompletableFuture externalResult + ) { + CompletableFuture result = externalResult == null + ? new CompletableFuture<>() + : externalResult; + if (!canMutate()) { + completeClaim(result, grave, GraveClaimOutcome.FAILED, 0, 0, 0, "Graveyard is read-only"); + return; + } + if (!grave.ownerUuid().equals(owner.getUniqueId())) { + completeClaim(result, grave, GraveClaimOutcome.NOT_OWNER, 0, 0, 0, "Owner mismatch"); + return; + } + if (!settings.inventoryScope().equals(grave.inventoryScope())) { + completeClaim(result, grave, GraveClaimOutcome.WRONG_INVENTORY_SCOPE, 0, 0, 0, "Wrong inventory scope"); + return; + } + if (!CLAIMABLE_STATES.contains(grave.status())) { + completeClaim(result, grave, GraveClaimOutcome.NOT_CLAIMABLE, 0, 0, 0, "Grave is not claimable"); + return; + } + UUID ownerUuid = owner.getUniqueId(); + if (hasPendingOwnerOperation(ownerUuid) || !activeOwnerOperations.add(ownerUuid)) { + completeClaim(result, grave, GraveClaimOutcome.BUSY, 0, 0, 0, "Owner claim already in progress"); + return; + } + if (!activeOperations.add(grave.graveId())) { + activeOwnerOperations.remove(ownerUuid); + completeClaim(result, grave, GraveClaimOutcome.BUSY, 0, 0, 0, "Claim already in progress"); + return; + } + + UUID operationToken = UUID.randomUUID(); + repository.reserveOperation(grave.graveId(), operationToken, CLAIMABLE_STATES) + .whenComplete((reserved, reserveFailure) -> { + if (reserveFailure != null || !Boolean.TRUE.equals(reserved)) { + runMain(() -> { + releaseClaimLocks(grave, ownerUuid); + completeClaim(result, grave, GraveClaimOutcome.BUSY, 0, 0, 0, "Could not reserve grave"); + }); + return; + } + loadDecodedPayload(grave).whenComplete((payload, payloadFailure) -> runMain(() -> { + if (payloadFailure != null || payload == null) { + releaseFailedClaim(grave, ownerUuid, operationToken, result, payloadFailure); + return; + } + applyClaim(grave, owner, actor, reason, operationToken, payload, result); + })); + }); + } + + private void applyClaim( + Grave grave, + Player owner, + Player actor, + ClaimReason reason, + UUID operationToken, + GravePayload payload, + CompletableFuture result + ) { + if (!owner.isOnline()) { + releaseFailedClaim(grave, owner.getUniqueId(), operationToken, result, null); + return; + } + PlayerInventoryState beforeInventory = PlayerInventoryState.capture(owner); + int beforeExperience = owner.calculateTotalExperiencePoints(); + ClaimJournalRecord prepared = null; + boolean saveAttempted = false; + try { + ClaimTransferPlan plan = claimPlanner.plan(beforeInventory, payload); + if (!plan.changed()) { + releaseReservedOperation(grave.graveId(), operationToken, () -> { + releaseClaimLocks(grave, owner.getUniqueId()); + completeClaim( + result, + grave, + GraveClaimOutcome.NOTHING_FIT, + 0, + payload.entries().size(), + 0, + "No inventory space" + ); + }); + owner.sendMessage(feature.getLocalizationHandler() + .getMessage("graveyard.inventory_full") + .with("grave_id", grave.shortId()) + .forAudience(owner) + .build()); + return; + } + + EncodedGravePayload encodedRemaining = payloadCodec.encode(plan.remainingPayload()); + prepared = new ClaimJournalRecord( + operationToken, + ClaimJournalState.PREPARED, + grave.graveId(), + owner.getUniqueId(), + actor.getUniqueId(), + payload.revision(), + plan.transferredEntries(), + plan.transferredExperience(), + encodedRemaining + ); + journal.writeClaim(prepared); + receipts.putClaim(owner, operationToken, grave.graveId()); + plan.resultingInventory().apply(owner); + if (plan.transferredExperience() > 0) { + owner.giveExp(plan.transferredExperience()); + } + saveAttempted = true; + owner.saveData(); + ClaimJournalRecord applied = prepared.withState(ClaimJournalState.PLAYER_APPLIED); + try { + journal.writeClaim(applied); + pendingClaims.put(operationToken, applied); + } catch (IOException journalFailure) { + unresolvedClaims.put(operationToken, prepared); + feature.getLogger().log( + Level.SEVERE, + "Playerdata was saved for claim " + operationToken + + " but its PLAYER_APPLIED journal transition failed. " + + "The persisted player receipt will drive recovery.", + journalFailure + ); + } + hideGrave(grave.graveId()); + finalizeAppliedClaim(grave, owner, actor, payload, plan, applied, result, reason); + } catch (IOException | RuntimeException exception) { + if (!saveAttempted) { + rollbackClaimBeforeSave( + grave, + owner, + beforeInventory, + beforeExperience, + operationToken, + prepared, + payload, + result, + exception + ); + return; + } + if (prepared != null) { + unresolvedClaims.put(operationToken, prepared); + } + hideGrave(grave.graveId()); + releaseClaimLocks(grave, owner.getUniqueId()); + feature.getLogger().log( + Level.SEVERE, + "Claim " + operationToken + " reached the playerdata-save boundary; " + + "it will be resolved from the receipt without rolling back inventory.", + exception + ); + owner.sendMessage(feature.getLocalizationHandler() + .getMessage("graveyard.claim_recovery_pending") + .with("grave_id", grave.shortId()) + .forAudience(owner) + .build()); + completeClaim( + result, + grave, + GraveClaimOutcome.RECOVERY_PENDING, + prepared == null ? 0 : prepared.transferredEntries(), + prepared == null ? payload.entries().size() : -1, + prepared == null ? 0 : prepared.transferredExperience(), + "Claim recovery pending" + ); + } + } + + private void rollbackClaimBeforeSave( + Grave grave, + Player owner, + PlayerInventoryState beforeInventory, + int beforeExperience, + UUID operationToken, + ClaimJournalRecord prepared, + GravePayload payload, + CompletableFuture result, + Throwable failure + ) { + try { + beforeInventory.apply(owner); + owner.setExperienceLevelAndProgress(beforeExperience); + receipts.clearClaim(owner); + } catch (RuntimeException rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + releaseReservedOperation(grave.graveId(), operationToken, () -> { + if (prepared != null) { + try { + journal.writeClaim(prepared.withState(ClaimJournalState.ABORTED)); + journal.deleteClaim(prepared.operationToken()); + } catch (IOException journalFailure) { + failure.addSuppressed(journalFailure); + } + } + releaseClaimLocks(grave, owner.getUniqueId()); + feature.getLogger().log(Level.SEVERE, "Could not apply claim for grave " + grave.graveId(), failure); + completeClaim(result, grave, GraveClaimOutcome.FAILED, 0, payload.entries().size(), 0, "Claim failed"); + }); + } + + private void finalizeAppliedClaim( + Grave grave, + Player owner, + Player actor, + GravePayload previous, + ClaimTransferPlan plan, + ClaimJournalRecord journalRecord, + CompletableFuture result, + ClaimReason reason + ) { + GraveStatus finalStatus = claimFinalStatus(grave, plan.remainingPayload()); + repository.finalizeClaim( + grave, + journalRecord.operationToken(), + actor.getUniqueId(), + previous, + plan.remainingPayload(), + journalRecord.remainingPayload(), + finalStatus, + plan.transferredEntries(), + plan.transferredExperience() + ).whenComplete((success, failure) -> runMain(() -> { + if (failure != null || !Boolean.TRUE.equals(success)) { + feature.getLogger().log( + Level.SEVERE, + "Claim " + journalRecord.operationToken() + " was saved to playerdata but not finalized; " + + "the recovery journal will retry it.", + failure + ); + releaseClaimLocks(grave, owner.getUniqueId()); + completeClaim( + result, + grave, + GraveClaimOutcome.RECOVERY_PENDING, + plan.transferredEntries(), + plan.remainingPayload().entries().size(), + plan.transferredExperience(), + "Claim applied; durable database finalization is pending" + ); + return; + } + completeFinalizedClaim( + grave, + owner, + plan.remainingPayload(), + journalRecord, + finalStatus, + result, + reason + ); + })); + } + + private void completeFinalizedClaim( + Grave grave, + Player owner, + GravePayload remaining, + ClaimJournalRecord journalRecord, + GraveStatus finalStatus, + CompletableFuture result, + ClaimReason reason + ) { + grave.updatePayload(remaining, journalRecord.remainingPayload().checksum(), finalStatus); + if (finalStatus == GraveStatus.ORPHANED_WORLD) { + pendingWorldPauses.remove(grave.graveId()); + } + payloadCache.put(grave.graveId(), remaining); + pendingClaims.remove(journalRecord.operationToken()); + unresolvedClaims.remove(journalRecord.operationToken()); + releaseClaimLocks(grave, owner.getUniqueId()); + receipts.clearClaim(owner); + try { + owner.saveData(); + } catch (RuntimeException exception) { + feature.getLogger().warning("Could not persist claim-receipt cleanup for " + owner.getName()); + } + try { + journal.writeClaim(journalRecord.withState(ClaimJournalState.GRAVE_FINALIZED)); + journal.deleteClaim(journalRecord.operationToken()); + } catch (IOException exception) { + feature.getLogger().log(Level.WARNING, "Could not clean finalized Graveyard claim journal.", exception); + } + + if (finalStatus == GraveStatus.CLAIMED) { + spatialIndex.remove(grave.graveId()); + trackedGraves.values().removeIf(grave.graveId()::equals); + playEffect(grave, settings.claimParticle(), settings.claimSound(), 24); + } else if (finalStatus.isVisible()) { + spatialIndex.put(grave); + reconcileNearby(grave); + } else { + spatialIndex.remove(grave.graveId()); + } + owner.sendMessage(feature.getLocalizationHandler() + .getMessage(finalStatus == GraveStatus.CLAIMED + ? "graveyard.claimed" + : "graveyard.partially_claimed") + .with("grave_id", grave.shortId()) + .with("remaining", Integer.toString(remaining.entries().size())) + .forAudience(owner) + .build()); + completeClaim( + result, + grave, + finalStatus == GraveStatus.CLAIMED + ? GraveClaimOutcome.CLAIMED + : GraveClaimOutcome.PARTIAL, + journalRecord.transferredEntries(), + remaining.entries().size(), + journalRecord.transferredExperience(), + reason.name() + ); + if (finalStatus == GraveStatus.CLAIMED) { + tryNextPendingDelivery(owner); + } + } + + private void queuePendingDelivery( + Player actor, + Grave grave, + CompletableFuture result + ) { + if (!canMutate() || !activeOperations.add(grave.graveId())) { + result.complete(result(grave.graveId(), GraveClaimOutcome.BUSY, "Grave is busy or read-only")); + return; + } + long remaining = grave.remainingActiveMillis(feature.getPlugin().getServerActiveClock().nowMillis()); + repository.pauseForState( + grave, + EnumSet.of(GraveStatus.ACTIVE, GraveStatus.PARTIAL, GraveStatus.ORPHANED_WORLD), + GraveStatus.DELIVERY_PENDING, + remaining, + actor.getUniqueId(), + "DELIVERY_PENDING" + ).whenComplete((success, failure) -> runMain(() -> { + activeOperations.remove(grave.graveId()); + if (failure != null || !Boolean.TRUE.equals(success)) { + result.complete(result(grave.graveId(), GraveClaimOutcome.FAILED, "Could not queue delivery")); + return; + } + grave.pause(remaining, GraveStatus.DELIVERY_PENDING); + hideGrave(grave.graveId()); + spatialIndex.remove(grave.graveId()); + result.complete(result(grave.graveId(), GraveClaimOutcome.DELIVERY_QUEUED, "Delivery queued")); + })); + } + + private CompletionStage transitionAndApply( + Player actor, + Grave grave, + Set expected, + GraveStatus target, + String action + ) { + CompletableFuture result = new CompletableFuture<>(); + if (!beginAdministrativeOperation(grave, result)) { + return result; + } + repository.transitionState( + grave, + expected, + target, + actor == null ? null : actor.getUniqueId(), + action, + null + ).whenComplete((success, failure) -> runMain(() -> { + activeOperations.remove(grave.graveId()); + if (failure != null || !Boolean.TRUE.equals(success)) { + result.complete(false); + return; + } + grave.setStatus(target); + if (!target.isVisible()) { + hideGrave(grave.graveId()); + spatialIndex.remove(grave.graveId()); + } + if (target == GraveStatus.EXPIRED) { + trackedGraves.values().removeIf(grave.graveId()::equals); + playEffect(grave, settings.expiryParticle(), settings.expirySound(), 32); + } + result.complete(true); + })); + return result; + } + + private CompletionStage loadDecodedPayload(Grave grave) { + GravePayload cached = payloadCache.get(grave.graveId()); + if (cached != null && cached.revision() == grave.payloadRevision()) { + return CompletableFuture.completedFuture(cached); + } + return repository.loadPayload(grave).thenApply(encoded -> { + if (encoded.isEmpty()) { + throw new IllegalStateException("Missing payload for grave " + grave.graveId()); + } + try { + GravePayload payload = payloadCodec.decode(encoded.get().bytes(), encoded.get().checksum()); + if (payload.revision() != grave.payloadRevision()) { + throw new IOException( + "Grave payload revision mismatch: expected " + grave.payloadRevision() + + " but decoded " + payload.revision() + ); + } + payloadCache.put(grave.graveId(), payload); + return payload; + } catch (IOException exception) { + throw new IllegalStateException("Corrupt payload for grave " + grave.graveId(), exception); + } + }); + } + + private void releaseFailedClaim( + Grave grave, + UUID ownerUuid, + UUID operationToken, + CompletableFuture result, + Throwable failure + ) { + boolean corruptPayload = isCorruptPayloadFailure(failure); + releaseReservedOperation(grave.graveId(), operationToken, () -> { + releaseClaimLocks(grave, ownerUuid); + if (corruptPayload) { + quarantineCorruptPayload(grave, failure); + } + }); + if (failure != null) { + feature.getLogger().log(Level.SEVERE, "Could not load grave payload " + grave.graveId(), failure); + } + completeClaim(result, grave, GraveClaimOutcome.FAILED, 0, grave.itemEntryCount(), 0, "Claim failed"); + } + + private void releaseReservedOperation(UUID graveId, UUID operationToken, Runnable completion) { + PendingOperationRelease pending = new PendingOperationRelease(operationToken, completion); + pendingOperationReleases.put(graveId, pending); + repository.releaseOperation(graveId, operationToken).whenComplete((released, failure) -> runMain(() -> { + if (failure != null || !Boolean.TRUE.equals(released)) { + if (failure != null) { + feature.getLogger().log( + Level.SEVERE, + "Could not release grave operation " + operationToken + "; it will be retried.", + failure + ); + } + return; + } + if (pendingOperationReleases.remove(graveId, pending)) { + completion.run(); + } + })); + } + + private void retryPendingOperationReleases() { + for (Map.Entry entry : List.copyOf(pendingOperationReleases.entrySet())) { + UUID graveId = entry.getKey(); + PendingOperationRelease pending = entry.getValue(); + repository.releaseOperation(graveId, pending.operationToken()) + .whenComplete((released, failure) -> runMain(() -> { + if (failure == null + && Boolean.TRUE.equals(released) + && pendingOperationReleases.remove(graveId, pending)) { + pending.completion().run(); + } + })); + } + } + + private void releaseClaimLocks(Grave grave, UUID ownerUuid) { + activeOperations.remove(grave.graveId()); + activeOwnerOperations.remove(ownerUuid); + } + + private boolean hasPendingOwnerOperation(UUID ownerUuid) { + return pendingClaims.values().stream().anyMatch(record -> record.ownerUuid().equals(ownerUuid)) + || unresolvedClaims.values().stream().anyMatch(record -> record.ownerUuid().equals(ownerUuid)); + } + + private void quarantineCorruptPayload(Grave grave, Throwable failure) { + repository.transitionState( + grave, + CLAIMABLE_STATES, + GraveStatus.CORRUPT, + null, + "PAYLOAD_CORRUPT", + failure == null ? null : failure.getMessage() + ).whenComplete((success, transitionFailure) -> runMain(() -> { + if (transitionFailure != null || !Boolean.TRUE.equals(success)) { + feature.getLogger().log( + Level.SEVERE, + "Could not quarantine corrupt grave payload " + grave.graveId(), + transitionFailure + ); + return; + } + grave.setStatus(GraveStatus.CORRUPT); + payloadCache.remove(grave.graveId()); + hideGrave(grave.graveId()); + spatialIndex.remove(grave.graveId()); + })); + } + + private boolean isCorruptPayloadFailure(Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof IOException) { + return true; + } + String message = current.getMessage(); + if (message != null + && (message.startsWith("Corrupt payload") + || message.startsWith("Missing payload") + || message.startsWith("Unsupported Graveyard payload codec") + || message.startsWith("Compressed Graveyard payload") + || message.startsWith("Graveyard payload metadata mismatch") + || message.startsWith("Grave payload revision mismatch"))) { + return true; + } + current = current.getCause(); + } + return false; + } + + private void completeClaim( + CompletableFuture result, + Grave grave, + GraveClaimOutcome outcome, + int transferredEntries, + int remainingEntries, + int transferredExperience, + String message + ) { + if (result != null && !result.isDone()) { + result.complete(new GraveClaimResult( + grave.graveId(), + outcome, + transferredEntries, + remainingEntries, + transferredExperience, + message + )); + } + } + + private GraveClaimResult result(UUID graveId, GraveClaimOutcome outcome, String message) { + return new GraveClaimResult(graveId, outcome, 0, 0, 0, message); + } + + private void maintainLease() { + if (shuttingDown.get() || !leaseRequestInFlight.compareAndSet(false, true)) { + return; + } + CompletionStage request = mutable.get() + ? repository.renewLease(leaseScopeKey, leaseOwnerToken, settings.leaseDurationMillis()) + : repository.acquireLease(leaseScopeKey, leaseOwnerToken, settings.leaseDurationMillis()); + request.whenComplete((success, failure) -> { + leaseRequestInFlight.set(false); + if (failure != null || !Boolean.TRUE.equals(success)) { + if (mutable.getAndSet(false)) { + feature.getLogger().log( + Level.SEVERE, + "Graveyard lost its instance lease; all mutations are disabled until it is reacquired.", + failure + ); + } + return; + } + boolean newlyAcquired = !mutable.getAndSet(true); + if (newlyAcquired) { + feature.getLogger().info("Acquired Graveyard instance lease for " + leaseScopeKey); + clearAbandonedOperations(); + } + }); + } + + private void clearAbandonedOperations() { + Set preserved = new HashSet<>(); + pendingClaims.values().forEach(record -> preserved.add(record.operationToken())); + unresolvedClaims.values().forEach(record -> preserved.add(record.operationToken())); + try { + preserved.addAll(journal.quarantinedClaimTokens()); + } catch (IOException exception) { + feature.getLogger().log( + Level.SEVERE, + "Could not inspect quarantined Graveyard claim journals; stale reservations will not be cleared.", + exception + ); + return; + } + long cutoff = System.currentTimeMillis() - settings.leaseDurationMillis(); + repository.clearAbandonedOperations( + settings.serverId(), + settings.inventoryScope(), + cutoff, + preserved + ).whenComplete((cleared, failure) -> { + if (failure != null) { + feature.getLogger().log(Level.SEVERE, "Could not clear abandoned Graveyard operations.", failure); + } else if (cleared != null && cleared > 0) { + feature.getLogger().warning("Cleared " + cleared + " abandoned Graveyard operation reservations."); + } + }); + } + + private void tick() { + if (shuttingDown.get()) { + return; + } + loadPersistedGraves(); + pauseUnavailableWorlds(); + retryPendingWorldPauses(); + resumeAvailableOrphans(); + expireDueGraves(); + retryUnprojectedCaptures(); + retryPendingOperationReleases(); + retryUnresolvedClaimSavesForOnlineOwners(); + retryPendingClaimsForOnlineOwners(); + purgeRetainedGraves(); + for (Player player : Bukkit.getOnlinePlayers()) { + reconcileViewer(player); + updateTracking(player); + } + } + + private void purgeRetainedGraves() { + long now = System.currentTimeMillis(); + if (!canMutate() + || now < nextRetentionSweepMillis + || !retentionSweepInFlight.compareAndSet(false, true)) { + return; + } + long expiredCutoff = Math.max(0L, now - settings.expiredRetentionMillis()); + long claimedCutoff = Math.max(0L, now - settings.claimedRetentionMillis()); + repository.purgeRetained( + settings.serverId(), + settings.inventoryScope(), + expiredCutoff, + claimedCutoff, + settings.purgeBatchSize() + ).whenComplete((purged, failure) -> runMain(() -> { + retentionSweepInFlight.set(false); + if (failure != null) { + nextRetentionSweepMillis = System.currentTimeMillis() + + Math.min(60_000L, settings.purgeIntervalMillis()); + feature.getLogger().log(Level.WARNING, "Could not purge retained Graveyard records.", failure); + return; + } + if (purged != null) { + for (UUID graveId : purged) { + Grave grave = graves.get(graveId); + if (grave != null) { + removeRuntimeGrave(grave); + } + } + if (!purged.isEmpty()) { + feature.getLogger().info("Purged " + purged.size() + " retained Graveyard records."); + } + } + boolean batchWasFull = purged != null && purged.size() >= settings.purgeBatchSize(); + nextRetentionSweepMillis = System.currentTimeMillis() + + (batchWasFull ? 1_000L : settings.purgeIntervalMillis()); + })); + } + + private void pauseUnavailableWorlds() { + for (Grave grave : graves.values()) { + if (EXPIRABLE_STATES.contains(grave.status()) + && grave.placementType() != GravePlacementType.REMOTE_ONLY + && grave.location().resolve().isEmpty()) { + pauseForOrphanedWorld(grave); + } + } + } + + private void retryPendingWorldPauses() { + if (!canMutate()) { + return; + } + for (Map.Entry entry : List.copyOf(pendingWorldPauses.entrySet())) { + Grave grave = graves.get(entry.getKey()); + if (grave == null || grave.status() != GraveStatus.ORPHANED_WORLD) { + pendingWorldPauses.remove(entry.getKey(), entry.getValue()); + continue; + } + if (!activeOperations.add(grave.graveId())) { + continue; + } + repository.pauseForState( + grave, + EXPIRABLE_STATES, + GraveStatus.ORPHANED_WORLD, + entry.getValue(), + null, + "WORLD_UNAVAILABLE" + ).whenComplete((success, failure) -> runMain(() -> { + activeOperations.remove(grave.graveId()); + if (failure == null && Boolean.TRUE.equals(success)) { + pendingWorldPauses.remove(grave.graveId(), entry.getValue()); + } + })); + } + } + + private void resumeAvailableOrphans() { + if (!canMutate()) { + return; + } + long activeNow = feature.getPlugin().getServerActiveClock().nowMillis(); + for (Grave grave : graves.values()) { + if (grave.status() != GraveStatus.ORPHANED_WORLD + || grave.pausedRemainingMillis() == null + || pendingWorldPauses.containsKey(grave.graveId()) + || grave.location().resolve().isEmpty()) { + continue; + } + resumeAvailableOrphan(grave, activeNow); + } + } + + private void resumeAvailableOrphan(Grave grave, long activeNow) { + CaptureJournalRecord local = unprojectedCaptures.get(grave.graveId()); + if (local != null) { + grave.resume(activeNow); + try { + journal.writeCapture(local); + } catch (IOException exception) { + grave.pause(grave.remainingActiveMillis(activeNow), GraveStatus.ORPHANED_WORLD); + feature.getLogger().log(Level.SEVERE, "Could not resume journal-backed grave.", exception); + return; + } + activate(grave); + reconcileNearby(grave); + return; + } + if (!activeOperations.add(grave.graveId())) { + return; + } + repository.resumeOrphaned(grave, activeNow).whenComplete((success, failure) -> runMain(() -> { + activeOperations.remove(grave.graveId()); + if (failure != null || !Boolean.TRUE.equals(success)) { + return; + } + grave.resume(activeNow); + activate(grave); + reconcileNearby(grave); + })); + } + + private void expireDueGraves() { + if (!canMutate()) { + return; + } + long activeNow = feature.getPlugin().getServerActiveClock().nowMillis(); + for (Grave grave : graves.values()) { + if (!EXPIRABLE_STATES.contains(grave.status()) + || grave.remainingActiveMillis(activeNow) > 0L + || activeOperations.contains(grave.graveId())) { + continue; + } + if (!activeOperations.add(grave.graveId())) { + continue; + } + repository.transitionState( + grave, + EXPIRABLE_STATES, + GraveStatus.EXPIRED, + null, + "EXPIRED", + null + ).whenComplete((success, failure) -> runMain(() -> { + activeOperations.remove(grave.graveId()); + if (failure != null || !Boolean.TRUE.equals(success)) { + return; + } + grave.setStatus(GraveStatus.EXPIRED); + hideGrave(grave.graveId()); + spatialIndex.remove(grave.graveId()); + trackedGraves.values().removeIf(grave.graveId()::equals); + playEffect(grave, settings.expiryParticle(), settings.expirySound(), 32); + })); + } + } + + private void reconcileViewer(Player viewer) { + if (!viewer.isOnline()) { + return; + } + int radiusChunks = (int) Math.ceil(settings.despawnDistance() / 16.0); + List candidates = spatialIndex.nearby( + viewer.getWorld().getUID(), + viewer.getLocation().getBlockX() >> 4, + viewer.getLocation().getBlockZ() >> 4, + radiusChunks + ).stream() + .map(graves::get) + .filter(java.util.Objects::nonNull) + .sorted(Comparator + .comparing((Grave grave) -> !grave.ownerUuid().equals(viewer.getUniqueId())) + .thenComparingDouble(grave -> distanceSquared(viewer, grave))) + .toList(); + + Set desired = new HashSet<>(); + int rendered = 0; + for (Grave grave : candidates) { + GraveViewerState current = viewerStates + .computeIfAbsent(grave.graveId(), ignored -> new HashMap<>()) + .get(viewer.getUniqueId()); + boolean currentlySpawned = current != null && current.spawned(); + if (rendered >= settings.maxRenderedPerViewer() + || !shouldShow(viewer, grave, currentlySpawned)) { + continue; + } + desired.add(grave.graveId()); + rendered++; + ensureShown(grave, viewer); + } + + for (Map.Entry> entry : viewerStates.entrySet()) { + GraveViewerState state = entry.getValue().get(viewer.getUniqueId()); + if (state != null && state.spawned() && !desired.contains(entry.getKey())) { + hide(entry.getKey(), viewer); + } + } + } + + private boolean shouldShow(Player viewer, Grave grave, boolean currentlySpawned) { + if (!grave.status().isVisible() + || grave.placementType() == GravePlacementType.REMOTE_ONLY + || !viewer.getWorld().getUID().equals(grave.location().worldUuid())) { + return false; + } + Optional resolved = grave.location().resolve(); + if (resolved.isEmpty()) { + return false; + } + if (!viewer.isChunkSent(Chunk.getChunkKey(grave.location().chunkX(), grave.location().chunkZ()))) { + return false; + } + double maximum = currentlySpawned ? settings.despawnDistance() : settings.spawnDistance(); + if (viewer.getLocation().distanceSquared(resolved.get()) > maximum * maximum) { + return false; + } + return !grave.ownerWasVanished() + || grave.ownerUuid().equals(viewer.getUniqueId()) + || hasAdminPermission(viewer, INSPECT_PERMISSION); + } + + private void ensureShown(Grave grave, Player viewer) { + GravePacketIdentity identity = packetIdentities.computeIfAbsent( + grave.graveId(), + ignored -> createPacketIdentity(grave) + ); + Map graveViewers = viewerStates.computeIfAbsent( + grave.graveId(), + ignored -> new HashMap<>() + ); + GraveViewerState state = graveViewers.computeIfAbsent( + viewer.getUniqueId(), + ignored -> new GraveViewerState() + ); + String timer = timerText(grave); + 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); + } catch (RuntimeException exception) { + state.markHidden(); + feature.getLogger().warning( + "Could not render grave " + grave.graveId() + " for " + viewer.getName() + + ": " + exception.getMessage() + ); + } + return; + } + if (!timer.equals(state.renderedTimer())) { + try { + renderer.updateTimer(grave, identity, viewer, timer); + state.setRenderedTimer(timer); + } catch (RuntimeException exception) { + feature.getLogger().warning( + "Could not update grave timer " + grave.graveId() + " for " + viewer.getName() + ); + } + } + } + + private GravePacketIdentity createPacketIdentity(Grave grave) { + World world = grave.location().resolve() + .orElseThrow(() -> new IllegalStateException("Cannot allocate packet ids for an unavailable grave")) + .getWorld(); + GravePacketIdentity identity = GravePacketIdentity.create(grave.rotateVisualGeneration(), world); + interactionEntities.put( + identity.interactionEntityId(), + new InteractionHandle(grave.graveId(), identity.generation()) + ); + return identity; + } + + private void hide(UUID graveId, Player viewer) { + GravePacketIdentity identity = packetIdentities.get(graveId); + Map states = viewerStates.get(graveId); + GraveViewerState state = states == null ? null : states.get(viewer.getUniqueId()); + if (identity != null && state != null && state.spawned()) { + renderer.destroy(identity, viewer); + state.markHidden(); + } + } + + private void hideGrave(UUID graveId) { + GravePacketIdentity identity = packetIdentities.remove(graveId); + if (identity != null) { + interactionEntities.remove(identity.interactionEntityId()); + } + Map states = viewerStates.remove(graveId); + if (identity == null || states == null) { + return; + } + for (Map.Entry entry : states.entrySet()) { + Player viewer = Bukkit.getPlayer(entry.getKey()); + if (viewer != null && entry.getValue().spawned()) { + renderer.destroy(identity, viewer); + } + } + } + + private void hideViewer(Player viewer) { + for (UUID graveId : new ArrayList<>(viewerStates.keySet())) { + hide(graveId, viewer); + Map states = viewerStates.get(graveId); + if (states != null) { + states.remove(viewer.getUniqueId()); + } + } + } + + private void forgetViewer(UUID viewerId) { + for (Map states : viewerStates.values()) { + states.remove(viewerId); + } + } + + private void destroyAllPacketEntities() { + for (UUID graveId : new ArrayList<>(packetIdentities.keySet())) { + hideGrave(graveId); + } + packetIdentities.clear(); + viewerStates.clear(); + interactionEntities.clear(); + } + + private void removeRuntimeGrave(Grave grave) { + hideGrave(grave.graveId()); + spatialIndex.remove(grave.graveId()); + payloadCache.remove(grave.graveId()); + graves.remove(grave.graveId(), grave); + shortIds.remove(normalizeShortId(grave.shortId()), grave.graveId()); + Set ownerGraves = ownerIndex.get(grave.ownerUuid()); + if (ownerGraves != null) { + ownerGraves.remove(grave.graveId()); + if (ownerGraves.isEmpty()) { + ownerIndex.remove(grave.ownerUuid(), ownerGraves); + } + } + trackedGraves.values().removeIf(grave.graveId()::equals); + activeOperations.remove(grave.graveId()); + pendingWorldPauses.remove(grave.graveId()); + projectionInFlight.remove(grave.graveId()); + } + + private void pauseForOrphanedWorld(Grave grave) { + hideGrave(grave.graveId()); + spatialIndex.remove(grave.graveId()); + if (!EXPIRABLE_STATES.contains(grave.status())) { + return; + } + long remaining = grave.remainingActiveMillis(feature.getPlugin().getServerActiveClock().nowMillis()); + GraveStatus previousStatus = grave.status(); + CaptureJournalRecord local = unprojectedCaptures.get(grave.graveId()); + if (local != null) { + grave.pause(remaining, GraveStatus.ORPHANED_WORLD); + try { + journal.writeCapture(local); + } catch (IOException exception) { + feature.getLogger().log( + Level.SEVERE, + "Could not persist orphaned state for journal-backed grave " + grave.graveId(), + exception + ); + } + return; + } + if (!canMutate()) { + return; + } + grave.pause(remaining, GraveStatus.ORPHANED_WORLD); + pendingWorldPauses.put(grave.graveId(), remaining); + if (!activeOperations.add(grave.graveId())) { + return; + } + repository.pauseForState( + grave, + EnumSet.of(previousStatus), + GraveStatus.ORPHANED_WORLD, + remaining, + null, + "WORLD_UNAVAILABLE" + ).whenComplete((success, failure) -> runMain(() -> { + activeOperations.remove(grave.graveId()); + if (failure != null || !Boolean.TRUE.equals(success)) { + return; + } + pendingWorldPauses.remove(grave.graveId()); + })); + } + + private void activate(Grave grave) { + Grave previous = graves.put(grave.graveId(), grave); + if (previous != null && previous != grave) { + hideGrave(previous.graveId()); + spatialIndex.remove(previous.graveId()); + } + shortIds.put(normalizeShortId(grave.shortId()), grave.graveId()); + ownerIndex.computeIfAbsent(grave.ownerUuid(), ignored -> ConcurrentHashMap.newKeySet()) + .add(grave.graveId()); + if (grave.status().isVisible() && grave.placementType() != GravePlacementType.REMOTE_ONLY) { + if (grave.location().resolve().isPresent()) { + spatialIndex.put(grave); + } else { + pauseForOrphanedWorld(grave); + } + } + } + + private void projectCapture(CaptureJournalRecord record) { + if (!canMutate() || !projectionInFlight.add(record.grave().graveId())) { + return; + } + repository.saveCaptured(record.grave(), record.payload()) + .whenComplete((ignored, failure) -> { + projectionInFlight.remove(record.grave().graveId()); + if (failure != null) { + feature.getLogger().log( + Level.WARNING, + "Grave " + record.grave().graveId() + + " remains journal-backed because database projection failed.", + failure + ); + return; + } + try { + journal.writeCapture(record.withState(CaptureJournalState.PROJECTED)); + journal.deleteCapture(record.grave().graveId()); + unprojectedCaptures.remove(record.grave().graveId()); + } catch (IOException exception) { + feature.getLogger().log(Level.WARNING, "Could not clean projected capture journal.", exception); + } + }); + } + + private void retryUnprojectedCaptures() { + if (!canMutate()) { + return; + } + for (CaptureJournalRecord record : List.copyOf(unprojectedCaptures.values())) { + projectCapture(record); + } + } + + private void recoverLocalJournals() { + try { + for (CaptureJournalRecord record : journal.loadCaptures()) { + switch (record.state()) { + case COMMITTED -> { + GravePayload payload = payloadCodec.decode( + record.payload().bytes(), + record.payload().checksum() + ); + payloadCache.put(record.grave().graveId(), payload); + unprojectedCaptures.put(record.grave().graveId(), record); + activate(record.grave()); + } + case PROJECTED, ABORTED -> journal.deleteCapture(record.grave().graveId()); + case PREPARED -> unresolvedCaptures.put(record.operationToken(), record); + } + } + for (ClaimJournalRecord record : journal.loadClaims()) { + switch (record.state()) { + case PLAYER_APPLIED -> pendingClaims.put(record.operationToken(), record); + case PREPARED -> unresolvedClaims.put(record.operationToken(), record); + case GRAVE_FINALIZED, RECOVERED, ABORTED -> journal.deleteClaim(record.operationToken()); + } + } + } catch (IOException exception) { + feature.getLogger().log(Level.SEVERE, "Could not recover Graveyard operation journals.", exception); + } + } + + private void recoverCaptureReceipt(Player player) { + Optional receipt = receipts.capture(player); + UUID protectedToken = null; + if (receipt.isPresent()) { + protectedToken = receipt.get().operationToken(); + CaptureJournalRecord record = unresolvedCaptures.get(protectedToken); + if (record != null && record.grave().graveId().equals(receipt.get().graveId())) { + try { + GravePayload payload = payloadCodec.decode( + record.payload().bytes(), + record.payload().checksum() + ); + CaptureJournalRecord committed = record.withState(CaptureJournalState.COMMITTED); + journal.writeCapture(committed); + unresolvedCaptures.remove(record.operationToken(), record); + acceptCommittedCapture(committed, payload); + clearCaptureReceipt(player); + } catch (IOException | RuntimeException exception) { + feature.getLogger().log(Level.SEVERE, "Could not recover committed grave capture.", exception); + return; + } + } else { + clearCaptureReceipt(player); + protectedToken = null; + } + } + + for (CaptureJournalRecord record : List.copyOf(unresolvedCaptures.values())) { + if (!record.grave().ownerUuid().equals(player.getUniqueId()) + || record.operationToken().equals(protectedToken)) { + continue; + } + try { + journal.writeCapture(record.withState(CaptureJournalState.ABORTED)); + journal.deleteCapture(record.grave().graveId()); + unresolvedCaptures.remove(record.operationToken(), record); + } catch (IOException exception) { + feature.getLogger().warning("Could not clean aborted capture " + record.grave().graveId()); + } + } + } + + private void clearCaptureReceipt(Player player) { + receipts.clearCapture(player); + savePlayerDataSafely(player, "capture receipt cleanup"); + } + + private void recoverClaimReceipt(Player player) { + Optional receipt = receipts.claim(player); + if (receipt.isEmpty()) { + abortUnappliedClaims(player, null); + return; + } + UUID operationToken = receipt.get().operationToken(); + ClaimJournalRecord record = pendingClaims.get(operationToken); + if (record == null) { + ClaimJournalRecord prepared = unresolvedClaims.get(operationToken); + if (prepared != null && prepared.graveId().equals(receipt.get().graveId())) { + ClaimJournalRecord applied = prepared.withState(ClaimJournalState.PLAYER_APPLIED); + try { + journal.writeClaim(applied); + } catch (IOException exception) { + feature.getLogger().log(Level.SEVERE, "Could not promote recovered claim journal.", exception); + return; + } + unresolvedClaims.remove(operationToken, prepared); + pendingClaims.put(operationToken, applied); + record = applied; + } + } + if (record == null || !record.graveId().equals(receipt.get().graveId())) { + receipts.clearClaim(player); + savePlayerDataSafely(player, "stale claim receipt cleanup"); + abortUnappliedClaims(player, null); + return; + } + retryPendingClaim(record, player); + abortUnappliedClaims(player, operationToken); + } + + private void abortUnappliedClaims(Player player, UUID protectedToken) { + for (ClaimJournalRecord unresolved : List.copyOf(unresolvedClaims.values())) { + if (!unresolved.ownerUuid().equals(player.getUniqueId()) + || unresolved.operationToken().equals(protectedToken)) { + continue; + } + releaseReservedOperation(unresolved.graveId(), unresolved.operationToken(), () -> { + try { + journal.writeClaim(unresolved.withState(ClaimJournalState.ABORTED)); + journal.deleteClaim(unresolved.operationToken()); + unresolvedClaims.remove(unresolved.operationToken(), unresolved); + } catch (IOException exception) { + feature.getLogger().warning( + "Could not clean unapplied claim journal " + unresolved.operationToken() + ); + } + }); + } + } + + private void retryUnresolvedClaimSavesForOnlineOwners() { + for (ClaimJournalRecord prepared : List.copyOf(unresolvedClaims.values())) { + Player owner = Bukkit.getPlayer(prepared.ownerUuid()); + if (owner == null || !owner.isOnline()) { + continue; + } + Optional receipt = receipts.claim(owner); + if (receipt.isEmpty() || !receipt.get().operationToken().equals(prepared.operationToken())) { + continue; + } + if (!activeOwnerOperations.add(owner.getUniqueId())) { + continue; + } + try { + owner.saveData(); + ClaimJournalRecord applied = prepared.withState(ClaimJournalState.PLAYER_APPLIED); + journal.writeClaim(applied); + unresolvedClaims.remove(prepared.operationToken(), prepared); + pendingClaims.put(applied.operationToken(), applied); + } catch (IOException | RuntimeException exception) { + activeOwnerOperations.remove(owner.getUniqueId()); + continue; + } + activeOwnerOperations.remove(owner.getUniqueId()); + retryPendingClaim(pendingClaims.get(prepared.operationToken()), owner); + } + } + + private void retryPendingClaimsForOnlineOwners() { + for (ClaimJournalRecord record : List.copyOf(pendingClaims.values())) { + Player owner = Bukkit.getPlayer(record.ownerUuid()); + if (owner != null && owner.isOnline()) { + retryPendingClaim(record, owner); + } + } + } + + private void retryPendingClaim(ClaimJournalRecord record, Player owner) { + if (record == null) { + return; + } + Grave grave = graves.get(record.graveId()); + if (grave == null || !activeOwnerOperations.add(owner.getUniqueId())) { + return; + } + if (!activeOperations.add(grave.graveId())) { + activeOwnerOperations.remove(owner.getUniqueId()); + return; + } + Optional receipt = receipts.claim(owner); + if (receipt.isEmpty() || !receipt.get().operationToken().equals(record.operationToken())) { + releaseReservedOperation(record.graveId(), record.operationToken(), () -> { + try { + journal.writeClaim(record.withState(ClaimJournalState.ABORTED)); + journal.deleteClaim(record.operationToken()); + pendingClaims.remove(record.operationToken(), record); + } catch (IOException exception) { + feature.getLogger().warning("Could not clean unapplied claim journal " + record.operationToken()); + } finally { + releaseClaimLocks(grave, owner.getUniqueId()); + } + }); + return; + } + repository.loadPayloadForRecovery(grave.graveId()).whenComplete((currentEncoded, failure) -> { + if (failure != null || currentEncoded.isEmpty()) { + runMain(() -> releaseClaimLocks(grave, owner.getUniqueId())); + return; + } + try { + GravePayload previous = payloadCodec.decode( + currentEncoded.get().bytes(), + currentEncoded.get().checksum() + ); + GravePayload remaining = payloadCodec.decode( + record.remainingPayload().bytes(), + record.remainingPayload().checksum() + ); + if (previous.revision() != record.previousRevision() + && previous.revision() != remaining.revision()) { + throw new IOException( + "Unexpected durable payload revision " + previous.revision() + + " for recovery operation " + record.operationToken() + ); + } + GraveStatus finalStatus = claimFinalStatus(grave, remaining); + repository.finalizeClaim( + grave, + record.operationToken(), + record.actorUuid(), + previous, + remaining, + record.remainingPayload(), + finalStatus, + record.transferredEntries(), + record.transferredExperience() + ).whenComplete((success, finalizeFailure) -> runMain(() -> { + if (finalizeFailure != null || !Boolean.TRUE.equals(success)) { + releaseClaimLocks(grave, owner.getUniqueId()); + return; + } + completeFinalizedClaim( + grave, + owner, + remaining, + record, + finalStatus, + new CompletableFuture<>(), + ClaimReason.PENDING_DELIVERY + ); + })); + } catch (IOException exception) { + runMain(() -> releaseClaimLocks(grave, owner.getUniqueId())); + feature.getLogger().log(Level.SEVERE, "Could not decode pending claim recovery.", exception); + } + }); + } + + private void tryNextPendingDelivery(Player owner) { + if (!owner.isOnline() + || activeOwnerOperations.contains(owner.getUniqueId()) + || hasPendingOwnerOperation(owner.getUniqueId())) { + return; + } + ownerIndex.getOrDefault(owner.getUniqueId(), Set.of()).stream() + .map(graves::get) + .filter(java.util.Objects::nonNull) + .filter(grave -> grave.status() == GraveStatus.DELIVERY_PENDING) + .min(Comparator.comparingLong(Grave::createdWallMillis)) + .ifPresent(grave -> requestClaimForPlayer( + grave, + owner, + owner, + ClaimReason.PENDING_DELIVERY, + null + )); + } + + private void savePlayerDataSafely(Player player, String operation) { + try { + player.saveData(); + } catch (RuntimeException exception) { + feature.getLogger().log( + Level.WARNING, + "Could not persist Graveyard " + operation + " for " + player.getName(), + exception + ); + } + } + + private void reconcileNearby(Grave grave) { + grave.location().resolve().ifPresent(location -> { + double radiusSquared = settings.despawnDistance() * settings.despawnDistance(); + for (Player player : location.getWorld().getPlayers()) { + if (player.getLocation().distanceSquared(location) <= radiusSquared) { + reconcileViewer(player); + } + } + }); + } + + private void initializeOnlinePlayers() { + for (Player player : Bukkit.getOnlinePlayers()) { + onPlayerJoin(player); + } + } + + private void loadPersistedGraves() { + if (runtimeLoadComplete.get() + || shuttingDown.get() + || System.currentTimeMillis() < nextRuntimeLoadAttemptMillis + || !runtimeLoadInFlight.compareAndSet(false, true)) { + return; + } + repository.loadRuntimeGraves(settings.serverId(), settings.inventoryScope()) + .whenComplete((loaded, failure) -> runMain(() -> { + runtimeLoadInFlight.set(false); + if (failure != null) { + nextRuntimeLoadAttemptMillis = System.currentTimeMillis() + 10_000L; + feature.getLogger().log( + Level.WARNING, + "Could not load persisted Graveyard graves; retrying in 10 seconds.", + failure + ); + return; + } + for (Grave grave : loaded) { + if (!unprojectedCaptures.containsKey(grave.graveId())) { + activate(grave); + } + } + runtimeLoadComplete.set(true); + initializeOnlinePlayers(); + })); + } + + private boolean validatePhysicalInteraction(Player player, Grave grave) { + Optional location = grave.location().resolve(); + if (location.isEmpty() + || !grave.status().isPlayerClaimable() + || !player.getWorld().equals(location.get().getWorld()) + || player.getLocation().distanceSquared(location.get()) + > settings.interactionDistance() * settings.interactionDistance()) { + return false; + } + return !settings.requireLineOfSight() || player.hasLineOfSight(location.get().clone().add(0.0, 0.8, 0.0)); + } + + private int glowColor(Grave grave, Player viewer) { + if (grave.ownerUuid().equals(viewer.getUniqueId())) { + return settings.ownerGlowRgb(); + } + if (hasAdminPermission(viewer, INSPECT_PERMISSION)) { + return settings.staffGlowRgb(); + } + 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 void updateTracking(Player player) { + UUID graveId = trackedGraves.get(player.getUniqueId()); + if (graveId == null) { + return; + } + Grave grave = graves.get(graveId); + if (grave == null || !isOwnerListable(grave.status())) { + trackedGraves.remove(player.getUniqueId()); + return; + } + 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 + )); + return; + } + int distance = (int) Math.round(player.getLocation().distance(location.get())); + player.sendActionBar(Component.text( + "Grave " + grave.shortId() + " · " + distance + "m · " + timerText(grave), + NamedTextColor.AQUA + )); + } + + private void playEffect(Grave grave, Particle particle, Sound sound, int count) { + grave.location().resolve().ifPresent(location -> { + location.getWorld().spawnParticle( + particle, + location.clone().add(0.0, 0.8, 0.0), + count, + 0.6, + 0.8, + 0.6, + 0.02 + ); + location.getWorld().playSound(location, sound, 0.7f, 1.0f); + }); + } + + private int visiblePairCount() { + int count = 0; + for (Map states : viewerStates.values()) { + count += (int) states.values().stream().filter(GraveViewerState::spawned).count(); + } + return count; + } + + private double distanceSquared(Player player, Grave grave) { + return grave.location().resolve() + .filter(location -> location.getWorld().equals(player.getWorld())) + .map(location -> player.getLocation().distanceSquared(location)) + .orElse(Double.MAX_VALUE); + } + + private 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"; + } + if (seconds >= 300L) { + seconds = ((seconds + 9L) / 10L) * 10L; + } + return seconds / 60L + "m " + seconds % 60L + "s"; + } + + private static String normalizeShortId(String shortId) { + return shortId == null ? "" : shortId.trim().toUpperCase(Locale.ROOT); + } + + private boolean beginAdministrativeOperation(Grave grave, CompletableFuture result) { + if (!canMutate() || !activeOperations.add(grave.graveId())) { + result.complete(false); + return false; + } + return true; + } + + private static boolean hasAdminPermission(Player player, String childPermission) { + return player.hasPermission(ADMIN_PERMISSION) || player.hasPermission(childPermission); + } + + public static boolean isOwnerListable(GraveStatus status) { + return COMMAND_VISIBLE_STATES.contains(status); + } + + public static boolean isRestorable(GraveStatus status) { + return RESTORABLE_STATES.contains(status); + } + + public static boolean isPurgeable(GraveStatus status) { + return PURGEABLE_STATES.contains(status); + } + + private static GraveStatus claimFinalStatus(Grave grave, GravePayload remaining) { + if (remaining.isEmpty()) { + return GraveStatus.CLAIMED; + } + return switch (grave.status()) { + case ORPHANED_WORLD -> GraveStatus.ORPHANED_WORLD; + case DELIVERY_PENDING -> GraveStatus.DELIVERY_PENDING; + default -> GraveStatus.PARTIAL; + }; + } + + private void runMain(Runnable action) { + if (Bukkit.isPrimaryThread()) { + action.run(); + } else if (!shuttingDown.get()) { + feature.getLifecycleManager().getTaskManager().scheduleOneTimeTask(action); + } + } + + private record PendingOperationRelease(UUID operationToken, Runnable completion) { + } + + private record InteractionHandle(UUID graveId, long generation) { + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveSpatialIndex.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveSpatialIndex.java new file mode 100644 index 00000000..eef6e56d --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveSpatialIndex.java @@ -0,0 +1,76 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.runtime; + +import nl.hauntedmc.serverfeatures.features.graveyard.model.Grave; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +public final class GraveSpatialIndex { + private final Map>> byWorld = new HashMap<>(); + private final Map positions = new HashMap<>(); + + public synchronized void put(Grave grave) { + remove(grave.graveId()); + UUID worldId = grave.location().worldUuid(); + long chunkKey = chunkKey(grave.location().chunkX(), grave.location().chunkZ()); + byWorld.computeIfAbsent(worldId, ignored -> new HashMap<>()) + .computeIfAbsent(chunkKey, ignored -> new HashSet<>()) + .add(grave.graveId()); + positions.put(grave.graveId(), new IndexedPosition(worldId, chunkKey)); + } + + public synchronized void remove(UUID graveId) { + IndexedPosition position = positions.remove(graveId); + if (position == null) { + return; + } + Map> world = byWorld.get(position.worldId()); + if (world == null) { + return; + } + Set graves = world.get(position.chunkKey()); + if (graves != null) { + graves.remove(graveId); + if (graves.isEmpty()) { + world.remove(position.chunkKey()); + } + } + if (world.isEmpty()) { + byWorld.remove(position.worldId()); + } + } + + public synchronized List nearby(UUID worldId, int centerChunkX, int centerChunkZ, int radiusChunks) { + Map> world = byWorld.get(worldId); + if (world == null) { + return List.of(); + } + List result = new ArrayList<>(); + for (int x = centerChunkX - radiusChunks; x <= centerChunkX + radiusChunks; x++) { + for (int z = centerChunkZ - radiusChunks; z <= centerChunkZ + radiusChunks; z++) { + Set graves = world.get(chunkKey(x, z)); + if (graves != null) { + result.addAll(graves); + } + } + } + return List.copyOf(result); + } + + public synchronized void clear() { + byWorld.clear(); + positions.clear(); + } + + static long chunkKey(int x, int z) { + return ((long) x << 32) ^ (z & 0xFFFFFFFFL); + } + + private record IndexedPosition(UUID worldId, long chunkKey) { + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/time/ServerActiveClock.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/time/ServerActiveClock.java new file mode 100644 index 00000000..0291edb5 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/time/ServerActiveClock.java @@ -0,0 +1,160 @@ +package nl.hauntedmc.serverfeatures.framework.time; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Level; + +/** + * Plugin-scoped monotonic clock that advances only while this backend process is online. + * + *

The checkpoint intentionally excludes unflushed crash time. Consumers can therefore live a few + * seconds longer after a hard crash, but can never expire early because wall-clock time advanced while + * the backend was offline.

+ */ +public final class ServerActiveClock implements AutoCloseable { + private static final long CHECKPOINT_PERIOD_TICKS = 100L; + private static final int CHECKPOINT_BYTES = Long.BYTES; + + private final Plugin plugin; + private final Path checkpointPath; + private final AtomicLong accumulatedMillis = new AtomicLong(); + private final AtomicBoolean closed = new AtomicBoolean(); + + private volatile long sessionStartedNanos; + private volatile BukkitTask checkpointTask; + + public ServerActiveClock(Plugin plugin) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + this.checkpointPath = plugin.getDataFolder().toPath() + .resolve("runtime") + .resolve("server-active-clock.bin"); + } + + public synchronized void start() { + if (sessionStartedNanos != 0L) { + return; + } + accumulatedMillis.set(readCheckpoint()); + sessionStartedNanos = System.nanoTime(); + checkpointTask = Bukkit.getScheduler().runTaskTimerAsynchronously( + plugin, + this::checkpointSafely, + CHECKPOINT_PERIOD_TICKS, + CHECKPOINT_PERIOD_TICKS + ); + } + + public synchronized long nowMillis() { + long started = sessionStartedNanos; + if (started == 0L) { + return accumulatedMillis.get(); + } + long elapsedNanos = Math.max(0L, System.nanoTime() - started); + return Math.addExact(accumulatedMillis.get(), elapsedNanos / 1_000_000L); + } + + public synchronized void checkpoint() throws IOException { + if (closed.get()) { + return; + } + long current = nowMillis(); + accumulatedMillis.set(current); + sessionStartedNanos = System.nanoTime(); + writeCheckpoint(current); + } + + private long readCheckpoint() { + try { + if (!Files.isRegularFile(checkpointPath)) { + return 0L; + } + byte[] bytes = Files.readAllBytes(checkpointPath); + if (bytes.length != CHECKPOINT_BYTES) { + throw new IOException("Unexpected active-clock checkpoint length: " + bytes.length); + } + return Math.max(0L, ByteBuffer.wrap(bytes).getLong()); + } catch (IOException exception) { + plugin.getLogger().log( + Level.WARNING, + "Could not read the server active-clock checkpoint; starting at zero.", + exception + ); + return 0L; + } + } + + private void writeCheckpoint(long value) throws IOException { + Files.createDirectories(checkpointPath.getParent()); + Path temporary = checkpointPath.resolveSibling(checkpointPath.getFileName() + ".tmp"); + byte[] bytes = ByteBuffer.allocate(CHECKPOINT_BYTES).putLong(value).array(); + try (FileChannel channel = FileChannel.open( + temporary, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE + )) { + channel.write(ByteBuffer.wrap(bytes)); + channel.force(true); + } + try { + Files.move( + temporary, + checkpointPath, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(temporary, checkpointPath, StandardCopyOption.REPLACE_EXISTING); + } + forceDirectory(checkpointPath.getParent()); + } + + private static void forceDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } catch (UnsupportedOperationException ignored) { + // Some filesystems do not expose directory fsync through FileChannel. + } + } + + private void checkpointSafely() { + try { + checkpoint(); + } catch (IOException exception) { + plugin.getLogger().log(Level.WARNING, "Could not checkpoint the server active clock.", exception); + } + } + + @Override + public synchronized void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + BukkitTask task = checkpointTask; + checkpointTask = null; + if (task != null) { + task.cancel(); + } + long current = nowMillis(); + accumulatedMillis.set(current); + sessionStartedNanos = 0L; + try { + writeCheckpoint(current); + } catch (IOException exception) { + plugin.getLogger().log(Level.SEVERE, "Could not write the final server active-clock checkpoint.", exception); + } + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/GraveyardTestItemStacks.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/GraveyardTestItemStacks.java new file mode 100644 index 00000000..731f2bb8 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/GraveyardTestItemStacks.java @@ -0,0 +1,78 @@ +package nl.hauntedmc.serverfeatures.features.graveyard; + +import nl.hauntedmc.serverfeatures.features.graveyard.persistence.GravePayloadCodec; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +public final class GraveyardTestItemStacks { + private GraveyardTestItemStacks() { + } + + public static ItemStack stack(Material material, int amount) { + AtomicInteger currentAmount = new AtomicInteger(amount); + ItemStack item = mock(ItemStack.class); + lenient().doAnswer(ignored -> material).when(item).getType(); + lenient().doAnswer(ignored -> currentAmount.get()).when(item).getAmount(); + lenient().doAnswer(invocation -> { + currentAmount.set(invocation.getArgument(0)); + return null; + }).when(item).setAmount(anyInt()); + lenient().doAnswer(ignored -> maximumStackSize(material)).when(item).getMaxStackSize(); + lenient().doAnswer(ignored -> stack(material, currentAmount.get())).when(item).clone(); + lenient().doAnswer(invocation -> { + ItemStack other = invocation.getArgument(0); + return other != null && other.getType() == material; + }).when(item).isSimilar(any(ItemStack.class)); + lenient().doAnswer(ignored -> encode(material, currentAmount.get())).when(item).serializeAsBytes(); + return item; + } + + public static GravePayloadCodec.ItemStackBinaryCodec binaryCodec() { + return new GravePayloadCodec.ItemStackBinaryCodec() { + @Override + public byte[] serialize(ItemStack item) { + return encode(item.getType(), item.getAmount()); + } + + @Override + public ItemStack deserialize(byte[] bytes) { + return decode(bytes); + } + }; + } + + private static byte[] encode(Material material, int amount) { + byte[] name = material.name().getBytes(StandardCharsets.US_ASCII); + ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES + name.length + Integer.BYTES); + buffer.putInt(name.length); + buffer.put(name); + buffer.putInt(amount); + return buffer.array(); + } + + private static ItemStack decode(byte[] encoded) { + ByteBuffer buffer = ByteBuffer.wrap(encoded); + int nameLength = buffer.getInt(); + byte[] name = new byte[nameLength]; + buffer.get(name); + Material material = Material.valueOf(new String(name, StandardCharsets.US_ASCII)); + int amount = buffer.getInt(); + return stack(material, amount); + } + + private static int maximumStackSize(Material material) { + return switch (material) { + case DIAMOND_SWORD -> 1; + default -> 64; + }; + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/PostDeathInventoryBuilderTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/PostDeathInventoryBuilderTest.java new file mode 100644 index 00000000..f263a574 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/capture/PostDeathInventoryBuilderTest.java @@ -0,0 +1,43 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.capture; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static nl.hauntedmc.serverfeatures.features.graveyard.GraveyardTestItemStacks.stack; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PostDeathInventoryBuilderTest { + @Test + void retainedStacksAreSplitSafelyAcrossStorageSlots() { + PlayerInventoryState original = new PlayerInventoryState(); + original.set(5, stack(Material.DIAMOND, 64)); + DeathInventorySnapshot snapshot = new DeathInventorySnapshot(UUID.randomUUID(), original, 0, null, 1L); + + PlayerInventoryState result = new PostDeathInventoryBuilder().build( + snapshot, + List.of(stack(Material.DIAMOND, 70)) + ); + + assertEquals(64, result.get(5).getAmount()); + assertEquals(6, result.get(0).getAmount()); + } + + @Test + void impossibleRetainedSetFailsClosedInsteadOfDroppingItems() { + PlayerInventoryState original = new PlayerInventoryState(); + DeathInventorySnapshot snapshot = new DeathInventorySnapshot(UUID.randomUUID(), original, 0, null, 1L); + List retained = java.util.stream.IntStream.range(0, 37) + .mapToObj(index -> stack(Material.DIAMOND_SWORD, 1)) + .toList(); + + assertThrows( + IllegalStateException.class, + () -> new PostDeathInventoryBuilder().build(snapshot, retained) + ); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/claim/GraveClaimPlannerTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/claim/GraveClaimPlannerTest.java new file mode 100644 index 00000000..f11d1d2b --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/claim/GraveClaimPlannerTest.java @@ -0,0 +1,62 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.claim; + +import nl.hauntedmc.serverfeatures.features.graveyard.capture.PlayerInventoryState; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GraveItemEntry; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GravePayload; +import nl.hauntedmc.serverfeatures.features.graveyard.persistence.GravePayloadCodec; +import org.bukkit.Material; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static nl.hauntedmc.serverfeatures.features.graveyard.GraveyardTestItemStacks.binaryCodec; +import static nl.hauntedmc.serverfeatures.features.graveyard.GraveyardTestItemStacks.stack; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GraveClaimPlannerTest { + private final GravePayloadCodec codec = new GravePayloadCodec( + 64, + 1_000_000, + 4_000_000, + binaryCodec() + ); + + @Test + void restoresPreferredSlotThenMergesWithoutCreatingOverstackedItems() throws Exception { + PlayerInventoryState inventory = new PlayerInventoryState(); + inventory.set(1, stack(Material.DIAMOND, 60)); + GraveItemEntry entry = codec.createEntry(UUID.randomUUID(), 0, stack(Material.DIAMOND, 70)); + + ClaimTransferPlan plan = new GraveClaimPlanner(codec, true).plan( + inventory, + new GravePayload(0L, List.of(entry), 10) + ); + + assertEquals(64, plan.resultingInventory().get(0).getAmount()); + assertEquals(64, plan.resultingInventory().get(1).getAmount()); + assertEquals(2, plan.resultingInventory().get(2).getAmount()); + assertTrue(plan.remainingPayload().isEmpty()); + assertEquals(10, plan.transferredExperience()); + } + + @Test + void keepsOverflowInTheGraveAndAllOrNothingModeLeavesInventoryUntouched() throws Exception { + PlayerInventoryState full = new PlayerInventoryState(); + for (int slot = 0; slot < 36; slot++) { + full.set(slot, stack(Material.COBBLESTONE, 64)); + } + GraveItemEntry entry = codec.createEntry(UUID.randomUUID(), 0, stack(Material.DIAMOND, 5)); + GravePayload payload = new GravePayload(0L, List.of(entry), 0); + + ClaimTransferPlan partial = new GraveClaimPlanner(codec, true).plan(full, payload); + assertFalse(partial.changed()); + assertEquals(5, codec.decodeItem(partial.remainingPayload().entries().getFirst()).getAmount()); + + ClaimTransferPlan atomic = new GraveClaimPlanner(codec, false).plan(full, payload); + assertFalse(atomic.changed()); + assertEquals(5, codec.decodeItem(atomic.remainingPayload().entries().getFirst()).getAmount()); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/config/DurationValueParserTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/config/DurationValueParserTest.java new file mode 100644 index 00000000..d1ae886d --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/config/DurationValueParserTest.java @@ -0,0 +1,24 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.config; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DurationValueParserTest { + @Test + void parsesSupportedUnits() { + assertEquals(250L, DurationValueParser.parseMillis("250ms", 1L)); + assertEquals(5_000L, DurationValueParser.parseMillis("5s", 1L)); + assertEquals(120_000L, DurationValueParser.parseMillis("2m", 1L)); + assertEquals(10_800_000L, DurationValueParser.parseMillis("3h", 1L)); + assertEquals(172_800_000L, DurationValueParser.parseMillis("2d", 1L)); + } + + @Test + void fallsBackForInvalidNonPositiveAndOverflowValues() { + assertEquals(42L, DurationValueParser.parseMillis("invalid", 42L)); + assertEquals(42L, DurationValueParser.parseMillis("0s", 42L)); + assertEquals(42L, DurationValueParser.parseMillis("999999999999999999999d", 42L)); + assertEquals(42L, DurationValueParser.parseMillis(null, 42L)); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/GraveOperationJournalTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/GraveOperationJournalTest.java new file mode 100644 index 00000000..7c383331 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/journal/GraveOperationJournalTest.java @@ -0,0 +1,166 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.journal; + +import nl.hauntedmc.serverfeatures.api.graveyard.GraveStatus; +import nl.hauntedmc.serverfeatures.features.graveyard.model.Grave; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GraveLocation; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GravePlacementType; +import nl.hauntedmc.serverfeatures.features.graveyard.persistence.EncodedGravePayload; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GraveOperationJournalTest { + @TempDir + Path directory; + + @Test + void captureTransitionsRoundTripAndDeleteAtomically() throws Exception { + GraveOperationJournal journal = new GraveOperationJournal(directory, 1024 * 1024); + UUID operation = UUID.randomUUID(); + UUID graveId = UUID.randomUUID(); + GraveLocation location = new GraveLocation(UUID.randomUUID(), "minecraft:world", 12.5, 64, -7.5, 90); + Grave grave = new Grave( + graveId, "ABC123", UUID.randomUUID(), "Player", "survival-1", "survival", + location, location, GravePlacementType.DEATH_LOCATION, GraveStatus.ACTIVE, + 10L, 20L, 30L, null, 2, 7, 0L, "checksum", "minecraft:fall", false + ); + EncodedGravePayload payload = new EncodedGravePayload(new byte[]{1, 2, 3, 4}, "encoded"); + + journal.writeCapture(new CaptureJournalRecord(operation, CaptureJournalState.PREPARED, grave, payload)); + CaptureJournalRecord loaded = journal.loadCaptures().getFirst(); + assertEquals(CaptureJournalState.PREPARED, loaded.state()); + assertEquals(graveId, loaded.grave().graveId()); + assertArrayEquals(payload.bytes(), loaded.payload().bytes()); + + journal.writeCapture(loaded.withState(CaptureJournalState.COMMITTED)); + assertEquals(CaptureJournalState.COMMITTED, journal.loadCaptures().getFirst().state()); + journal.deleteCapture(graveId); + assertTrue(journal.loadCaptures().isEmpty()); + } + + @Test + void malformedRecordsAreQuarantinedWithoutBreakingOtherRecords() throws Exception { + GraveOperationJournal journal = new GraveOperationJournal(directory, 1024 * 1024); + Path capture = directory.resolve("local/journal/capture"); + Files.writeString(capture.resolve("broken.properties"), "not-a-valid-record=true"); + + assertTrue(journal.loadCaptures().isEmpty()); + try (var files = Files.list(directory.resolve("local/journal/corrupt"))) { + assertEquals(1L, files.count()); + } + } + + @Test + void malformedNumericFieldsAreQuarantinedThroughTheJournalErrorPath() throws Exception { + GraveOperationJournal journal = new GraveOperationJournal(directory, 1024 * 1024); + UUID graveId = UUID.randomUUID(); + GraveLocation location = new GraveLocation(UUID.randomUUID(), "minecraft:world", 12.5, 64, -7.5, 90); + Grave grave = new Grave( + graveId, "ABC123", UUID.randomUUID(), "Player", "survival-1", "survival", + location, location, GravePlacementType.DEATH_LOCATION, GraveStatus.ACTIVE, + 10L, 20L, 30L, null, 2, 7, 0L, "checksum", "minecraft:fall", false + ); + journal.writeCapture(new CaptureJournalRecord( + UUID.randomUUID(), + CaptureJournalState.PREPARED, + grave, + new EncodedGravePayload(new byte[]{1, 2, 3}, "encoded") + )); + Path capturePath = directory.resolve("local/journal/capture").resolve(graveId + ".properties"); + Files.writeString( + capturePath, + Files.readString(capturePath).replace("itemEntryCount=2", "itemEntryCount=invalid") + ); + + UUID claimOperation = UUID.randomUUID(); + journal.writeClaim(new ClaimJournalRecord( + claimOperation, + ClaimJournalState.PREPARED, + graveId, + grave.ownerUuid(), + grave.ownerUuid(), + 4L, + 2, + 15, + new EncodedGravePayload(new byte[]{4, 5, 6}, "remaining") + )); + Path claimPath = directory.resolve("local/journal/claim").resolve(claimOperation + ".properties"); + Files.writeString( + claimPath, + Files.readString(claimPath).replace("previousRevision=4", "previousRevision=invalid") + ); + + assertTrue(journal.loadCaptures().isEmpty()); + assertTrue(journal.loadClaims().isEmpty()); + try (var files = Files.list(directory.resolve("local/journal/corrupt"))) { + assertEquals(2L, files.count()); + } + } + + @Test + void malformedNumericFieldsAreQuarantined() throws Exception { + GraveOperationJournal journal = new GraveOperationJournal(directory, 1024 * 1024); + UUID operation = UUID.randomUUID(); + UUID graveId = UUID.randomUUID(); + GraveLocation location = new GraveLocation(UUID.randomUUID(), "minecraft:world", 1, 64, 1, 0); + Grave grave = new Grave( + graveId, "NUM123", UUID.randomUUID(), "Player", "survival-1", "survival", + location, location, GravePlacementType.DEATH_LOCATION, GraveStatus.ACTIVE, + 10L, 20L, 30L, null, 1, 5, 0L, "checksum", null, false + ); + journal.writeCapture(new CaptureJournalRecord( + operation, + CaptureJournalState.COMMITTED, + grave, + new EncodedGravePayload(new byte[]{1}, "encoded") + )); + Path capture = directory.resolve("local/journal/capture").resolve(graveId + ".properties"); + String invalid = Files.readString(capture).replace("createdWallMillis=10", "createdWallMillis=invalid"); + Files.writeString(capture, invalid); + + assertTrue(journal.loadCaptures().isEmpty()); + try (var files = Files.list(directory.resolve("local/journal/corrupt"))) { + assertEquals(1L, files.count()); + } + } + + @Test + void claimTransitionsRoundTripAndQuarantinedTokensStayDiscoverable() throws Exception { + GraveOperationJournal journal = new GraveOperationJournal(directory, 1024 * 1024); + UUID operation = UUID.randomUUID(); + ClaimJournalRecord record = new ClaimJournalRecord( + operation, + ClaimJournalState.PREPARED, + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID(), + 4L, + 2, + 15, + new EncodedGravePayload(new byte[]{5, 6, 7}, "remaining") + ); + + journal.writeClaim(record); + ClaimJournalRecord loaded = journal.loadClaims().getFirst(); + assertEquals(operation, loaded.operationToken()); + assertEquals(ClaimJournalState.PREPARED, loaded.state()); + + journal.writeClaim(loaded.withState(ClaimJournalState.PLAYER_APPLIED)); + assertEquals(ClaimJournalState.PLAYER_APPLIED, journal.loadClaims().getFirst().state()); + + Path claim = directory.resolve("local/journal/claim"); + Files.writeString(claim.resolve(UUID.randomUUID() + ".properties"), "broken=true"); + journal.loadClaims(); + assertEquals(1, journal.quarantinedClaimTokens().size()); + + journal.deleteClaim(operation); + assertTrue(journal.loadClaims().isEmpty()); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GraveIdentifierTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GraveIdentifierTest.java new file mode 100644 index 00000000..436cd692 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GraveIdentifierTest.java @@ -0,0 +1,36 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.model; + +import org.junit.jupiter.api.Test; + +import java.time.ZoneId; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GraveIdentifierTest { + @Test + void createsReadablePlayerWorldTimeIdentifier() { + String identifier = GraveIdentifier.create( + "RemyMine", + "Survival World", + 1_722_720_312_222L, + ZoneId.of("Europe/Amsterdam") + ); + + assertEquals("remymine-survival-world-20240803-232512-222", identifier); + } + + @Test + void sanitizesAndBoundsIdentifier() { + String identifier = GraveIdentifier.create( + " !!! ", + "Very Long World Name With Spaces And Symbols !@#$%^&*() ".repeat(5), + 0L, + ZoneId.of("UTC") + ); + + assertTrue(identifier.startsWith("player-very-long-world-name-with-spaces-and-symbols-")); + assertTrue(identifier.endsWith("19700101-000000-000")); + assertTrue(identifier.length() <= GraveIdentifier.MAXIMUM_LENGTH); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GraveTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GraveTest.java new file mode 100644 index 00000000..afb22c0f --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/model/GraveTest.java @@ -0,0 +1,43 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.model; + +import nl.hauntedmc.serverfeatures.api.graveyard.GraveStatus; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class GraveTest { + @Test + void resumesPartialOrphanWithoutResettingItsRemainingLifetime() { + Grave grave = grave(2L, GraveStatus.ORPHANED_WORLD, 12_345L); + + grave.resume(50_000L); + + assertEquals(GraveStatus.PARTIAL, grave.status()); + assertEquals(62_345L, grave.expiresActiveMillis()); + assertNull(grave.pausedRemainingMillis()); + } + + @Test + void resumesEmptyOrphanAsClaimed() { + Grave grave = grave(1L, GraveStatus.ORPHANED_WORLD, 1_000L); + grave.updatePayload(new GravePayload(2L, java.util.List.of(), 0), "empty", GraveStatus.ORPHANED_WORLD); + grave.pause(1_000L, GraveStatus.ORPHANED_WORLD); + + grave.resume(20_000L); + + assertEquals(GraveStatus.CLAIMED, grave.status()); + } + + private static Grave grave(long payloadRevision, GraveStatus status, Long pausedRemaining) { + UUID world = UUID.randomUUID(); + GraveLocation location = new GraveLocation(world, "minecraft:world", 1, 64, 1, 0); + return new Grave( + UUID.randomUUID(), "ABC123DEF456", UUID.randomUUID(), "Player", "survival-1", "survival", + location, location, GravePlacementType.DEATH_LOCATION, status, + 1L, 2L, 3L, pausedRemaining, 1, 5, payloadRevision, "checksum", "minecraft:fall", false + ); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GraveInteractionPacketListenerTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GraveInteractionPacketListenerTest.java new file mode 100644 index 00000000..9555fd87 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GraveInteractionPacketListenerTest.java @@ -0,0 +1,21 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.packet; + +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientInteractEntity; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GraveInteractionPacketListenerTest { + @Test + void everyNormalEntityClickCanClaimAVirtualGrave() { + assertTrue(GraveInteractionPacketListener.isClaimInteraction( + WrapperPlayClientInteractEntity.InteractAction.INTERACT + )); + assertTrue(GraveInteractionPacketListener.isClaimInteraction( + WrapperPlayClientInteractEntity.InteractAction.INTERACT_AT + )); + assertTrue(GraveInteractionPacketListener.isClaimInteraction( + WrapperPlayClientInteractEntity.InteractAction.ATTACK + )); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GravePacketRendererContractTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GravePacketRendererContractTest.java new file mode 100644 index 00000000..fb484ac3 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/packet/GravePacketRendererContractTest.java @@ -0,0 +1,26 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.packet; + +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GravePacketRendererContractTest { + @Test + void packetSendBoundaryRetainsPacketWrapperType() { + Method send = Arrays.stream(GravePacketRenderer.class.getDeclaredMethods()) + .filter(method -> method.getName().equals("send")) + .findFirst() + .orElseThrow(); + + assertTrue(Modifier.isStatic(send.getModifiers())); + assertEquals(Player.class, send.getParameterTypes()[0]); + assertEquals(PacketWrapper.class, send.getParameterTypes()[1]); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GravePayloadCodecTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GravePayloadCodecTest.java new file mode 100644 index 00000000..0af4fd1d --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GravePayloadCodecTest.java @@ -0,0 +1,64 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.persistence; + +import nl.hauntedmc.serverfeatures.features.graveyard.model.GraveItemEntry; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GravePayload; +import org.bukkit.Material; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; + +import static nl.hauntedmc.serverfeatures.features.graveyard.GraveyardTestItemStacks.binaryCodec; +import static nl.hauntedmc.serverfeatures.features.graveyard.GraveyardTestItemStacks.stack; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class GravePayloadCodecTest { + private final GravePayloadCodec codec = new GravePayloadCodec( + 64, + 1_000_000, + 4_000_000, + binaryCodec() + ); + + @Test + void roundTripsValidatedItemsAndExperience() throws Exception { + GraveItemEntry entry = codec.createEntry(UUID.randomUUID(), 4, stack(Material.DIAMOND, 32)); + GravePayload source = new GravePayload(7L, List.of(entry), 125); + + EncodedGravePayload encoded = codec.encode(source); + GravePayload decoded = codec.decode(encoded.bytes(), encoded.checksum()); + + assertEquals(source.revision(), decoded.revision()); + assertEquals(source.remainingExperience(), decoded.remainingExperience()); + assertEquals(32, codec.decodeItem(decoded.entries().getFirst()).getAmount()); + } + + @Test + void rejectsDuplicateEntryIdentifiersAndChecksumMismatch() throws Exception { + UUID entryId = UUID.randomUUID(); + GraveItemEntry first = codec.createEntry(entryId, 0, stack(Material.STONE, 1)); + GraveItemEntry second = codec.createEntry(entryId, 1, stack(Material.DIRT, 1)); + GravePayload duplicate = new GravePayload(0L, List.of(first, second), 0); + + assertThrows(IOException.class, () -> codec.encode(duplicate)); + + EncodedGravePayload encoded = codec.encode(new GravePayload(0L, List.of(first), 0)); + assertThrows(IOException.class, () -> codec.decode(encoded.bytes(), "0".repeat(64))); + } + + @Test + void rejectsInvalidPreferredSlotsAndEmptyItems() { + assertThrows(IOException.class, () -> codec.createEntry( + UUID.randomUUID(), + 41, + stack(Material.STONE, 1) + )); + assertThrows(IOException.class, () -> codec.createEntry( + UUID.randomUUID(), + 0, + stack(Material.AIR, 0) + )); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GraveTableMappingTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GraveTableMappingTest.java new file mode 100644 index 00000000..04441b8e --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/persistence/GraveTableMappingTest.java @@ -0,0 +1,25 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.persistence; + +import jakarta.persistence.Table; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GraveTableMappingTest { + @Test + void everyGraveyardTableUsesPlayerPrefix() { + List> entities = List.of( + GraveMetadataEntity.class, + GravePayloadEntity.class, + GraveAuditEntity.class, + GraveLeaseEntity.class + ); + + for (Class entity : entities) { + Table table = entity.getAnnotation(Table.class); + assertTrue(table.name().startsWith("player_"), entity.getSimpleName()); + } + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveCommandVisibilityTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveCommandVisibilityTest.java new file mode 100644 index 00000000..83394154 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveCommandVisibilityTest.java @@ -0,0 +1,36 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.runtime; + +import nl.hauntedmc.serverfeatures.api.graveyard.GraveStatus; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GraveCommandVisibilityTest { + @Test + void completedGravesDisappearFromPlayerListsAndActiveSuggestions() { + assertTrue(GraveManager.isOwnerListable(GraveStatus.ACTIVE)); + assertTrue(GraveManager.isOwnerListable(GraveStatus.PARTIAL)); + assertTrue(GraveManager.isOwnerListable(GraveStatus.ORPHANED_WORLD)); + assertTrue(GraveManager.isOwnerListable(GraveStatus.DELIVERY_PENDING)); + + assertFalse(GraveManager.isOwnerListable(GraveStatus.CLAIMED)); + assertFalse(GraveManager.isOwnerListable(GraveStatus.EXPIRED)); + assertFalse(GraveManager.isOwnerListable(GraveStatus.CORRUPT)); + assertFalse(GraveManager.isOwnerListable(GraveStatus.ADMIN_RECOVERED)); + assertFalse(GraveManager.isOwnerListable(GraveStatus.PURGED)); + } + + @Test + void administrativeSuggestionsMatchAllowedTransitions() { + assertTrue(GraveManager.isRestorable(GraveStatus.EXPIRED)); + assertTrue(GraveManager.isRestorable(GraveStatus.ORPHANED_WORLD)); + assertFalse(GraveManager.isRestorable(GraveStatus.ACTIVE)); + + assertTrue(GraveManager.isPurgeable(GraveStatus.EXPIRED)); + assertTrue(GraveManager.isPurgeable(GraveStatus.CORRUPT)); + assertTrue(GraveManager.isPurgeable(GraveStatus.ADMIN_RECOVERED)); + assertFalse(GraveManager.isPurgeable(GraveStatus.ACTIVE)); + assertFalse(GraveManager.isPurgeable(GraveStatus.PURGED)); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveSpatialIndexTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveSpatialIndexTest.java new file mode 100644 index 00000000..3a9800dc --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/graveyard/runtime/GraveSpatialIndexTest.java @@ -0,0 +1,42 @@ +package nl.hauntedmc.serverfeatures.features.graveyard.runtime; + +import nl.hauntedmc.serverfeatures.api.graveyard.GraveStatus; +import nl.hauntedmc.serverfeatures.features.graveyard.model.Grave; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GraveLocation; +import nl.hauntedmc.serverfeatures.features.graveyard.model.GravePlacementType; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GraveSpatialIndexTest { + @Test + void indexesMovesAndRemovesGravesWithoutDuplicates() { + UUID world = UUID.randomUUID(); + Grave grave = grave(world, 1.0, 1.0); + GraveSpatialIndex index = new GraveSpatialIndex(); + + index.put(grave); + assertEquals(1, index.nearby(world, 0, 0, 0).size()); + + grave.relocate(new GraveLocation(world, "minecraft:world", 48.0, 64.0, 48.0, 0.0f), + GravePlacementType.NEARBY); + index.put(grave); + assertTrue(index.nearby(world, 0, 0, 0).isEmpty()); + assertEquals(1, index.nearby(world, 3, 3, 0).size()); + + index.remove(grave.graveId()); + assertTrue(index.nearby(world, 3, 3, 0).isEmpty()); + } + + private static Grave grave(UUID world, double x, double z) { + GraveLocation location = new GraveLocation(world, "minecraft:world", x, 64.0, z, 0.0f); + return new Grave( + UUID.randomUUID(), "ABC123", UUID.randomUUID(), "Player", "survival-1", "survival", + location, location, GravePlacementType.DEATH_LOCATION, GraveStatus.ACTIVE, + 1L, 1L, 60_001L, null, 1, 5, 0L, "checksum", "minecraft:generic", false + ); + } +}