# Running operations

> Run rollbacks, restores and undos. Build a request, estimate it, start it, follow its progress, cancel it, and read the result.

Source: https://oasis-protection.com/docs/api/operations/

An operation changes the world back. It is the same thing staff run with `/rollback`, `/rs` and `/undo`, with the same speed, the same care for later edits, and the same history afterwards. `oasis.operations()` has one method for each:

| Method                              | Does                                                                               |
| ----------------------------------- | ---------------------------------------------------------------------------------- |
| `rollback(Selection selection)`     | Puts back what the chosen edits changed, as it stood before them.                  |
| `restore(Selection selection)`      | Puts the chosen edits back after a rollback took them away.                        |
| `undo(long operation)`              | Reverses this operation, and the sweeps and the force that followed it, as one.    |
| `undoLast(UUID requester)`          | Reverses the last operation this person asked for, as `/oasis undo` does for them. |
| `undoLast(CommandSender requester)` | The same for a sender: a player, or the console for any other sender.              |

Each returns an `OperationRequest`: an operation you have not started yet. The `Operation` it starts, and the events it fires, name the verb as an [`OperationType`](/docs/api/reference/#operationtype): `ROLLBACK`, `RESTORE` or `UNDO`. A sweep and a force have the type of the operation they follow.

```java
oasis.operations().rollback(Selection.all().by(griefer).within(Duration.ofHours(2)))
    .requestedBy(staff)
    .audience(staff)
    .start()
    .result().thenAccept(result -> staff.sendMessage("Rolled back " + result.applied() + " edits."));
```

## The request

A request is immutable. Each method returns a new request, so you can keep one in a field and start it as often as you like.

| Method                              | Sets                                                                                                                                                                             | Default                                                                                    |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `requestedBy(UUID person)`          | Who asked. Staff read this name in lookups, and `undoLast` and `/oasis undo` reverse the last operation of this person.                                                          | Your plugin, `#plugin:<Name>`.                                                             |
| `requestedBy(CommandSender sender)` | Who asked: the player, or `Actor.CONSOLE` for any other sender.                                                                                                                  |                                                                                            |
| `audience(Audience audience)`       | Who sees it run: the start line, the progress bar and the summary, as a command shows them.                                                                                      | Nobody.                                                                                    |
| `targetTick(Duration tick)`         | The longest tick the operation may leave the server with. A longer tick finishes sooner, and the players feel it. A zero or negative duration throws `IllegalArgumentException`. | The config’s [`rollback.target-tick-ms`](/docs/reference/config/#rollback-target-tick-ms). |
| `estimate()`                        | Nothing. It reads how large the operation is. See [Estimate and confirm](#estimate-and-confirm).                                                                                 |                                                                                            |
| `start()`                           | Nothing. It starts the operation now and returns an `Operation`.                                                                                                                 |                                                                                            |

Name the staff member who asked with `requestedBy`, so the history says who asked for the rollback, and that staff member can `/oasis undo` it. Any `Audience` works: a player, a `CommandSender`, the console, or `Audience.audience(staff, Bukkit.getConsoleSender())` for several at once. The audience also gets the force link of the summary, as a staff member does after a command.

## Estimate and confirm

Oasis never asks for a confirmation when you start an operation through the API. When your plugin wants to ask one, call `estimate()` first:

```java
OperationRequest request = oasis.operations()
    .rollback(Selection.all().by(griefer).within(Duration.ofHours(2)))
    .requestedBy(staff);

request.estimate().thenAccept(estimate ->
    staff.sendMessage("This rolls back " + estimate.edits() + " edits in " + estimate.chunks()
        + " chunks, in about " + estimate.duration().toSeconds() + " seconds."));
```

An `Estimate` has three fields:

| Field        | Type       | Holds                                                                            |
| ------------ | ---------- | -------------------------------------------------------------------------------- |
| `edits()`    | `long`     | The edits the operation chooses.                                                 |
| `chunks()`   | `int`      | The chunks those edits are in.                                                   |
| `duration()` | `Duration` | How long it takes, at the rate this server has measured, within its target tick. |

The estimate reads history and changes nothing. It is the one place in the API that reads a whole selection, so ask for it only when you show it to someone. The operation decides as it runs which of the chosen edits it writes and which a later edit holds, so `edits()` counts what it chooses, not what it writes. The [result](#the-result) splits those edits into `applied()`, `kept()` and `left()`, plus what is `incomplete()`. A sweep can add edits that the estimate could not see.

The estimate of a [`forceKept()`](#kept-left-and-force) request is different. It names the kept positions and the chunks they lie in, as the operation that kept them counted them, and reads no history.

When the store can’t answer, a world of the selection does not exist, or an undo names nothing to undo, the estimate future completes exceptionally with an `OasisException`. [When a read fails](/docs/api/reading-history/#when-a-read-fails) lists its reasons and shows how to handle it. See [Example: /griefrevert](#example-griefrevert) for a full confirmation flow.

## Start and follow an operation

`start()` returns an `Operation` at once. The pass begins on a later tick.

| Method                                    | Returns                                                                                                                |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `id()`                                    | The id of the operation, once history has recorded it: the id of its first pass. Empty before that. Pass it to `undo`. |
| `type()`                                  | What the operation does: `OperationType.ROLLBACK`, `RESTORE` or `UNDO`.                                                |
| `progress()`                              | Where the operation is now, as a `Progress`.                                                                           |
| `onProgress(Consumer<Progress> listener)` | Adds a listener and returns the same `Operation`. See [onProgress](#onprogress).                                       |
| `result()`                                | A future of the `Result`. It completes on the main thread once the operation and its sweeps end.                       |
| `cancel()`                                | Stops the operation. See [Cancel](#cancel).                                                                            |

You can read an `Operation` from any thread.

### Progress

An operation writes a chunk at a time, so the chunks measure how far it has come.

| Field          | Type           | Holds                                                                       |
| -------------- | -------------- | --------------------------------------------------------------------------- |
| `phase()`      | `Phase`        | `PLANNING`, `APPLYING`, `SWEEPING` or `FINISHED`.                           |
| `edits()`      | `long`         | The edits written so far.                                                   |
| `total()`      | `OptionalLong` | The rows of the plan. Empty while the operation plans, or when it has none. |
| `exact()`      | `boolean`      | False when `total()` is an upper bound.                                     |
| `chunks()`     | `int`          | The chunks the operation writes, or 0 while it plans.                       |
| `doneChunks()` | `int`          | The chunks it has finished.                                                 |
| `startedAt()`  | `Instant`      | When the operation started.                                                 |

`fraction()` is how far the operation has come, from 0 to 1: the finished chunks over all its chunks. It reads 0 while the operation plans.

total() is not a count of edits

`total()` counts the history rows of the selection in the chunks the operation claimed, and the rows an earlier operation reversed among them. An operation often writes fewer edits than that. Use `fraction()` for a bar, and `edits()` for a count of what it wrote.

### onProgress

`onProgress` calls your listener on the main thread, at most once a tick, while the operation runs. When the operation ends, the listener always gets one last call with `Phase.FINISHED`, before `result()` completes. That holds after a pass, after a refusal such as `BUSY`, and after a cancel before the pass. A listener you add after the end gets no call.

When a listener throws a `RuntimeException`, Oasis logs it as a warning in the server log and goes on. The other listeners still get their calls, and the result still completes.

This listener draws the progress in a boss bar, and takes the bar away on the last call:

```java
BossBar bar = BossBar.bossBar(Component.text("Rolling back"), 0f, BossBar.Color.GREEN, BossBar.Overlay.PROGRESS);
staff.showBossBar(bar);

Operation operation = request.start();
operation.onProgress(progress ->
{
    bar.progress((float) progress.fraction());
    if (progress.phase() == Phase.FINISHED)
    {
        staff.hideBossBar(bar);
    }
});
```

With an `audience`, Oasis draws its own progress bar for that audience. Leave the audience unset when you draw your own, or the staff member sees two bars.

### Sweeps

While a rollback runs, lava and water can keep flowing and fire can keep spreading. The first pass could not select those edits, because they did not exist yet. So after its first pass, a rollback runs **sweeps**: follow-up passes of the same operation that take what the world made while it ran. `Progress.phase()` reads `SWEEPING` during a sweep, and `Result.swept()` counts what the sweeps wrote.

A restore, an undo and a force run one pass and sweep nothing.

## Cancel

`cancel()` stops the operation after the chunk it writes now. No sweep follows a cancel. The result then says `Outcome.CANCELLED`, and `applied()` counts what it wrote before it stopped.

You can cancel from the moment `start()` returns, from any thread. A cancel from another thread acts on the main thread, at the next tick. A cancel before the pass begins, such as one in the same tick as `start()`, ends the operation before it writes anything. Its result is `CANCELLED` with nothing applied and no failure, and no `OperationEndEvent` fires for it. That holds even for a request that could not have run, such as an undo with nothing to undo.

```java
Operation operation = request.start();
// Later, from a "Stop" button:
operation.cancel();
```

`oasis.operations().active()` lists the operations your plugin started that still run, so you can find one to cancel. It lists the operations of your plugin only, whoever `requestedBy` names. It does not list another plugin’s operations or the ones staff typed.

## The result

`result()` completes on the main thread with a `Result`. It never completes exceptionally: an operation that could not run ends with `Outcome.FAILED`.

| Field          | Type                         | Holds                                                                                                                                                                 |
| -------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `outcome()`    | `Outcome`                    | `FINISHED`, `CANCELLED` or `FAILED`.                                                                                                                                  |
| `failure()`    | `Optional<Failure>`          | Why it failed, or empty when it did not fail. See [When an operation fails](#when-an-operation-fails).                                                                |
| `applied()`    | `long`                       | The edits it wrote, its sweeps included.                                                                                                                              |
| `swept()`      | `long`                       | The part of `applied()` the sweeps wrote.                                                                                                                             |
| `kept()`       | `long`                       | The positions it left standing because a later edit by another player held them.                                                                                      |
| `left()`       | `long`                       | The positions it left standing because the world holds another block than the history names, and no edit says why.                                                    |
| `incomplete()` | `Optional<String>`           | What it chose and could not write, in words for people, or empty.                                                                                                     |
| `duration()`   | `Duration`                   | How long it took: from the moment it took the operation slot to the end of its last sweep. For an operation that failed or was cancelled before that, from its start. |
| `forceKept()`  | `Optional<OperationRequest>` | A request that changes the kept positions too. Present only when the operation finished and kept positions. See below.                                                |

`kept()` and `left()` count each position once. An edit the operation could not write whatever the world holds, such as items for a container that is gone, or a block whose data does not read, is in `incomplete()` and in neither count.

## Kept, left and force

A rollback is careful. Say a griefer broke a wall, and another player rebuilt part of it since. A rollback of the griefer leaves the rebuilt part alone, so it doesn’t undo somebody’s repair. Those positions are **kept**, and `kept()` counts them. World changes, such as flowing water, never hold a position.

A position is **left** when the world holds another block than the history names, and no edit says why. That can happen after a change that Oasis did not log, such as a plugin that changed blocks without the [logger](/docs/api/logging-edits/). No request changes a left position.

When the person who asked wants the kept positions changed too, start `forceKept()`. It is a request that changes the kept positions and nothing else, over the later edits that held them. It is the same as the force link staff click after a command.

`oasis.lookups().kept(id)` lists the kept positions themselves, each with the person who keeps it and the edit that holds it. See [The positions an operation kept](/docs/api/reading-history/#the-positions-an-operation-kept).

```java
operation.result().thenAccept(result ->
{
    result.forceKept().ifPresent(force ->
    {
        pendingForce.put(staff.getUniqueId(), force);
        staff.sendMessage(Component.text("Kept " + result.kept() + " blocks that other players changed since. ")
            .append(Component.text("[Force]", NamedTextColor.GOLD)
                .clickEvent(ClickEvent.runCommand("/griefrevert force"))));
    });
});
```

`forceKept()` is present only when the operation finished and kept positions. It is empty:

* when the operation kept nothing,
* when it did not finish, that is, when its outcome is `CANCELLED` or `FAILED`,
* for an undo,
* for the result of a force,
* and in an `OperationEndEvent`.

A request has no force of its own, so a force always follows the operation that kept the positions. A cancel and a failure both close a pass as cancelled, and a force needs a finished pass to take its positions from. So a cancelled or failed operation never offers a force, even when `kept()` is above 0. Check `forceKept()`, not `kept()`, before you offer a force button. An `estimate()` of the force request names the kept positions and their chunks, so you can show its size before the staff member clicks.

## One operation at a time

Oasis runs one operation at a time, for commands and plugins alike. A request that starts while another operation runs ends at once with `Outcome.FAILED` and `Failure.Reason.BUSY`. Nothing waits in a queue.

To run yours as soon as the other ends, listen for [`OperationEndEvent`](/docs/api/events/#operationendevent). When it fires, the slot is free, and you can start your operation from inside the listener.

## When an operation fails

`Result.failure()` holds a `Failure` with two fields. Test `reason()` in code, and show `message()` to people. The message is the line a staff member reads for the same failure of a command.

| `Failure.Reason`    | What happened                                                                                                                                                                           | What to do                                                                                                                       |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `BUSY`              | Another operation runs. Oasis runs one at a time.                                                                                                                                       | Start yours again when it ends.                                                                                                  |
| `MOVING`            | The history moves to another store with `/oasis move`.                                                                                                                                  | Start it again when the move ends.                                                                                               |
| `STARTING`          | Oasis still opens its store, just after the server started.                                                                                                                             | Start it again shortly.                                                                                                          |
| `OVERLOADED`        | The store holds as many requests as it takes.                                                                                                                                           | Start it again shortly.                                                                                                          |
| `NOTHING_TO_UNDO`   | `undoLast` names a person who has run no operation.                                                                                                                                     | Tell the person there is nothing to undo.                                                                                        |
| `NO_SUCH_OPERATION` | `undo` names an operation that the history does not hold.                                                                                                                               | Check the id you passed.                                                                                                         |
| `STORE`             | The store failed: Oasis stopped logging, or the store could not read or record the operation.                                                                                           | Tell staff. The operation does not run again by itself, and the server log names the error.                                      |
| `PASS`              | The pass stopped on an error after it began to write: the store failed under it, or a write to the world threw.                                                                         | Tell staff. The edits it wrote stay, and `applied()` counts them.                                                                |
| `NO_SUCH_WORLD`     | The selection names a world, chosen or left out, that is not loaded under that exact name, that Oasis never saw, and that the history holds no edit of. Most often a typo. Nothing ran. | Show the message, which names the world. Check the name: names are exact, as `World.getName()` reads them.                       |
| `WORLD_GONE`        | The operation has edits in a world that is not loaded, that no [world loader](/docs/api/worlds/) owns, and that has no folder Oasis can load. It wrote nothing.                         | Show the message, which names each such world. Name the worlds you want with `in`, or leave the world out with `not().in(name)`. |
| `WORLD_LOAD`        | A world of the operation did not load: its world loader failed, or its folder did not open.                                                                                             | Tell staff; the message names the world and why. The edits written before stay.                                                  |
| `WORLD_FORMAT`      | A newer build of Oasis or Packs Server wrote values into a world of the operation, in a format this build does not know. The pass wrote nothing into that world.                        | Install that build or a newer one; the message names it.                                                                         |

A failure before the operation reaches the slot, such as `BUSY`, `NOTHING_TO_UNDO`, `NO_SUCH_WORLD` or `WORLD_GONE`, fires no `OperationEndEvent`, because no operation ran.

```java
private void explain(Player staff, Result result)
{
    Failure failure = result.failure().orElseThrow();
    switch (failure.reason())
    {
        case BUSY, STARTING, OVERLOADED ->
            staff.sendMessage(failure.message() + " Run the command again in a moment.");
        case MOVING -> staff.sendMessage(failure.message() + " Run the command again when the move ends.");
        default -> staff.sendMessage("The rollback failed: " + failure.message());
    }
}
```

## Undo

`undo(id)` reverses one operation as a whole: its first pass, its sweeps and its force. Any pass id of the operation names all of it. That is the id of `Operation.id()`, the id of a sweep that `OperationChunkEvent.operation()` or `Edit.operation()` reads, or the id of a force. Keep the id from `Operation.id()`:

```java
Operation operation = request.start();
operation.result().thenAccept(result ->
    operation.id().ifPresent(id -> lastOperation.put(staff.getUniqueId(), id)));

// Later:
Long id = lastOperation.get(staff.getUniqueId());
if (id != null)
{
    oasis.operations().undo(id).requestedBy(staff).start();
}
```

`undoLast(requester)` reverses the last operation that person asked for, as `/oasis undo` does. Two cases are worth knowing:

* `undoLast(staff.getUniqueId())` reverses the last operation the staff member asked for, whether they typed it or your plugin named them with `requestedBy`.
* `undoLast(oasis.id())` reverses the last operation your plugin asked for in its own name.

An undo runs one pass and sweeps nothing. Its result never has a `forceKept()`.

## Example: /griefrevert

This plugin adds `/griefrevert <player> [hours]`. It sizes the rollback and asks the staff member to confirm it, draws the progress in a boss bar, reports the result, and offers a force button when the rollback finished and kept positions. `/griefrevert cancel` stops a running rollback.

```java
package com.example.griefrevert;

import com.oasis.api.Failure;
import com.oasis.api.Oasis;
import com.oasis.api.OasisApi;
import com.oasis.api.OasisException;
import com.oasis.api.Operation;
import com.oasis.api.OperationRequest;
import com.oasis.api.Progress;
import com.oasis.api.Result;
import com.oasis.api.Selection;
import io.papermc.paper.command.brigadier.BasicCommand;
import io.papermc.paper.command.brigadier.CommandSourceStack;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Level;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;

public final class GriefRevert extends JavaPlugin
{
    /** How long a staff member has to click Confirm or Force. */
    private static final Duration CLICK_WINDOW = Duration.ofSeconds(60);

    /** A request that waits for a click. */
    private record Pending(OperationRequest request, Instant asked)
    {
        boolean expired()
        {
            return asked.plus(CLICK_WINDOW).isBefore(Instant.now());
        }
    }

    private final Map<UUID, Pending> toConfirm = new HashMap<>();
    private final Map<UUID, Pending> toForce = new HashMap<>();
    private final Map<UUID, Operation> running = new HashMap<>();
    private OasisApi oasis;

    @Override
    public void onEnable()
    {
        oasis = Oasis.api(this);
        registerCommand("griefrevert", "Roll back a griefer, after a confirmation", new Command());
    }

    private final class Command implements BasicCommand
    {
        @Override
        public void execute(CommandSourceStack source, String[] args)
        {
            if (!(source.getSender() instanceof Player staff))
            {
                source.getSender().sendMessage("Run /griefrevert in game.");
                return;
            }
            if (args.length == 0 || args.length > 2)
            {
                staff.sendMessage(Component.text("Usage: /griefrevert <player> [hours]", NamedTextColor.RED));
                return;
            }
            switch (args[0].toLowerCase(Locale.ROOT))
            {
                case "confirm" -> start(staff, toConfirm.remove(staff.getUniqueId()));
                case "force" -> start(staff, toForce.remove(staff.getUniqueId()));
                case "cancel" -> cancel(staff);
                default -> ask(staff, args);
            }
        }

        @Override
        public String permission()
        {
            return "griefrevert.use";
        }
    }

    /** Step 1: size the rollback, and ask the staff member to confirm it. */
    private void ask(Player staff, String[] args)
    {
        OfflinePlayer griefer = Bukkit.getOfflinePlayerIfCached(args[0]);
        if (griefer == null)
        {
            staff.sendMessage(Component.text("No player called " + args[0] + " has played here.", NamedTextColor.RED));
            return;
        }
        long hours;
        try
        {
            hours = args.length == 2 ? Long.parseLong(args[1]) : 2;
        }
        catch (NumberFormatException e)
        {
            hours = 0;
        }
        if (hours < 1)
        {
            staff.sendMessage(Component.text("Hours is a whole number of 1 or more.", NamedTextColor.RED));
            return;
        }

        // The window counts back from the start, so a confirm a minute later still reads the last hours.
        OperationRequest request = oasis.operations()
            .rollback(Selection.all().by(griefer).within(Duration.ofHours(hours)))
            .requestedBy(staff);

        request.estimate().whenComplete((estimate, error) ->
        {
            if (error != null)
            {
                refused(staff, "Oasis could not size the rollback", error);
                return;
            }
            if (estimate.edits() == 0)
            {
                staff.sendMessage(Component.text(griefer.getName() + " made no edits to roll back.", NamedTextColor.GRAY));
                return;
            }
            toConfirm.put(staff.getUniqueId(), new Pending(request, Instant.now()));
            staff.sendMessage(Component.text("Roll back " + estimate.edits() + " edits of " + griefer.getName()
                    + " in " + estimate.chunks() + " chunks, in about " + estimate.duration().toSeconds() + "s? ")
                .append(button("[Confirm]", "/griefrevert confirm", NamedTextColor.GREEN)));
        });
    }

    /** Step 2: start a request the staff member clicked, with a boss bar of its progress. */
    private void start(Player staff, Pending pending)
    {
        if (pending == null || pending.expired())
        {
            staff.sendMessage(Component.text("There is nothing to confirm. Run /griefrevert <player> again.",
                NamedTextColor.RED));
            return;
        }
        BossBar bar = BossBar.bossBar(Component.text("Rolling back"), 0f, BossBar.Color.GREEN,
            BossBar.Overlay.PROGRESS);
        staff.showBossBar(bar);

        Operation operation = pending.request().start();
        running.put(staff.getUniqueId(), operation);
        operation.onProgress(progress ->
        {
            bar.progress((float) progress.fraction());
            bar.name(Component.text(label(progress)));
        });
        operation.result().thenAccept(result ->
        {
            staff.hideBossBar(bar);
            running.remove(staff.getUniqueId(), operation);
            report(staff, result);
        });
    }

    private void cancel(Player staff)
    {
        Operation operation = running.get(staff.getUniqueId());
        if (operation == null)
        {
            staff.sendMessage(Component.text("You have no rollback running.", NamedTextColor.GRAY));
            return;
        }
        operation.cancel();
        staff.sendMessage(Component.text("Stopping after the chunk it writes now.", NamedTextColor.YELLOW));
    }

    /** Step 3: say what happened, and offer a force when the rollback kept positions. */
    private void report(Player staff, Result result)
    {
        switch (result.outcome())
        {
            case FINISHED -> staff.sendMessage(Component.text("Rolled back " + result.applied() + " edits in "
                + result.duration().toSeconds() + "s.", NamedTextColor.GREEN));
            case CANCELLED -> staff.sendMessage(Component.text("Cancelled after " + result.applied() + " edits.",
                NamedTextColor.YELLOW));
            case FAILED ->
            {
                staff.sendMessage(Component.text(advice(result.failure().orElseThrow()), NamedTextColor.RED));
                return;
            }
        }
        if (result.left() > 0)
        {
            staff.sendMessage(Component.text("Left " + result.left() + " blocks that changed with no edit to say why.",
                NamedTextColor.GRAY));
        }
        result.incomplete().ifPresent(what ->
            staff.sendMessage(Component.text("Could not write: " + what, NamedTextColor.GRAY)));
        // Present only for a rollback that finished and kept positions, never after a cancel.
        result.forceKept().ifPresent(force -> offerForce(staff, result.kept(), force));
    }

    /** The estimate of a force names the kept positions and their chunks, from the counts of the rollback. */
    private void offerForce(Player staff, long kept, OperationRequest force)
    {
        force.estimate().whenComplete((estimate, error) ->
        {
            if (error != null)
            {
                refused(staff, "Oasis could not size the force", error);
                return;
            }
            toForce.put(staff.getUniqueId(), new Pending(force, Instant.now()));
            staff.sendMessage(Component.text("Kept " + kept + " blocks in " + estimate.chunks()
                    + " chunks that other players changed since. ", NamedTextColor.GOLD)
                .append(button("[Force]", "/griefrevert force", NamedTextColor.RED)));
        });
    }

    /** An estimate fails with an OasisException that names the reason. */
    private void refused(Player staff, String what, Throwable error)
    {
        if (error instanceof OasisException failed)
        {
            staff.sendMessage(Component.text(what + ": " + advice(failed.failure()), NamedTextColor.RED));
            return;
        }
        staff.sendMessage(Component.text(what + ". The server log has the details.", NamedTextColor.RED));
        getLogger().log(Level.WARNING, what, error);
    }

    private static String advice(Failure failure)
    {
        return switch (failure.reason())
        {
            case BUSY, STARTING, OVERLOADED -> failure.message() + " Try again in a moment.";
            case MOVING -> failure.message() + " Try again when the move ends.";
            case STORE, PASS -> failure.message() + " The server log has the details.";
            default -> failure.message();
        };
    }

    private static String label(Progress progress)
    {
        return switch (progress.phase())
        {
            case PLANNING -> "Planning the rollback";
            case APPLYING -> "Rolling back: " + progress.doneChunks() + " of " + progress.chunks() + " chunks";
            case SWEEPING -> "Sweeping up lava, water and fire";
            case FINISHED -> "Done";
        };
    }

    private static Component button(String text, String command, NamedTextColor color)
    {
        return Component.text(text, color).clickEvent(ClickEvent.runCommand(command));
    }
}
```

The flow has three steps:

1. `/griefrevert Steve 3` builds a request and calls `estimate()`. Nothing changes yet. The staff member reads the size and clicks **Confirm**.
2. `/griefrevert confirm` starts the request it kept. The `within` window counts back from that start, not from the estimate.
3. When the result arrives, the plugin reports it. If the rollback finished and kept positions, it sizes `forceKept()` with `estimate()` and offers **Force**, which starts that request through the same `start` method.

The estimate futures come straight from Oasis, so a failed one hands `whenComplete` the `OasisException` itself, with no `CompletionException` around it.

The request names the staff member with `requestedBy(staff)`, so lookups show who asked, and the staff member can reverse the rollback with `/oasis undo`.
