From 1bcaf2b7e74d6bdcff653efbd4e6f8b0fd0ac944 Mon Sep 17 00:00:00 2001 From: Leon Enneken Date: Sun, 6 Sep 2026 13:31:47 +0200 Subject: [PATCH 1/7] fix: log armor stand equipment before it is dropped on break --- .../mixin/entities/ArmorStandMixin.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/main/java/com/github/quiltservertools/ledger/mixin/entities/ArmorStandMixin.java b/src/main/java/com/github/quiltservertools/ledger/mixin/entities/ArmorStandMixin.java index a2fef7a1..67baa81b 100644 --- a/src/main/java/com/github/quiltservertools/ledger/mixin/entities/ArmorStandMixin.java +++ b/src/main/java/com/github/quiltservertools/ledger/mixin/entities/ArmorStandMixin.java @@ -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 cir) { @@ -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 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; + } } From 0544eeccc3ab0b478c29614b89221ad3b47e60d7 Mon Sep 17 00:00:00 2001 From: Leon Enneken Date: Sun, 27 Sep 2026 16:39:21 +0200 Subject: [PATCH 2/7] fix: log item drops when a player dies Inventory#dropAll and EntityEquipment#dropAll spawn the death drops directly via Level#addFreshEntity instead of going through Player#drop, so they were never logged. Hook both and log them as regular item drops. --- .../ledger/mixin/EntityEquipmentMixin.java | 28 ++++++++++++++++ .../ledger/mixin/InventoryMixin.java | 33 +++++++++++++++++++ src/main/resources/ledger.mixins.json | 2 ++ 3 files changed, 63 insertions(+) create mode 100644 src/main/java/com/github/quiltservertools/ledger/mixin/EntityEquipmentMixin.java create mode 100644 src/main/java/com/github/quiltservertools/ledger/mixin/InventoryMixin.java diff --git a/src/main/java/com/github/quiltservertools/ledger/mixin/EntityEquipmentMixin.java b/src/main/java/com/github/quiltservertools/ledger/mixin/EntityEquipmentMixin.java new file mode 100644 index 00000000..e1b58040 --- /dev/null +++ b/src/main/java/com/github/quiltservertools/ledger/mixin/EntityEquipmentMixin.java @@ -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 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; + } +} diff --git a/src/main/java/com/github/quiltservertools/ledger/mixin/InventoryMixin.java b/src/main/java/com/github/quiltservertools/ledger/mixin/InventoryMixin.java new file mode 100644 index 00000000..639665b0 --- /dev/null +++ b/src/main/java/com/github/quiltservertools/ledger/mixin/InventoryMixin.java @@ -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 original) { + boolean added = original.call(world, entity); + if (added && entity instanceof ItemEntity itemEntity) { + ItemDropCallback.EVENT.invoker().drop(itemEntity, this.player); + } + return added; + } +} diff --git a/src/main/resources/ledger.mixins.json b/src/main/resources/ledger.mixins.json index a463e58a..b82cc06c 100644 --- a/src/main/resources/ledger.mixins.json +++ b/src/main/resources/ledger.mixins.json @@ -17,10 +17,12 @@ "ContainersMixin", "CopperGolemGiveMixin", "DyeItemMixin", + "EntityEquipmentMixin", "FillCommandMixin", "FlintAndSteelItemMixin", "FrostWalkerEnchantmentMixin", "HoneycombItemMixin", + "InventoryMixin", "JukeboxPlayableMixin", "LilyPadBlockMixin", "PortalShapeMixin", From 2589e436b105d5f72e5fd9cafeac8fe42f9f1358 Mon Sep 17 00:00:00 2001 From: Leon Enneken Date: Sun, 27 Sep 2026 16:33:27 +0200 Subject: [PATCH 3/7] feat: add rollback button to single search and inspect results Each result line read from the database gets a clickable [R] that rolls back just that action. The click is handled server-side and re-checks the ledger.commands.rollback permission. --- .../ServerCommonPacketListenerImplMixin.java | 9 ++++ .../ledger/actions/AbstractActionType.kt | 33 +++++++++++- .../commands/subcommands/PageCommand.kt | 4 +- .../commands/subcommands/RollbackCommand.kt | 52 +++++++++++++++++++ .../ledger/database/DatabaseManager.kt | 7 +++ .../ledger/utility/MessageUtils.kt | 33 ++++++------ .../resources/data/ledger/lang/de_de.json | 4 ++ .../resources/data/ledger/lang/en_us.json | 4 ++ 8 files changed, 127 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/github/quiltservertools/ledger/mixin/ServerCommonPacketListenerImplMixin.java b/src/main/java/com/github/quiltservertools/ledger/mixin/ServerCommonPacketListenerImplMixin.java index c25d3ed3..563c516e 100644 --- a/src/main/java/com/github/quiltservertools/ledger/mixin/ServerCommonPacketListenerImplMixin.java +++ b/src/main/java/com/github/quiltservertools/ledger/mixin/ServerCommonPacketListenerImplMixin.java @@ -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; @@ -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(); } } } diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/actions/AbstractActionType.kt b/src/main/kotlin/com/github/quiltservertools/ledger/actions/AbstractActionType.kt index 8bc3b173..e740c18b 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/actions/AbstractActionType.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/actions/AbstractActionType.kt @@ -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 @@ -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) diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/PageCommand.kt b/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/PageCommand.kt index f09b3c81..d6f3f8ef 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/PageCommand.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/PageCommand.kt @@ -37,8 +37,8 @@ object PageCommand : BuildableCommand { source, results, Component.translatable( - "text.ledger.header.search" - ).setStyle(TextColorPallet.primary) + "text.ledger.header.search", + ).setStyle(TextColorPallet.primary), ) } diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/RollbackCommand.kt b/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/RollbackCommand.kt index cf69d8c2..51121ef0 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/RollbackCommand.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/RollbackCommand.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.launch import me.lucko.fabric.api.permissions.v0.Permissions import net.minecraft.commands.Commands import net.minecraft.network.chat.Component +import net.minecraft.server.level.ServerPlayer +import kotlin.time.ExperimentalTime object RollbackCommand : BuildableCommand { override fun build(): LiteralNode = Commands.literal("rollback") @@ -26,6 +28,56 @@ object RollbackCommand : BuildableCommand { ) .build() + /** + * Rolls back exactly the one action behind a clicked search or inspect result. The action id + * comes straight off a client packet, so the permission is re-checked here rather than relying + * on the command node. + */ + @OptIn(ExperimentalTime::class) + fun rollbackSingle(player: ServerPlayer, actionId: Int): Int { + if (!Permissions.check(player, "ledger.commands.rollback", CommandConsts.PERMISSION_LEVEL)) return -1 + + val source = player.createCommandSourceStack() + + Ledger.launch { + MessageUtils.warnBusy(source) + val action = DatabaseManager.selectRollbackById(actionId) + + if (action == null) { + // The clicked chat line sticks around after a rollback, so a second click on it is + // an expected case rather than a genuinely empty search. + source.sendFailure(Component.translatable("error.ledger.rollback.unavailable")) + return@launch + } + + source.level.launchMain { + if (!action.rollback(source.server)) { + source.sendFailure( + Component.translatable("text.ledger.rollback.fail", action.identifier, 1), + ) + return@launchMain + } + + Ledger.launch { + DatabaseManager.rollbackActions(setOf(action.id)) + } + + action.rolledBack = true + source.sendSuccess( + { + Component.translatable( + "text.ledger.rollback.single", + action.getMessage(source), + ).setStyle(TextColorPallet.primary) + }, + true, + ) + } + } + + return 1 + } + fun rollback(context: Context, params: ActionSearchParams): Int { val source = context.source params.ensureSpecific() diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/database/DatabaseManager.kt b/src/main/kotlin/com/github/quiltservertools/ledger/database/DatabaseManager.kt index bb6a5f58..f7836c38 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/database/DatabaseManager.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/database/DatabaseManager.kt @@ -211,6 +211,13 @@ object DatabaseManager { return@execute getActionsFromQuery(query) } + suspend fun selectRollbackById(id: Int): ActionType? = execute { + val query = Tables.Actions + .selectAll() + .where { (Tables.Actions.id eq id) and (Tables.Actions.rolledBack eq false) } + return@execute getActionsFromQuery(query).firstOrNull() + } + suspend fun previewActions(params: ActionSearchParams, type: Preview.Type): List = execute { when (type) { Preview.Type.ROLLBACK -> return@execute selectRollback(params) diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/utility/MessageUtils.kt b/src/main/kotlin/com/github/quiltservertools/ledger/utility/MessageUtils.kt index 8188c68f..7bb6ac94 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/utility/MessageUtils.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/utility/MessageUtils.kt @@ -26,7 +26,8 @@ import kotlin.time.toKotlinDuration object MessageUtils { val pageChangeAction: Identifier = Ledger.identifier("page-change") val teleportAction: Identifier = Ledger.identifier("teleport") - + val rollbackAction: Identifier = Ledger.identifier("rollback-single") + @OptIn(ExperimentalTime::class) suspend fun sendSearchResults(source: CommandSourceStack, results: SearchResults, header: Component) { // If the player has a Ledger compatible client, we send results as action packets rather than as chat messages @@ -50,17 +51,17 @@ object MessageUtils { Component.translatable( "text.ledger.footer.search", Component.translatable( - "text.ledger.footer.page_backward" + "text.ledger.footer.page_backward", ).setStyle(TextColorPallet.primaryVariant).withStyle { - val tag: CompoundTag = CompoundTag().apply { this.putInt("page", results.page-1) } - + val tag: CompoundTag = CompoundTag().apply { this.putInt("page", results.page - 1) } + if (results.page > 1) { it.withHoverEvent( HoverEvent.ShowText( - Component.translatable("text.ledger.footer.page_backward.hover") - ) + Component.translatable("text.ledger.footer.page_backward.hover"), + ), ).withClickEvent( - ClickEvent.Custom(pageChangeAction, Optional.of(tag)) + ClickEvent.Custom(pageChangeAction, Optional.of(tag)), ) } else { Style.EMPTY @@ -69,23 +70,23 @@ object MessageUtils { results.page.toString().literal().setStyle(TextColorPallet.primaryVariant), results.pages.toString().literal().setStyle(TextColorPallet.primaryVariant), Component.translatable( - "text.ledger.footer.page_forward" + "text.ledger.footer.page_forward", ).setStyle(TextColorPallet.primaryVariant).withStyle { - val tag: CompoundTag = CompoundTag().apply { this.putInt("page", results.page+1) } + val tag: CompoundTag = CompoundTag().apply { this.putInt("page", results.page + 1) } if (results.page < results.pages) { it.withHoverEvent( HoverEvent.ShowText( - Component.translatable("text.ledger.footer.page_forward.hover") - ) + Component.translatable("text.ledger.footer.page_forward.hover"), + ), ).withClickEvent( - ClickEvent.Custom(pageChangeAction, Optional.of(tag)) + ClickEvent.Custom(pageChangeAction, Optional.of(tag)), ) } else { Style.EMPTY } - } - ).setStyle(TextColorPallet.primary) + }, + ).setStyle(TextColorPallet.primary), ) } @@ -135,8 +136,8 @@ object MessageUtils { message.withStyle { it.withHoverEvent( HoverEvent.ShowText( - timeMessage - ) + timeMessage, + ), ) } return message diff --git a/src/main/resources/data/ledger/lang/de_de.json b/src/main/resources/data/ledger/lang/de_de.json index 6357f3cc..39e04f18 100644 --- a/src/main/resources/data/ledger/lang/de_de.json +++ b/src/main/resources/data/ledger/lang/de_de.json @@ -24,8 +24,11 @@ "text.ledger.action_message": "%1$s %2$s %3$s %4$s %5$s", "text.ledger.action_message.time_diff": "Vor %s", "text.ledger.action_message.location.hover": "Zum Teleportieren klicken", + "text.ledger.action_message.rollback": "[R]", + "text.ledger.action_message.rollback.hover": "Klicken, um diesen Eintrag zurückzusetzen", "text.ledger.rollback.start": "Setzt %s Aktionen zurück", + "text.ledger.rollback.single": "%s zurückgesetzt", "text.ledger.rollback.finish": "Zurücksetzen vollständig", "text.ledger.rollback.fail": "Fehler beim Zurücksetzen von %s x%s", @@ -41,6 +44,7 @@ "error.ledger.no_cached_params": "Keine Suche gefunden. Führe zuerst eine Suche durch", "error.ledger.command.no_results": "Keine Einträge für diese Suche", + "error.ledger.rollback.unavailable": "Dieser Eintrag wurde bereits zurückgesetzt oder existiert nicht mehr", "error.ledger.no_more_pages": "Keine weiteren Seiten", "error.ledger.unknown_param": "Unbekannte Angaben %s", "error.ledger.no_preview": "Keine laufende Vorschau", diff --git a/src/main/resources/data/ledger/lang/en_us.json b/src/main/resources/data/ledger/lang/en_us.json index b40f7d0c..7d0c0f81 100644 --- a/src/main/resources/data/ledger/lang/en_us.json +++ b/src/main/resources/data/ledger/lang/en_us.json @@ -24,9 +24,12 @@ "text.ledger.action_message": "%1$s %2$s %3$s %4$s %5$s", "text.ledger.action_message.time_diff": "%s ago", "text.ledger.action_message.location.hover": "Click to teleport", + "text.ledger.action_message.rollback": "[R]", + "text.ledger.action_message.rollback.hover": "Click to roll back this entry", "text.ledger.action_message.with": "with", "text.ledger.rollback.start": "Rolling back %s actions", + "text.ledger.rollback.single": "Rolled back %s", "text.ledger.rollback.finish": "Rollback complete", "text.ledger.rollback.fail": "Failed to rollback %s x%s", @@ -42,6 +45,7 @@ "error.ledger.no_cached_params": "No cached search. Try preforming a search first", "error.ledger.command.no_results": "No results for that search", + "error.ledger.rollback.unavailable": "That entry was already rolled back or no longer exists", "error.ledger.no_more_pages": "No more pages", "error.ledger.unknown_param": "Unknown parameter %s", "error.ledger.no_preview": "No current preview", From 0e2053e465001b7ff260b3c813e566da6a88d2e3 Mon Sep 17 00:00:00 2001 From: Leon Enneken Date: Sun, 27 Sep 2026 16:43:08 +0200 Subject: [PATCH 4/7] feat: log item despawns Items that reach the end of their lifetime are now logged as item-despawn with the source @despawn. Rolling one back respawns the item with its age reset, so it does not despawn again on the next tick; restoring removes it again. --- docs/actions.md | 3 +++ .../mixin/entities/ItemEntityMixin.java | 7 ++++++ .../ledger/actions/ItemDespawnActionType.kt | 10 +++++++++ .../ledger/actions/ItemPickUpActionType.kt | 5 ++++- .../ledger/actionutils/ActionFactory.kt | 11 ++++++++++ .../ledger/callbacks/ItemDespawnCallback.kt | 22 +++++++++++++++++++ .../listeners/EntityCallbackListener.kt | 7 ++++++ .../ledger/registry/ActionRegistry.kt | 1 + .../ledger/utility/Sources.kt | 1 + .../resources/data/ledger/lang/de_de.json | 1 + .../resources/data/ledger/lang/en_us.json | 1 + 11 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/com/github/quiltservertools/ledger/actions/ItemDespawnActionType.kt create mode 100644 src/main/kotlin/com/github/quiltservertools/ledger/callbacks/ItemDespawnCallback.kt diff --git a/docs/actions.md b/docs/actions.md index bdfc5887..987c48af 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -7,6 +7,9 @@ These are the currently logged action types: - block-change - item-insert - item-remove +- item-pick-up +- item-drop +- item-despawn - entity-killed - entity-mount - entity-dismount \ No newline at end of file diff --git a/src/main/java/com/github/quiltservertools/ledger/mixin/entities/ItemEntityMixin.java b/src/main/java/com/github/quiltservertools/ledger/mixin/entities/ItemEntityMixin.java index d8a5f569..c44fc701 100644 --- a/src/main/java/com/github/quiltservertools/ledger/mixin/entities/ItemEntityMixin.java +++ b/src/main/java/com/github/quiltservertools/ledger/mixin/entities/ItemEntityMixin.java @@ -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; @@ -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)) diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/actions/ItemDespawnActionType.kt b/src/main/kotlin/com/github/quiltservertools/ledger/actions/ItemDespawnActionType.kt new file mode 100644 index 00000000..8ff69548 --- /dev/null +++ b/src/main/kotlin/com/github/quiltservertools/ledger/actions/ItemDespawnActionType.kt @@ -0,0 +1,10 @@ +package com.github.quiltservertools.ledger.actions + +import net.minecraft.nbt.CompoundTag + +class ItemDespawnActionType : ItemPickUpActionType() { + override val identifier = "item-despawn" + + // 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) } +} diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/actions/ItemPickUpActionType.kt b/src/main/kotlin/com/github/quiltservertools/ledger/actions/ItemPickUpActionType.kt index d04c6404..a2c837ad 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/actions/ItemPickUpActionType.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/actions/ItemPickUpActionType.kt @@ -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 @@ -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()) diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/actionutils/ActionFactory.kt b/src/main/kotlin/com/github/quiltservertools/ledger/actionutils/ActionFactory.kt index 5c9cbc8d..a3757daa 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/actionutils/ActionFactory.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/actionutils/ActionFactory.kt @@ -8,6 +8,7 @@ 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.ItemDespawnActionType import com.github.quiltservertools.ledger.actions.ItemDropActionType import com.github.quiltservertools.ledger.actions.ItemInsertActionType import com.github.quiltservertools.ledger.actions.ItemPickUpActionType @@ -159,6 +160,16 @@ 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 itemDropAction(entity: ItemEntity, source: LivingEntity): ItemDropActionType { val action = ItemDropActionType() diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/callbacks/ItemDespawnCallback.kt b/src/main/kotlin/com/github/quiltservertools/ledger/callbacks/ItemDespawnCallback.kt new file mode 100644 index 00000000..95bf6f1c --- /dev/null +++ b/src/main/kotlin/com/github/quiltservertools/ledger/callbacks/ItemDespawnCallback.kt @@ -0,0 +1,22 @@ +package com.github.quiltservertools.ledger.callbacks + +import net.fabricmc.fabric.api.event.Event +import net.fabricmc.fabric.api.event.EventFactory +import net.minecraft.world.entity.item.ItemEntity + +fun interface ItemDespawnCallback { + fun despawn(entity: ItemEntity) + + companion object { + @JvmField + val EVENT: Event = EventFactory.createArrayBacked( + ItemDespawnCallback::class.java, + ) { listeners -> + ItemDespawnCallback { entity -> + for (listener in listeners) { + listener.despawn(entity) + } + } + } + } +} diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/listeners/EntityCallbackListener.kt b/src/main/kotlin/com/github/quiltservertools/ledger/listeners/EntityCallbackListener.kt index 6c1f3c71..a5e7d96a 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/listeners/EntityCallbackListener.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/listeners/EntityCallbackListener.kt @@ -3,17 +3,24 @@ package com.github.quiltservertools.ledger.listeners import com.github.quiltservertools.ledger.actionutils.ActionFactory import com.github.quiltservertools.ledger.callbacks.EntityKillCallback import com.github.quiltservertools.ledger.callbacks.EntityModifyCallback +import com.github.quiltservertools.ledger.callbacks.ItemDespawnCallback import com.github.quiltservertools.ledger.database.ActionQueueService import net.minecraft.core.BlockPos import net.minecraft.nbt.CompoundTag import net.minecraft.world.damagesource.DamageSource import net.minecraft.world.entity.Entity +import net.minecraft.world.entity.item.ItemEntity import net.minecraft.world.item.ItemStack import net.minecraft.world.level.Level fun registerEntityListeners() { EntityKillCallback.EVENT.register(::onKill) EntityModifyCallback.EVENT.register(::onModify) + ItemDespawnCallback.EVENT.register(::onItemDespawn) +} + +private fun onItemDespawn(entity: ItemEntity) { + ActionQueueService.addToQueue(ActionFactory.itemDespawnAction(entity)) } private fun onKill(world: Level, pos: BlockPos, entity: Entity, source: DamageSource) { diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/registry/ActionRegistry.kt b/src/main/kotlin/com/github/quiltservertools/ledger/registry/ActionRegistry.kt index 84f2c113..5f5aadc2 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/registry/ActionRegistry.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/registry/ActionRegistry.kt @@ -33,6 +33,7 @@ object ActionRegistry { registerActionType { ItemRemoveActionType() } registerActionType { ItemPickUpActionType() } registerActionType { ItemDropActionType() } + registerActionType { ItemDespawnActionType() } registerActionType { EntityKillActionType() } registerActionType { EntityChangeActionType() } registerActionType { EntityMountActionType() } diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/utility/Sources.kt b/src/main/kotlin/com/github/quiltservertools/ledger/utility/Sources.kt index b7565156..e5686a27 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/utility/Sources.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/utility/Sources.kt @@ -39,5 +39,6 @@ object Sources { const val COPPER_GOLEM = "copper_golem" const val WAX = "wax" const val REANIMATE = "reanimate" + const val DESPAWN = "despawn" const val UNKNOWN = "unknown" } diff --git a/src/main/resources/data/ledger/lang/de_de.json b/src/main/resources/data/ledger/lang/de_de.json index 6357f3cc..65118432 100644 --- a/src/main/resources/data/ledger/lang/de_de.json +++ b/src/main/resources/data/ledger/lang/de_de.json @@ -49,6 +49,7 @@ "text.ledger.action.block-break": "zerstört", "text.ledger.action.item-insert": "hinzugefügt", "text.ledger.action.item-remove": "entfernt", + "text.ledger.action.item-despawn": "despawnt", "text.ledger.action.entity-kill": "getötet", "text.ledger.network.protocols_mismatched": "Keine passende Protokoll-Version: Client-Mod ist Ledger Protokoll-Version %d und die Client Version ist %d", diff --git a/src/main/resources/data/ledger/lang/en_us.json b/src/main/resources/data/ledger/lang/en_us.json index b40f7d0c..e050e6c9 100644 --- a/src/main/resources/data/ledger/lang/en_us.json +++ b/src/main/resources/data/ledger/lang/en_us.json @@ -56,6 +56,7 @@ "text.ledger.action.item-remove": "removed", "text.ledger.action.item-pick-up": "picked up", "text.ledger.action.item-drop": "dropped", + "text.ledger.action.item-despawn": "despawned", "text.ledger.action.entity-kill": "killed", "text.ledger.action.entity-change": "changed", "text.ledger.action.entity-mount": "mounted", From edbd457fc6f288b76ea39d4ee20c460acd06fa7b Mon Sep 17 00:00:00 2001 From: Leon Enneken Date: Sun, 27 Sep 2026 17:17:05 +0200 Subject: [PATCH 5/7] feat: require extra permission to roll back item despawns Rolling back item-despawn actions now needs ledger.commands.rollback.item-despawn (default level 4). Without it, despawns are excluded from rollbacks and rollback previews, and asking for action:item-despawn explicitly fails with an error. --- docs/commands/rollback.md | 7 +++- .../ledger/actions/ItemDespawnActionType.kt | 35 ++++++++++++++++++- .../commands/subcommands/PreviewCommand.kt | 4 +++ .../commands/subcommands/RollbackCommand.kt | 2 ++ .../resources/data/ledger/lang/de_de.json | 1 + .../resources/data/ledger/lang/en_us.json | 1 + 6 files changed, 48 insertions(+), 2 deletions(-) diff --git a/docs/commands/rollback.md b/docs/commands/rollback.md index 44cf6692..48d00bcf 100644 --- a/docs/commands/rollback.md +++ b/docs/commands/rollback.md @@ -15,4 +15,9 @@ It is ***highly*** recommended to use the [preview command](../commands/preview. - Block - Item -- Entity \ No newline at end of file +- 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. \ No newline at end of file diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/actions/ItemDespawnActionType.kt b/src/main/kotlin/com/github/quiltservertools/ledger/actions/ItemDespawnActionType.kt index 8ff69548..d9f7d4d4 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/actions/ItemDespawnActionType.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/actions/ItemDespawnActionType.kt @@ -1,10 +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 = "item-despawn" + 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)) } + } + } } diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/PreviewCommand.kt b/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/PreviewCommand.kt index 9f73b09c..7203f1c0 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/PreviewCommand.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/PreviewCommand.kt @@ -1,6 +1,7 @@ package com.github.quiltservertools.ledger.commands.subcommands import com.github.quiltservertools.ledger.Ledger +import com.github.quiltservertools.ledger.actions.ItemDespawnActionType import com.github.quiltservertools.ledger.actionutils.ActionSearchParams import com.github.quiltservertools.ledger.actionutils.Preview import com.github.quiltservertools.ledger.commands.BuildableCommand @@ -52,6 +53,9 @@ object PreviewCommand : BuildableCommand { val source = context.source val player = source.playerOrException params.ensureSpecific() + if (type == Preview.Type.ROLLBACK) { + ItemDespawnActionType.restrictRollback(source, params) + } Ledger.launch { MessageUtils.warnBusy(source) val actions = DatabaseManager.previewActions(params, type) diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/RollbackCommand.kt b/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/RollbackCommand.kt index cf69d8c2..a94d9dae 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/RollbackCommand.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/RollbackCommand.kt @@ -1,6 +1,7 @@ package com.github.quiltservertools.ledger.commands.subcommands import com.github.quiltservertools.ledger.Ledger +import com.github.quiltservertools.ledger.actions.ItemDespawnActionType import com.github.quiltservertools.ledger.actionutils.ActionSearchParams import com.github.quiltservertools.ledger.commands.BuildableCommand import com.github.quiltservertools.ledger.commands.CommandConsts @@ -29,6 +30,7 @@ object RollbackCommand : BuildableCommand { fun rollback(context: Context, params: ActionSearchParams): Int { val source = context.source params.ensureSpecific() + ItemDespawnActionType.restrictRollback(source, params) Ledger.launch { MessageUtils.warnBusy(source) val actions = DatabaseManager.selectRollback(params) diff --git a/src/main/resources/data/ledger/lang/de_de.json b/src/main/resources/data/ledger/lang/de_de.json index 65118432..c8f650b7 100644 --- a/src/main/resources/data/ledger/lang/de_de.json +++ b/src/main/resources/data/ledger/lang/de_de.json @@ -41,6 +41,7 @@ "error.ledger.no_cached_params": "Keine Suche gefunden. Führe zuerst eine Suche durch", "error.ledger.command.no_results": "Keine Einträge für diese Suche", + "error.ledger.rollback.item_despawn_permission": "Du hast keine Berechtigung, despawnte Items zurückzusetzen", "error.ledger.no_more_pages": "Keine weiteren Seiten", "error.ledger.unknown_param": "Unbekannte Angaben %s", "error.ledger.no_preview": "Keine laufende Vorschau", diff --git a/src/main/resources/data/ledger/lang/en_us.json b/src/main/resources/data/ledger/lang/en_us.json index e050e6c9..9b301789 100644 --- a/src/main/resources/data/ledger/lang/en_us.json +++ b/src/main/resources/data/ledger/lang/en_us.json @@ -42,6 +42,7 @@ "error.ledger.no_cached_params": "No cached search. Try preforming a search first", "error.ledger.command.no_results": "No results for that search", + "error.ledger.rollback.item_despawn_permission": "You don't have permission to roll back despawned items", "error.ledger.no_more_pages": "No more pages", "error.ledger.unknown_param": "Unknown parameter %s", "error.ledger.no_preview": "No current preview", From 784223b5cfdc118836ea26a83da401276b8c100e Mon Sep 17 00:00:00 2001 From: Leon Enneken Date: Sun, 27 Sep 2026 17:17:57 +0200 Subject: [PATCH 6/7] fix: check item-despawn permission for single rollbacks --- .../ledger/commands/subcommands/RollbackCommand.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/RollbackCommand.kt b/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/RollbackCommand.kt index 2afcfd26..c9e34665 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/RollbackCommand.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/commands/subcommands/RollbackCommand.kt @@ -51,6 +51,11 @@ object RollbackCommand : BuildableCommand { return@launch } + if (action is ItemDespawnActionType && !ItemDespawnActionType.canRollback(source)) { + source.sendFailure(Component.translatable("error.ledger.rollback.item_despawn_permission")) + return@launch + } + source.level.launchMain { if (!action.rollback(source.server)) { source.sendFailure( From b55b929c9b8ccf94dba926af9d9c228030209930 Mon Sep 17 00:00:00 2001 From: Leon Enneken Date: Fri, 2 Oct 2026 16:03:46 +0200 Subject: [PATCH 7/7] feat: log tools and armor breaking When a player's tool, weapon or worn armor runs out of durability it is logged as item-break. Rolling it back drops the item where it broke with one durability left; restoring removes it again. --- docs/actions.md | 1 + .../ledger/mixin/ItemStackMixin.java | 23 +++++++++++++++++++ .../ledger/actions/ItemBreakActionType.kt | 6 +++++ .../ledger/actionutils/ActionFactory.kt | 17 ++++++++++++++ .../ledger/callbacks/ItemBreakCallback.kt | 23 +++++++++++++++++++ .../ledger/listeners/PlayerEventListener.kt | 7 ++++++ .../ledger/registry/ActionRegistry.kt | 1 + .../resources/data/ledger/lang/de_de.json | 1 + .../resources/data/ledger/lang/en_us.json | 1 + src/main/resources/ledger.mixins.json | 1 + 10 files changed, 81 insertions(+) create mode 100644 src/main/java/com/github/quiltservertools/ledger/mixin/ItemStackMixin.java create mode 100644 src/main/kotlin/com/github/quiltservertools/ledger/actions/ItemBreakActionType.kt create mode 100644 src/main/kotlin/com/github/quiltservertools/ledger/callbacks/ItemBreakCallback.kt diff --git a/docs/actions.md b/docs/actions.md index bdfc5887..de783305 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -7,6 +7,7 @@ These are the currently logged action types: - block-change - item-insert - item-remove +- item-break - entity-killed - entity-mount - entity-dismount \ No newline at end of file diff --git a/src/main/java/com/github/quiltservertools/ledger/mixin/ItemStackMixin.java b/src/main/java/com/github/quiltservertools/ledger/mixin/ItemStackMixin.java new file mode 100644 index 00000000..72b1d0ca --- /dev/null +++ b/src/main/java/com/github/quiltservertools/ledger/mixin/ItemStackMixin.java @@ -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 onBreak, CallbackInfo ci) { + if (player != null) { + ItemBreakCallback.EVENT.invoker().breakItem((ItemStack) (Object) this, player); + } + } +} diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/actions/ItemBreakActionType.kt b/src/main/kotlin/com/github/quiltservertools/ledger/actions/ItemBreakActionType.kt new file mode 100644 index 00000000..c5e1bb37 --- /dev/null +++ b/src/main/kotlin/com/github/quiltservertools/ledger/actions/ItemBreakActionType.kt @@ -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" +} diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/actionutils/ActionFactory.kt b/src/main/kotlin/com/github/quiltservertools/ledger/actionutils/ActionFactory.kt index 5c9cbc8d..ccb33dc4 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/actionutils/ActionFactory.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/actionutils/ActionFactory.kt @@ -8,6 +8,7 @@ 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.ItemDropActionType import com.github.quiltservertools.ledger.actions.ItemInsertActionType import com.github.quiltservertools.ledger.actions.ItemPickUpActionType @@ -159,6 +160,22 @@ object ActionFactory { 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() diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/callbacks/ItemBreakCallback.kt b/src/main/kotlin/com/github/quiltservertools/ledger/callbacks/ItemBreakCallback.kt new file mode 100644 index 00000000..ff013e87 --- /dev/null +++ b/src/main/kotlin/com/github/quiltservertools/ledger/callbacks/ItemBreakCallback.kt @@ -0,0 +1,23 @@ +package com.github.quiltservertools.ledger.callbacks + +import net.fabricmc.fabric.api.event.Event +import net.fabricmc.fabric.api.event.EventFactory +import net.minecraft.world.entity.player.Player +import net.minecraft.world.item.ItemStack + +fun interface ItemBreakCallback { + fun breakItem(stack: ItemStack, player: Player) + + companion object { + @JvmField + val EVENT: Event = EventFactory.createArrayBacked( + ItemBreakCallback::class.java, + ) { listeners -> + ItemBreakCallback { stack, player -> + for (listener in listeners) { + listener.breakItem(stack, player) + } + } + } + } +} diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/listeners/PlayerEventListener.kt b/src/main/kotlin/com/github/quiltservertools/ledger/listeners/PlayerEventListener.kt index 414ae4d7..8b04ce99 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/listeners/PlayerEventListener.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/listeners/PlayerEventListener.kt @@ -4,6 +4,7 @@ import com.github.quiltservertools.ledger.Ledger import com.github.quiltservertools.ledger.actionutils.ActionFactory import com.github.quiltservertools.ledger.callbacks.EntityDismountCallback import com.github.quiltservertools.ledger.callbacks.EntityMountCallback +import com.github.quiltservertools.ledger.callbacks.ItemBreakCallback import com.github.quiltservertools.ledger.callbacks.ItemDropCallback import com.github.quiltservertools.ledger.callbacks.ItemPickUpCallback import com.github.quiltservertools.ledger.database.ActionQueueService @@ -28,6 +29,7 @@ import net.minecraft.world.entity.Entity import net.minecraft.world.entity.LivingEntity import net.minecraft.world.entity.item.ItemEntity import net.minecraft.world.entity.player.Player +import net.minecraft.world.item.ItemStack import net.minecraft.world.item.context.BlockPlaceContext import net.minecraft.world.level.Level import net.minecraft.world.level.block.entity.BlockEntity @@ -42,6 +44,7 @@ fun registerPlayerListeners() { UseBlockCallback.EVENT.register(::onUseBlock) ItemPickUpCallback.EVENT.register(::onItemPickUp) ItemDropCallback.EVENT.register(::onItemDrop) + ItemBreakCallback.EVENT.register(::onItemBreak) EntityMountCallback.EVENT.register(::onEntityMount) EntityDismountCallback.EVENT.register(::onEntityDismount) } @@ -129,3 +132,7 @@ private fun onEntityMount(entity: Entity, playerEntity: Player) { private fun onEntityDismount(entity: Entity, playerEntity: Player) { ActionQueueService.addToQueue(ActionFactory.entityDismountAction(entity, playerEntity)) } + +private fun onItemBreak(stack: ItemStack, player: Player) { + ActionQueueService.addToQueue(ActionFactory.itemBreakAction(stack, player)) +} diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/registry/ActionRegistry.kt b/src/main/kotlin/com/github/quiltservertools/ledger/registry/ActionRegistry.kt index 84f2c113..1c8ce617 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/registry/ActionRegistry.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/registry/ActionRegistry.kt @@ -33,6 +33,7 @@ object ActionRegistry { registerActionType { ItemRemoveActionType() } registerActionType { ItemPickUpActionType() } registerActionType { ItemDropActionType() } + registerActionType { ItemBreakActionType() } registerActionType { EntityKillActionType() } registerActionType { EntityChangeActionType() } registerActionType { EntityMountActionType() } diff --git a/src/main/resources/data/ledger/lang/de_de.json b/src/main/resources/data/ledger/lang/de_de.json index 6357f3cc..1a8ecb6f 100644 --- a/src/main/resources/data/ledger/lang/de_de.json +++ b/src/main/resources/data/ledger/lang/de_de.json @@ -49,6 +49,7 @@ "text.ledger.action.block-break": "zerstört", "text.ledger.action.item-insert": "hinzugefügt", "text.ledger.action.item-remove": "entfernt", + "text.ledger.action.item-break": "verschlissen", "text.ledger.action.entity-kill": "getötet", "text.ledger.network.protocols_mismatched": "Keine passende Protokoll-Version: Client-Mod ist Ledger Protokoll-Version %d und die Client Version ist %d", diff --git a/src/main/resources/data/ledger/lang/en_us.json b/src/main/resources/data/ledger/lang/en_us.json index b40f7d0c..55f4fb3b 100644 --- a/src/main/resources/data/ledger/lang/en_us.json +++ b/src/main/resources/data/ledger/lang/en_us.json @@ -56,6 +56,7 @@ "text.ledger.action.item-remove": "removed", "text.ledger.action.item-pick-up": "picked up", "text.ledger.action.item-drop": "dropped", + "text.ledger.action.item-break": "wore out", "text.ledger.action.entity-kill": "killed", "text.ledger.action.entity-change": "changed", "text.ledger.action.entity-mount": "mounted", diff --git a/src/main/resources/ledger.mixins.json b/src/main/resources/ledger.mixins.json index b82cc06c..5cf3119b 100644 --- a/src/main/resources/ledger.mixins.json +++ b/src/main/resources/ledger.mixins.json @@ -23,6 +23,7 @@ "FrostWalkerEnchantmentMixin", "HoneycombItemMixin", "InventoryMixin", + "ItemStackMixin", "JukeboxPlayableMixin", "LilyPadBlockMixin", "PortalShapeMixin",