Skip to content
Select theme
  • Auto
  • Dark
  • Light
Download

Reading history

oasis.lookups() reads history, newest first, a page at a time. It has three ways in: find reads the edits a selection chooses, history reads the history of one block, as the inspector shows it, and kept lists the positions an operation kept.

oasis.lookups().find(Selection.all().by(player).within(Duration.ofHours(1)))
.thenAccept(page ->
{
for (Edit edit : page.items())
{
viewer.sendMessage(edit.cause().word() + " " + edit.after());
}
});

find(selection) returns the first page, with Lookups.PAGE_SIZE edits to a page. That is 8, the size staff read in chat. find(selection, size) takes a page size of your own, such as 45 for a chest inventory. A size below 1 throws IllegalArgumentException.

The future completes on the main thread, so you can send messages and open inventories in the callback.

A Page<T> has three methods. find and history return a Page<Edit>, and kept returns a Page<Kept>.

Method Returns
items() The items on this page, newest first for edits. The list may be empty.
hasNext() True when another page follows this one.
next() A future of the page after this one. It completes on the main thread.

next() throws java.util.NoSuchElementException when hasNext() is false, so check first:

private void showAll(Player viewer, Page<Edit> page)
{
for (Edit edit : page.items())
{
viewer.sendMessage(edit.cause().word() + " " + edit.after());
}
if (page.hasNext())
{
page.next().thenAccept(next -> showAll(viewer, next));
}
}

next() reads on from the last edit of the page. Turning a page costs one page, however deep you are. A page keeps its place, so an edit that someone makes while you read does not push edits onto the next page. To see new edits, start again with find.

A page has no total and no page number. See Pages carry no totals for the reason. When you want to show “more” without a count, hasNext() is all you need.

oasis.lookups().history(block).thenAccept(page ->
{
for (Edit edit : page.items())
{
String mark = edit.current() ? " (stands now)" : "";
viewer.sendMessage(edit.cause().word() + " " + edit.after() + mark);
}
});

history(block) reads the history of one block the way the inspector shows it. It holds only the kinds that describe the world at a place: blocks, containers, entities and items. It leaves out books, chat, commands, logins and logouts. history(block, size) takes a page size of your own, and a size below 1 throws.

history(BlockPosition) reads the same history for a position whose world need not be loaded, such as the position() of an edit you read. history(position, size) takes a page size of your own.

history marks the edit that stands at the block now with current(). At most one edit in all its pages has the mark.

Selection.all().at(block) with find is different: it reads every kind at the block, chat included, and marks no edit as current.

An operation leaves a position standing when a later edit by another person holds it. kept(operation) lists those positions, as /oasis kept does, a page at a time:

oasis.lookups().kept(operation.id().getAsLong()).thenAccept(page ->
{
for (Kept kept : page.items())
{
BlockPosition at = kept.position();
viewer.sendMessage(at.x() + " " + at.y() + " " + at.z() + " is held by " + name(kept.keeper()));
}
});

A Kept holds the position(), the keeper() (the person whose later edit holds the position: a player id or an Actor.id()), and holding(), the newest edit at the position. holding() is empty when a purge deleted the edits there.

Any pass id of the operation’s group names it: the id of the operation, of a sweep, or of a force. After a force, the list holds what the force kept. An id that the history does not hold fails with NO_SUCH_OPERATION. kept(operation, size) takes a page size of your own, and a size below 1 throws.

Kept, left and force explains how an operation keeps a position.

A read can fail when the store can’t answer. Then the future of find, history, kept, Page.next() or estimate() completes exceptionally with an OasisException. It still completes on the main thread.

OasisException is a RuntimeException that carries a Failure, the same type a failed operation carries. Test failure().reason() in code, and show failure().message() to people. Its cause, when it has one, is the error Oasis met, for your server log. A read fails with one of these reasons:

Reason What happened What to do
STARTING Oasis still opens its store, just after the server started. Ask again shortly.
OVERLOADED The store holds as many requests as it takes. Ask again shortly.
MOVING The history moves to another store with /oasis move. Ask again when the move ends.
STORE The store failed. Tell staff. The server log names the error.
NOTHING_TO_UNDO Only for an estimate of undoLast: the person has run no operation. Tell the person there is nothing to undo.
NO_SUCH_OPERATION For kept, and for an estimate of undo: the history does not hold that operation. Check the id you passed.
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 store holds no edits of. Most often a typo. Check the name. Names are exact, as World.getName() reads them.

A callback of whenComplete or exceptionally on the future that Oasis returns gets the OasisException itself. A callback on a stage further down the chain, such as after a thenApply, gets it wrapped in a CompletionException. This helper handles both:

/** The OasisException behind an error of an Oasis future, or null for any other error. */
private static OasisException oasisFailure(Throwable error)
{
Throwable cause = error instanceof CompletionException && error.getCause() != null ? error.getCause() : error;
return cause instanceof OasisException failed ? failed : null;
}
oasis.lookups().find(selection).whenComplete((page, error) ->
{
if (error == null)
{
showAll(viewer, page);
return;
}
OasisException failed = oasisFailure(error);
if (failed == null)
{
getLogger().log(Level.WARNING, "The lookup failed", error);
return;
}
switch (failed.failure().reason())
{
case STARTING, OVERLOADED -> viewer.sendMessage(failed.failure().message() + " Try again in a moment.");
case MOVING -> viewer.sendMessage(failed.failure().message() + " Try again when the move ends.");
default ->
{
viewer.sendMessage("The lookup failed: " + failed.failure().message());
getLogger().log(Level.WARNING, "The lookup failed", failed);
}
}
});

A size below 1 and a next() with no next page throw at once, from the call itself. They never reach the future.

An Edit is a record. Each field is one of these:

Field Type What it holds
time() Instant When the edit happened.
person() UUID Who is behind the edit: the player who acted or started the chain, or the id of the Actor that acted when no player is.
actor() Optional<Actor> The world actor that carried the edit, such as Actor.LAVA. Empty for an edit the person made directly. A world actor with nobody behind it is both the person and the actor.
position() BlockPosition Where the edit happened: the stored world name, x, y and z.
kind() Kind What changed: a block, a container, an entity, and so on.
cause() Cause Why it changed, such as Cause.BREAKING or Cause.FLOW.
before() String The block or entity before the edit, or an empty string.
after() String The block or entity after the edit, or an empty string.
item() Optional<ItemChange> The items that moved, or empty for an edit that moved none.
text() String The chat message, the command, or the book text, or an empty string.
reversed() boolean True when a rollback, a restore or an undo has put the position back.
current() boolean True on the edit that stands at the block now. Only history sets it.
operation() OptionalLong The pass of a rollback, a restore or an undo that wrote this edit, or empty.

The person and the actor are explained in Core concepts. To turn a person into a name, use oasis.key(edit.person()) for an actor or a plugin, and Bukkit for a player:

private String name(UUID person)
{
return oasis.key(person).orElseGet(() ->
{
String player = Bukkit.getOfflinePlayer(person).getName();
return player == null ? person.toString() : player;
});
}

What before() and after() hold depends on the kind:

Kind before() and after()
BLOCK The block data as a string, such as minecraft:stone or minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]. A broken block has minecraft:air after it.
ENTITY The namespaced entity id, such as minecraft:zombie. An entity that appeared has an empty before(). An entity that went away has an empty after().
CONTAINER and ITEM Empty strings. item() says what moved.
CHAT, COMMAND and BOOK Empty strings. text() holds the message, the command or the book text.

Bukkit.createBlockData(edit.after()) turns a block string back into BlockData, if you want to show or compare it.

item() holds an ItemChange with two fields: type(), the namespaced item id such as minecraft:diamond, and amount(), how many moved. The amount is always positive. The cause says which way the items went:

Cause Direction
TAKING Out of a container.
PUTTING Into a container.
DROPPING Out of the player’s inventory, onto the ground.
PICKUP From the ground, into the player’s inventory.

reversed() is true when a rollback, a restore or an undo has put the position back. A rolled-back grief reads as reversed, and the inspector shows it crossed out. After a rollback, the history of the block also holds the edit Oasis wrote to put the block back. That edit has operation() set.

current() marks the one edit whose result stands at the block now. On a page of find, it is always false.

operation() holds an id when Oasis wrote the edit itself, for a rollback, a restore or an undo. It is the id of the pass that wrote the edit: the first pass, a sweep, or a force. It is empty for an edit that Oasis did not write for an operation.

Pass any of those ids to undo(id), and Oasis reverses the whole operation: its first pass, its sweeps and its force.

This plugin adds /history <player>, which shows what a player did in the last seven days, a page at a time. /history more shows the next page. It keeps the last page each viewer read, and reads the next one only when the viewer asks.

package com.example.history;
import com.oasis.api.Edit;
import com.oasis.api.Failure;
import com.oasis.api.Oasis;
import com.oasis.api.OasisApi;
import com.oasis.api.OasisException;
import com.oasis.api.Page;
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.Map;
import java.util.UUID;
import java.util.logging.Level;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
public final class HistoryPlugin extends JavaPlugin
{
private static final int PAGE_SIZE = 10;
private final Map<UUID, Page<Edit>> lastPage = new HashMap<>();
private OasisApi oasis;
@Override
public void onEnable()
{
oasis = Oasis.api(this);
registerCommand("history", "Show what a player did, a page at a time", new HistoryCommand());
}
private final class HistoryCommand implements BasicCommand
{
@Override
public void execute(CommandSourceStack source, String[] args)
{
if (!(source.getSender() instanceof Player viewer))
{
source.getSender().sendMessage("Only players can read history pages.");
return;
}
if (args.length != 1)
{
viewer.sendMessage(Component.text("Usage: /history <player> or /history more", NamedTextColor.RED));
return;
}
if (args[0].equalsIgnoreCase("more"))
{
Page<Edit> page = lastPage.get(viewer.getUniqueId());
if (page == null || !page.hasNext())
{
viewer.sendMessage(Component.text("There is no next page.", NamedTextColor.GRAY));
return;
}
page.next().whenComplete((next, error) -> show(viewer, next, error));
return;
}
OfflinePlayer target = Bukkit.getOfflinePlayerIfCached(args[0]);
if (target == null)
{
viewer.sendMessage(Component.text("No player called " + args[0] + " has played here.",
NamedTextColor.RED));
return;
}
Selection selection = Selection.all().by(target).within(Duration.ofDays(7));
oasis.lookups().find(selection, PAGE_SIZE).whenComplete((page, error) -> show(viewer, page, error));
}
@Override
public String permission()
{
return "history.use";
}
}
/** Runs on the main thread, because every Oasis future completes there. */
private void show(Player viewer, Page<Edit> page, Throwable error)
{
if (!viewer.isOnline())
{
return;
}
if (error != null)
{
// The future comes straight from Oasis, so the error is the OasisException itself.
String why = error instanceof OasisException failed ? failed.failure().message() : "an error, see the log";
viewer.sendMessage(Component.text("The lookup failed: " + why, NamedTextColor.RED));
if (!(error instanceof OasisException failed) || failed.failure().reason() == Failure.Reason.STORE)
{
getLogger().log(Level.WARNING, "A history lookup failed", error);
}
return;
}
lastPage.put(viewer.getUniqueId(), page);
if (page.items().isEmpty())
{
viewer.sendMessage(Component.text("No edits found.", NamedTextColor.GRAY));
return;
}
for (Edit edit : page.items())
{
viewer.sendMessage(line(edit));
}
if (page.hasNext())
{
viewer.sendMessage(Component.text("[Next page]", NamedTextColor.AQUA)
.clickEvent(ClickEvent.runCommand("/history more")));
}
}
private Component line(Edit edit)
{
Duration age = Duration.between(edit.time(), Instant.now());
String who = name(edit.person()) + edit.actor().map(actor -> " via " + actor.key()).orElse("");
String where = edit.position().x() + " " + edit.position().y() + " " + edit.position().z();
Component text = Component.text(ago(age) + " " + who + " " + edit.cause().word() + " " + what(edit)
+ " at " + where);
// Oasis crosses out what a rollback put back. Do the same.
return edit.reversed() ? text.decorate(TextDecoration.STRIKETHROUGH) : text;
}
private String what(Edit edit)
{
return switch (edit.kind())
{
case BLOCK, ENTITY -> shortId(edit.after().isEmpty() ? edit.before() : edit.after());
case CONTAINER, ITEM -> edit.item().map(item -> item.amount() + " " + shortId(item.type())).orElse("");
default -> edit.text();
};
}
/** minecraft:oak_stairs[facing=north] reads as oak_stairs. */
private static String shortId(String id)
{
int bracket = id.indexOf('[');
String plain = bracket < 0 ? id : id.substring(0, bracket);
return plain.startsWith("minecraft:") ? plain.substring("minecraft:".length()) : plain;
}
private static String ago(Duration age)
{
if (age.toDays() > 0)
{
return age.toDays() + "d";
}
if (age.toHours() > 0)
{
return age.toHours() + "h";
}
return Math.max(1, age.toMinutes()) + "m";
}
private String name(UUID person)
{
return oasis.key(person).orElseGet(() ->
{
String player = Bukkit.getOfflinePlayer(person).getName();
return player == null ? person.toString() : player;
});
}
}

A few things to note:

  • The command reads one page per request. Nothing reads ahead, and nothing counts.
  • show checks isOnline(), because the viewer can leave before the page arrives.
  • lastPage keeps one Page per viewer. A page holds its edits and its place, so it is cheap to keep. Remove a viewer’s entry in a PlayerQuitEvent listener if your server has many viewers.