
GPExpansion
The ultimate add-on for GriefPrevention 3D Subdivisions
Список изменений
GPExpansion v1.1.17
Three fixes, all on the same theme: work that ran on every tick, every player move, or every map repaint without needing to.
The headline is a startup failure. With DiscordSRV and Skript installed alongside GriefPrevention3D, Paper reports an unsatisfiable load order:
[LoadOrderTree] Circular plugin loading detected:
[LoadOrderTree] 1) GPExpansion -> DiscordSRV -> Skript -> GriefPrevention -> GPExpansion
Alongside it, two profiler findings: BanEnforcementListener.onMove() accounting for 4.50% of sampled server-thread CPU time, and the /claimmap editor visibly stalling on every click. Both trace back to the same reflective claim lookup.
No configuration changes, no new permissions, no lang keys. version.config-version stays at 1.1.2.
Bug Fixes
The DiscordSRV load-order cycle
Two of the four edges in that loop are ours, declared in paper-plugin.yml:
GriefPrevention: load: AFTER— GriefPrevention loads after GPExpansion. This one stays. GriefPrevention3D declaresprovides: [GriefPrevention]and registers its ownclaimcommand; Bukkit's command map only assigns an unprefixed label to the first plugin that asks for it, so loading first is what gives GPExpansion/claimrather than/gpexpansion:claim.DiscordSRV: load: BEFORE— DiscordSRV loads before GPExpansion. This is the edge that closes the loop, because DiscordSRV soft-depends on Skript, and Skript soft-depends on GriefPrevention.
The DiscordSRV entry is now removed, with the reasoning recorded in the file next to the existing Nexo exclusion, which was omitted for the same class of reason.
Removing it cost nothing, because the integration it existed for was never active. DiscordSRVChatCaptureBridge — added in v1.0.6 to stop setup-wizard replies leaking into the Discord relay — was written, compiled, and shipped with no call site anywhere in the plugin. It has been dead code in every release since.
Rather than delete it, this release wires it up and makes it independent of load order:
register()hooks immediately if DiscordSRV is already enabled, and otherwise registers a one-shotPluginEnableEventlistener that hooks when DiscordSRV enables and then unregisters itself.- It is called from
onEnable()with a predicate covering both capture paths —SetupWizardManager.hasActiveSession()andDescriptionInputManager.hasPending().
The hook was always fully reflective (join-classpath: false, Class.forName against DiscordSRV's own class loader, registerEvent with a hand-built EventExecutor), so nothing about it required a declared dependency in the first place — only the assumption that DiscordSRV would have enabled first.
This is a visible behaviour change. On a server with DiscordSRV, chat typed in response to a /mailbox, /rentclaim, /sellclaim, or /claim desc prompt now stops at the server instead of being relayed to Discord. That was the intended behaviour in v1.0.6; it starts working here.
Performance
getClaimAt() swept the entire claim table on every miss
GPBridge.getClaimAt() is the single hot path shared by ban enforcement, claim flight, the map editor, and most GUIs. On a lookup that found nothing it did four things in sequence:
- call GriefPrevention's
getClaimAtwithignoreHeight = true; - on null, call it again with
ignoreHeight = false; - on null, run
bruteForceFindClaim(location, true)— iterate every claim on the server, invokingClaim.contains()reflectively on each; - on null, run
bruteForceFindClaim(location, false)— the same sweep again.
Steps 2 and 4 could never find anything steps 1 and 3 had not. Claim.contains(location, ignoreHeight, excludeSubdivisions) only adds a Y-bounds test when ignoreHeight is false, so the true pass matches a strict superset: if it returns null, so will the false pass. Both retries were pure duplicate work.
Step 3 is worse than redundant — it is redundant and unbounded. GriefPrevention3D's DataStore.getClaimAt() resolves through getChunkClaims(), a chunk-indexed lookup; a null from it is authoritative. The sweep re-derived that answer in O(number of claims), with a reflective call per claim, and it ran on every miss — which is to say, every block a player walks in wilderness.
Now:
- The
ignoreHeight = falseretry is gone. - The sweep runs only when the resolved signature is the legacy two-argument
getClaimAt(Location, boolean), which cannot express subclaim selection and is the one case where a fallback is defensible. GriefPrevention3D resolves to the four-argument signature and upstream GriefPrevention to the three- or four-argument one, so neither reaches it.
A wilderness lookup drops from two reflective invokes plus two full table sweeps to one reflective invoke.
v1.1.13 already identified the doubled reflective invoke while fixing claim flight, and worked around it there by not calling the method. This fixes it at the source, for every caller.
BanEnforcementListener.onMove() was ~40% of GPExpansion's server-thread cost
Spark attributed 4.50% of sampled server-thread CPU time to this one handler. Against GPExpansion's total of 11.35% in the same profile, that is roughly 40% of everything the plugin was doing on the server thread — spent in a single movement listener.
Two things that figure does not say. It is not 4.5% of every tick: PlayerMoveEvent fires many times per tick per player, and the profiler aggregates all of those calls across the sampling window, so the cost is spread unevenly across ticks and scales with how many players are moving. And converting it to wall time — 4.5% of a 50 ms budget is 2.25 ms — is arithmetic on an average, not a per-tick measurement; a sampling percentage alone does not support a claim about what any individual tick spent. What it does establish is proportion, and the proportion was the problem.
The handler ran checkBanned() on every block-coordinate change for every player. That call reaches getClaimAt(), so before the fix above, every player walking anywhere in the world was triggering two full claim-table sweeps per block. Enforcement was paying full lookup cost continuously to discover that almost nobody is banned from almost anywhere.
The listener now keeps a footprint index. rebuildBanIndex() collects the world name and X/Z bounds of the top-level claims that actually ban somebody, and nearBannedClaim() rejects a movement with a string compare and four integer comparisons against that list. On a typical server the list is empty or has a handful of entries, so the handler becomes a few comparisons and a return. Only a move that lands inside one of those footprints proceeds to the real check.
The index is a pre-filter, never a decision — checkBanned() still resolves the claim, applies bypass permissions, respects 3D subdivision Y-bounds, and evaluates public-ban trust exactly as before. Bans are stored against the top-level claim ID (/claim ban resolves through mainClaimId), which is why indexing top-level footprints is sufficient, and why a bounding box is a safe over-approximation for shaped claims.
Invalidation is two-part:
- Ban changes take effect immediately.
ClaimDataStorenow carries abanRevisioncounter, bumped bysetPublicBanned,addBannedPlayer,removeBannedPlayer,set,remove, initial load, andbans.ymlmigration. The move handler compares one volatile int; a mismatch rebuilds inline before deciding anything. There is no window in which a freshly banned player can walk in unchallenged. - Claim geometry changes are picked up within 5 seconds. A resize, creation, or deletion does not touch ban data, so a 100-tick repeating task rebuilds unconditionally.
If claim data cannot be reached — GriefPrevention still loading, or the bridge unavailable — the index marks itself unusable and every move falls through to the full check. Enforcement degrades to the old cost, never to silence.
Two smaller changes on the same path:
onInteract()got the same pre-filter. It ran a full claim lookup on every right- and left-click.- The
beingEjectedcheck now testsisEmpty()before the set lookup, which is the common case by a wide margin.
ClaimDataStore was creating an entry for every claim a player entered
isPublicBanned() and getBannedPlayers() were implemented on get(claimId), which is computeIfAbsent. Every ban check against a claim with no stored data inserted an empty ClaimData — so simply walking around the server grew the in-memory map by one entry per distinct claim visited, on a hot path, in a plain HashMap.
Both are now plain reads returning the default when absent. getBans() still uses get(), because its two callers (the ban list command and BannedPlayersGUI) legitimately want the entry.
The claim map editor
/claimmap rebuilds all 45 tiles on every click — zoom, pan, mode toggle, and each cell edit. Per tile it resolves the dominant claim and the selected claim's coverage. Both were badly sized.
Sampling density. getDominantClaimInCell() probed every block for cells 20 wide or narrower. At the 20x20 zoom that is 400 getClaimAt() calls per tile, 18,000 per repaint — each of which, before the fix above, could sweep the whole claim table twice. Sampling is now capped at 7 probes per axis, so a tile costs roughly 64 lookups regardless of zoom, and a repaint around 2,900. The 50% coverage threshold that decides whether a claim owns a tile is unchanged; a claim too small to be found by the coarser grid was already too small to clear that threshold.
Coverage counting. getClaimCoverageInCell() is O(1) arithmetic for rectangular claims, which is the overwhelming majority. For genuinely non-rectangular shaped claims it ran a point-in-polygon ray cast per block: 40,000 per tile at the 200x200 zoom, up to 1.8M per repaint. Counting is now exact below 4,096 blocks of overlap and estimated from a strided sample above it, scaled back to the true area. The reflective probe fallback — used when a fork does not expose getBoundaryPolygon() — gets the same treatment at a lower threshold of 1,024, since each of its probes is a reflective call rather than arithmetic.
Reflective handle caching in GPBridge
GPBridge resolves GriefPrevention's API through Class.getMethod on every call. Paper's reflection remapper makes that expensive — the bridge already noted this in PolygonView, where the polygon accessors were hand-cached for exactly this reason.
claimContains() was the worst case: it called getClass().getMethods(), which copies the entire method array, then linear-scanned it — once per block probe, inside the coverage loops described above.
There is now a ClassValue-keyed cache of resolved handles, storing misses as well as hits so repeated absent-method lookups stay cheap. ClassValue ties entries to the class rather than to a static map, so a plugin reload does not pin GriefPrevention's old class loader. Applied to claimContains, getClaimId, getClaimCorners, getClaimWorld, getClaimAreaSafe, isOwner, isShapedClaim, resolveClaimBoundaryPolygon, and the min/max Y accessors — the handles reached from per-block and per-move loops.
Behaviour Changes
- Wizard and description chat no longer reaches Discord. See the load-order section. Intended since v1.0.6, active from this release.
- A null from GriefPrevention is now final. With the brute-force sweep gone, a lookup returns empty wherever GriefPrevention's own chunk index says there is no claim. If a fork's index is ever wrong, GPExpansion no longer papers over it — it agrees with the fork. This is the correct behaviour, but it is a change: the sweep could previously mask an indexing bug at ruinous cost.
- Map tile classification is approximate at high zoom. Dominant-claim resolution and shaped-claim coverage are sampled rather than exhaustive above the thresholds above. Both were already approximate —
getDominantClaimInCellhas always been a sampler — this widens the stride. Expect no visible difference on rectangular claims, which are exact either way. - Ban geometry changes lag by up to 5 seconds. Resizing or deleting a claim that bans somebody takes up to 100 ticks to appear in the footprint index. Ban changes are immediate. The worst case is a claim expanded to cover ground it did not before, where enforcement on the new strip starts a few seconds late.
Compatibility
Built and verified against GriefPrevention3D 18.2.7.
The getClaimAt change is signature-driven, not fork-driven. GriefPrevention3D resolves to getClaimAt(Location, boolean, boolean, Claim) and upstream GriefPrevention to the three- or four-argument form; both skip the sweep. A fork exposing only getClaimAt(Location, boolean) keeps the old fallback behaviour unchanged.
Nothing in this release adds a compile-time dependency. The DiscordSRV hook is reflective and optional; with DiscordSRV absent, register() installs a PluginEnableEvent listener that never fires.
Notes
- No API, command, permission, placeholder, or configuration changes.
- Verified by a clean
mvn packageagainst GriefPrevention3D 18.2.7, and by tracingDataStore.getClaimAtandClaim.containsin the GriefPrevention3D source to confirm theignoreHeightsuperset relation and the chunk-index guarantee. None of it has been run on a live server, and no after-profile was taken — the 4.50% is a before-state sampling figure from Spark, describing share of server-thread CPU time over the profiling window, not per-tick duration. The claims here are structural, that the work no longer happens, not measured TPS improvements. A comparable after-profile on the same server, at similar player count and movement load, is what would confirm the size of the win. - Still outstanding, carried over:
BanEnforcementListener's ejection path and/claim banstill fall back togetHighestBlockYAt(), so ejecting from a nether claim can deposit a player on the roof. Untouched by this release — the fix above changes when the ejection path runs, not where it puts you.getSafeDestination()uses-1as its "no ground found" sentinel, which collides with a genuine ground block at y=-1 in 1.18+ worlds.ClaimDataStoreremains a plainHashMapwith no synchronisation. The footprint rebuild reads it from the global scheduler, which is the main thread on Paper and Purpur. On Folia it is a region thread, in common with the rest of the store's existing access pattern.
