# Events

> Listen for each chunk an operation rewrites, and for the end of every operation and every purge, whoever started it.

Source: https://oasis-protection.com/docs/api/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.

Note

With Oasis as a `softdepend`, register these listeners only after you check that Oasis is enabled, from a class that loads only then. See [When Oasis is optional](/docs/api/getting-started/#when-oasis-is-optional).

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

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.

```java
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

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`](/docs/api/operations/#the-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`).

Tip

Inside an event class, the bare name `Result` means Bukkit’s `Event.Result`. In your own listener class there is no clash, so `import com.oasis.api.Result` works as usual.

### 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.

RollbackReporter.java

```java
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

Oasis fires `PurgeEndEvent` when a purge ends: one a staff member typed with `/oasis purge`, and one a plugin started with [`oasis.purges()`](/docs/api/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`](/docs/api/purges/#the-result): `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`.

```java
@EventHandler
public 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.");
}
```
