Events
Oasis fires three Bukkit events, in com.oasis.api.event. Listen to them as to any Bukkit event. You don’t need Oasis.api(this) to listen.
| Event | Fires when |
|---|---|
OperationChunkEvent |
An operation is done with one chunk. |
OperationEndEvent |
An operation ends, with its sweeps. |
PurgeEndEvent |
A purge ends. |
All three fire on the main thread. The two operation events fire for every operation: one a staff member typed, one a plugin started, and one a recovery finished at startup. That makes them different from Operation.result(), which only your own operations have. PurgeEndEvent fires for every purge in the same way. Oasis fires these events, and a plugin only listens to them. Never construct or call one yourself.
OperationChunkEvent
Section titled “OperationChunkEvent”Oasis fires OperationChunkEvent once for each chunk of each pass, when the pass is done with it: a rollback, a restore, an undo, a sweep, and a recovery at startup.
| Method | Returns |
|---|---|
operation() |
The id of the pass. A sweep has an id of its own. undo of any pass id reverses the whole operation. |
requester() |
Who asked for the operation: a player, the console’s id, or a plugin’s id. |
type() |
What the operation does: OperationType.ROLLBACK, RESTORE or UNDO. A sweep has the type of its operation. |
world() |
The world of the chunk. |
chunkX(), chunkZ() |
The chunk coordinates, in chunks. |
complete() |
True when the pass wrote every edit it chose in this chunk. False when a cancel or a failure ended the chunk part way. |
The chunk changed either way, so treat a complete() of false as a change too.
Recipe: redraw a map
Section titled “Recipe: redraw a map”An operation writes its blocks with no block events. A map plugin that redraws a chunk on BlockPlaceEvent or BlockBreakEvent never hears about a rollback, and its map still shows the grief. Listen for OperationChunkEvent and ask the map to redraw the chunk instead.
A big rollback can rewrite thousands of chunks, and a sweep can visit a chunk again. So this listener collects the chunks in a set, and hands them to the map once a second.
package com.example.mapredraw;
import com.oasis.api.event.OperationChunkEvent;import java.util.LinkedHashSet;import java.util.Set;import java.util.UUID;import org.bukkit.Bukkit;import org.bukkit.World;import org.bukkit.event.EventHandler;import org.bukkit.event.Listener;import org.bukkit.plugin.java.JavaPlugin;
public final class MapRedraw extends JavaPlugin implements Listener{ /** The one call into your map plugin: redraw this chunk. */ public interface ChunkRenderer { void render(World world, int chunkX, int chunkZ); }
private record ChunkKey(UUID world, int x, int z) { }
private final Set<ChunkKey> changed = new LinkedHashSet<>(); private ChunkRenderer renderer;
@Override public void onEnable() { // Replace this with the call of your map plugin. See the note below. renderer = (world, chunkX, chunkZ) -> getLogger().info("Redraw " + world.getName() + " " + chunkX + ", " + chunkZ); getServer().getPluginManager().registerEvents(this, this); getServer().getScheduler().runTaskTimer(this, this::flush, 20L, 20L); }
@EventHandler public void onChunk(OperationChunkEvent event) { changed.add(new ChunkKey(event.world().getUID(), event.chunkX(), event.chunkZ())); }
/** Once a second, on the main thread: redraw each changed chunk once. */ private void flush() { for (ChunkKey key : changed) { World world = Bukkit.getWorld(key.world()); if (world != null) { renderer.render(world, key.x(), key.z()); } } changed.clear(); }}This recipe does not depend on any map plugin. ChunkRenderer is the one place your map plugin’s API goes: put its “render this chunk” call in the lambda. If that call is not safe on the main thread, hand the chunks to the thread your map plugin asks for.
OperationEndEvent
Section titled “OperationEndEvent”Oasis fires OperationEndEvent when an operation ends, with its sweeps. When it fires, the operation slot is free, so a listener can start the next operation.
| Method | Returns |
|---|---|
operation() |
An OptionalLong: the id of the first pass, which names the operation. Empty when it failed before history recorded its first pass. |
requester() |
Who asked for the operation: a player, the console’s id, or a plugin’s id. |
type() |
What the operation did: OperationType.ROLLBACK, RESTORE or UNDO. |
result() |
How it ended, as a Result. |
recovered() |
True when a recovery at startup finished this operation after a crash or a stop. |
result() is the same Result that Operation.result() gives the plugin that started it, with the same counts and the same duration(). There is one difference: its forceKept() is always empty. A force request belongs to the plugin that started the operation, and staff force a typed operation with its link.
The event fires only for an operation that took the slot. An operation that never ran fires none. That covers an API operation that a cancel ended before its pass, and one that Oasis refused before it reached the slot, such as a start that found another operation running (BUSY) or one with a world nobody can load (WORLD_GONE).
Recipe: report operations to Discord
Section titled “Recipe: report operations to Discord”This plugin posts a line to a Discord webhook for every operation that ends. It names the operation with type(), and who asked with oasis.key(), which knows plugins and actors, and falls back to the player’s name.
package com.example.reporter;
import com.oasis.api.Failure;import com.oasis.api.Oasis;import com.oasis.api.OasisApi;import com.oasis.api.Result;import com.oasis.api.event.OperationEndEvent;import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.util.UUID;import org.bukkit.Bukkit;import org.bukkit.event.EventHandler;import org.bukkit.event.Listener;import org.bukkit.plugin.java.JavaPlugin;
public final class RollbackReporter extends JavaPlugin implements Listener{ private final HttpClient http = HttpClient.newHttpClient(); private OasisApi oasis; private URI webhook;
@Override public void onEnable() { String url = getConfig().getString("discord-webhook", ""); if (url.isBlank()) { getLogger().warning("Set discord-webhook in config.yml to report operations."); return; } webhook = URI.create(url); oasis = Oasis.api(this); getServer().getPluginManager().registerEvents(this, this); }
@EventHandler public void onEnd(OperationEndEvent event) { Result result = event.result(); String who = name(event.requester()); String number = event.operation().stream().mapToObj(id -> " #" + id).findFirst().orElse(""); String what = switch (event.type()) { case ROLLBACK -> "rollback"; case RESTORE -> "restore"; case UNDO -> "undo"; }; String line = switch (result.outcome()) { case FINISHED -> String.format("%s's %s%s finished: %d edits written, %d kept, %d left, in %ds.", who, what, number, result.applied(), result.kept(), result.left(), result.duration().toSeconds()); case CANCELLED -> String.format("%s's %s%s was cancelled after %d edits.", who, what, number, result.applied()); case FAILED -> String.format("%s's %s failed: %s", who, what, result.failure().map(Failure::message).orElse("no reason given")); }; if (event.recovered()) { line += " A recovery at startup finished it."; } post(line); }
/** A plugin or an actor by its key, such as #plugin:GriefRevert or #console, or a player by name. */ private String name(UUID person) { return oasis.key(person).orElseGet(() -> { String player = Bukkit.getOfflinePlayer(person).getName(); return player == null ? person.toString() : player; }); }
/** Sends the line off the main thread. The listener never waits for Discord. */ private void post(String content) { String json = "{\"content\":\"" + escape(content) + "\"}"; HttpRequest request = HttpRequest.newBuilder(webhook) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); http.sendAsync(request, HttpResponse.BodyHandlers.discarding()).whenComplete((response, error) -> { if (error != null) { getLogger().warning("Discord did not take the report: " + error.getMessage()); } else if (response.statusCode() >= 300) { getLogger().warning("Discord answered " + response.statusCode() + " to the report."); } }); }
private static String escape(String text) { StringBuilder out = new StringBuilder(); for (char c : text.toCharArray()) { switch (c) { case '"' -> out.append("\\\""); case '\\' -> out.append("\\\\"); case '\n' -> out.append("\\n"); default -> out.append(c); } } return out.toString(); }}Because the event fires for every operation, this one listener reports the rollbacks staff type, the ones other plugins start, and the ones a recovery finishes after a crash.
oasis.key() knows a plugin’s key only when that plugin asked for the API since the server started. For a plugin that hasn’t, and for an id Oasis doesn’t know, the recipe falls back to the raw id.
PurgeEndEvent
Section titled “PurgeEndEvent”Oasis fires PurgeEndEvent when a purge ends: one a staff member typed with /oasis purge, and one a plugin started with oasis.purges(). When it fires, the purge slot is free, so a listener can start the next purge.
| Method | Returns |
|---|---|
requester() |
Who asked for the purge: a player, the console’s id, or a plugin’s id. |
result() |
How it ended, as a PurgeResult: outcome(), failure(), deleted() and duration(). A purge that began fails only with Failure.Reason.STORE, and deleted() then holds the edits it deleted before the failure. |
A purge that Oasis refused before it began fires no event. That covers a start that found another purge or an operation running, a start while the store rebuilds or moves, and a selection with a world that does not exist. The Purge.result() of such a purge ends Outcome.FAILED with the reason.
A purge of worlds forgets the worlds that are not loaded and hold no edit any more before the event fires. A request that names such a world fails with NO_SUCH_WORLD.
@EventHandlerpublic void onPurge(PurgeEndEvent event){ String who = oasis.key(event.requester()).orElse(event.requester().toString()); PurgeResult result = event.result(); if (result.failure().isPresent()) { getLogger().warning("The purge of " + who + " failed: " + result.failure().get().message()); return; } getLogger().info("The purge of " + who + " deleted " + result.deleted() + " edits.");}