diff --git a/plugins/civmodcore-paper/src/main/java/vg/civcraft/mc/civmodcore/inventory/items/ItemUtils.java b/plugins/civmodcore-paper/src/main/java/vg/civcraft/mc/civmodcore/inventory/items/ItemUtils.java index 0099ec61d1..a90372eee5 100644 --- a/plugins/civmodcore-paper/src/main/java/vg/civcraft/mc/civmodcore/inventory/items/ItemUtils.java +++ b/plugins/civmodcore-paper/src/main/java/vg/civcraft/mc/civmodcore/inventory/items/ItemUtils.java @@ -4,9 +4,14 @@ import java.util.Arrays; import java.util.List; import java.util.Objects; +import java.util.function.Consumer; import java.util.function.Predicate; import net.kyori.adventure.text.Component; import net.kyori.adventure.translation.Translatable; +import net.minecraft.core.component.DataComponents; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.component.CustomData; import org.bukkit.Material; import org.bukkit.craftbukkit.inventory.CraftItemStack; import org.bukkit.inventory.ItemStack; @@ -48,16 +53,30 @@ public static String getItemName(@Nullable final ItemStack item) { return item == null ? null : ChatUtils.stringify(Component.translatable(item)); } - /** - * Checks whether the given item can be interpreted as an empty slot. - * - * @param item The item to check. - * @return Returns true if the item can be interpreted as an empty slot. - */ - public static boolean isEmptyItem(final ItemStack item) { + /// Checks whether the given item can be interpreted as an empty slot. + /// + /// @apiNote This is not merely a null-safe version of [org.bukkit.inventory.ItemStack#isEmpty()]: this does not + /// check the item's amount. The purpose of this function is to determine whether the item is safe to + /// operate on (a stack of buttons with -2 amount can still have its display name set). + @Contract("null -> true") + public static boolean isEmptyItem( + final ItemStack item + ) { return item == null || item.getType() == Material.AIR; } + /// Checks whether the given item can be interpreted as an empty slot. + /// + /// @apiNote This is not merely a null-safe version of [net.minecraft.world.item.ItemStack#isEmpty()]: this does + /// not check the item's amount. The purpose of this function is to determine whether the item is safe to + /// operate on (a stack of buttons with -2 amount can still have its display name set). + @Contract("null -> true") + public static boolean isEmptyItem( + final net.minecraft.world.item.ItemStack item + ) { + return item == null || item == net.minecraft.world.item.ItemStack.EMPTY || item.getItem() == Items.AIR; + } + /** * Checks if an ItemStack is valid. An ItemStack is considered valid if when added to an inventory, it shows as an * item with an amount within appropriate bounds. Therefore {@code new ItemStack(Material.AIR)} will not be @@ -151,25 +170,21 @@ public static boolean areItemsSimilar(@Nullable final ItemStack former, return MetaUtils.areMetasEqual(former.getItemMeta(), latter.getItemMeta()); } - /** - * Returns the NMS version of a given item, preferring the item's craft handle but will fall back upon creating an - * NMS copy. - * - * @param item The item to get the NMS version of. - * @return The NMS version, either handle or copy. - */ - @Contract("!null -> !null") - @Nullable - public static net.minecraft.world.item.ItemStack getNMSItemStack(@Nullable final ItemStack item) { + /// Unwraps a given Bukkit item to retrieve the internal NMS item. + /// + /// @return Returns the internal NMS item, or null. Will return null if the given item is "empty" (as determined by + /// [#isEmptyItem]). + public static @Nullable net.minecraft.world.item.ItemStack getNMSItemStack( + final ItemStack item + ) { if (item == null) { return null; } - if (item instanceof CraftItemStack craftItem) { - if (craftItem.handle != null) { - return craftItem.handle; - } + final net.minecraft.world.item.ItemStack nms = CraftItemStack.unwrap(item); + if (isEmptyItem(nms)) { + return null; } - return CraftItemStack.asNMSCopy(item); + return nms; } /** @@ -384,6 +399,40 @@ && getItemMeta(item) instanceof Damageable damageable) { return null; } + /// Retrieves the `minecraft:custom_data` component from an item. Do **NOT** modify it. + /// + /// @param item Cannot be null or "empty" (as determined by Bukkit). + /// + /// @apiNote This should **ONLY** be used for inspection. If you want to set data, use [#editCustomData] instead. + public static @Nullable CompoundTag inspectCustomData( + final @NotNull ItemStack item + ) { + final net.minecraft.world.item.ItemStack nms = Objects.requireNonNull(getNMSItemStack(item)); + if (nms.get(DataComponents.CUSTOM_DATA) instanceof final CustomData component) { + //noinspection deprecation + return component.getUnsafe(); + } + return null; + } + + /// Edits the `minecraft:custom_data` component of an item. If the provided NBT is empty after being edited, the + /// component is removed from the item. + /// + /// @param item Cannot be null or "empty" (as determined by Bukkit). + /// + /// @apiNote It is good practice to namespace your data onto a child component instead of setting it directly onto + /// the provided nbt. + public static void editCustomData( + final @NotNull ItemStack item, + final @NotNull Consumer<@NotNull CompoundTag> editor + ) { + CustomData.update( + DataComponents.CUSTOM_DATA, + Objects.requireNonNull(getNMSItemStack(item)), + Objects.requireNonNull(editor) + ); + } + /** * Handles an item's metadata. * diff --git a/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/ItemExchangeListener.java b/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/ItemExchangeListener.java index d84e8f6323..76260bfac3 100644 --- a/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/ItemExchangeListener.java +++ b/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/ItemExchangeListener.java @@ -2,6 +2,7 @@ import com.untamedears.itemexchange.events.BrowseOrPurchaseEvent; import com.untamedears.itemexchange.events.SuccessfulPurchaseEvent; +import com.untamedears.itemexchange.items.Token; import com.untamedears.itemexchange.rules.BulkExchangeRule; import com.untamedears.itemexchange.rules.ExchangeRule; import com.untamedears.itemexchange.rules.ShopRule; @@ -12,6 +13,11 @@ import java.util.Hashtable; import java.util.Map; import java.util.stream.Stream; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.Style; import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.GameMode; @@ -22,6 +28,7 @@ import org.bukkit.block.data.type.WallSign; import org.bukkit.entity.Item; import org.bukkit.entity.Player; +import org.bukkit.event.Event; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.block.Action; @@ -33,6 +40,7 @@ import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.inventory.CraftingInventory; import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; import vg.civcraft.mc.civmodcore.inventory.InventoryUtils; import vg.civcraft.mc.civmodcore.inventory.RecipeManager; import vg.civcraft.mc.civmodcore.inventory.items.ItemUtils; @@ -290,4 +298,36 @@ public void onInventoryPickupItem(InventoryPickupItemEvent event) { } } + @EventHandler(ignoreCancelled = true) + public void sendTokenOnRightClick( + final @NotNull PlayerInteractEvent event + ) { + switch (event.getAction()) { + case RIGHT_CLICK_AIR: + case RIGHT_CLICK_BLOCK: + break; + default: + return; + } + final Token itemToken = Token.fromItem(event.getItem()); + if (itemToken == null) { + return; + } + event.setUseInteractedBlock(Event.Result.DENY); + event.setUseItemInHand(Event.Result.DENY); + event.getPlayer().sendMessage(Component.textOfChildren( + Component.text( + "That item holds the following token: ", + NamedTextColor.GRAY + ), + Component.text( + itemToken.token(), + Style.style() + .color(NamedTextColor.WHITE) + .clickEvent(ClickEvent.copyToClipboard(itemToken.token())) + .hoverEvent(HoverEvent.showText(Component.text("Click to copy token to clipboard"))) + .build() + ) + )); + } } diff --git a/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/ItemExchangePlugin.java b/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/ItemExchangePlugin.java index 24c3ee4694..16a372bbdb 100644 --- a/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/ItemExchangePlugin.java +++ b/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/ItemExchangePlugin.java @@ -13,6 +13,7 @@ import com.untamedears.itemexchange.rules.modifiers.LoreModifier; import com.untamedears.itemexchange.rules.modifiers.PotionModifier; import com.untamedears.itemexchange.rules.modifiers.RepairModifier; +import com.untamedears.itemexchange.rules.modifiers.TokenModifier; import java.util.List; import vg.civcraft.mc.civmodcore.ACivMod; import vg.civcraft.mc.civmodcore.commands.CommandManager; @@ -44,6 +45,7 @@ public void onEnable() { commands = new CommandRegistrar(this); commands.init(); modifiers = new ModifierRegistrar(); + modifiers.registerModifier(TokenModifier.TEMPLATE); // 50 modifiers.registerModifier(DisplayNameModifier.TEMPLATE); // 100 modifiers.registerModifier(EnchantModifier.TEMPLATE); // 200 modifiers.registerModifier(EnchantStorageModifier.TEMPLATE); // 201 diff --git a/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/commands/CommandRegistrar.java b/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/commands/CommandRegistrar.java index ff09d745bf..fa8e48e7eb 100644 --- a/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/commands/CommandRegistrar.java +++ b/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/commands/CommandRegistrar.java @@ -19,6 +19,7 @@ public void registerCommands() { registerCommand(new InfoCommand()); registerCommand(new ReloadCommand(getPlugin())); registerCommand(new SetCommand()); + registerCommand(new TokenCommand()); } /** diff --git a/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/commands/SetCommand.java b/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/commands/SetCommand.java index ddd1b130b8..6031879562 100644 --- a/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/commands/SetCommand.java +++ b/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/commands/SetCommand.java @@ -6,14 +6,20 @@ import co.aikar.commands.annotation.CommandAlias; import co.aikar.commands.annotation.CommandCompletion; import co.aikar.commands.annotation.Description; +import co.aikar.commands.annotation.Optional; import co.aikar.commands.annotation.Single; import co.aikar.commands.annotation.Subcommand; import co.aikar.commands.annotation.Syntax; +import com.untamedears.itemexchange.items.Token; import com.untamedears.itemexchange.rules.ExchangeRule; +import com.untamedears.itemexchange.rules.modifiers.TokenModifier; +import com.untamedears.itemexchange.utility.ModifierHandler; import com.untamedears.itemexchange.utility.RuleHandler; +import org.apache.commons.lang3.StringUtils; import org.bukkit.ChatColor; import org.bukkit.Material; import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; import vg.civcraft.mc.civmodcore.inventory.items.ItemUtils; @CommandAlias(SetCommand.ALIAS) @@ -72,4 +78,42 @@ public void switchIO(Player player) { } } + // ============================================================ + // Tokens + // ============================================================ + + @Subcommand("token") + @Description("Sets [or removes] the token modifier.") + @Syntax("[token]") + public void commandSetToken( + final @NotNull Player sender, + final @Optional @Single String value + ) { + try (final var handler = new ModifierHandler<>(sender, TokenModifier.TEMPLATE)) { + if (StringUtils.isEmpty(value)) { + handler.setModifier(null); + handler.relay(ChatColor.GREEN + "Successfully removed token modifier."); + return; + } + final Token token = Token.create(value); + if (token == null) { + throw new InvalidCommandArgument("%s is not a valid token!".formatted(value)); + } + final TokenModifier modifier = handler.ensureModifier(); + modifier.token = token; + handler.relay(ChatColor.GREEN + "Successfully set token modifier."); + } + } + + @Subcommand("anytoken") + @Description("Adds (or resets) a token modifier to accept any token.") + public void commandSetAnyToken( + final @NotNull Player sender + ) { + try (final var handler = new ModifierHandler<>(sender, TokenModifier.TEMPLATE)) { + final TokenModifier modifier = handler.ensureModifier(); + modifier.token = null; + handler.relay(ChatColor.GREEN + "Successfully allowed any token."); + } + } } diff --git a/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/commands/TokenCommand.java b/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/commands/TokenCommand.java new file mode 100644 index 0000000000..b8caf57cc9 --- /dev/null +++ b/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/commands/TokenCommand.java @@ -0,0 +1,37 @@ +package com.untamedears.itemexchange.commands; + +import co.aikar.commands.BaseCommand; +import co.aikar.commands.InvalidCommandArgument; +import co.aikar.commands.annotation.CommandAlias; +import co.aikar.commands.annotation.Single; +import co.aikar.commands.annotation.Subcommand; +import co.aikar.commands.annotation.Syntax; +import com.untamedears.itemexchange.items.Token; +import com.untamedears.itemexchange.utility.Utilities; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +@CommandAlias("ietoken|iet") +public final class TokenCommand extends BaseCommand { + @Subcommand("create|c") + @Syntax("") + public void createToken( + final @NotNull Player sender, + final @Single String value + ) { + final Token token = Token.create(value); + if (token == null) { + throw new InvalidCommandArgument("%s is not a valid token!".formatted(value)); + } + Utilities.giveItemsOrDrop( + sender.getInventory(), + token.asItem() + ); + sender.sendMessage(Component.text( + "Token created!", + NamedTextColor.GREEN + )); + } +} diff --git a/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/items/Token.java b/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/items/Token.java new file mode 100644 index 0000000000..afcefeb43d --- /dev/null +++ b/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/items/Token.java @@ -0,0 +1,118 @@ +package com.untamedears.itemexchange.items; + +import io.papermc.paper.datacomponent.DataComponentTypes; +import io.papermc.paper.datacomponent.item.CustomModelData; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextDecoration; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.StringTag; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import vg.civcraft.mc.civmodcore.inventory.items.ItemUtils; + +public record Token( + @NotNull String token +) { + private static final String TOKEN_KEY = "ie:token"; + private static final Material TOKEN_MATERIAL = Material.STONE_BUTTON; + + public Token { + if (token == null) { + throw new IllegalArgumentException("Token cannot be null"); + } + token = token.trim(); + final int length = token.length(); + if (length < 1) { + throw new IllegalArgumentException("Token cannot be empty"); + } + else if (length > 32) { + throw new IllegalArgumentException("Token cannot be longer than 32 characters"); + } + if (StringUtils.containsWhitespace(token)) { + throw new IllegalArgumentException("Token cannot contain whitespace"); + } + } + + public static @Nullable Token create( + final String raw + ) { + try { + return new Token(raw); + } + catch (final Exception ignored) { + return null; + } + } + + @Override + public boolean equals( + final Object obj + ) { + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + if (!(obj instanceof final Token other)) { + return false; + } + return Strings.CI.equals( + this.token(), + other.token() + ); + } + + public @NotNull ItemStack asItem() { + final var item = new ItemStack(TOKEN_MATERIAL); + item.setData( + DataComponentTypes.ITEM_NAME, + Component.textOfChildren( + Component.text( + "Token: ", + Style.style() + .color(NamedTextColor.GRAY) + .decoration(TextDecoration.ITALIC, TextDecoration.State.FALSE) + .build() + ), + Component.text( + this.token(), + Style.style() + .color(NamedTextColor.WHITE) + .decoration(TextDecoration.ITALIC, TextDecoration.State.FALSE) + .build() + ) + ) + ); + item.setData( + DataComponentTypes.CUSTOM_MODEL_DATA, + CustomModelData.customModelData() + .addString(TOKEN_KEY) + .build() + ); + ItemUtils.editCustomData(item, (nbt) -> nbt.putString(TOKEN_KEY, this.token())); + return item; + } + + public static @Nullable Token fromItem( + final ItemStack item + ) { + if (item == null || item.getType() != TOKEN_MATERIAL) { + return null; + } + final CompoundTag nbt = ItemUtils.inspectCustomData(item); + if (nbt == null) { + return null; + } + if (!(nbt.get(TOKEN_KEY) instanceof StringTag(String token))) { + return null; + } + return create(token); + } +} diff --git a/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/rules/modifiers/TokenModifier.java b/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/rules/modifiers/TokenModifier.java new file mode 100644 index 0000000000..a439cac610 --- /dev/null +++ b/plugins/itemexchange-paper/src/main/java/com/untamedears/itemexchange/rules/modifiers/TokenModifier.java @@ -0,0 +1,95 @@ +package com.untamedears.itemexchange.rules.modifiers; + +import com.untamedears.itemexchange.items.Token; +import com.untamedears.itemexchange.rules.interfaces.Modifier; +import com.untamedears.itemexchange.rules.interfaces.ModifierData; +import java.util.List; +import net.minecraft.nbt.StringTag; +import org.bukkit.ChatColor; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import vg.civcraft.mc.civmodcore.nbt.NbtCompound; + +@Modifier(slug = "TOKEN", order = 50) +public final class TokenModifier extends ModifierData { + public static final TokenModifier TEMPLATE = new TokenModifier(); + + public static final String TOKEN_KEY = "token"; + + public volatile Token token = null; + + @Override + public @Nullable TokenModifier construct( + final ItemStack item + ) { + final Token itemToken = Token.fromItem(item); + if (itemToken == null) { + return null; + } + final var modifier = new TokenModifier(); + modifier.token = itemToken; + return modifier; + } + + @Override + public boolean isBroken() { + return false; + } + + @Override + public boolean conforms( + final ItemStack item + ) { + final Token itemToken = Token.fromItem(item); + if (itemToken == null) { + return false; + } + if (this.token instanceof final Token selfToken) { + return selfToken.equals(itemToken); + } + return true; + } + + @Override + public void toNBT( + final @NotNull NbtCompound nbt + ) { + switch (this.token) { + case Token(String tokenString) -> nbt.setString(TOKEN_KEY, tokenString); + case null -> nbt.remove(TOKEN_KEY); + } + } + + public static @NotNull TokenModifier fromNBT( + final @NotNull NbtCompound nbt + ) { + final var modifier = new TokenModifier(); + modifier.token = switch (nbt.internal().get(TOKEN_KEY)) { + case StringTag(String tokenString) -> Token.create(tokenString); + case null, default -> null; + }; + return modifier; + } + + @Override + public @NotNull String getDisplayListing() { + return ChatColor.GRAY + "Token"; + } + + @Override + public @Nullable List<@NotNull String> getDisplayInfo() { + return List.of(ChatColor.GOLD + switch (this.token) { + case Token $ -> "Token: " + ChatColor.MAGIC + "madeyoulook"; + case null -> "Any token"; + }); + } + + @Override + public String toString() { + return getSlug() + + "{" + + "token=" + this.token + + "}"; + } +}