Skip to content
Closed
4 changes: 4 additions & 0 deletions docs/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ These are the currently logged action types:
- block-change
- item-insert
- item-remove
- item-pick-up
- item-drop
- item-despawn
- item-break
- entity-killed
- entity-mount
- entity-dismount
7 changes: 6 additions & 1 deletion docs/commands/rollback.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,9 @@ It is ***highly*** recommended to use the [preview command](../commands/preview.

- Block
- Item
- Entity
- Entity

**Despawned items**

Rolling back `item-despawn` actions additionally requires `ledger.commands.rollback.item-despawn` (defaults to permission level 4).
Without it, despawned items are left out of rollbacks and previews, and explicitly searching for `action:item-despawn` fails.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.github.quiltservertools.ledger.mixin;

import com.github.quiltservertools.ledger.callbacks.ItemDropCallback;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import com.llamalad7.mixinextras.sugar.Local;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityEquipment;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;

@Mixin(EntityEquipment.class)
public abstract class EntityEquipmentMixin {
// Drops armor and offhand when a player dies (called from Inventory#dropAll). Only players are
// logged, matching the regular drop hook in ServerPlayerMixin.
@WrapOperation(method = "dropAll", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/Level;addFreshEntity(Lnet/minecraft/world/entity/Entity;)Z"))
private boolean ledgerLogDeathDrop(Level world, Entity entity, Operation<Boolean> original, @Local(argsOnly = true) LivingEntity owner) {
boolean added = original.call(world, entity);
if (added && owner instanceof Player player && entity instanceof ItemEntity itemEntity) {
ItemDropCallback.EVENT.invoker().drop(itemEntity, player);
}
return added;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.github.quiltservertools.ledger.mixin;

import com.github.quiltservertools.ledger.callbacks.ItemDropCallback;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;

@Mixin(Inventory.class)
public abstract class InventoryMixin {
@Shadow
@Final
public Player player;

// On death the inventory spawns its items directly instead of going through Player#drop, so
// the drop hook in ServerPlayerMixin never sees them. Armor and offhand are handled in
// EntityEquipmentMixin.
@WrapOperation(method = "dropAll", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/Level;addFreshEntity(Lnet/minecraft/world/entity/Entity;)Z"))
private boolean ledgerLogDeathDrop(Level world, Entity entity, Operation<Boolean> original) {
boolean added = original.call(world, entity);
if (added && entity instanceof ItemEntity itemEntity) {
ItemDropCallback.EVENT.invoker().drop(itemEntity, this.player);
}
return added;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.github.quiltservertools.ledger.mixin;

import com.github.quiltservertools.ledger.callbacks.ItemBreakCallback;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;

import java.util.function.Consumer;

@Mixin(ItemStack.class)
public abstract class ItemStackMixin {
// Every durability loss (tools, weapons, worn armor) ends up here. The player is null when the
// item is not held or worn by a player, e.g. a mob's armor.
@Inject(method = "applyDamage", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/item/ItemStack;shrink(I)V"))
private void ledgerLogItemBreak(int damage, ServerPlayer player, Consumer<ItemStack> onBreak, CallbackInfo ci) {
if (player != null) {
ItemBreakCallback.EVENT.invoker().breakItem((ItemStack) (Object) this, player);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.github.quiltservertools.ledger.mixin;

import com.github.quiltservertools.ledger.commands.subcommands.PageCommand;
import com.github.quiltservertools.ledger.commands.subcommands.RollbackCommand;
import com.github.quiltservertools.ledger.commands.subcommands.TeleportCommand;
import com.github.quiltservertools.ledger.utility.MessageUtils;
import net.minecraft.core.BlockPos;
Expand Down Expand Up @@ -54,6 +55,14 @@ private void handleLedgerPageChangeClick(ServerboundCustomClickActionPacket pack
TeleportCommand.INSTANCE.teleport(player, serverLevel, pos);
});
ci.cancel();
} else if (packet.id().equals(MessageUtils.INSTANCE.getRollbackAction())) {
ServerPlayer player = game.player;
packet.payload().flatMap(Tag::asCompound)
.flatMap(root -> root.getInt("id"))
.ifPresent(actionId ->
RollbackCommand.INSTANCE.rollbackSingle(player, actionId)
);
ci.cancel();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public abstract class ArmorStandMixin {
private CompoundTag oldEntityTags;
@Unique
private ItemStack oldEntityStack;
@Unique
private boolean ledgerBreakLogged;

@Inject(method = "swapItem", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/decoration/ArmorStand;setItemSlot(Lnet/minecraft/world/entity/EquipmentSlot;Lnet/minecraft/world/item/ItemStack;)V"))
private void legerLogOldEntity(Player player, EquipmentSlot slot, ItemStack playerStack, InteractionHand hand, CallbackInfoReturnable<Boolean> cir) {
Expand All @@ -45,15 +47,45 @@ private void ledgerArmorStandInteract(Player player, EquipmentSlot slot, ItemSta
}
}

// brokenByAnything drops the armor stand's equipment and empties every slot before kill is
// called, so logging at the kill call would store an armor stand without its items and the
// rollback would bring back an empty one. Log here, while the equipment is still attached.
@Inject(method = "brokenByAnything", at = @At("HEAD"))
private void ledgerArmorStandBroken(ServerLevel world, DamageSource damageSource, CallbackInfo ci) {
LivingEntity entity = (LivingEntity) (Object) this;
EntityKillCallback.EVENT.invoker().kill(world, entity.blockPosition(), entity, damageSource);
this.ledgerBreakLogged = true;
}

@Inject(method = "causeDamage", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/decoration/ArmorStand;kill(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/entity/Entity;)V"))
private void ledgerArmorStandKill(ServerLevel world, DamageSource damageSource, float amount, CallbackInfo ci) {
if (ledgerConsumeBreakLogged()) {
return;
}
LivingEntity entity = (LivingEntity) (Object) this;
EntityKillCallback.EVENT.invoker().kill(entity.level(), entity.blockPosition(), entity, damageSource);
}

@Inject(method = "hurtServer", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/decoration/ArmorStand;kill(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/entity/Entity;)V"))
private void ledgerArmorStandKill(ServerLevel world, DamageSource source, float amount, CallbackInfoReturnable<Boolean> cir) {
if (ledgerConsumeBreakLogged()) {
return;
}
LivingEntity entity = (LivingEntity) (Object) this;
EntityKillCallback.EVENT.invoker().kill(world, entity.blockPosition(), entity, source);
}

/**
* Every brokenByAnything call is immediately followed by a kill call, which must not log the
* armor stand a second time. Paths that kill without breaking (creative, damage that bypasses
* invulnerability) never set the flag and are still logged by the kill hooks above.
*/
@Unique
private boolean ledgerConsumeBreakLogged() {
if (!this.ledgerBreakLogged) {
return false;
}
this.ledgerBreakLogged = false;
return true;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.github.quiltservertools.ledger.mixin.entities;

import com.github.quiltservertools.ledger.callbacks.ItemDespawnCallback;
import com.github.quiltservertools.ledger.callbacks.ItemPickUpCallback;
import com.llamalad7.mixinextras.sugar.Local;
import com.llamalad7.mixinextras.sugar.Share;
Expand Down Expand Up @@ -31,6 +32,12 @@ public void storeItemStack(Player player, CallbackInfo ci, @Local ItemStack item
originalItemStackRef.set(itemStack.copy());
}

// The first discard in tick removes empty stacks, the second one is the despawn at the end of the lifetime
@Inject(method = "tick", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/item/ItemEntity;discard()V", ordinal = 1))
private void logItemDespawn(CallbackInfo ci) {
ItemDespawnCallback.EVENT.invoker().despawn((ItemEntity) (Object) this);
}

// insertStack modifies the ItemStack instance of the ItemEntity
// The player may not be able to pick up all items from the stack
@Inject(method = "playerTouch", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/player/Inventory;add(Lnet/minecraft/world/item/ItemStack;)Z", shift = At.Shift.AFTER))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ import java.time.Instant
import java.util.*
import kotlin.time.ExperimentalTime

private const val NO_ID = -1

abstract class AbstractActionType : ActionType {
override var id: Int = -1
override var id: Int = NO_ID
override var timestamp: Instant = Instant.now()
override var pos: BlockPos = BlockPos.ZERO
override var world: Identifier? = null
Expand Down Expand Up @@ -55,11 +57,40 @@ abstract class AbstractActionType : ActionType {

if (rolledBack) {
message.withStyle(ChatFormatting.STRIKETHROUGH)
} else {
getRollbackMessage()?.let { message.append(" ".literal()).append(it) }
}

return message
}

/**
* Rollback button appended to a single search or inspect result. Only rendered for actions that
* were read back from the database (which is where the id comes from) and are not rolled back
* yet. The click is handled by ServerCommonPacketListenerImplMixin, which checks the rollback
* permission before acting on it.
*/
open fun getRollbackMessage(): Component? {
if (id == NO_ID) return null

// Bound outside the apply block below, where `id` would resolve to CompoundTag's own id.
val actionId = id

return Component.translatable("text.ledger.action_message.rollback")
.setStyle(TextColorPallet.primaryVariant)
.withStyle {
val tag: CompoundTag = CompoundTag().apply { this.putInt("id", actionId) }

it.withHoverEvent(
HoverEvent.ShowText(
Component.translatable("text.ledger.action_message.rollback.hover"),
),
).withClickEvent(
ClickEvent.Custom(MessageUtils.rollbackAction, Optional.of(tag)),
)
}
}

@ExperimentalTime
open fun getTimeMessage(): Component = MessageUtils.instantToText(timestamp)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.github.quiltservertools.ledger.actions

// Rolling back drops the broken item where it broke, restoring removes it again
class ItemBreakActionType : ItemPickUpActionType() {
override val identifier = "item-break"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.github.quiltservertools.ledger.actions

import com.github.quiltservertools.ledger.actionutils.ActionSearchParams
import com.github.quiltservertools.ledger.utility.Negatable
import com.mojang.brigadier.exceptions.SimpleCommandExceptionType
import me.lucko.fabric.api.permissions.v0.Permissions
import net.minecraft.commands.CommandSourceStack
import net.minecraft.nbt.CompoundTag
import net.minecraft.network.chat.Component

class ItemDespawnActionType : ItemPickUpActionType() {
override val identifier = IDENTIFIER

// The item was logged at the end of its lifetime, so it would despawn again on the next tick
override fun getRespawnTag(): CompoundTag = super.getRespawnTag().apply { putShort("Age", 0) }

companion object {
const val IDENTIFIER = "item-despawn"
const val ROLLBACK_PERMISSION = "ledger.commands.rollback.item-despawn"

// Stricter than the regular rollback permission (level 3), so despawned items are only brought back on purpose
private const val ROLLBACK_PERMISSION_LEVEL = 4

fun canRollback(source: CommandSourceStack): Boolean =
Permissions.check(source, ROLLBACK_PERMISSION, ROLLBACK_PERMISSION_LEVEL)

/**
* Keeps despawned items out of a rollback for sources without [ROLLBACK_PERMISSION]. Asking for them
* explicitly fails instead, so an empty result is not mistaken for "nothing despawned here".
*/
fun restrictRollback(source: CommandSourceStack, params: ActionSearchParams) {
if (canRollback(source)) return

if (params.actions?.contains(Negatable.allow(IDENTIFIER)) == true) {
throw SimpleCommandExceptionType(
Component.translatable("error.ledger.rollback.item_despawn_permission"),
).create()
}

params.actions = (params.actions ?: mutableSetOf()).apply { add(Negatable.deny(IDENTIFIER)) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.github.quiltservertools.ledger.utility.getWorld
import com.github.quiltservertools.ledger.utility.literal
import net.minecraft.commands.CommandSourceStack
import net.minecraft.core.UUIDUtil
import net.minecraft.nbt.CompoundTag
import net.minecraft.nbt.TagParser
import net.minecraft.network.chat.Component
import net.minecraft.network.chat.HoverEvent
Expand Down Expand Up @@ -45,10 +46,12 @@ open class ItemPickUpActionType : AbstractActionType() {
}
}

protected open fun getRespawnTag(): CompoundTag = TagParser.parseCompoundFully(oldObjectState!!)

override fun rollback(server: MinecraftServer): Boolean {
val world = server.getWorld(world)!!

val oldEntity = TagParser.parseCompoundFully(oldObjectState!!)
val oldEntity = getRespawnTag()
val optionalUUID = oldEntity.read(UUID, UUIDUtil.CODEC)
if (optionalUUID.isEmpty) return false
val entity = world.getEntity(optionalUUID.get())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import com.github.quiltservertools.ledger.actions.EntityChangeActionType
import com.github.quiltservertools.ledger.actions.EntityDismountActionType
import com.github.quiltservertools.ledger.actions.EntityKillActionType
import com.github.quiltservertools.ledger.actions.EntityMountActionType
import com.github.quiltservertools.ledger.actions.ItemBreakActionType
import com.github.quiltservertools.ledger.actions.ItemDespawnActionType
import com.github.quiltservertools.ledger.actions.ItemDropActionType
import com.github.quiltservertools.ledger.actions.ItemInsertActionType
import com.github.quiltservertools.ledger.actions.ItemPickUpActionType
Expand Down Expand Up @@ -159,6 +161,32 @@ object ActionFactory {
return action
}

fun itemDespawnAction(entity: ItemEntity): ItemDespawnActionType {
val action = ItemDespawnActionType()

setItemData(action, entity.blockPosition(), entity.level(), entity.item, Sources.DESPAWN)

action.oldObjectState = entity.createNbt().toString()

return action
}

fun itemBreakAction(stack: ItemStack, player: Player): ItemBreakActionType {
val action = ItemBreakActionType()

// Logged with one durability left, so a rollback hands back a usable item instead of one
// that breaks again on its next use
val restoredStack = stack.copy().apply { damageValue = maxDamage - 1 }
val entity = ItemEntity(player.level(), player.x, player.y, player.z, restoredStack)

setItemData(action, player.blockPosition(), player.level(), restoredStack, Sources.PLAYER)

action.oldObjectState = entity.createNbt().toString()
action.sourceProfile = player.nameAndId()

return action
}

fun itemDropAction(entity: ItemEntity, source: LivingEntity): ItemDropActionType {
val action = ItemDropActionType()

Expand Down
Loading