diff --git a/README.md b/README.md index 94679c1..d184af3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,15 @@ # InvView Allows you to get the inventory and echest of other players + +## Trinkets Updated support + +With Trinkets Updated 4.2.1+26.3 installed, `/view trinket ` opens an online +or offline player's active trinket slots. InvView itself remains +optional on the client; the usual client requirements of Trinkets still apply. + +The menu displays up to 18 slots per page, supports additional pages, and includes +a regular/cosmetic toggle when cosmetic slots are available. The exact slots depend +on the target player's active Trinkets configuration. If the slot structure or target +player entity changes while viewing, the menu closes and asks you to reopen it. +Switching pages or modes preserves the cursor stack and cancels any unfinished drag +operation. diff --git a/build.gradle b/build.gradle index 9f44f3b..11ea8ac 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,7 @@ repositories { maven { url "https://maven.jamieswhiteshirt.com/libs-release/" } maven { url "https://maven.shedaniel.me/" } maven { url 'https://maven.nucleoid.xyz' } + maven { url 'https://maven.gnomecraft.net/releases/' } maven { url 'https://oss.sonatype.org/content/repositories/snapshots' } mavenCentral() maven { url 'https://api.modrinth.com/maven' } @@ -48,7 +49,14 @@ dependencies { implementation include("eu.pb4:sgui:${project.sgui_version}") implementation include("me.lucko:fabric-permissions-api:${project.permission_api_version}") -// modImplementation "dev.emi:trinkets:${project.trinkets_version}" + compileOnly "eu.pb4:trinkets:${project.trinkets_version}" + if (providers.gradleProperty('withTrinkets').map { it.toBoolean() }.getOrElse(false)) { + localRuntime "eu.pb4:trinkets:${project.trinkets_version}" + } + testImplementation "eu.pb4:trinkets:${project.trinkets_version}" + testImplementation platform('org.junit:junit-bom:5.12.2') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // modImplementation "com.github.apace100:apoli:${project.apoli_version}" // Dev Mods - These can be commented/uncommented to test compatibility @@ -68,7 +76,7 @@ processResources { // ensure that the encoding is set to UTF-8, no matter what the system default is // this fixes some edge cases with special characters not displaying correctly // see http://yodaconditions.net/blog/fix-for-java-file-encoding-problems-with-gradle.html -tasks.withType(JavaCompile) { +tasks.withType(JavaCompile).configureEach { options.encoding = "UTF-8" } @@ -77,6 +85,10 @@ java { targetCompatibility = JavaVersion.VERSION_25 } +test { + useJUnitPlatform() +} + jar { from "LICENSE" } diff --git a/gradle.properties b/gradle.properties index 4af24e9..9cfe0a5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,7 @@ archives_base_name=InvView # Dependencies # check this on https://fabricmc.net/develop/ fabric_version=0.160.5+26.3 -# trinkets_version=3.10.0 +trinkets_version=4.2.1+26.3 # apoli_version=2.12.0 sgui_version=2.2.0+26.3 permission_api_version=0.7.0 diff --git a/src/main/java/us/potatoboy/invview/InvView.java b/src/main/java/us/potatoboy/invview/InvView.java index a5ee235..ef62327 100644 --- a/src/main/java/us/potatoboy/invview/InvView.java +++ b/src/main/java/us/potatoboy/invview/InvView.java @@ -15,10 +15,12 @@ import net.minecraft.nbt.NbtIo; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.permissions.PermissionLevel; import net.minecraft.util.ProblemReporter; import net.minecraft.util.Util; import net.minecraft.world.level.storage.LevelResource; import net.minecraft.world.level.storage.TagValueOutput; +import us.potatoboy.invview.compat.trinkets.TrinketsCompat; import java.io.File; import java.nio.file.Files; import java.nio.file.Path; @@ -31,7 +33,7 @@ public class InvView implements ModInitializer { @Override public void onInitialize() { - isTrinkets = FabricLoader.getInstance().isModLoaded("trinkets"); + isTrinkets = FabricLoader.getInstance().isModLoaded("trinkets_updated"); isLuckPerms = FabricLoader.getInstance().isModLoaded("luckperms"); isApoli = FabricLoader.getInstance().isModLoaded("apoli"); @@ -39,30 +41,23 @@ public void onInitialize() { LiteralCommandNode viewNode = Commands .literal("view") - .requires(Permissions.require("invview.command.root", 2)) + .requires(Permissions.require(InvViewPermissions.ROOT, PermissionLevel.GAMEMASTERS)) .build(); LiteralCommandNode invNode = Commands .literal("inv") - .requires(Permissions.require("invview.command.inv", 2)) + .requires(Permissions.require(InvViewPermissions.INVENTORY, PermissionLevel.GAMEMASTERS)) .then(Commands.argument("target", GameProfileArgument.gameProfile()) .executes(ViewCommand::inv)) .build(); LiteralCommandNode echestNode = Commands .literal("echest") - .requires(Permissions.require("invview.command.echest", 2)) + .requires(Permissions.require(InvViewPermissions.ENDER_CHEST, PermissionLevel.GAMEMASTERS)) .then(Commands.argument("target", GameProfileArgument.gameProfile()) .executes(ViewCommand::eChest)) .build(); -// LiteralCommandNode trinketNode = CommandManager -// .literal("trinket") -// .requires(Permissions.require("invview.command.trinket", 2)) -// .then(CommandManager.argument("target", GameProfileArgumentType.gameProfile()) -// .executes(ViewCommand::trinkets)) -// .build(); -// // LiteralCommandNode apoliNode = CommandManager // .literal("origin-inv") // .requires(Permissions.require("invview.command.origin", 2)) @@ -75,7 +70,7 @@ public void onInitialize() { viewNode.addChild(echestNode); if (isTrinkets) { -// viewNode.addChild(trinketNode); + TrinketsCompat.register(viewNode); } if (isApoli) { // viewNode.addChild(apoliNode); diff --git a/src/main/java/us/potatoboy/invview/InvViewPermissions.java b/src/main/java/us/potatoboy/invview/InvViewPermissions.java new file mode 100644 index 0000000..912b5ce --- /dev/null +++ b/src/main/java/us/potatoboy/invview/InvViewPermissions.java @@ -0,0 +1,13 @@ +package us.potatoboy.invview; + +public final class InvViewPermissions { + public static final String ROOT = "invview.command.root"; + public static final String INVENTORY = "invview.command.inv"; + public static final String ENDER_CHEST = "invview.command.echest"; + public static final String TRINKET = "invview.command.trinket"; + public static final String PROTECTED = "invview.protected"; + public static final String MODIFY = "invview.can_modify"; + + private InvViewPermissions() { + } +} diff --git a/src/main/java/us/potatoboy/invview/PlayerLookup.java b/src/main/java/us/potatoboy/invview/PlayerLookup.java new file mode 100644 index 0000000..6c1e607 --- /dev/null +++ b/src/main/java/us/potatoboy/invview/PlayerLookup.java @@ -0,0 +1,45 @@ +package us.potatoboy.invview; + +import com.mojang.authlib.GameProfile; +import com.mojang.logging.LogUtils; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.core.registries.Registries; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ClientInformation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.players.NameAndId; +import net.minecraft.util.ProblemReporter; +import net.minecraft.world.level.storage.TagValueInput; +import us.potatoboy.invview.mixin.EntityAccessor; + +public final class PlayerLookup { + private PlayerLookup() { + } + + public static ServerPlayer getPlayer(MinecraftServer server, NameAndId profile) { + ServerPlayer online = server.getPlayerList().getPlayer(profile.id()); + if (online != null) { + return online; + } + + ServerPlayer player = new ServerPlayer(server, server.overworld(), + new GameProfile(profile.id(), profile.name()), ClientInformation.createDefault()); + try (var reporter = new ProblemReporter.ScopedCollector(LogUtils.getLogger())) { + server.getPlayerList().loadPlayerData(profile).ifPresent(data -> { + var input = TagValueInput.create(reporter, server.registryAccess(), data); + player.load(input); + input.getString("Dimension").ifPresent(dimension -> { + Identifier id = Identifier.tryParse(dimension); + if (id != null) { + var level = server.getLevel(ResourceKey.create(Registries.DIMENSION, id)); + if (level != null) { + ((EntityAccessor) player).callSetLevel(level); + } + } + }); + }); + } + return player; + } +} diff --git a/src/main/java/us/potatoboy/invview/ViewCommand.java b/src/main/java/us/potatoboy/invview/ViewCommand.java index be19456..7d2b66e 100644 --- a/src/main/java/us/potatoboy/invview/ViewCommand.java +++ b/src/main/java/us/potatoboy/invview/ViewCommand.java @@ -1,50 +1,32 @@ package us.potatoboy.invview; -import com.mojang.authlib.GameProfile; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; -import com.mojang.logging.LogUtils; import eu.pb4.sgui.api.elements.GuiElementBuilder; import eu.pb4.sgui.api.gui.SimpleGui; import me.lucko.fabric.api.permissions.v0.Permissions; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.arguments.GameProfileArgument; -import net.minecraft.core.registries.Registries; import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; -import net.minecraft.resources.ResourceKey; -import net.minecraft.server.MinecraftServer; -import net.minecraft.server.level.ClientInformation; -import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; -import net.minecraft.server.players.NameAndId; -import net.minecraft.util.ProblemReporter; import net.minecraft.world.inventory.MenuType; import net.minecraft.world.inventory.PlayerEnderChestContainer; import net.minecraft.world.inventory.Slot; import net.minecraft.world.item.Items; -import net.minecraft.world.level.storage.TagValueInput; -import net.minecraft.world.level.storage.ValueInput; import us.potatoboy.invview.gui.SavingPlayerDataGui; import us.potatoboy.invview.gui.UnmodifiableSlot; -import us.potatoboy.invview.mixin.EntityAccessor; - -import java.util.Optional; public class ViewCommand { - private static final MinecraftServer minecraftServer = InvView.getMinecraftServer(); - private static final String permProtected = "invview.protected"; - private static final String permModify = "invview.can_modify"; private static final String msgProtected = "Requested inventory is protected"; public static int inv(CommandContext context) throws CommandSyntaxException { ServerPlayer player = context.getSource().getPlayer(); ServerPlayer requestedPlayer = getRequestedPlayer(context); - boolean canModify = Permissions.check(context.getSource(), permModify, true); + boolean canModify = Permissions.check(context.getSource(), InvViewPermissions.MODIFY, true); - Permissions.check(requestedPlayer.getUUID(), permProtected, false).thenAcceptAsync(isProtected -> { + Permissions.check(requestedPlayer.getUUID(), InvViewPermissions.PROTECTED, false).thenAcceptAsync(isProtected -> { if (isProtected) { context.getSource().sendFailure(Component.literal(msgProtected)); } else { @@ -68,9 +50,9 @@ public static int eChest(CommandContext context) throws Comm ServerPlayer requestedPlayer = getRequestedPlayer(context); PlayerEnderChestContainer requestedEchest = requestedPlayer.getEnderChestInventory(); - boolean canModify = Permissions.check(context.getSource(), permModify, true); + boolean canModify = Permissions.check(context.getSource(), InvViewPermissions.MODIFY, true); - Permissions.check(requestedPlayer.getUUID(), permProtected, false).thenAcceptAsync(isProtected -> { + Permissions.check(requestedPlayer.getUUID(), InvViewPermissions.PROTECTED, false).thenAcceptAsync(isProtected -> { if (isProtected) { context.getSource().sendFailure(Component.literal(msgProtected)); } else { @@ -97,37 +79,6 @@ public static int eChest(CommandContext context) throws Comm return 1; } -// public static int trinkets(CommandContext context) throws CommandSyntaxException { -// ServerPlayerEntity player = context.getSource().getPlayer(); -// ServerPlayerEntity requestedPlayer = getRequestedPlayer(context); -// TrinketComponent requestedComponent = TrinketsApi.getTrinketComponent(requestedPlayer).get(); -// -// boolean canModify = Permissions.check(context.getSource(), permModify, true); -// -// Permissions.check(requestedPlayer.getUuid(), permProtected, false).thenAcceptAsync(isProtected -> { -// if (isProtected) { -// context.getSource().sendError(Text.literal(msgProtected)); -// } else { -// SimpleGui gui = new SavingPlayerDataGui(ScreenHandlerType.GENERIC_9X2, player, requestedPlayer); -// addBackground(gui); -// gui.setTitle(requestedPlayer.getName()); -// int index = 0; -// for (Map group : requestedComponent.getInventory().values()) { -// for (TrinketInventory inventory : group.values()) { -// for (int i = 0; i < inventory.size(); i++) { -// gui.setSlotRedirect(index, canModify ? new Slot(inventory, i, 0, 0) : new UnmodifiableSlot(inventory, i)); -// index += 1; -// } -// } -// } -// -// gui.open(); -// } -// }); -// -// return 1; -// } - // public static int apoli(CommandContext context) throws CommandSyntaxException { // ServerPlayerEntity player = context.getSource().getPlayer(); // ServerPlayerEntity requestedPlayer = getRequestedPlayer(context); @@ -164,34 +115,8 @@ public static int eChest(CommandContext context) throws Comm private static ServerPlayer getRequestedPlayer(CommandContext context) throws CommandSyntaxException { - NameAndId playerConfigEntry = GameProfileArgument.getGameProfiles(context, "target").iterator().next(); - ServerPlayer requestedPlayer = minecraftServer.getPlayerList().getPlayerByName(playerConfigEntry.name()); - - // If player is not currently online - if (requestedPlayer == null) { - requestedPlayer = new ServerPlayer(minecraftServer, minecraftServer.overworld(), new GameProfile(playerConfigEntry.id(), playerConfigEntry.name()), - ClientInformation.createDefault()); - Optional readViewOpt = minecraftServer.getPlayerList() - .loadPlayerData(playerConfigEntry).map(playerData -> TagValueInput.create(new ProblemReporter.ScopedCollector(LogUtils.getLogger()), minecraftServer.registryAccess(), playerData)); - readViewOpt.ifPresent(requestedPlayer::load); - - // Avoids player's dimension being reset to the overworld - if (readViewOpt.isPresent()) { - ValueInput readView = readViewOpt.get(); - Optional dimension = readView.getString("Dimension"); - - if (dimension.isPresent()) { - ServerLevel world = minecraftServer.getLevel( - ResourceKey.create(Registries.DIMENSION, Identifier.tryParse(dimension.get()))); - - if (world != null) { - ((EntityAccessor) requestedPlayer).callSetLevel(world); - } - } - } - } - - return requestedPlayer; + return PlayerLookup.getPlayer(context.getSource().getServer(), + GameProfileArgument.getGameProfiles(context, "target").iterator().next()); } private static void addBackground(SimpleGui gui) { diff --git a/src/main/java/us/potatoboy/invview/compat/trinkets/ReadOnlyTrinketsSlot.java b/src/main/java/us/potatoboy/invview/compat/trinkets/ReadOnlyTrinketsSlot.java new file mode 100644 index 0000000..39c9f39 --- /dev/null +++ b/src/main/java/us/potatoboy/invview/compat/trinkets/ReadOnlyTrinketsSlot.java @@ -0,0 +1,58 @@ +package us.potatoboy.invview.compat.trinkets; + +import eu.pb4.trinkets.api.TrinketSlotAccess; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.NonNull; + +final class ReadOnlyTrinketsSlot extends TrinketsSlot { + ReadOnlyTrinketsSlot(TrinketSlotAccess access) { + super(access); + } + + @Override + public @NonNull ItemStack getItem() { + return super.getItem().copy(); + } + + @Override + public boolean mayPlace(@NonNull ItemStack stack) { + return false; + } + + @Override + public boolean mayPickup(@NonNull Player player) { + return false; + } + + @Override + public boolean allowModification(@NonNull Player player) { + return false; + } + + @Override + public @NonNull ItemStack remove(int amount) { + return ItemStack.EMPTY; + } + + @Override + public @NonNull ItemStack safeInsert(@NonNull ItemStack stack, int count) { + return stack; + } + + @Override + public void setByPlayer(@NonNull ItemStack stack) { + } + + @Override + public void setByPlayer(@NonNull ItemStack stack, @NonNull ItemStack previous) { + } + + @Override + public void set(@NonNull ItemStack stack) { + } + + @Override + public void setChanged() { + } +} diff --git a/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsCompat.java b/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsCompat.java new file mode 100644 index 0000000..62ab393 --- /dev/null +++ b/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsCompat.java @@ -0,0 +1,54 @@ +package us.potatoboy.invview.compat.trinkets; + +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.tree.LiteralCommandNode; +import com.mojang.logging.LogUtils; +import me.lucko.fabric.api.permissions.v0.Permissions; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.GameProfileArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.permissions.PermissionLevel; +import us.potatoboy.invview.InvViewPermissions; +import us.potatoboy.invview.PlayerLookup; + +public final class TrinketsCompat { + private TrinketsCompat() { + } + + public static void register(LiteralCommandNode root) { + root.addChild(Commands.literal("trinket") + .requires(Permissions.require(InvViewPermissions.TRINKET, PermissionLevel.GAMEMASTERS)) + .then(Commands.argument("target", GameProfileArgument.gameProfile()) + .executes(TrinketsCompat::open)) + .build()); + } + + private static int open(CommandContext context) throws CommandSyntaxException { + var source = context.getSource(); + var viewer = source.getPlayerOrException(); + var profile = GameProfileArgument.getGameProfiles(context, "target").iterator().next(); + var server = source.getServer(); + Permissions.check(profile.id(), InvViewPermissions.PROTECTED, false).thenAcceptAsync(protectedPlayer -> { + if (viewer.hasDisconnected()) { + return; + } + if (protectedPlayer) { + source.sendFailure(Component.literal("Requested inventory is protected")); + return; + } + var target = PlayerLookup.getPlayer(server, profile); + var gui = new TrinketsGui(server, viewer, target, + Permissions.check(source, InvViewPermissions.MODIFY, true)); + if (gui.prepare() && !gui.open()) { + source.sendFailure(Component.literal("Could not open trinket inventory")); + } + }, server).exceptionally(error -> { + LogUtils.getLogger().error("Failed to open trinkets for {}", profile.id(), error); + server.execute(() -> source.sendFailure(Component.literal("Failed to open trinket inventory"))); + return null; + }); + return 1; + } +} diff --git a/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsGui.java b/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsGui.java new file mode 100644 index 0000000..db69fb4 --- /dev/null +++ b/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsGui.java @@ -0,0 +1,238 @@ +package us.potatoboy.invview.compat.trinkets; + +import eu.pb4.sgui.api.ClickType; +import eu.pb4.sgui.api.elements.GuiElement; +import eu.pb4.sgui.api.elements.GuiElementBuilder; +import eu.pb4.trinkets.api.TrinketSlotAccess; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.game.ServerboundContainerClickPacket; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.inventory.ContainerInput; +import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import us.potatoboy.invview.gui.ContainerClickValidator; +import us.potatoboy.invview.gui.SavingPlayerDataGui; + +import java.util.ArrayList; +import java.util.List; + +final class TrinketsGui extends SavingPlayerDataGui implements ContainerClickValidator { + private static final int PAGE_SIZE = 18; + private static final int PREVIOUS_PAGE_SLOT = 18; + private static final int INFO_SLOT = 22; + private static final int MODE_SLOT = 24; + private static final int NEXT_PAGE_SLOT = 26; + private static final String NO_SLOTS_MESSAGE = "Requested player has no trinket slots"; + private static final String EDITING_LABEL = "Trinkets: editing"; + private static final String READ_ONLY_LABEL = "Trinkets: read only"; + private static final String PREVIOUS_PAGE_LABEL = "Previous page"; + private static final String NEXT_PAGE_LABEL = "Next page"; + private static final String REGULAR_MODE_LABEL = "Show regular slots"; + private static final String COSMETIC_MODE_LABEL = "Show cosmetic slots"; + + private final TrinketsSession session; + private final boolean canModify; + private final TrinketsPageState pageState = new TrinketsPageState(); + private final List boundSlots = new ArrayList<>(); + private TrinketsLayout layout; + private String closeReason; + private boolean removed; + private boolean dirty; + + TrinketsGui(MinecraftServer server, ServerPlayer viewer, ServerPlayer target, boolean canModify) { + super(MenuType.GENERIC_9x3, viewer, target); + this.session = new TrinketsSession(server, target); + this.canModify = canModify; + } + + boolean prepare() { + var candidate = session.readLayout(); + if (candidate.regular().isEmpty()) { + getPlayer().sendSystemMessage(Component.literal(NO_SLOTS_MESSAGE)); + return false; + } + layout = candidate; + renderPage(); + return true; + } + + private void unbindSlots() { + boundSlots.forEach(TrinketsSlot::unbind); + boundSlots.clear(); + } + + private boolean validateSession() { + if (removed || closeReason != null) { + return false; + } + closeReason = session.invalidReason(layout); + if (closeReason != null) { + unbindSlots(); + return false; + } + return true; + } + + private void resetDrag() { + if (wrappedMenu != null) { + // Vanilla resets an ongoing quick-craft on a non-drag input. PICKUP at + // -1 otherwise does nothing (unlike -999, which drops the cursor stack). + wrappedMenu.clicked(-1, 0, ContainerInput.PICKUP, getPlayer()); + } + } + + private boolean isUsable() { + return closeReason == null && !removed; + } + + private void renderPage() { + resetDrag(); + unbindSlots(); + var visible = pageState.visibleSlots(layout, PAGE_SIZE); + boolean wasOpen = isOpen(); + clearMenuSlots(); + bindVisibleSlots(visible); + renderNavigation(); + refreshTitleAndContent(wasOpen); + } + + private void clearMenuSlots() { + // Old redirects are already inert. Replace every menu position before the + // final title/content refresh publishes the new layout as a complete state. + for (int i = 0; i < getSize(); i++) { + setSlot(i, new GuiElementBuilder(Items.BARRIER).setName(Component.literal(" ")).build()); + } + } + + private void bindVisibleSlots(List visible) { + int firstSlotNumber = pageState.page() * PAGE_SIZE; + var info = new GuiElementBuilder(Items.PAPER) + .setName(Component.literal((canModify ? EDITING_LABEL : READ_ONLY_LABEL) + + " — Page " + (pageState.page() + 1) + "/" + pageState.pageCount(layout, PAGE_SIZE))); + for (int i = 0; i < visible.size(); i++) { + var access = visible.get(i); + var slot = canModify ? new TrinketsSlot(access, () -> dirty = true) : new ReadOnlyTrinketsSlot(access); + slot.bindToMenuIndex(i); + boundSlots.add(slot); + setSlot(i, slot); + info.addLoreLine(Component.literal((firstSlotNumber + i + 1) + ": " + access.slotType().getId() + + " [" + (access.index() + 1) + "]")); + } + setSlot(INFO_SLOT, info.build()); + } + + private void renderNavigation() { + if (pageState.page() > 0) { + setSlot(PREVIOUS_PAGE_SLOT, new GuiElementBuilder(Items.ARROW) + .setName(Component.literal(PREVIOUS_PAGE_LABEL)) + .build()); + } + if (!layout.cosmetic().isEmpty()) { + setSlot(MODE_SLOT, new GuiElementBuilder(Items.ENDER_EYE) + .setName(Component.literal(pageState.mode().isCosmetic() + ? REGULAR_MODE_LABEL : COSMETIC_MODE_LABEL)) + .build()); + } + if (pageState.page() + 1 < pageState.pageCount(layout, PAGE_SIZE)) { + setSlot(NEXT_PAGE_SLOT, new GuiElementBuilder(Items.ARROW) + .setName(Component.literal(NEXT_PAGE_LABEL)) + .build()); + } + } + + private void refreshTitleAndContent(boolean wasOpen) { + if (wasOpen) { + // SGUI's title/content refresh otherwise reuses the old state id. + wrappedMenu.incrementStateId(); + } + int pages = pageState.pageCount(layout, PAGE_SIZE); + var title = session.target().getName().copy() + .append(pageState.mode().isCosmetic() ? " — Cosmetic trinkets" : " — Trinkets"); + if (pages > 1) { + title.append(" — " + (pageState.page() + 1) + "/" + pages); + } + setTitle(title); + } + + @Override + public boolean acceptPacket(ServerboundContainerClickPacket packet) { + if (packet.containerId() != getSyncId()) { + return false; + } + int index = packet.slotNum(); + boolean validIndex = index == -999 || index == -1 + || (index >= 0 && index < wrappedMenu.slots.size()); + if (!validateSession() || !validIndex || packet.stateId() != wrappedMenu.getStateId()) { + resetDrag(); + forceUpdateAll(); + return false; + } + return true; + } + + @Override + public void onTick() { + if (!removed && !validateSession()) { + // Never close inside onAnyClick: SGUI is still handling the old menu. + getPlayer().sendSystemMessage(Component.literal(closeReason)); + close(); + } + } + + @Override + public boolean onAnyClick(int index, ClickType type, ContainerInput action) { + return validateSession() && (canModify || !(getCustomSlot(index) instanceof TrinketsSlot)); + } + + @Override + public boolean onClick(int index, ClickType type, ContainerInput action, GuiElement element) { + boolean controlClick = action == ContainerInput.PICKUP + && (type == ClickType.MOUSE_LEFT || type == ClickType.MOUSE_RIGHT); + if (isUsable() && controlClick && index == PREVIOUS_PAGE_SLOT + && pageState.previous(layout, PAGE_SIZE)) { + renderPage(); + } else if (isUsable() && controlClick && index == NEXT_PAGE_SLOT + && pageState.next(layout, PAGE_SIZE)) { + renderPage(); + } else if (isUsable() && controlClick && index == MODE_SLOT + && !layout.cosmetic().isEmpty()) { + pageState.toggleMode(layout, PAGE_SIZE); + renderPage(); + } else if (isOpen()) { + // SGUI has already consumed the client's predicted changes. Replace + // them with authoritative content, including the cursor, even when + // no server-side stack changed (read-only clicks and GUI elements). + resetDrag(); + forceUpdateAll(); + } + return false; // Resynchronization is performed above, not by SGUI. + } + + @Override + public ItemStack quickMove(int index) { + if (!canModify || !validateSession()) { + return ItemStack.EMPTY; + } + var slot = getSlotRedirectOrPlayer(index); + if (slot == null || !slot.isActive() || !slot.mayPickup(getPlayer())) { + return ItemStack.EMPTY; + } + return super.quickMove(index); + } + + @Override + public void onRemoved() { + if (removed) { + return; + } + removed = true; + unbindSlots(); + // A respawn/reconnected player is a different entity. Never serialize + // the stale entity over its replacement's player data. + if (dirty && session.targetIsCurrent()) { + super.onRemoved(); + } + } +} diff --git a/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsLayout.java b/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsLayout.java new file mode 100644 index 0000000..b7abf61 --- /dev/null +++ b/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsLayout.java @@ -0,0 +1,91 @@ +package us.potatoboy.invview.compat.trinkets; + +import eu.pb4.trinkets.api.TrinketAttachment; +import eu.pb4.trinkets.api.TrinketInventory; +import eu.pb4.trinkets.api.TrinketSlotAccess; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +record TrinketsLayout(List regular, List cosmetic) { + TrinketsLayout { + regular = List.copyOf(regular); + cosmetic = List.copyOf(cosmetic); + } + + static TrinketsLayout read(TrinketAttachment attachment) { + var inventories = new ArrayList<>(attachment.getInventories().values()); + inventories.sort(Comparator + .comparingInt((TrinketInventory inv) -> { + var group = attachment.getGroups().get(inv.slotType().group()); + return group != null ? group.order() : 0; + }) + .thenComparing(inv -> inv.slotType().group()) + .thenComparingInt(inv -> inv.slotType().order()) + .thenComparing(inv -> inv.slotType().getId())); + + var regular = new ArrayList(); + var cosmetic = new ArrayList(); + for (var inventory : inventories) { + int size = inventory.getContainerSize(); + for (int i = 0; i < size; i++) { + var access = inventory.getSlotAccess(i); + if (access != null) { + regular.add(access); + } + var cosmeticAccess = inventory.getCosmeticSlotAccess(i); + if (cosmeticAccess != null) { + cosmetic.add(cosmeticAccess); + } + } + } + return new TrinketsLayout(regular, cosmetic); + } + + List slots(TrinketsMode mode) { + return mode.isCosmetic() ? cosmetic : regular; + } + + int pageCount(TrinketsMode mode, int pageSize) { + if (pageSize <= 0) { + throw new IllegalArgumentException("pageSize must be positive"); + } + var slots = slots(mode); + return (slots.size() + pageSize - 1) / pageSize; + } + + List page(TrinketsMode mode, int page, int pageSize) { + if (page < 0) { + throw new IllegalArgumentException("page must not be negative"); + } + if (pageSize <= 0) { + throw new IllegalArgumentException("pageSize must be positive"); + } + var slots = slots(mode); + int start = page * pageSize; + if (start >= slots.size()) { + return List.of(); + } + return slots.subList(start, Math.min(start + pageSize, slots.size())); + } + + boolean sameBindings(TrinketsLayout other) { + return sameSlots(regular, other.regular) && sameSlots(cosmetic, other.cosmetic); + } + + private static boolean sameSlots(List first, List second) { + if (first.size() != second.size()) { + return false; + } + for (int i = 0; i < first.size(); i++) { + var a = first.get(i); + var b = second.get(i); + // Trinkets inventory equality compares types, not inventory lifetimes. + if (a.inventory() != b.inventory() || a.index() != b.index() || a.cosmetic() != b.cosmetic()) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsMode.java b/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsMode.java new file mode 100644 index 0000000..f0c3cf9 --- /dev/null +++ b/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsMode.java @@ -0,0 +1,14 @@ +package us.potatoboy.invview.compat.trinkets; + +enum TrinketsMode { + REGULAR, + COSMETIC; + + boolean isCosmetic() { + return this == COSMETIC; + } + + TrinketsMode toggled() { + return isCosmetic() ? REGULAR : COSMETIC; + } +} diff --git a/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsPageState.java b/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsPageState.java new file mode 100644 index 0000000..9654299 --- /dev/null +++ b/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsPageState.java @@ -0,0 +1,55 @@ +package us.potatoboy.invview.compat.trinkets; + +import eu.pb4.trinkets.api.TrinketSlotAccess; + +import java.util.List; + +final class TrinketsPageState { + private TrinketsMode mode = TrinketsMode.REGULAR; + private int page; + + TrinketsMode mode() { + return mode; + } + + int page() { + return page; + } + + int pageCount(TrinketsLayout layout, int pageSize) { + return Math.max(1, layout.pageCount(mode, pageSize)); + } + + List visibleSlots(TrinketsLayout layout, int pageSize) { + clamp(layout, pageSize); + return layout.page(mode, page, pageSize); + } + + boolean previous(TrinketsLayout layout, int pageSize) { + clamp(layout, pageSize); + if (page == 0) { + return false; + } + page--; + return true; + } + + boolean next(TrinketsLayout layout, int pageSize) { + clamp(layout, pageSize); + if (page + 1 >= pageCount(layout, pageSize)) { + return false; + } + page++; + return true; + } + + void toggleMode(TrinketsLayout layout, int pageSize) { + mode = mode.toggled(); + page = 0; + clamp(layout, pageSize); + } + + private void clamp(TrinketsLayout layout, int pageSize) { + page = Math.clamp(page, 0, pageCount(layout, pageSize) - 1); + } +} diff --git a/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsSession.java b/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsSession.java new file mode 100644 index 0000000..f6935f2 --- /dev/null +++ b/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsSession.java @@ -0,0 +1,43 @@ +package us.potatoboy.invview.compat.trinkets; + +import eu.pb4.trinkets.api.TrinketAttachment; +import eu.pb4.trinkets.api.TrinketsApi; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; + +final class TrinketsSession { + private static final String PLAYER_CHANGED = "Player state changed. Reopen the trinket inventory."; + private static final String SLOTS_CHANGED = "Trinket slots changed. Reopen the trinket inventory."; + + private final MinecraftServer server; + private final ServerPlayer target; + private final boolean onlineTarget; + private final TrinketAttachment attachment; + + TrinketsSession(MinecraftServer server, ServerPlayer target) { + this.server = server; + this.target = target; + this.onlineTarget = server.getPlayerList().getPlayer(target.getUUID()) == target; + this.attachment = TrinketsApi.getAttachment(target); + } + + ServerPlayer target() { + return target; + } + + TrinketsLayout readLayout() { + return TrinketsLayout.read(attachment); + } + + boolean targetIsCurrent() { + var current = server.getPlayerList().getPlayer(target.getUUID()); + return onlineTarget ? current == target && target.isAlive() : current == null; + } + + String invalidReason(TrinketsLayout expectedLayout) { + if (!targetIsCurrent()) { + return PLAYER_CHANGED; + } + return expectedLayout.sameBindings(readLayout()) ? null : SLOTS_CHANGED; + } +} diff --git a/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsSlot.java b/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsSlot.java new file mode 100644 index 0000000..9c8df6b --- /dev/null +++ b/src/main/java/us/potatoboy/invview/compat/trinkets/TrinketsSlot.java @@ -0,0 +1,81 @@ +package us.potatoboy.invview.compat.trinkets; + +import eu.pb4.trinkets.api.TrinketSlotAccess; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.NonNull; + +class TrinketsSlot extends Slot { + private final TrinketSlotAccess access; + private final Runnable changeListener; + private boolean bound = true; + + TrinketsSlot(TrinketSlotAccess access) { + this(access, () -> { + }); + } + + TrinketsSlot(TrinketSlotAccess access, Runnable changeListener) { + super(access.inventory(), access.index(), 0, 0); + this.access = access; + this.changeListener = changeListener; + } + + @Override + public boolean isActive() { + return bound && access.inventory().getAttachment().getInventory(access.slotType().getId()) == access.inventory() + && access.isValid(); + } + + void unbind() { + bound = false; + } + + void bindToMenuIndex(int menuIndex) { + index = menuIndex; + } + + @Override + public void setChanged() { + if (isActive()) { + super.setChanged(); + changeListener.run(); + } + } + + @Override + public @NonNull ItemStack getItem() { + return isActive() ? access.get() : ItemStack.EMPTY; + } + + @Override + public void set(@NonNull ItemStack stack) { + if (isActive() && access.set(stack)) { + setChanged(); + } + } + + @Override + public @NonNull ItemStack remove(int amount) { + if (!isActive() || amount <= 0) { + return ItemStack.EMPTY; + } + // SlotAccess selects the regular or cosmetic storage; Container.removeItem does not. + ItemStack remaining = access.get(); + ItemStack removed = remaining.split(amount); + set(remaining.isEmpty() ? ItemStack.EMPTY : remaining); + return removed; + } + + @Override + public boolean mayPlace(@NonNull ItemStack stack) { + // Like /view inv, this is an administrative editor, not an equip action. + return isActive(); + } + + @Override + public boolean mayPickup(@NonNull Player player) { + return isActive(); + } +} diff --git a/src/main/java/us/potatoboy/invview/gui/ContainerClickValidator.java b/src/main/java/us/potatoboy/invview/gui/ContainerClickValidator.java new file mode 100644 index 0000000..2fa909b --- /dev/null +++ b/src/main/java/us/potatoboy/invview/gui/ContainerClickValidator.java @@ -0,0 +1,10 @@ +package us.potatoboy.invview.gui; + +import net.minecraft.network.protocol.game.ServerboundContainerClickPacket; + +/** + * Validates a container click before SGUI applies the client's predicted state. + */ +public interface ContainerClickValidator { + boolean acceptPacket(ServerboundContainerClickPacket packet); +} diff --git a/src/main/java/us/potatoboy/invview/gui/UnmodifiableSlot.java b/src/main/java/us/potatoboy/invview/gui/UnmodifiableSlot.java index 9ba30c1..c1602e6 100644 --- a/src/main/java/us/potatoboy/invview/gui/UnmodifiableSlot.java +++ b/src/main/java/us/potatoboy/invview/gui/UnmodifiableSlot.java @@ -4,6 +4,7 @@ import net.minecraft.world.entity.player.Player; import net.minecraft.world.inventory.Slot; import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.NonNull; public class UnmodifiableSlot extends Slot { public UnmodifiableSlot(Container inventory, int index) { @@ -11,37 +12,37 @@ public UnmodifiableSlot(Container inventory, int index) { } @Override - public boolean mayPlace(ItemStack stack) { + public boolean mayPlace(@NonNull ItemStack stack) { return false; } @Override - public boolean mayPickup(Player playerEntity) { + public boolean mayPickup(@NonNull Player playerEntity) { return false; } @Override - public boolean allowModification(Player player) { + public boolean allowModification(@NonNull Player player) { return false; } @Override - public ItemStack remove(int amount) { + public @NonNull ItemStack remove(int amount) { return ItemStack.EMPTY; } @Override - public ItemStack safeInsert(ItemStack stack, int count) { + public @NonNull ItemStack safeInsert(@NonNull ItemStack stack, int count) { return stack; } @Override - public void setByPlayer(ItemStack stack) { + public void setByPlayer(@NonNull ItemStack stack) { } @Override - public void set(ItemStack stack) { + public void set(@NonNull ItemStack stack) { } } diff --git a/src/main/java/us/potatoboy/invview/mixin/ContainerClickValidationMixin.java b/src/main/java/us/potatoboy/invview/mixin/ContainerClickValidationMixin.java new file mode 100644 index 0000000..34e061c --- /dev/null +++ b/src/main/java/us/potatoboy/invview/mixin/ContainerClickValidationMixin.java @@ -0,0 +1,30 @@ +package us.potatoboy.invview.mixin; + +import eu.pb4.sgui.api.containerwrappers.SlotBasedWrapperMenu; +import net.minecraft.network.protocol.game.ServerboundContainerClickPacket; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.network.ServerGamePacketListenerImpl; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import us.potatoboy.invview.gui.ContainerClickValidator; + +@Mixin(ServerGamePacketListenerImpl.class) +public abstract class ContainerClickValidationMixin { + @Shadow + public ServerPlayer player; + + // After the vanilla thread check, but before SGUI's AFTER resetLastActionTime + // injection. Do not let SGUI or vanilla apply predictions for an obsolete layout. + @Inject(method = "handleContainerClick", at = @At(value = "INVOKE", + target = "Lnet/minecraft/server/level/ServerPlayer;resetLastActionTime()V"), cancellable = true) + private void invview$validateContainerClick(ServerboundContainerClickPacket packet, CallbackInfo ci) { + if (player.containerMenu instanceof SlotBasedWrapperMenu menu + && menu.getBackingGui() instanceof ContainerClickValidator validator + && !validator.acceptPacket(packet)) { + ci.cancel(); + } + } +} diff --git a/src/main/resources/invview.mixins.json b/src/main/resources/invview.mixins.json index f5cd253..a694b4a 100644 --- a/src/main/resources/invview.mixins.json +++ b/src/main/resources/invview.mixins.json @@ -4,7 +4,8 @@ "package": "us.potatoboy.invview.mixin", "compatibilityLevel": "JAVA_25", "mixins": [ - "EntityAccessor" + "EntityAccessor", + "ContainerClickValidationMixin" ], "injectors": { "defaultRequire": 1 diff --git a/src/test/java/us/potatoboy/invview/compat/trinkets/TrinketsLayoutTest.java b/src/test/java/us/potatoboy/invview/compat/trinkets/TrinketsLayoutTest.java new file mode 100644 index 0000000..db12ec8 --- /dev/null +++ b/src/test/java/us/potatoboy/invview/compat/trinkets/TrinketsLayoutTest.java @@ -0,0 +1,137 @@ +package us.potatoboy.invview.compat.trinkets; + +import eu.pb4.trinkets.api.SlotGroup; +import eu.pb4.trinkets.api.SlotType; +import eu.pb4.trinkets.api.TrinketAttachment; +import eu.pb4.trinkets.api.TrinketInventory; +import eu.pb4.trinkets.api.TrinketSlotAccess; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; + +import static org.junit.jupiter.api.Assertions.*; + +class TrinketsLayoutTest { + @Test + void detectsReplacementReorderingAndCosmeticChanges() { + var first = layoutInventory("hand", "hand/first", 0, 1, false); + var second = layoutInventory("hand", "hand/second", 1, 1, false); + var a = new TrinketSlotAccess(first, 0); + var b = new TrinketSlotAccess(second, 0); + var layout = new TrinketsLayout(List.of(a, b), List.of()); + + assertTrue(layout.sameBindings(new TrinketsLayout( + List.of(new TrinketSlotAccess(first, 0), b), List.of()))); + assertFalse(layout.sameBindings(new TrinketsLayout(List.of(b, a), List.of()))); + assertFalse(layout.sameBindings(new TrinketsLayout(List.of(a), List.of()))); + assertFalse(layout.sameBindings(new TrinketsLayout(List.of(a, b), + List.of(new TrinketSlotAccess(first, 0, true))))); + assertFalse(new TrinketsLayout(List.of(a), List.of()) + .sameBindings(new TrinketsLayout(List.of(b), List.of()))); + } + + @Test + void paginatesRegularAndCosmeticSlotsIndependently() { + var inventory = layoutInventory("hand", "hand/ring", 0, 19, true); + var regular = new ArrayList(); + var cosmetic = new ArrayList(); + for (int i = 0; i < 19; i++) regular.add(new TrinketSlotAccess(inventory, i)); + for (int i = 0; i < 5; i++) cosmetic.add(new TrinketSlotAccess(inventory, i, true)); + var layout = new TrinketsLayout(regular, cosmetic); + + assertEquals(2, layout.pageCount(TrinketsMode.REGULAR, 18)); + assertEquals(1, layout.pageCount(TrinketsMode.COSMETIC, 18)); + assertEquals(18, layout.page(TrinketsMode.REGULAR, 0, 18).size()); + assertSame(regular.get(18), layout.page(TrinketsMode.REGULAR, 1, 18).getFirst()); + assertEquals(cosmetic, layout.page(TrinketsMode.COSMETIC, 0, 18)); + assertTrue(layout.page(TrinketsMode.REGULAR, 2, 18).isEmpty()); + } + + @Test + void rejectsInvalidPaginationArguments() { + var layout = new TrinketsLayout(List.of(), List.of()); + + assertThrows(IllegalArgumentException.class, + () -> layout.pageCount(TrinketsMode.REGULAR, 0)); + assertThrows(IllegalArgumentException.class, + () -> layout.page(TrinketsMode.REGULAR, -1, 18)); + assertThrows(IllegalArgumentException.class, + () -> layout.page(TrinketsMode.REGULAR, 0, 0)); + } + + @Test + void readsActiveInventoriesInGroupTypeAndIndexOrder() { + var hat = layoutInventory("head", "head/hat", -512, 1, false); + var face = layoutInventory("head", "head/face", -1024, 1, true); + var rings = layoutInventory("hand", "hand/ring", -512, 2, true); + var attachment = proxy(TrinketAttachment.class, (name, args) -> switch (name) { + case "getInventories" -> Map.of("hand/ring", rings, "head/hat", hat, "head/face", face); + case "getGroups" -> Map.of("hand", slotGroup("hand", -1200), "head", slotGroup("head", -1400)); + default -> throw new UnsupportedOperationException(name); + }); + + var layout = TrinketsLayout.read(attachment); + + assertEquals(List.of("head/face@0", "head/hat@0", "hand/ring@0", "hand/ring@1"), + layout.regular().stream().map(TrinketSlotAccess::getSerializedName).toList()); + assertEquals(List.of("head/face@0?cosmetic", "hand/ring@0?cosmetic", "hand/ring@1?cosmetic"), + layout.cosmetic().stream().map(TrinketSlotAccess::getSerializedName).toList()); + } + + @Test + void pageStateNavigatesAndResetsWhenModeChanges() { + var inventory = layoutInventory("hand", "hand/ring", 0, 19, true); + var regular = new ArrayList(); + for (int i = 0; i < 19; i++) regular.add(new TrinketSlotAccess(inventory, i)); + var cosmetic = List.of(new TrinketSlotAccess(inventory, 0, true)); + var layout = new TrinketsLayout(regular, cosmetic); + var state = new TrinketsPageState(); + + assertTrue(state.next(layout, 18)); + assertEquals(1, state.page()); + assertFalse(state.next(layout, 18)); + assertTrue(state.previous(layout, 18)); + state.next(layout, 18); + state.toggleMode(layout, 18); + assertEquals(TrinketsMode.COSMETIC, state.mode()); + assertEquals(0, state.page()); + assertEquals(cosmetic, state.visibleSlots(layout, 18)); + } + + private static SlotGroup slotGroup(String group, int order) { + return proxy(SlotGroup.class, (name, args) -> switch (name) { + case "name" -> group; + case "order" -> order; + default -> throw new UnsupportedOperationException(name); + }); + } + + private static TrinketInventory layoutInventory(String group, String id, int order, int size, + boolean cosmetic) { + var type = proxy(SlotType.class, (name, args) -> switch (name) { + case "group" -> group; + case "getId" -> id; + case "order" -> order; + default -> throw new UnsupportedOperationException(name); + }); + var inventory = new TrinketInventory[1]; + inventory[0] = proxy(TrinketInventory.class, (name, args) -> switch (name) { + case "slotType" -> type; + case "getContainerSize" -> size; + case "getSlotAccess" -> new TrinketSlotAccess(inventory[0], (int) args[0]); + case "getCosmeticSlotAccess" -> cosmetic + ? new TrinketSlotAccess(inventory[0], (int) args[0], true) : null; + default -> throw new UnsupportedOperationException(name); + }); + return inventory[0]; + } + + private static T proxy(Class type, BiFunction handler) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, + (proxy, method, args) -> handler.apply(method.getName(), args))); + } +} diff --git a/src/test/java/us/potatoboy/invview/compat/trinkets/TrinketsSlotTest.java b/src/test/java/us/potatoboy/invview/compat/trinkets/TrinketsSlotTest.java new file mode 100644 index 0000000..36f87b6 --- /dev/null +++ b/src/test/java/us/potatoboy/invview/compat/trinkets/TrinketsSlotTest.java @@ -0,0 +1,188 @@ +package us.potatoboy.invview.compat.trinkets; + +import eu.pb4.trinkets.api.SlotType; +import eu.pb4.trinkets.api.TrinketAttachment; +import eu.pb4.trinkets.api.TrinketInventory; +import eu.pb4.trinkets.api.TrinketSlotAccess; +import net.minecraft.SharedConstants; +import net.minecraft.core.Holder; +import net.minecraft.core.component.DataComponentMap; +import net.minecraft.core.component.DataComponents; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.Items; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.util.function.BiFunction; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class TrinketsSlotTest { + @BeforeAll + static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + private static ItemStack stack(Item item, int count) { + // Fixture-local components, without rebinding global registry entries. + var components = DataComponentMap.builder().set(DataComponents.MAX_STACK_SIZE, 64).build(); + return new ItemStack(Holder.direct(item, components), count); + } + + @Test + void cosmeticRemovalConservesItemsAndLeavesRegularStorageUntouched() { + var storage = new Storage(); + storage.regular = stack(Items.DIAMOND, 5); + storage.cosmetic = stack(Items.EMERALD, 4); + var slot = storage.slot(true, false); + + var removed = slot.remove(3); + + assertSame(Items.EMERALD, removed.getItem()); + assertEquals(3, removed.getCount()); + assertEquals(1, storage.cosmetic.getCount()); + assertEquals(5, storage.regular.getCount()); + assertSame(Items.DIAMOND, storage.regular.getItem()); + assertTrue(storage.changes > 0); + assertEquals(1, slot.remove(20).getCount()); + assertTrue(storage.cosmetic.isEmpty()); + } + + @Test + void insertionRespectsSlotLimitAndSelectsCosmeticStorage() { + var storage = new Storage(); + var slot = storage.slot(true, false); + var input = stack(Items.DIAMOND, 5); + + slot.safeInsert(input, input.getCount()); + + assertEquals(2, storage.cosmetic.getCount()); + assertEquals(3, input.getCount()); + assertTrue(storage.regular.isEmpty()); + } + + @Test + @SuppressWarnings("DataFlowIssue") // These slot guards do not inspect the player argument. + void readOnlyDoesNotExposeMutableStacksOrAcceptAnyWrites() { + var storage = new Storage(); + storage.cosmetic = stack(Items.EMERALD, 2); + var slot = storage.slot(true, true); + slot.getItem().shrink(1); + assertFalse(slot.mayPlace(stack(Items.DIAMOND, 1))); + assertFalse(slot.mayPickup(null)); + assertFalse(slot.allowModification(null)); + assertTrue(slot.remove(2).isEmpty()); + var input = stack(Items.DIAMOND, 1); + assertSame(input, slot.safeInsert(input, 1)); + slot.set(input); + slot.setByPlayer(input); + slot.setByPlayer(input, ItemStack.EMPTY); + slot.setChanged(); + + assertSame(Items.EMERALD, storage.cosmetic.getItem()); + assertEquals(2, storage.cosmetic.getCount()); + assertTrue(storage.regular.isEmpty()); + assertEquals(0, storage.changes); + } + + @Test + @SuppressWarnings("DataFlowIssue") // The inactive-slot guard runs before the player could be inspected. + void replacedInventoryCannotBeEditedThroughStaleSlot() { + var storage = new Storage(); + storage.regular = stack(Items.DIAMOND, 2); + var slot = storage.slot(false, false); + storage.current = false; + + assertFalse(slot.isActive()); + assertFalse(slot.mayPlace(stack(Items.EMERALD, 1))); + assertFalse(slot.mayPickup(null)); + assertTrue(slot.getItem().isEmpty()); + assertTrue(slot.remove(1).isEmpty()); + slot.set(ItemStack.EMPTY); + assertEquals(2, storage.regular.getCount()); + } + + @Test + void oldGuiBindingCannotWriteEvenWhenInventoryIsStillCurrent() { + var storage = new Storage(); + storage.regular = stack(Items.DIAMOND, 2); + var oldSlot = storage.slot(false, false); + oldSlot.unbind(); + var newSlot = storage.slot(true, false); + + assertTrue(newSlot.isActive()); + assertFalse(oldSlot.isActive()); + assertFalse(oldSlot.mayPlace(stack(Items.EMERALD, 1))); + assertTrue(oldSlot.remove(2).isEmpty()); + oldSlot.setByPlayer(stack(Items.EMERALD, 1)); + var input = stack(Items.EMERALD, 1); + assertSame(input, oldSlot.safeInsert(input, 1)); + assertEquals(2, storage.regular.getCount()); + assertSame(Items.DIAMOND, storage.regular.getItem()); + newSlot.set(input); + assertSame(Items.EMERALD, storage.cosmetic.getItem()); + } + + @Test + void changeListenerRunsOnlyForActiveWritableSlot() { + var storage = new Storage(); + var notifications = new AtomicInteger(); + var slot = new TrinketsSlot(new TrinketSlotAccess(storage.inventory, 0), notifications::incrementAndGet); + + slot.set(stack(Items.DIAMOND, 1)); + assertEquals(1, notifications.get()); + + slot.unbind(); + slot.setChanged(); + assertEquals(1, notifications.get()); + } + + // Model independent regular/cosmetic storage without a running Minecraft entity. + private static final class Storage { + ItemStack regular = ItemStack.EMPTY; + ItemStack cosmetic = ItemStack.EMPTY; + boolean current = true; + int changes; + final SlotType type = proxy(SlotType.class, (name, args) -> { + if (name.equals("getId")) return "hand/ring"; + throw new UnsupportedOperationException(name); + }); + final TrinketAttachment attachment = proxy(TrinketAttachment.class, (name, args) -> { + if (name.equals("getInventory")) return current ? inventory() : null; + throw new UnsupportedOperationException(name); + }); + final TrinketInventory inventory = proxy(TrinketInventory.class, (name, args) -> switch (name) { + case "slotType" -> type; + case "getAttachment" -> attachment; + case "isValidSlot" -> (int) args[0] == 0; + case "hasCosmeticItems" -> true; + case "getItem" -> regular; + case "getCosmeticItem" -> cosmetic; + case "getMaxStackSize" -> 2; + case "setItem" -> { regular = (ItemStack) args[1]; yield null; } + case "setCosmeticItem" -> { cosmetic = (ItemStack) args[1]; yield true; } + case "setChanged" -> { changes++; yield null; } + default -> throw new UnsupportedOperationException(name); + }); + + private TrinketInventory inventory() { + return inventory; + } + + TrinketsSlot slot(boolean cosmetic, boolean readOnly) { + var access = new TrinketSlotAccess(inventory, 0, cosmetic); + return readOnly ? new ReadOnlyTrinketsSlot(access) : new TrinketsSlot(access); + } + } + + private static T proxy(Class type, BiFunction handler) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, + (proxy, method, args) -> handler.apply(method.getName(), args))); + } + +}