Offline player cache
Optional UUID and name cache that backs offline-player tab-completion, and how to own its lifecycle.
OfflinePlayerCache resolves UUID ↔ name for players who are not online, and
backs OfflinePlayerArgument's tab-completion. It is optional: without it,
commands still work, they just cannot complete or accept offline player names.
The two-line version
@Override
public void onEnable() {
OfflinePlayerCache.install(this);
}
@Override
public void onDisable() {
OfflinePlayerCache.uninstall(this);
}install builds a cache with defaults, registers its PlayerJoinEvent
listener, and sets it as the global instance the static helpers and
OfflinePlayerArgument read. uninstall reverses all three, including shutting
down the cache's HttpClient.
Using it in a command
this.addRequiredArgument("player", new OfflinePlayerArgument());The argument converts a name to a UUID, so read it back as one:
UUID playerId = dispatch.getArgument("player", UUID.class);
OfflinePlayerCache cache = OfflinePlayerCache.getGlobalInstance();
if (cache == null) {
dispatch.getSender().sendMessage("Offline player lookup is unavailable.");
return CommandResultType.FAILURE;
}
OfflinePlayer target = cache.get(playerId);
String name = cache.getName(playerId);getGlobalInstance() is @Nullable — someone may have built a cache without
setting it as global, or not installed one at all. With no cache installed,
OfflinePlayerArgument rejects every value with a distinct "no offline player
cache is installed" error and suggests nothing.
Pass a custom error type if the default message does not suit you:
new OfflinePlayerArgument(new DynamicCommandExceptionType(name ->
MessageComponentSerializer.message().serialize(
Component.text("No player found: " + name).color(NamedTextColor.RED))));Owning the lifecycle
The builder covers the cases install does not:
OfflinePlayerCache cache = OfflinePlayerCache.builder(this)
.maximumSize(5000)
.expireAfterAccess(Duration.ofMinutes(10))
.maxSuggestions(50)
.prePopulate(false)
.logger(PluginLogger.of(this.getLogger()))
.recordStats(true)
.buildAndRegister();
// onDisable
cache.close();| Builder option | Controls |
|---|---|
maximumSize(long) | LRU bound on cached OfflinePlayer objects |
expireAfterWrite(Duration) | Time-based eviction from write |
expireAfterAccess(Duration) | Time-based eviction from last read |
prePopulate(boolean) | Seed the name index from Bukkit.getOfflinePlayers() |
maxSuggestions(int) | Cap on tab-completion results |
mojangTimeout(Duration) | Timeout for name refreshes against Mojang |
logger(PluginLogger) | Where the cache's diagnostics go |
recordStats(boolean) | Enables stats() |
build() gives you the cache without registering anything; register() hooks
the join listener; buildAndRegister() does both. A cache you build yourself is
not the global instance — OfflinePlayerArgument will not see it unless
install set one.
Always close a cache you own
Each cache owns an HttpClient
That client has its own selector and executor threads. A cache that is never closed leaks that pool — and a plugin reloaded twenty times has leaked it twenty times.
close() unregisters the listener, clears both tiers and shuts the client down.
Call it in onDisable, or use install/uninstall, which do it for you.
Two tiers, and why eviction is safe
| Tier | Holds | Bounded |
|---|---|---|
| LRU cache | OfflinePlayer objects | maximumSize |
| Name index | UUID ↔ String | no |
The heavy Bukkit objects are bounded, and a miss falls back to
Bukkit.getOfflinePlayer() — so eviction costs a lookup, never correctness. The
name index is deliberately unbounded because it is the authoritative mapping
commands resolve against.
That unboundedness is why maxSuggestions exists: an empty prefix would
otherwise serialise every name the server has ever seen, on every keystroke.
Renames
When a name is claimed by a new UUID, the old entry is marked stale and refreshed asynchronously against Mojang's session server. If Mojang is unreachable or rate-limits the request, the stale name is kept and a warning is logged — the cache never blocks a command on a network call.
Shading
The global instance is static. Two plugins that shade this library without
relocating it will disagree about which of them owns that field, in a way that
depends on classloading order and surfaces in the other plugin.
Relocate when you shade, or skip install
entirely and hold your own instance.
Next
Last updated on