Skip to Content
1robie

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 optionControls
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

TierHoldsBounded
LRU cacheOfflinePlayer objectsmaximumSize
Name indexUUID ↔ Stringno

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

  • Arguments — reading arguments back
  • Modules — what else ships in the library

Last updated on

On this page