diff --git a/src/main/java/party/cybsec/oyeshops/command/AdminCommands.java b/src/main/java/party/cybsec/oyeshops/command/AdminCommands.java index 1bb1c61..6ab96e8 100644 --- a/src/main/java/party/cybsec/oyeshops/command/AdminCommands.java +++ b/src/main/java/party/cybsec/oyeshops/command/AdminCommands.java @@ -13,6 +13,7 @@ import party.cybsec.oyeshops.model.Shop; import party.cybsec.oyeshops.permission.PermissionManager; import party.cybsec.oyeshops.gui.HelpBook; import party.cybsec.oyeshops.gui.SetupDialog; +import party.cybsec.oyeshops.gui.ConfigDialog; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.block.data.type.WallSign; @@ -48,6 +49,7 @@ public class AdminCommands implements CommandExecutor, TabCompleter { case "info" -> handleInfo(sender); case "help" -> handleHelp(sender); case "setup" -> handleSetup(sender); + case "config" -> handleConfig(sender); case "reload" -> handleReload(sender); case "inspect", "i" -> handleInspect(sender); case "spoof", "s" -> handleSpoof(sender); @@ -65,6 +67,8 @@ public class AdminCommands implements CommandExecutor, TabCompleter { .append(Component.text(" - open interactive guide", NamedTextColor.GRAY))); sender.sendMessage(Component.text("/oyeshops setup", NamedTextColor.YELLOW) .append(Component.text(" - open shop wizard", NamedTextColor.GRAY))); + sender.sendMessage(Component.text("/oyeshops config", NamedTextColor.YELLOW) + .append(Component.text(" - configure looking shop", NamedTextColor.GRAY))); sender.sendMessage(Component.text("/oyeshops on", NamedTextColor.YELLOW) .append(Component.text(" - enable shop creation", NamedTextColor.GRAY))); sender.sendMessage(Component.text("/oyeshops off", NamedTextColor.YELLOW) @@ -373,7 +377,7 @@ public class AdminCommands implements CommandExecutor, TabCompleter { List completions = new ArrayList<>(); if (args.length == 1) { List subCommands = new ArrayList<>( - List.of("help", "setup", "on", "off", "toggle", "notify", "info", "enable", "disable")); + List.of("help", "setup", "config", "on", "off", "toggle", "notify", "info", "enable", "disable")); if (PermissionManager.isAdmin(sender)) { subCommands.addAll(List.of("reload", "inspect", "spoof", "unregister")); } @@ -435,4 +439,37 @@ public class AdminCommands implements CommandExecutor, TabCompleter { SetupDialog.open(player, block, plugin); } + + private void handleConfig(CommandSender sender) { + if (!(sender instanceof Player player)) { + sender.sendMessage(Component.text("players only", NamedTextColor.RED)); + return; + } + + Block block = player.getTargetBlockExact(5); + if (block == null) { + player.sendMessage(Component.text("you must look at a shop sign or its container", NamedTextColor.RED)); + return; + } + + Shop shop = null; + if (block.getBlockData() instanceof WallSign) { + shop = plugin.getShopRegistry().getShop(block.getLocation()); + } else if (party.cybsec.oyeshops.listener.ShopActivationListener.isContainer(block.getType())) { + // try to find shop on any of the adjacent wall signs + shop = plugin.getShopRegistry().getShopByContainer(block); + } + + if (shop == null) { + player.sendMessage(Component.text("that is not a part of any shop", NamedTextColor.RED)); + return; + } + + if (!shop.getOwner().equals(player.getUniqueId()) && !PermissionManager.isAdmin(player)) { + player.sendMessage(Component.text("you do not own this shop", NamedTextColor.RED)); + return; + } + + ConfigDialog.open(player, shop, plugin); + } } diff --git a/src/main/java/party/cybsec/oyeshops/config/ConfigManager.java b/src/main/java/party/cybsec/oyeshops/config/ConfigManager.java index f54496c..beca5ea 100644 --- a/src/main/java/party/cybsec/oyeshops/config/ConfigManager.java +++ b/src/main/java/party/cybsec/oyeshops/config/ConfigManager.java @@ -1,16 +1,16 @@ package party.cybsec.oyeshops.config; import org.bukkit.configuration.file.FileConfiguration; -import org.bukkit.plugin.Plugin; +import party.cybsec.oyeshops.OyeShopsPlugin; /** - * configuration loading and management + * manages plugin configuration */ public class ConfigManager { - private final Plugin plugin; + private final OyeShopsPlugin plugin; private FileConfiguration config; - public ConfigManager(Plugin plugin) { + public ConfigManager(OyeShopsPlugin plugin) { this.plugin = plugin; reload(); } @@ -18,26 +18,23 @@ public class ConfigManager { public void reload() { plugin.saveDefaultConfig(); plugin.reloadConfig(); - this.config = plugin.getConfig(); - } - - public boolean isAllowProductOutput() { - return config.getBoolean("hoppers.allow-product-output", true); - } - - public boolean isBlockPriceInput() { - return config.getBoolean("hoppers.block-price-input", true); - } - - public int getMaxTransactionsPerShop() { - return config.getInt("history.max-transactions-per-shop", 100); - } - - public boolean isAutoPrune() { - return config.getBoolean("history.auto-prune", true); + config = plugin.getConfig(); } public FileConfiguration getConfig() { return config; } + + public void save() { + plugin.saveConfig(); + } + + // transaction settings + public boolean isAutoPrune() { + return config.getBoolean("transactions.auto-prune", false); + } + + public int getMaxTransactionsPerShop() { + return config.getInt("transactions.max-per-shop", 100); + } } diff --git a/src/main/java/party/cybsec/oyeshops/database/DatabaseManager.java b/src/main/java/party/cybsec/oyeshops/database/DatabaseManager.java index 4a56c34..2de95ed 100644 --- a/src/main/java/party/cybsec/oyeshops/database/DatabaseManager.java +++ b/src/main/java/party/cybsec/oyeshops/database/DatabaseManager.java @@ -93,6 +93,25 @@ public class DatabaseManager { stmt.execute("alter table shops add column created_at integer not null default 0"); } catch (SQLException ignored) { } + try { + stmt.execute("alter table shops add column merchant_ui boolean not null default false"); + } catch (SQLException ignored) { + } + + try { + stmt.execute("alter table shops add column cosmetic_sign boolean not null default false"); + } catch (SQLException ignored) { + } + + try { + stmt.execute("alter table shops add column custom_title text"); + } catch (SQLException ignored) { + } + + try { + stmt.execute("alter table shops add column disc text"); + } catch (SQLException ignored) { + } // indexes stmt.execute(""" diff --git a/src/main/java/party/cybsec/oyeshops/database/ShopRepository.java b/src/main/java/party/cybsec/oyeshops/database/ShopRepository.java index bd2bbff..73b4523 100644 --- a/src/main/java/party/cybsec/oyeshops/database/ShopRepository.java +++ b/src/main/java/party/cybsec/oyeshops/database/ShopRepository.java @@ -29,8 +29,9 @@ public class ShopRepository { String sql = """ insert into shops (world_uuid, sign_x, sign_y, sign_z, owner_uuid, price_item, price_quantity, product_item, product_quantity, - owed_amount, enabled, created_at) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + owed_amount, enabled, created_at, custom_title, + cosmetic_sign, disc) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """; try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql, @@ -48,6 +49,9 @@ public class ShopRepository { stmt.setInt(10, shop.getOwedAmount()); stmt.setBoolean(11, shop.isEnabled()); stmt.setLong(12, shop.getCreatedAt()); + stmt.setString(13, shop.getCustomTitle()); + stmt.setBoolean(14, shop.isCosmeticSign()); + stmt.setString(15, shop.getDisc()); stmt.executeUpdate(); @@ -166,6 +170,23 @@ public class ShopRepository { } } + /** + * update shop configuration fields + */ + public void updateShopConfig(Shop shop) throws SQLException { + String sql = """ + update shops set custom_title = ?, disc = ? + where shop_id = ? + """; + + try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) { + stmt.setString(1, shop.getCustomTitle()); + stmt.setString(2, shop.getDisc()); + stmt.setInt(3, shop.getId()); + stmt.executeUpdate(); + } + } + /** * delete shop */ @@ -247,6 +268,9 @@ public class ShopRepository { int owedAmount = rs.getInt("owed_amount"); boolean enabled = rs.getBoolean("enabled"); long createdAt = rs.getLong("created_at"); + String customTitle = rs.getString("custom_title"); + boolean cosmeticSign = rs.getBoolean("cosmetic_sign"); + String disc = rs.getString("disc"); World world = Bukkit.getWorld(worldUuid); if (world == null) { @@ -256,6 +280,8 @@ public class ShopRepository { Location location = new Location(world, x, y, z); Trade trade = new Trade(priceItem, priceQty, productItem, productQty); - return new Shop(id, location, ownerUuid, trade, owedAmount, enabled, createdAt); + return new Shop(id, location, ownerUuid, trade, owedAmount, enabled, createdAt, + customTitle, cosmeticSign, disc); } + } diff --git a/src/main/java/party/cybsec/oyeshops/gui/ConfigDialog.java b/src/main/java/party/cybsec/oyeshops/gui/ConfigDialog.java new file mode 100644 index 0000000..5fe4b83 --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/gui/ConfigDialog.java @@ -0,0 +1,95 @@ +package party.cybsec.oyeshops.gui; + +import io.papermc.paper.dialog.Dialog; +import io.papermc.paper.registry.data.dialog.DialogBase; +import io.papermc.paper.registry.data.dialog.ActionButton; +import io.papermc.paper.registry.data.dialog.type.DialogType; +import io.papermc.paper.registry.data.dialog.action.DialogAction; +import io.papermc.paper.registry.data.dialog.input.DialogInput; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import org.bukkit.entity.Player; +import party.cybsec.oyeshops.OyeShopsPlugin; +import party.cybsec.oyeshops.model.Shop; + +import java.sql.SQLException; +import java.util.List; +import java.util.Set; + +/** + * config dialog for shop settings + */ +public class ConfigDialog { + + // valid disc names + private static final Set VALID_DISCS = Set.of( + "none", "blocks", "chirp", "far", "mall", "mellohi", "stal", "strad", "ward", "wait"); + + /** + * open config dialog for a specific shop + */ + public static void open(Player player, Shop shop, OyeShopsPlugin plugin) { + Dialog dialog = Dialog.create(builder -> builder.empty() + .base(DialogBase.builder(Component.text("shop #" + shop.getId() + " config", NamedTextColor.GOLD)) + .inputs(List.of( + DialogInput.text("custom_title", + Component.text("custom title (optional)", NamedTextColor.YELLOW)) + .initial(shop.getCustomTitle() != null ? shop.getCustomTitle() : "") + .build(), + DialogInput.text("disc", + Component.text( + "music disc: none/blocks/chirp/far/mall/mellohi/stal/strad/ward/wait", + NamedTextColor.AQUA)) + .initial(shop.getDisc() != null ? shop.getDisc() : "none") + .build())) + .build()) + .type(DialogType.confirmation( + ActionButton.builder(Component.text("save", TextColor.color(0xB0FFA0))) + .tooltip(Component.text("save configuration")) + .action(DialogAction.customClick((view, audience) -> { + Player p = (Player) audience; + String title = view.getText("custom_title"); + String disc = view.getText("disc"); + + // validate disc + disc = disc.toLowerCase().trim(); + if (!disc.isEmpty() && !VALID_DISCS.contains(disc)) { + p.sendMessage(Component.text("invalid disc: " + disc + + ". valid options: none, blocks, chirp, far, mall, mellohi, stal, strad, ward, wait", + NamedTextColor.RED)); + open(p, shop, plugin); // reopen dialog + return; + } + + shop.setCustomTitle(title.isEmpty() ? null : title); + shop.setDisc(disc.isEmpty() || disc.equals("none") ? null : disc); + + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + try { + plugin.getShopRepository().updateShopConfig(shop); + plugin.getServer().getScheduler().runTask(plugin, () -> { + p.sendMessage( + Component.text("shop config saved", NamedTextColor.GREEN)); + }); + } catch (SQLException e) { + plugin.getServer().getScheduler().runTask(plugin, () -> { + p.sendMessage(Component.text("database error", NamedTextColor.RED)); + }); + e.printStackTrace(); + } + }); + }, ClickCallback.Options.builder().uses(1).build())) + .build(), + ActionButton.builder(Component.text("cancel", TextColor.color(0xFFA0B1))) + .tooltip(Component.text("discard changes")) + .action(DialogAction.customClick((view, audience) -> { + ((Player) audience) + .sendMessage(Component.text("config cancelled", NamedTextColor.YELLOW)); + }, ClickCallback.Options.builder().build())) + .build()))); + + player.showDialog(dialog); + } +} diff --git a/src/main/java/party/cybsec/oyeshops/gui/HelpBook.java b/src/main/java/party/cybsec/oyeshops/gui/HelpBook.java index a9ccb47..9f4ed6b 100644 --- a/src/main/java/party/cybsec/oyeshops/gui/HelpBook.java +++ b/src/main/java/party/cybsec/oyeshops/gui/HelpBook.java @@ -2,8 +2,6 @@ package party.cybsec.oyeshops.gui; import net.kyori.adventure.inventory.Book; 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.TextDecoration; import org.bukkit.entity.Player; @@ -32,10 +30,7 @@ public class HelpBook { .append(Component.text("steps to start:", NamedTextColor.GRAY)) .append(Component.newline()) .append(Component.text("1. type ", NamedTextColor.BLACK)) - .append(Component.text("/oyes on", NamedTextColor.BLUE) - .clickEvent(ClickEvent.runCommand("/oyes on")) - .hoverEvent(HoverEvent.showText(Component.text("click to enable shops", - NamedTextColor.GRAY)))) + .append(Component.text("/oyes on", NamedTextColor.BLUE)) .append(Component.newline()) .append(Component.text("2. place a chest", NamedTextColor.BLACK)) .append(Component.newline()) @@ -53,10 +48,7 @@ public class HelpBook { .append(Component.text("1. look at a sign", NamedTextColor.GRAY)) .append(Component.newline()) .append(Component.text("2. type ", NamedTextColor.GRAY)) - .append(Component.text("/oyes setup", NamedTextColor.BLUE) - .clickEvent(ClickEvent.runCommand("/oyes setup")) - .hoverEvent(HoverEvent.showText(Component.text("click to start wizard", - NamedTextColor.GRAY)))) + .append(Component.text("/oyes setup", NamedTextColor.BLUE)) .append(Component.newline()) .append(Component.newline()) .append(Component.text("...or turn the page", NamedTextColor.DARK_GRAY)) @@ -150,18 +142,12 @@ public class HelpBook { .append(Component.text("commands", NamedTextColor.GOLD, TextDecoration.BOLD)) .append(Component.newline()) .append(Component.newline()) - .append(Component.text("/oyes notify", NamedTextColor.BLUE) - .clickEvent(ClickEvent.runCommand("/oyes notify")) - .hoverEvent(HoverEvent.showText(Component.text("click to toggle alerts", - NamedTextColor.GRAY)))) + .append(Component.text("/oyes notify", NamedTextColor.BLUE)) .append(Component.newline()) .append(Component.text("get low stock alerts.", NamedTextColor.DARK_GRAY)) .append(Component.newline()) .append(Component.newline()) - .append(Component.text("/oyes info", NamedTextColor.BLUE) - .clickEvent(ClickEvent.runCommand("/oyes info")) - .hoverEvent(HoverEvent.showText( - Component.text("click for info", NamedTextColor.GRAY)))) + .append(Component.text("/oyes info", NamedTextColor.BLUE)) .append(Component.newline()) .append(Component.text("plugin info.", NamedTextColor.DARK_GRAY)) .build()); diff --git a/src/main/java/party/cybsec/oyeshops/gui/SetupDialog.java b/src/main/java/party/cybsec/oyeshops/gui/SetupDialog.java index dae9d98..cb0d624 100644 --- a/src/main/java/party/cybsec/oyeshops/gui/SetupDialog.java +++ b/src/main/java/party/cybsec/oyeshops/gui/SetupDialog.java @@ -30,11 +30,11 @@ public class SetupDialog { .inputs(List.of( // product (selling) DialogInput.text("product_item", - Component.text("what are you selling? (e.g. oak log)", + Component.text("selling what? (e.g. dirt)", NamedTextColor.YELLOW)) .build(), DialogInput.text("product_qty", - Component.text("how many per purchase? (e.g. 64)", + Component.text("how many? (e.g. 1)", NamedTextColor.YELLOW)) .initial("1") .build(), @@ -48,6 +48,15 @@ public class SetupDialog { Component.text("how many? (e.g. 10)", NamedTextColor.GREEN)) .initial("1") + .build(), + + // cosmetic sign toggle + DialogInput.bool("cosmetic_sign", + Component.text("cosmetic sign? (don't rewrite text)", + NamedTextColor.AQUA)) + .initial(false) + .onTrue("enabled") + .onFalse("disabled") .build())) .build()) .type(DialogType.confirmation( @@ -64,43 +73,20 @@ public class SetupDialog { String priceQtyStr = view.getText("price_qty"); Player p = (Player) audience; - // 1. parse quantities - int productQty; + // 1. parse price qty int priceQty; try { - productQty = Integer.parseInt( - productQtyStr); priceQty = Integer .parseInt(priceQtyStr); } catch (NumberFormatException e) { p.sendMessage(Component.text( - "invalid quantity provided", - NamedTextColor.RED)); - // reopen - open(p, signBlock, plugin); - return; - } - - if (productQty <= 0 || priceQty <= 0) { - p.sendMessage(Component.text( - "quantities must be positive", - NamedTextColor.RED)); - open(p, signBlock, plugin); - return; - } - - // 2. parse materials - Material productMat = plugin.getSignParser() - .parseMaterial(productStr); - if (productMat == null) { - p.sendMessage(Component.text( - "invalid product item: " - + productStr, + "invalid price quantity", NamedTextColor.RED)); open(p, signBlock, plugin); return; } + // 2. parse price material Material priceMat = plugin.getSignParser() .parseMaterial(priceStr); if (priceMat == null) { @@ -112,19 +98,43 @@ public class SetupDialog { return; } - // 3. create trade & activation + // 3. Regular parsing logic + int productQty; + try { + productQty = Integer.parseInt( + productQtyStr); + } catch (NumberFormatException e) { + p.sendMessage(Component.text( + "invalid product quantity", + NamedTextColor.RED)); + open(p, signBlock, plugin); + return; + } + Material productMat = plugin + .getSignParser() + .parseMaterial(productStr); + if (productMat == null) { + p.sendMessage(Component.text( + "invalid product: " + + productStr, + NamedTextColor.RED)); + open(p, signBlock, plugin); + return; + } Trade trade = new Trade(priceMat, priceQty, productMat, productQty); + + boolean cosmeticSign = view + .getBoolean("cosmetic_sign"); + PendingActivation activation = new PendingActivation( p.getUniqueId(), signBlock.getLocation(), trade, - System.currentTimeMillis()); + System.currentTimeMillis(), + cosmeticSign); - // 4. finalize shop immediately - plugin.getLogger().info( - "DEBUG: SetupDialog creating shop at " - + signBlock.getLocation()); + // 4. finalize plugin.getServer().getScheduler() .runTask(plugin, () -> { plugin.getShopActivationListener() diff --git a/src/main/java/party/cybsec/oyeshops/listener/ChestInteractionListener.java b/src/main/java/party/cybsec/oyeshops/listener/ChestInteractionListener.java index e35b846..d63f39b 100644 --- a/src/main/java/party/cybsec/oyeshops/listener/ChestInteractionListener.java +++ b/src/main/java/party/cybsec/oyeshops/listener/ChestInteractionListener.java @@ -4,10 +4,12 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.Material; +import org.bukkit.Sound; import org.bukkit.block.Barrel; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.block.Chest; +import org.bukkit.block.DoubleChest; import org.bukkit.block.data.type.WallSign; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -15,7 +17,9 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.block.Action; import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.DoubleChestInventory; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryHolder; import org.bukkit.inventory.ItemStack; @@ -59,7 +63,8 @@ public class ChestInteractionListener implements Listener { return; } - // check if there's a shop sign attached + // check if there's a shop sign attached to this container or its double chest + // partner Shop shop = findShopForContainer(block); if (shop == null) { return; // not a shop container @@ -138,7 +143,7 @@ public class ChestInteractionListener implements Listener { // update database int newOwed = owed - withdrawn; shop.setOwedAmount(newOwed); - plugin.getShopRepository().updateOwedAmount(shop.getId(), -withdrawn); + plugin.getShopRepository().updateOwedAmount(shop.getId(), newOwed); player.sendMessage( Component.text("withdrew " + withdrawn + " " + formatMaterial(priceItem), NamedTextColor.GREEN) @@ -154,36 +159,95 @@ public class ChestInteractionListener implements Listener { } /** - * open shop GUI for buyer + * open shop gui for buyer */ private void openShopGui(Player player, Shop shop, Block containerBlock) { Trade trade = shop.getTrade(); - // create fake inventory showing only product items - Inventory shopInventory = Bukkit.createInventory( - new ShopInventoryHolder(shop), - 27, - Component.text("shop: " + trade.priceQuantity() + " " + formatMaterial(trade.priceItem()) + - " → " + trade.productQuantity() + " " + formatMaterial(trade.productItem()))); + // create title + Component title = Component.text("shop: " + trade.priceQuantity() + " " + formatMaterial(trade.priceItem()) + + " → " + trade.productQuantity() + " " + formatMaterial(trade.productItem())); - // get real container inventory + // respect custom title if set + if (shop.getCustomTitle() != null && !shop.getCustomTitle().isEmpty()) { + title = Component.text(shop.getCustomTitle()); + } + + // get real container inventory (handles double chests correctly) Inventory realInventory = getContainerInventory(containerBlock); if (realInventory == null) { return; } - // copy only product items to fake inventory (first 27 found) + // determine inventory size based on container type + int invSize = realInventory.getSize(); + if (invSize > 54) + invSize = 54; // cap at double chest + if (invSize < 27) + invSize = 27; // minimum single chest + + // create fake inventory showing only product items + Inventory shopInventory = Bukkit.createInventory( + new ShopInventoryHolder(shop), + invSize, + title); + + // copy matching items to fake inventory int shopSlot = 0; for (ItemStack item : realInventory.getContents()) { - if (shopSlot >= 27) + if (shopSlot >= invSize) break; - if (item != null && item.getType() == trade.productItem()) { + if (item != null && trade.matchesProduct(item.getType())) { shopInventory.setItem(shopSlot++, item.clone()); } } viewingShop.put(player, shop); player.openInventory(shopInventory); + + // play shop owner's configured disc if set + String discName = shop.getDisc(); + if (discName != null && !discName.isEmpty()) { + playDisc(player, discName); + } + } + + /** + * play a music disc for a player + */ + private void playDisc(Player player, String discName) { + Sound discSound = getDiscSound(discName); + if (discSound != null) { + player.playSound(player.getLocation(), discSound, 1.0f, 1.0f); + } + } + + /** + * stop a music disc for a player + */ + private void stopDisc(Player player, String discName) { + Sound discSound = getDiscSound(discName); + if (discSound != null) { + player.stopSound(discSound); + } + } + + /** + * get Sound enum for disc name + */ + private Sound getDiscSound(String discName) { + return switch (discName.toLowerCase()) { + case "blocks" -> Sound.MUSIC_DISC_BLOCKS; + case "chirp" -> Sound.MUSIC_DISC_CHIRP; + case "far" -> Sound.MUSIC_DISC_FAR; + case "mall" -> Sound.MUSIC_DISC_MALL; + case "mellohi" -> Sound.MUSIC_DISC_MELLOHI; + case "stal" -> Sound.MUSIC_DISC_STAL; + case "strad" -> Sound.MUSIC_DISC_STRAD; + case "ward" -> Sound.MUSIC_DISC_WARD; + case "wait" -> Sound.MUSIC_DISC_WAIT; + default -> null; + }; } @EventHandler @@ -202,20 +266,24 @@ public class ChestInteractionListener implements Listener { // if clicked in the shop inventory area if (event.getRawSlot() < event.getInventory().getSize()) { ItemStack clicked = event.getCurrentItem(); - if (clicked != null && clicked.getType() == shop.getTrade().productItem()) { + Trade trade = shop.getTrade(); + if (clicked != null && trade.matchesProduct(clicked.getType())) { // determine quantity based on click type int units = 1; if (event.isShiftClick()) { // shift-click: calculate max units based on items clicked - units = clicked.getAmount() / shop.getTrade().productQuantity(); + units = clicked.getAmount() / trade.productQuantity(); if (units < 1) units = 1; } - // open confirmation GUI + // open confirmation gui player.closeInventory(); openConfirmationGui(player, shop, units); } + } else { + // clicked in player inventory - play negative feedback sound + player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1.0f, 1.0f); } return; } @@ -240,6 +308,20 @@ public class ChestInteractionListener implements Listener { } } + @EventHandler + public void onInventoryClose(InventoryCloseEvent event) { + if (event.getPlayer() instanceof Player player) { + // stop disc when closing shop + Shop shop = viewingShop.remove(player); + if (shop != null) { + String discName = shop.getDisc(); + if (discName != null && !discName.isEmpty()) { + stopDisc(player, discName); + } + } + } + } + /** * open confirmation GUI */ @@ -267,9 +349,46 @@ public class ChestInteractionListener implements Listener { } /** - * find shop attached to a container block + * find shop attached to a container block (handles double chests) */ private Shop findShopForContainer(Block containerBlock) { + // first check this block directly + Shop shop = findShopOnBlock(containerBlock); + if (shop != null) { + return shop; + } + + // if this is a chest, check the other half of a double chest + if (containerBlock.getState() instanceof Chest chest) { + Inventory inv = chest.getInventory(); + if (inv instanceof DoubleChestInventory doubleInv) { + DoubleChest doubleChest = doubleInv.getHolder(); + if (doubleChest != null) { + // check both sides + Chest left = (Chest) doubleChest.getLeftSide(); + Chest right = (Chest) doubleChest.getRightSide(); + + if (left != null) { + shop = findShopOnBlock(left.getBlock()); + if (shop != null) + return shop; + } + if (right != null) { + shop = findShopOnBlock(right.getBlock()); + if (shop != null) + return shop; + } + } + } + } + + return null; + } + + /** + * find shop sign on a specific block + */ + private Shop findShopOnBlock(Block containerBlock) { // check all adjacent blocks for a wall sign pointing at this container for (BlockFace face : new BlockFace[] { BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST }) { Block adjacent = containerBlock.getRelative(face); @@ -287,11 +406,12 @@ public class ChestInteractionListener implements Listener { } /** - * get container inventory + * get container inventory (handles double chests to return full 54 slot + * inventory) */ private Inventory getContainerInventory(Block block) { if (block.getState() instanceof Chest chest) { - return chest.getInventory(); + return chest.getInventory(); // returns DoubleChestInventory if double chest } else if (block.getState() instanceof Barrel barrel) { return barrel.getInventory(); } diff --git a/src/main/java/party/cybsec/oyeshops/listener/ShopActivationListener.java b/src/main/java/party/cybsec/oyeshops/listener/ShopActivationListener.java index 419776e..86f81ac 100644 --- a/src/main/java/party/cybsec/oyeshops/listener/ShopActivationListener.java +++ b/src/main/java/party/cybsec/oyeshops/listener/ShopActivationListener.java @@ -116,8 +116,15 @@ public class ShopActivationListener implements Listener { : ""; } - // 3. check for setup wizard + // check for setup wizard if (lines[0].equalsIgnoreCase("setup") && lines[1].isEmpty() && lines[2].isEmpty() && lines[3].isEmpty()) { + // check if player has use permission (needed to activate shops) + if (!PermissionManager.canUse(player)) { + player.sendMessage( + Component.text("you need oyeshops.use permission to use the setup wizard", NamedTextColor.RED)); + return; + } + // clear sign text to avoid "setup" staying on the sign event.line(0, Component.text("")); SetupDialog.open(player, block, plugin); @@ -142,7 +149,7 @@ public class ShopActivationListener implements Listener { // trigger confirmation instead of immediate creation PendingActivation activation = new PendingActivation(player.getUniqueId(), block.getLocation(), trade, - System.currentTimeMillis()); + System.currentTimeMillis(), false); plugin.getActivationManager().add(player.getUniqueId(), activation); sendConfirmationMessage(player, trade); @@ -187,14 +194,15 @@ public class ShopActivationListener implements Listener { Block block = signLocation.getBlock(); // verify it's still a sign - if (!(block.getState() instanceof Sign sign)) { + if (!(block.getState() instanceof Sign)) { player.sendMessage(Component.text("activation failed: sign is gone", NamedTextColor.RED)); return; } Trade trade = activation.trade(); long createdAt = System.currentTimeMillis(); - Shop shop = new Shop(-1, signLocation, player.getUniqueId(), trade, 0, true, createdAt); + Shop shop = new Shop(-1, signLocation, player.getUniqueId(), trade, 0, true, createdAt, null, + activation.cosmeticSign(), null); plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { try { @@ -212,11 +220,11 @@ public class ShopActivationListener implements Listener { } Shop registeredShop = new Shop(shopId, signLocation, player.getUniqueId(), trade, 0, true, - createdAt); + createdAt, null, activation.cosmeticSign(), null); plugin.getShopRegistry().register(registeredShop); plugin.getLogger().info("DEBUG: registered shop " + shopId + " in registry"); - rewriteSignLines(finalSign, trade); + rewriteSignLines(finalSign, registeredShop); player.sendMessage(Component.text("shop #" + shopId + " initialized!", NamedTextColor.GREEN)); }); } catch (SQLException e) { @@ -228,8 +236,14 @@ public class ShopActivationListener implements Listener { }); } - private void rewriteSignLines(Sign sign, Trade trade) { + public void rewriteSignLines(Sign sign, Shop shop) { + if (shop.isCosmeticSign()) { + return; + } + + Trade trade = shop.getTrade(); String pricePart = trade.priceQuantity() + " " + abbreviateMaterial(trade.priceItem()); + String productPart = trade.productQuantity() + " " + abbreviateMaterial(trade.productItem()); sign.line(0, Component.text(pricePart)); diff --git a/src/main/java/party/cybsec/oyeshops/model/PendingActivation.java b/src/main/java/party/cybsec/oyeshops/model/PendingActivation.java index c88ca82..e755b10 100644 --- a/src/main/java/party/cybsec/oyeshops/model/PendingActivation.java +++ b/src/main/java/party/cybsec/oyeshops/model/PendingActivation.java @@ -10,7 +10,8 @@ public record PendingActivation( UUID owner, Location location, Trade trade, - long createdAt) { + long createdAt, + boolean cosmeticSign) { /** * check if this activation has expired @@ -28,6 +29,6 @@ public record PendingActivation( trade.productQuantity(), trade.priceItem(), trade.priceQuantity()); - return new PendingActivation(owner, location, invertedTrade, createdAt); + return new PendingActivation(owner, location, invertedTrade, createdAt, cosmeticSign); } } diff --git a/src/main/java/party/cybsec/oyeshops/model/Shop.java b/src/main/java/party/cybsec/oyeshops/model/Shop.java index 38ab3ef..504af76 100644 --- a/src/main/java/party/cybsec/oyeshops/model/Shop.java +++ b/src/main/java/party/cybsec/oyeshops/model/Shop.java @@ -15,9 +15,12 @@ public class Shop { private int owedAmount; private boolean enabled; private final long createdAt; + private String customTitle; + private boolean cosmeticSign; + private String disc; public Shop(int id, Location signLocation, UUID owner, Trade trade, int owedAmount, boolean enabled, - long createdAt) { + long createdAt, String customTitle, boolean cosmeticSign, String disc) { this.id = id; this.signLocation = signLocation; this.owner = owner; @@ -25,6 +28,9 @@ public class Shop { this.owedAmount = owedAmount; this.enabled = enabled; this.createdAt = createdAt; + this.customTitle = customTitle; + this.cosmeticSign = cosmeticSign; + this.disc = disc; } public int getId() { @@ -63,6 +69,30 @@ public class Shop { return createdAt; } + public String getCustomTitle() { + return customTitle; + } + + public void setCustomTitle(String customTitle) { + this.customTitle = customTitle; + } + + public boolean isCosmeticSign() { + return cosmeticSign; + } + + public void setCosmeticSign(boolean cosmeticSign) { + this.cosmeticSign = cosmeticSign; + } + + public String getDisc() { + return disc; + } + + public void setDisc(String disc) { + this.disc = disc; + } + /** * get chest location from sign location */ diff --git a/src/main/java/party/cybsec/oyeshops/model/Trade.java b/src/main/java/party/cybsec/oyeshops/model/Trade.java index d93a056..56853a4 100644 --- a/src/main/java/party/cybsec/oyeshops/model/Trade.java +++ b/src/main/java/party/cybsec/oyeshops/model/Trade.java @@ -4,7 +4,7 @@ import org.bukkit.Material; /** * immutable trade definition - * quantity of -1 indicates AUTO detection needed for that side + * quantity of -1 indicates auto detection needed for that side */ public record Trade( Material priceItem, @@ -12,38 +12,46 @@ public record Trade( Material productItem, int productQuantity) { + // primary constructor public Trade { - // allow -1 for AUTO detection, but otherwise must be positive + // allow -1 for auto detection, but otherwise must be positive if (priceQuantity <= 0 && priceQuantity != -1) { throw new IllegalArgumentException("price quantity must be positive or -1 for auto"); } if (productQuantity <= 0 && productQuantity != -1) { throw new IllegalArgumentException("product quantity must be positive or -1 for auto"); } - // for AUTO, materials might be AIR (unknown) + // for auto, materials might be air (unknown) if (priceQuantity != -1 && productQuantity != -1 && priceItem == productItem && priceItem != Material.AIR) { throw new IllegalArgumentException("price and product must be different materials"); } } /** - * check if this trade needs AUTO detection on the product side + * check if this trade needs auto detection on the product side */ public boolean isAutoProduct() { return productQuantity == -1; } /** - * check if this trade needs AUTO detection on the price side + * check if this trade needs auto detection on the price side */ public boolean isAutoPrice() { return priceQuantity == -1; } /** - * check if this trade needs any AUTO detection + * check if this trade needs any auto detection */ public boolean isAutoDetect() { return isAutoProduct() || isAutoPrice(); } + + /** + * check if a material matches this trade's product + */ + public boolean matchesProduct(Material material) { + return productItem == material; + } } diff --git a/src/main/java/party/cybsec/oyeshops/parser/MaterialAliasRegistry.java b/src/main/java/party/cybsec/oyeshops/parser/MaterialAliasRegistry.java index 0857849..266438b 100644 --- a/src/main/java/party/cybsec/oyeshops/parser/MaterialAliasRegistry.java +++ b/src/main/java/party/cybsec/oyeshops/parser/MaterialAliasRegistry.java @@ -28,6 +28,7 @@ public class MaterialAliasRegistry { // short forms Map.entry("dia", "diamond"), + Map.entry("d", "diamond"), Map.entry("dias", "diamond"), Map.entry("em", "emerald"), Map.entry("ems", "emerald"), @@ -133,6 +134,7 @@ public class MaterialAliasRegistry { Map.entry("netherrack", "netherrack"), Map.entry("endstone", "end_stone"), Map.entry("end stone", "end_stone"), + Map.entry("bamboo block", "bamboo_block"), // food Map.entry("steak", "cooked_beef"), @@ -202,13 +204,13 @@ public class MaterialAliasRegistry { /** * resolve material from normalized text - * tries multiple strategies: - * 1. exact alias match (longest first) - * 2. word-by-word alias match - * 3. space-to-underscore conversion for direct enum match - * 4. direct material enum match - * 5. with _INGOT/_BLOCK suffixes - * 6. strip trailing 's' for plurals + * priority order: + * 1. exact full text match (space to underscore) - handles "bamboo block" -> + * BAMBOO_BLOCK + * 2. exact alias match (longest first for multi-word aliases) + * 3. direct material enum match for each word + * 4. with _INGOT/_BLOCK suffixes + * 5. strip trailing 's' for plurals */ public Material resolve(String text) { text = text.toLowerCase().trim(); @@ -217,13 +219,25 @@ public class MaterialAliasRegistry { return null; } - // 1. try longest alias match first (for multi-word aliases) + // remove numbers and extra whitespace for material matching + String materialText = text.replaceAll("\\d+", "").trim().replaceAll("\\s+", " "); + + // 1. try exact full text match with underscore conversion first + // this handles "bamboo block" -> "bamboo_block" -> BAMBOO_BLOCK + String underscored = materialText.replace(" ", "_"); + try { + return Material.valueOf(underscored.toUpperCase()); + } catch (IllegalArgumentException ignored) { + } + + // 2. try exact alias match (longest first for multi-word aliases) String longestMatch = null; Material longestMaterial = null; for (Map.Entry entry : aliases.entrySet()) { String alias = entry.getKey(); - if (text.contains(alias)) { + // check if the text equals or contains the alias + if (materialText.equals(alias) || materialText.contains(alias)) { if (longestMatch == null || alias.length() > longestMatch.length()) { longestMatch = alias; longestMaterial = entry.getValue(); @@ -235,30 +249,19 @@ public class MaterialAliasRegistry { return longestMaterial; } - // 2. try word-by-word alias match - String[] words = text.split("\\s+"); + // 3. try each word directly as material + String[] words = materialText.split("\\s+"); for (String word : words) { if (aliases.containsKey(word)) { return aliases.get(word); } - } - // 3. try space-to-underscore conversion for multi-word materials - // e.g., "netherite pickaxe" -> "netherite_pickaxe" - String underscored = text.replace(" ", "_"); - try { - return Material.valueOf(underscored.toUpperCase()); - } catch (IllegalArgumentException ignored) { - } - - // 4. try each word directly as material - for (String word : words) { try { return Material.valueOf(word.toUpperCase()); } catch (IllegalArgumentException ignored) { } - // 5. try with common suffixes + // 4. try with common suffixes try { return Material.valueOf(word.toUpperCase() + "_INGOT"); } catch (IllegalArgumentException ignored) { @@ -269,7 +272,7 @@ public class MaterialAliasRegistry { } catch (IllegalArgumentException ignored) { } - // 6. try stripping trailing 's' for plurals + // 5. try stripping trailing 's' for plurals if (word.endsWith("s") && word.length() > 1) { String singular = word.substring(0, word.length() - 1); try { @@ -279,19 +282,30 @@ public class MaterialAliasRegistry { } } - // 7. try the whole text with underscores for complex names + // 6. last resort: scan all materials for longest match + Material bestMatch = null; + int bestLength = 0; + for (Material material : Material.values()) { String materialName = material.name().toLowerCase(); String materialSpaced = materialName.replace("_", " "); - if (text.contains(materialSpaced) || text.contains(materialName)) { - if (longestMatch == null || materialName.length() > longestMatch.length()) { - longestMatch = materialName; - longestMaterial = material; + if (materialText.equals(materialSpaced) || materialText.equals(materialName)) { + return material; // exact match + } + + if (materialText.contains(materialSpaced) || materialText.contains(materialName)) { + if (materialName.length() > bestLength) { + bestLength = materialName.length(); + bestMatch = material; } } } - return longestMaterial; + return bestMatch; + } + + public Material parseMaterial(String name) { + return resolve(name); } } diff --git a/src/main/java/party/cybsec/oyeshops/parser/SignParser.java b/src/main/java/party/cybsec/oyeshops/parser/SignParser.java index 6cf023c..9dfb284 100644 --- a/src/main/java/party/cybsec/oyeshops/parser/SignParser.java +++ b/src/main/java/party/cybsec/oyeshops/parser/SignParser.java @@ -50,9 +50,9 @@ public class SignParser { /** * parse sign lines into a trade - * + * * @return trade if valid, null if invalid or ambiguous - * trade with quantity -1 means AUTO detection needed + * trade with quantity -1 means auto detection needed */ public Trade parse(String[] lines) { // concatenate all lines with spaces @@ -74,6 +74,7 @@ public class SignParser { return null; } + // check for auto detection boolean isAuto = productSection.toLowerCase().contains(AUTO_KEYWORD); ItemQuantity product; if (isAuto) { diff --git a/src/main/java/party/cybsec/oyeshops/registry/ShopRegistry.java b/src/main/java/party/cybsec/oyeshops/registry/ShopRegistry.java index 993413d..8ac5e47 100644 --- a/src/main/java/party/cybsec/oyeshops/registry/ShopRegistry.java +++ b/src/main/java/party/cybsec/oyeshops/registry/ShopRegistry.java @@ -97,6 +97,25 @@ public class ShopRegistry { shopsById.clear(); } + /** + * find shop by container block (checks adjacent faces for signs) + */ + public Shop getShopByContainer(org.bukkit.block.Block container) { + for (org.bukkit.block.BlockFace face : new org.bukkit.block.BlockFace[] { + org.bukkit.block.BlockFace.NORTH, org.bukkit.block.BlockFace.SOUTH, + org.bukkit.block.BlockFace.EAST, org.bukkit.block.BlockFace.WEST }) { + org.bukkit.block.Block adjacent = container.getRelative(face); + if (adjacent.getBlockData() instanceof org.bukkit.block.data.type.WallSign wallSign) { + if (wallSign.getFacing().getOppositeFace() == face.getOppositeFace()) { + Shop shop = getShop(adjacent.getLocation()); + if (shop != null) + return shop; + } + } + } + return null; + } + /** * create location key for map lookup */ diff --git a/src/main/java/party/cybsec/oyeshops/transaction/TransactionManager.java b/src/main/java/party/cybsec/oyeshops/transaction/TransactionManager.java index d1fcf59..ac244d0 100644 --- a/src/main/java/party/cybsec/oyeshops/transaction/TransactionManager.java +++ b/src/main/java/party/cybsec/oyeshops/transaction/TransactionManager.java @@ -97,22 +97,7 @@ public class TransactionManager { return Result.INVENTORY_FULL; } - // update owed amount in database - plugin.getShopRepository().updateOwedAmount(shop.getId(), totalPrice); - shop.setOwedAmount(shop.getOwedAmount() + totalPrice); - - // record transaction - plugin.getTransactionRepository().recordTransaction( - shop.getId(), - buyer.getUniqueId(), - units); - - // prune old transactions if configured - if (plugin.getConfigManager().isAutoPrune()) { - int maxHistory = plugin.getConfigManager().getMaxTransactionsPerShop(); - plugin.getTransactionRepository().pruneTransactions(shop.getId(), maxHistory); - } - + finalizeTransaction(buyer, shop, units, totalPrice); return Result.SUCCESS; } catch (SQLException e) { @@ -124,6 +109,24 @@ public class TransactionManager { } } + private void finalizeTransaction(Player buyer, Shop shop, int units, int totalPrice) throws SQLException { + // update owed amount in database + plugin.getShopRepository().updateOwedAmount(shop.getId(), shop.getOwedAmount() + totalPrice); + shop.setOwedAmount(shop.getOwedAmount() + totalPrice); + + // record transaction + plugin.getTransactionRepository().recordTransaction( + shop.getId(), + buyer != null ? buyer.getUniqueId() : null, + units); + + // prune old transactions if configured + if (plugin.getConfigManager().isAutoPrune()) { + int maxHistory = plugin.getConfigManager().getMaxTransactionsPerShop(); + plugin.getTransactionRepository().pruneTransactions(shop.getId(), maxHistory); + } + } + /** * check if inventory has required items */ @@ -185,7 +188,7 @@ public class TransactionManager { /** * get the shop's container inventory */ - private Inventory getShopInventory(Shop shop) { + public Inventory getShopInventory(Shop shop) { Location signLoc = shop.getSignLocation(); Block signBlock = signLoc.getBlock(); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 0a8bd45..e172fbe 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,29 +1,11 @@ -# hopper protection -hoppers: - allow-product-output: true - block-price-input: true +# oyeShops configuration -# transaction history -history: - max-transactions-per-shop: 100 - auto-prune: true +# transaction settings +transactions: + auto-prune: false + max-per-shop: 100 -# material aliases +# custom material aliases (add your own shortcuts) aliases: - # common abbreviations - dia: diamond - dias: diamond - iron: iron_ingot - gold: gold_ingot - emerald: emerald - ems: emerald - - # blocks - stone: stone - dirt: dirt - cobble: cobblestone - - # tools - pick: diamond_pickaxe - sword: diamond_sword - axe: diamond_axe + # example: + # myalias: diamond