1 Commits
1.1.0 ... 1.3.0

17 changed files with 549 additions and 187 deletions

View File

@@ -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<String> completions = new ArrayList<>();
if (args.length == 1) {
List<String> 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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("""

View File

@@ -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);
}
}

View File

@@ -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<String> 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);
}
}

View File

@@ -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());

View File

@@ -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()

View File

@@ -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();
}

View File

@@ -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));

View File

@@ -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);
}
}

View File

@@ -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
*/

View File

@@ -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;
}
}

View File

@@ -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<String, Material> 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);
}
}

View File

@@ -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) {

View File

@@ -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
*/

View File

@@ -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();

View File

@@ -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