BlazeBans API/Getting started
Threading
Every method on `PunishmentService` and `PlayerService` returns a `CompletableFuture`, because every one of them may hit the database. Getting this wrong is the most common way to break a server with this API, so it is worth the ten minutes.
The two rules
Never block on a server thread. No get(), no join(), no getNow() on a future you did not already complete yourself.
Never touch Bukkit from a callback without scheduling back. A future's callback runs on whatever thread completed it, which is not a thread where Bukkit API access is safe.
Everything below is an application of those two.
Why blocking is worse than it looks
// Never do this.
boolean banned = api.punishments().isBanned(name, uuid).join();On the main thread, that stops the entire server until the database answers. Locally that might be two milliseconds and appear to work fine. Against a remote MySQL server it can be eighty, and at twenty ticks per second you have just deleted a third of the server's time budget for one lookup.
It is also the failure mode that hides best in testing, because your test server has SQLite on the same disk. It shows up on the production network with the database in another datacentre.
Doing it properly
Chain a callback instead:
api.punishments()
.activeBan(player.getName(), player.getUniqueId())
.thenAccept(activeBan -> activeBan.ifPresent(punishment ->
getLogger().info(player.getName() + " is banned for: " + punishment.reason())
))
.exceptionally(error -> {
getLogger().warning("Could not query BlazeBans: " + error.getMessage());
return null;
});Logging is safe from any thread. Anything touching the world, an entity, or a player is not.
Scheduling back to a safe thread
To do something with the result in game, hop back first. Paper's own scheduler API works on both Paper and Folia, so this is one code path rather than two:
api.punishments()
.activeMute(player.getName(), player.getUniqueId())
.thenAccept(activeMute -> {
if (activeMute.isEmpty()) {
return;
}
String reason = activeMute.get().reason();
// Back onto a thread that owns this player before touching them.
player.getScheduler().run(plugin, task -> {
player.sendMessage(Component.text("You are muted: " + reason));
}, null);
});Capture plain values, not live objects. In the example above, reason is a String pulled out before the hop. Capturing a Player reference is fine because you are scheduling against that player, but do not read its state from the async callback.
The third argument to EntityScheduler#run is a retired callback, run if the entity is gone before the task fires. null means "do nothing", which is usually right for a message.
Errors
A storage or runtime failure completes the future exceptionally. If you do not handle it, the exception is swallowed and you get silence.
api.punishments().isBanned(name, uuid)
.whenComplete((banned, error) -> {
if (error != null) {
getLogger().warning("BlazeBans lookup failed: " + error.getMessage());
return;
}
// use banned
});whenComplete sees both outcomes. exceptionally handles only the failure and can substitute a value. Use whichever reads better, but use one of them.
Decide what a failure means for your feature. For a cosmetic effect, doing nothing is right. For something enforcing a rule, failing closed is usually safer than failing open.
Events and threads
Some BlazeBans events are asynchronous. BlazeBansMutedChatEvent fires from chat handling, which Paper runs off the main thread.
@EventHandler
public void onMutedChat(BlazeBansMutedChatEvent event) {
if (event.isAsynchronous()) {
// Bukkit API access is not safe here.
}
}Check event.isAsynchronous() before touching anything. The cancellation itself is safe from either thread, so an exemption listener needs no scheduling at all.
Combining lookups
Do not chain futures sequentially when they do not depend on each other:
// Two round trips, one after the other.
api.punishments().isBanned(name, uuid)
.thenCompose(banned -> api.punishments().isMuted(name, uuid)
.thenApply(muted -> banned || muted));Start both, then combine:
var banned = api.punishments().isBanned(name, uuid);
var muted = api.punishments().isMuted(name, uuid);
banned.thenCombine(muted, (isBanned, isMuted) -> isBanned || isMuted)
.thenAccept(punished -> { /* ... */ });For more than two, CompletableFuture.allOf(...) then read each one's join() inside that callback, where it is already complete and does not block.
Rate
There is no per-plugin cache in front of these calls. A lookup on every chat message is a database query on every chat message.
If you need punishment state frequently, keep your own short-lived cache. BlazeBans' own Skript integration caches for one second and its placeholders refresh every five, which is the right order of magnitude:
private final Map<UUID, CachedState> cache = new ConcurrentHashMap<>();
private static final long TTL_MILLIS = 1_000L;
private record CachedState(boolean muted, long expiresAt) {}Populate it from a listener on BlazeBansPunishmentCreatedEvent and BlazeBansPunishmentRevokedEvent and it stays close to correct without polling at all.
The short version
// Wrong
boolean banned = api.punishments().isBanned(name, uuid).join();
// Right
api.punishments().isBanned(name, uuid)
.thenAccept(banned -> {
if (!banned) {
return;
}
player.getScheduler().run(plugin, task -> doSomethingInGame(player), null);
})
.exceptionally(error -> {
getLogger().warning("Lookup failed: " + error.getMessage());
return null;
});
