fix: remove the silent failure paths around island level - #203
Merged
Conversation
Callback carries no failure channel, so a caller could not tell that its callback would never run. Every one of these sites returned quietly and left whatever it had scheduled inside the callback permanently pending. ChunkSnapshotLevelLogic returned without invoking the callback when a WorldGuard island region was missing, and logged nothing. Four consumers were affected: /is info and /is level printed nothing at all, IslandInfoEvent never fired its callback so API consumers waited forever, and RecalculateTopTen - which advances its queue only from inside the callback - stopped draining for good on a single unscoreable island. uSkyBlock.calculateScoreAsync additionally dereferenced a null IslandInfo for an unknown island name. LevelLogic.calculateScoreAsync now returns whether the callback will run, and implementations must log before refusing. Callers handle the refusal: the two commands tell the player and clear the patience cooldown they would otherwise leak, RecalculateTopTen skips the island and keeps draining, and InternalEvents logs on behalf of the API consumer that cannot be told. AbstractPlayerInfoCommand returned false without a word when no player argument was given. It now reports through an overridable hook, because AbstractIslandInfoCommand relies on that false to fall back to the island the sender is standing on; it overrides the hook and reports once its own fallback has failed too. Its final return false is no longer silent either. Two behaviour fixes fall out of that rewrite: the "Player X has no island" error was previously also emitted when the island existed and only the argument count was wrong, and the standing-on-an-island fallback no longer runs when a player name was supplied but did not resolve - that case already reported an invalid player and should not silently retarget.
…chedule Addresses review of #203. Translation templates were not regenerated, so `extractTranslation` followed by `git diff --exit-code` in build.yml would have failed the PR before it built, and Crowdin would never have seen the three new strings. RecalculateTopTen's self-reschedule did not work. The class extends BukkitRunnable, so `scheduler.async(RecalculateTopTen.this)` bound to Scheduler.async(BukkitRunnable), which delegates to runTaskAsynchronously and throws IllegalStateException once the instance has been scheduled - which it has, by RecalculateRunnable. Both the pre-existing success path and the skip path added here threw. Casting to Runnable submits it as a fresh task. That success path has been broken since a0404c3 ("Move all scheduling to Scheduler", v3.2.0), which rebound a plain-Runnable submission to the BukkitRunnable overload, so top-ten recalculation has been scoring one island per cycle rather than draining its queue. Only servers with a non-zero `island.autoRefreshScore` are affected; the shipped default is 0m. Non-player senders with no arguments were still unreported: the location fallback is Player-only, and the no-op onMissingPlayerArgument override suppressed the parent's message. execute() now handles the empty-argument case up front and routes non-players to the inherited message, so the override is gone and the "super already said so" comment is true for the case it describes. No branch is left doing nothing. The sealed parent hook doExecute(CommandSender, PlayerInfo) threw nothing and returned nothing; it now throws UnsupportedOperationException. The playerInfo-null case after a successful super.execute() is unreachable by construction and now throws IllegalStateException rather than being dropped or NPEing later. Message wording: "Ask an administrator to check the console" told a player to perform a step they cannot; it now reads "Please contact a server admin", matching four existing msgids. The level-logic warning no longer asserts a missing WorldGuard region, since getIslandRegionAt also returns null when the world has no RegionManager. The unresolved-island warning no longer claims the name is unknown, which cannot happen - a null name or maintenance mode is what it actually means. Also: guard the scheduled refusal message in LevelCommand with isOnline(), as the sibling callback fifteen lines up already does, and log the already-computed island name in InternalEvents instead of re-deriving it from a Location whose World is a WeakReference that throws once unloaded.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Callbackhas no failure channel, so a caller could not tell that its callback wouldnever run. Each of these sites returned quietly and left whatever it had scheduled
inside the callback permanently pending — with nothing in the console either.
What was silent
ChunkSnapshotLevelLogic.calculateScoreAsyncreturned without invoking the callback whenWorldGuardHandler.getIslandRegionAt(l)returned null, and logged nothing. Four consumers:/is info/is levelIslandInfoEventRecalculateTopTenuSkyBlock.calculateScoreAsyncadditionally dereferenced a nullIslandInfo.AbstractPlayerInfoCommand.executereturnedfalsewithout a word when no playerargument was supplied, and
AbstractIslandInfoCommand.executedid the same at its finalreturn — which is why
/usb is infowith no argument, off-island, produced nothing.Approach
LevelLogic.calculateScoreAsyncnow returns whether the callback will run, andimplementations must log before refusing. Every caller handles the refusal: the commands
tell the player and clear the cooldown,
RecalculateTopTenskips the island and keepsdraining,
InternalEventslogs.No branch is left doing nothing. Where a state is unreachable by construction it throws
rather than returning quietly — the sealed
doExecute(CommandSender, PlayerInfo)hookthrows
UnsupportedOperationException, and a nullplayerInfoafter a successfulsuper.execute()throwsIllegalStateException.Also repairs a pre-existing regression
RecalculateTopTen extends BukkitRunnable, soscheduler.async(RecalculateTopTen.this)bound to
Scheduler.async(BukkitRunnable)→runTaskAsynchronously→checkNotYetScheduled(), which throws once the instance has been scheduled — which it has,by
RecalculateRunnable. The success path has thrown sincea0404c3e("Move allscheduling to Scheduler", v3.2.0), which rebound a plain-
Runnablesubmission to theBukkitRunnableoverload. Both sites now cast toRunnable.Scope is narrower than it first looks. The auto path is the only affected one:
/is top,GenTopTenCommand→IslandLogic.generateTopTenislandInfo.getLevel()off disk, sorts, firesRANK_UPDATEDisland.autoRefreshScore→RecalculateRunnable→RecalculateTopTencalculateScoreAsyncSo the leaderboard itself was never broken — only the background refreshing of the levels
it sorts, and those still updated whenever a player ran
/is levelor/is info(thewrapper calls
islandInfo.setLevel(...)). The shipped defaultautoRefreshScoreis0m,so on default configuration the affected code never ran at all.
Verified that
RecalculateTopTenis the only self-schedulingBukkitRunnablein Core —the other nine subclasses never reschedule themselves, and
IncrementalRunnable(the enginebehind chunk snapshots, generation and purge)
implements Runnable, so itsscheduler.sync(this, …)binds to the safe overload.The failure mode is not merely inferred from
checkNotYetScheduled(): it is present once inproduction history, from
/is resetonv3.2.0-SNAPSHOTin Dec 2025 —IllegalStateException: Already scheduled as 552. That path has been reworked across threereleases since and static analysis finds nothing on master that could reproduce it, so it is
evidence the mechanism is real rather than a second open bug.
Scope note on IslandInfoEvent
For the other consumers the fix is real. Here the consumer's
Callbackstill never runsand still has no way to learn that — the event exposes only
getIslandLocation()andgetCallback(). The change makes it diagnosable, not fixed. A proper failure channelneeds an APIv2 addition; follow-up worth filing.
Behaviour change worth review
The standing-on-an-island fallback no longer runs when a player name was supplied but
did not resolve. That case already reported an invalid player, and silently retargeting to
wherever the sender happens to stand is surprising.
In practice this was already dead code: the default
bukkitPlayerDB resolves any stringthrough
Bukkit.getOfflinePlayer(String), which never returns null, sosuper.execute()could not fail with arguments present. Only
MemoryPlayerDBcould. The one realconsequence is that
/usb island get|setrequire a player name — which they always did.Compatibility
void→booleanonuSkyBlock.calculateScoreAsyncis binary-incompatible for anythingcompiled against
ovh.uskyblock:uSkyBlock-Core≤ 3.6.1, which would seeNoSuchMethodError. Accepted deliberately: the stability guarantee coversus.talabrek.ultimateskyblock.api(uSkyBlock-API), which this PR does not touch, and Core3.4.2 → 3.5.0 already removed public classes on a minor bump.
LevelLogicis Guice-boundwith no third-party registration hook. Worth a line in the 3.7.0 notes.
Notes
AweLevelLogictakes the signature change only; its body is commented out and it throws.Translation templates regenerated (
./gradlew translation) — three new msgids, no otherchurn;
extractTranslationis idempotent against the committed.potfiles, sobuild.yml'sgit diff --exit-codepasses.No new tests. The missing-region failure needs a live island whose WorldGuard region has
been removed, which the harness has no fixture for, and asserting that the boolean is
checked would pin the mechanism rather than the behaviour. The
InternalEventsTeststubchange from
doNothing()todoReturn(true)is load-bearing, not cosmetic: Mockito'sDoesNothingrejects a non-void method at stubbing time, sodoNothing()would fail@BeforeEachfor all six tests.Coverage gap worth a follow-up: no ittest scenario touches level logic,
calculateScoreAsync,IslandInfoEventor the top-ten refresh. A scenario that setsautoRefreshScore, occupies two islands and asserts both levels move would have caughtthe
a0404c3eregression in 2025, and unlike the missing-region case it is a fixture theharness can support. A unit-level
RecalculateTopTenTestis also possible, but note amocked
Scheduleraccepts the sameBukkitRunnabletwice, so a naive test passes whileproduction throws.