9 Commits
1.0.0 ... 1.3.0

20 changed files with 997 additions and 146 deletions

1
.gitignore vendored
View File

@@ -30,3 +30,4 @@ out/
# logs # logs
logs/ logs/
*.log *.log
oyetickets/

View File

@@ -9,10 +9,11 @@ description = "deterministic item-for-item chest barter"
repositories { repositories {
mavenCentral() mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/") maven("https://repo.papermc.io/repository/maven-public/")
maven("https://repo.papermc.io/repository/maven-snapshots/")
} }
dependencies { dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT")
implementation("org.xerial:sqlite-jdbc:3.47.1.0") implementation("org.xerial:sqlite-jdbc:3.47.1.0")
} }

View File

@@ -11,7 +11,12 @@ import party.cybsec.oyeshops.OyeShopsPlugin;
import party.cybsec.oyeshops.model.PendingActivation; import party.cybsec.oyeshops.model.PendingActivation;
import party.cybsec.oyeshops.model.Shop; import party.cybsec.oyeshops.model.Shop;
import party.cybsec.oyeshops.permission.PermissionManager; import party.cybsec.oyeshops.permission.PermissionManager;
import party.cybsec.oyeshops.listener.ShopActivationListener; 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;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.ArrayList; import java.util.ArrayList;
@@ -42,12 +47,15 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
case "toggle" -> handleToggle(sender); case "toggle" -> handleToggle(sender);
case "notify" -> handleNotifyToggle(sender); case "notify" -> handleNotifyToggle(sender);
case "info" -> handleInfo(sender); case "info" -> handleInfo(sender);
case "help" -> handleHelp(sender);
case "setup" -> handleSetup(sender);
case "config" -> handleConfig(sender);
case "reload" -> handleReload(sender); case "reload" -> handleReload(sender);
case "inspect", "i" -> handleInspect(sender); case "inspect", "i" -> handleInspect(sender);
case "spoof", "s" -> handleSpoof(sender); case "spoof", "s" -> handleSpoof(sender);
case "unregister", "delete", "remove" -> handleUnregister(sender, args); case "unregister", "delete", "remove" -> handleUnregister(sender, args);
case "_activate" -> handleActivate(sender, args); case "_activate" -> handleActivate(sender, args);
default -> sendHelp(sender); default -> handleHelp(sender);
} }
return true; return true;
@@ -55,6 +63,12 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
private void sendHelp(CommandSender sender) { private void sendHelp(CommandSender sender) {
sender.sendMessage(Component.text("=== oyeshops commands ===", NamedTextColor.GOLD)); sender.sendMessage(Component.text("=== oyeshops commands ===", NamedTextColor.GOLD));
sender.sendMessage(Component.text("/oyeshops help", NamedTextColor.YELLOW)
.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) sender.sendMessage(Component.text("/oyeshops on", NamedTextColor.YELLOW)
.append(Component.text(" - enable shop creation", NamedTextColor.GRAY))); .append(Component.text(" - enable shop creation", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops off", NamedTextColor.YELLOW) sender.sendMessage(Component.text("/oyeshops off", NamedTextColor.YELLOW)
@@ -86,6 +100,14 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
sender.sendMessage(Component.text("cybsec made this plugin", NamedTextColor.GRAY)); sender.sendMessage(Component.text("cybsec made this plugin", NamedTextColor.GRAY));
} }
private void handleHelp(CommandSender sender) {
if (sender instanceof Player player) {
HelpBook.open(player);
} else {
sendHelp(sender);
}
}
private void handleReload(CommandSender sender) { private void handleReload(CommandSender sender) {
if (!PermissionManager.isAdmin(sender)) { if (!PermissionManager.isAdmin(sender)) {
sender.sendMessage(Component.text("no permission", NamedTextColor.RED)); sender.sendMessage(Component.text("no permission", NamedTextColor.RED));
@@ -236,6 +258,18 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
plugin.getPlayerPreferenceRepository().enableShops(player.getUniqueId()); plugin.getPlayerPreferenceRepository().enableShops(player.getUniqueId());
player.sendMessage(Component.text("shop creation enabled. you can now make chest shops!", player.sendMessage(Component.text("shop creation enabled. you can now make chest shops!",
NamedTextColor.GREEN)); NamedTextColor.GREEN));
// show help book on first enable
if (!plugin.getPlayerPreferenceRepository().hasSeenIntro(player.getUniqueId())) {
plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
HelpBook.open(player);
try {
plugin.getPlayerPreferenceRepository().setSeenIntro(player.getUniqueId());
} catch (SQLException e) {
e.printStackTrace();
}
}, 20L); // delay slightly
}
} catch (SQLException e) { } catch (SQLException e) {
player.sendMessage(Component.text("database error", NamedTextColor.RED)); player.sendMessage(Component.text("database error", NamedTextColor.RED));
e.printStackTrace(); e.printStackTrace();
@@ -342,7 +376,8 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) { public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
List<String> completions = new ArrayList<>(); List<String> completions = new ArrayList<>();
if (args.length == 1) { if (args.length == 1) {
List<String> subCommands = new ArrayList<>(List.of("on", "off", "toggle", "notify", "enable", "disable")); List<String> subCommands = new ArrayList<>(
List.of("help", "setup", "config", "on", "off", "toggle", "notify", "info", "enable", "disable"));
if (PermissionManager.isAdmin(sender)) { if (PermissionManager.isAdmin(sender)) {
subCommands.addAll(List.of("reload", "inspect", "spoof", "unregister")); subCommands.addAll(List.of("reload", "inspect", "spoof", "unregister"));
} }
@@ -365,4 +400,76 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
} }
return completions; return completions;
} }
private void handleSetup(CommandSender sender) {
if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("players only", NamedTextColor.RED));
return;
}
if (!PermissionManager.canCreate(player)) {
sender.sendMessage(Component.text("no permission", NamedTextColor.RED));
return;
}
Block block = player.getTargetBlockExact(5);
if (block == null || !(block.getBlockData() instanceof WallSign wallSign)) {
player.sendMessage(Component.text("you must look at a wall sign to use the wizard", NamedTextColor.RED));
return;
}
// duplicate validation logic from ShopActivationListener for safety
BlockFace attachedFace = wallSign.getFacing().getOppositeFace();
Block attachedBlock = block.getRelative(attachedFace);
if (!party.cybsec.oyeshops.listener.ShopActivationListener.isContainer(attachedBlock.getType())) {
player.sendMessage(Component.text("sign must be on a chest or barrel", NamedTextColor.RED));
return;
}
if (!PermissionManager.isAdmin(player)) {
java.util.UUID sessionOwner = plugin.getContainerMemoryManager().getOwner(attachedBlock.getLocation());
if (sessionOwner == null || !sessionOwner.equals(player.getUniqueId())) {
player.sendMessage(
Component.text("you can only create shops on containers you placed this session",
NamedTextColor.RED));
return;
}
}
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; package party.cybsec.oyeshops.config;
import org.bukkit.configuration.file.FileConfiguration; 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 { public class ConfigManager {
private final Plugin plugin; private final OyeShopsPlugin plugin;
private FileConfiguration config; private FileConfiguration config;
public ConfigManager(Plugin plugin) { public ConfigManager(OyeShopsPlugin plugin) {
this.plugin = plugin; this.plugin = plugin;
reload(); reload();
} }
@@ -18,26 +18,23 @@ public class ConfigManager {
public void reload() { public void reload() {
plugin.saveDefaultConfig(); plugin.saveDefaultConfig();
plugin.reloadConfig(); plugin.reloadConfig();
this.config = plugin.getConfig(); 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);
} }
public FileConfiguration getConfig() { public FileConfiguration getConfig() {
return config; 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

@@ -69,10 +69,50 @@ public class DatabaseManager {
player_uuid text primary key, player_uuid text primary key,
shops_enabled boolean not null default false, shops_enabled boolean not null default false,
notify_low_stock boolean not null default false, notify_low_stock boolean not null default false,
seen_intro boolean not null default false,
enabled_at integer enabled_at integer
) )
"""); """);
// migration: add seen_intro if missing
try {
stmt.execute("alter table player_preferences add column seen_intro boolean not null default false");
} catch (SQLException ignored) {
}
// migration: add owed_amount, enabled, created_at to shops if missing
try {
stmt.execute("alter table shops add column owed_amount integer not null default 0");
} catch (SQLException ignored) {
}
try {
stmt.execute("alter table shops add column enabled boolean not null default true");
} catch (SQLException ignored) {
}
try {
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 // indexes
stmt.execute(""" stmt.execute("""
create index if not exists idx_shop_location create index if not exists idx_shop_location

View File

@@ -15,9 +15,9 @@ import java.util.UUID;
public class PlayerPreferenceRepository { public class PlayerPreferenceRepository {
private final DatabaseManager dbManager; private final DatabaseManager dbManager;
// in-memory cache for performance
private final Set<UUID> enabledPlayers = new HashSet<>(); private final Set<UUID> enabledPlayers = new HashSet<>();
private final Set<UUID> notifyPlayers = new HashSet<>(); private final Set<UUID> notifyPlayers = new HashSet<>();
private final Set<UUID> seenIntroPlayers = new HashSet<>();
public PlayerPreferenceRepository(DatabaseManager dbManager) { public PlayerPreferenceRepository(DatabaseManager dbManager) {
this.dbManager = dbManager; this.dbManager = dbManager;
@@ -27,7 +27,7 @@ public class PlayerPreferenceRepository {
* load all player preferences into cache * load all player preferences into cache
*/ */
public void loadPreferences() throws SQLException { public void loadPreferences() throws SQLException {
String sql = "select player_uuid, shops_enabled, notify_low_stock from player_preferences"; String sql = "select player_uuid, shops_enabled, notify_low_stock, seen_intro from player_preferences";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql); try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) { ResultSet rs = stmt.executeQuery()) {
@@ -39,6 +39,9 @@ public class PlayerPreferenceRepository {
if (rs.getBoolean("notify_low_stock")) { if (rs.getBoolean("notify_low_stock")) {
notifyPlayers.add(uuid); notifyPlayers.add(uuid);
} }
if (rs.getBoolean("seen_intro")) {
seenIntroPlayers.add(uuid);
}
} }
} }
} }
@@ -57,6 +60,31 @@ public class PlayerPreferenceRepository {
return notifyPlayers.contains(playerUuid); return notifyPlayers.contains(playerUuid);
} }
/**
* check if player has seen the intro
*/
public boolean hasSeenIntro(UUID playerUuid) {
return seenIntroPlayers.contains(playerUuid);
}
/**
* set seen intro flag
*/
public void setSeenIntro(UUID playerUuid) throws SQLException {
String sql = """
insert into player_preferences (player_uuid, seen_intro)
values (?, true)
on conflict(player_uuid) do update set seen_intro = true
""";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setString(1, playerUuid.toString());
stmt.executeUpdate();
}
seenIntroPlayers.add(playerUuid);
}
/** /**
* enable shops for player * enable shops for player
*/ */

View File

@@ -29,8 +29,9 @@ public class ShopRepository {
String sql = """ String sql = """
insert into shops (world_uuid, sign_x, sign_y, sign_z, owner_uuid, insert into shops (world_uuid, sign_x, sign_y, sign_z, owner_uuid,
price_item, price_quantity, product_item, product_quantity, price_item, price_quantity, product_item, product_quantity,
owed_amount, enabled, created_at) owed_amount, enabled, created_at, custom_title,
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) cosmetic_sign, disc)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""; """;
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql, try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql,
@@ -48,6 +49,9 @@ public class ShopRepository {
stmt.setInt(10, shop.getOwedAmount()); stmt.setInt(10, shop.getOwedAmount());
stmt.setBoolean(11, shop.isEnabled()); stmt.setBoolean(11, shop.isEnabled());
stmt.setLong(12, shop.getCreatedAt()); stmt.setLong(12, shop.getCreatedAt());
stmt.setString(13, shop.getCustomTitle());
stmt.setBoolean(14, shop.isCosmeticSign());
stmt.setString(15, shop.getDisc());
stmt.executeUpdate(); 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 * delete shop
*/ */
@@ -178,6 +199,21 @@ public class ShopRepository {
} }
} }
/**
* delete shop by location (world, x, y, z)
*/
public void deleteShopByLocation(Location loc) throws SQLException {
String sql = "delete from shops where world_uuid = ? and sign_x = ? and sign_y = ? and sign_z = ?";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setString(1, loc.getWorld().getUID().toString());
stmt.setInt(2, loc.getBlockX());
stmt.setInt(3, loc.getBlockY());
stmt.setInt(4, loc.getBlockZ());
stmt.executeUpdate();
}
}
/** /**
* get all shops owned by player * get all shops owned by player
*/ */
@@ -232,6 +268,9 @@ public class ShopRepository {
int owedAmount = rs.getInt("owed_amount"); int owedAmount = rs.getInt("owed_amount");
boolean enabled = rs.getBoolean("enabled"); boolean enabled = rs.getBoolean("enabled");
long createdAt = rs.getLong("created_at"); 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); World world = Bukkit.getWorld(worldUuid);
if (world == null) { if (world == null) {
@@ -241,6 +280,8 @@ public class ShopRepository {
Location location = new Location(world, x, y, z); Location location = new Location(world, x, y, z);
Trade trade = new Trade(priceItem, priceQty, productItem, productQty); 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

@@ -0,0 +1,174 @@
package party.cybsec.oyeshops.gui;
import net.kyori.adventure.inventory.Book;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.entity.Player;
import java.util.ArrayList;
import java.util.List;
/**
* utility to open an interactive help book for players
* all text is lowercase for consistency
*/
public class HelpBook {
public static void open(Player player) {
List<Component> pages = new ArrayList<>();
// page 1: introduction
pages.add(Component.text()
.append(Component.text("oyeshops guide", NamedTextColor.GOLD, TextDecoration.BOLD))
.append(Component.newline())
.append(Component.newline())
.append(Component.text("welcome to the simple item barter system.",
NamedTextColor.DARK_GRAY))
.append(Component.newline())
.append(Component.newline())
.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))
.append(Component.newline())
.append(Component.text("2. place a chest", NamedTextColor.BLACK))
.append(Component.newline())
.append(Component.text("3. place a wall sign", NamedTextColor.BLACK))
.build());
// page 2: setup wizard (intro)
pages.add(Component.text()
.append(Component.text("setup wizard", NamedTextColor.GOLD, TextDecoration.BOLD))
.append(Component.newline())
.append(Component.newline())
.append(Component.text("hate typing?", NamedTextColor.DARK_GRAY))
.append(Component.newline())
.append(Component.newline())
.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))
.append(Component.newline())
.append(Component.newline())
.append(Component.text("...or turn the page", NamedTextColor.DARK_GRAY))
.build());
// page 3: setup wizard (sign trigger)
pages.add(Component.text()
.append(Component.text("sign trigger", NamedTextColor.GOLD, TextDecoration.BOLD))
.append(Component.newline())
.append(Component.newline())
.append(Component.text("just write ", NamedTextColor.GRAY))
.append(Component.text("setup", NamedTextColor.BLUE, TextDecoration.BOLD))
.append(Component.text(" on the first line of the sign.", NamedTextColor.GRAY))
.append(Component.newline())
.append(Component.newline())
.append(Component.text(
"this opens a fancy menu where you can click to create your shop.",
NamedTextColor.DARK_GRAY))
.build());
// page 4: manual creation
pages.add(Component.text()
.append(Component.text("manual setup", NamedTextColor.GOLD, TextDecoration.BOLD))
.append(Component.newline())
.append(Component.newline())
.append(Component.text("write exactly this:", NamedTextColor.DARK_GRAY))
.append(Component.newline())
.append(Component.newline())
.append(Component.text("line 1: ", NamedTextColor.GRAY))
.append(Component.text("1 diamond", NamedTextColor.BLACK))
.append(Component.newline())
.append(Component.text("line 2: ", NamedTextColor.GRAY))
.append(Component.text("for", NamedTextColor.BLACK))
.append(Component.newline())
.append(Component.text("line 3: ", NamedTextColor.GRAY))
.append(Component.text("64 dirt", NamedTextColor.BLACK))
.build());
// page 5: auto detection
pages.add(Component.text()
.append(Component.text("auto detection", NamedTextColor.GOLD, TextDecoration.BOLD))
.append(Component.newline())
.append(Component.newline())
.append(Component.text("lazy? just put items in the chest and write:",
NamedTextColor.DARK_GRAY))
.append(Component.newline())
.append(Component.newline())
.append(Component.text("auto", NamedTextColor.BLUE, TextDecoration.BOLD))
.append(Component.newline())
.append(Component.text("for 10 gold", NamedTextColor.BLACK))
.append(Component.newline())
.append(Component.newline())
.append(Component.text("the plugin will check the chest contents.",
NamedTextColor.DARK_GRAY))
.build());
// page 6: ownership
pages.add(Component.text()
.append(Component.text("ownership rules", NamedTextColor.GOLD, TextDecoration.BOLD))
.append(Component.newline())
.append(Component.newline())
.append(Component.text(
"you can only make shops on containers you placed ",
NamedTextColor.DARK_GRAY))
.append(Component.text("this session", NamedTextColor.BLACK, TextDecoration.ITALIC))
.append(Component.text(".", NamedTextColor.DARK_GRAY))
.append(Component.newline())
.append(Component.newline())
.append(Component.text(
"prevents stealing old chests!",
NamedTextColor.DARK_GRAY))
.build());
// page 7: containers
pages.add(Component.text()
.append(Component.text("supported blocks", NamedTextColor.GOLD, TextDecoration.BOLD))
.append(Component.newline())
.append(Component.newline())
.append(Component.text("- chests", NamedTextColor.BLACK))
.append(Component.newline())
.append(Component.text("- barrels", NamedTextColor.BLACK))
.append(Component.newline())
.append(Component.text("- trapped chests", NamedTextColor.BLACK))
.append(Component.newline())
.append(Component.newline())
.append(Component.text("double chests supported.", NamedTextColor.DARK_GRAY))
.build());
// page 8: commands
pages.add(Component.text()
.append(Component.text("commands", NamedTextColor.GOLD, TextDecoration.BOLD))
.append(Component.newline())
.append(Component.newline())
.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))
.append(Component.newline())
.append(Component.text("plugin info.", NamedTextColor.DARK_GRAY))
.build());
// page 9: tips
pages.add(Component.text()
.append(Component.text("pro tips", NamedTextColor.GOLD, TextDecoration.BOLD))
.append(Component.newline())
.append(Component.newline())
.append(Component.text("- use wall signs.", NamedTextColor.BLACK))
.append(Component.newline())
.append(Component.text("- abbreviations: ", NamedTextColor.BLACK))
.append(Component.text("dia", NamedTextColor.BLUE))
.append(Component.text(" = diamond.", NamedTextColor.BLACK))
.append(Component.newline())
.append(Component.text("- shops are ", NamedTextColor.BLACK))
.append(Component.text("off", NamedTextColor.RED))
.append(Component.text(" by default.", NamedTextColor.BLACK))
.build());
Book book = Book.book(Component.text("oyeshops manual"), Component.text("oyeshops"), pages);
player.openBook(book);
}
}

View File

@@ -0,0 +1,157 @@
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.Material;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import party.cybsec.oyeshops.OyeShopsPlugin;
import party.cybsec.oyeshops.model.PendingActivation;
import party.cybsec.oyeshops.model.Trade;
import java.util.List;
/**
* opens a setup wizard for creating shops using paper's dialog api
*/
public class SetupDialog {
public static void open(Player player, Block signBlock, OyeShopsPlugin plugin) {
Dialog dialog = Dialog.create(builder -> builder.empty()
.base(DialogBase.builder(Component.text("shop setup wizard", NamedTextColor.GOLD))
.inputs(List.of(
// product (selling)
DialogInput.text("product_item",
Component.text("selling what? (e.g. dirt)",
NamedTextColor.YELLOW))
.build(),
DialogInput.text("product_qty",
Component.text("how many? (e.g. 1)",
NamedTextColor.YELLOW))
.initial("1")
.build(),
// price (buying)
DialogInput.text("price_item",
Component.text("what do you want? (e.g. diamond)",
NamedTextColor.GREEN))
.build(),
DialogInput.text("price_qty",
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(
ActionButton.builder(Component.text("create shop",
TextColor.color(0xAEFFC1)))
.tooltip(Component
.text("click to confirm trade details"))
.action(DialogAction.customClick((view, audience) -> {
String productStr = view
.getText("product_item");
String productQtyStr = view
.getText("product_qty");
String priceStr = view.getText("price_item");
String priceQtyStr = view.getText("price_qty");
Player p = (Player) audience;
// 1. parse price qty
int priceQty;
try {
priceQty = Integer
.parseInt(priceQtyStr);
} catch (NumberFormatException e) {
p.sendMessage(Component.text(
"invalid price quantity",
NamedTextColor.RED));
open(p, signBlock, plugin);
return;
}
// 2. parse price material
Material priceMat = plugin.getSignParser()
.parseMaterial(priceStr);
if (priceMat == null) {
p.sendMessage(Component.text(
"invalid payment item: "
+ priceStr,
NamedTextColor.RED));
open(p, signBlock, plugin);
return;
}
// 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(),
cosmeticSign);
// 4. finalize
plugin.getServer().getScheduler()
.runTask(plugin, () -> {
plugin.getShopActivationListener()
.finalizeShop(p, activation);
});
}, 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(
"setup cancelled",
NamedTextColor.YELLOW));
}, ClickCallback.Options.builder().build()))
.build())));
player.showDialog(dialog);
}
}

View File

@@ -4,10 +4,12 @@ import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.Sound;
import org.bukkit.block.Barrel; import org.bukkit.block.Barrel;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.block.BlockFace; import org.bukkit.block.BlockFace;
import org.bukkit.block.Chest; import org.bukkit.block.Chest;
import org.bukkit.block.DoubleChest;
import org.bukkit.block.data.type.WallSign; import org.bukkit.block.data.type.WallSign;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
@@ -15,7 +17,9 @@ import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.block.Action; import org.bukkit.event.block.Action;
import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.DoubleChestInventory;
import org.bukkit.inventory.Inventory; import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder; import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
@@ -59,7 +63,8 @@ public class ChestInteractionListener implements Listener {
return; 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); Shop shop = findShopForContainer(block);
if (shop == null) { if (shop == null) {
return; // not a shop container return; // not a shop container
@@ -138,7 +143,7 @@ public class ChestInteractionListener implements Listener {
// update database // update database
int newOwed = owed - withdrawn; int newOwed = owed - withdrawn;
shop.setOwedAmount(newOwed); shop.setOwedAmount(newOwed);
plugin.getShopRepository().updateOwedAmount(shop.getId(), -withdrawn); plugin.getShopRepository().updateOwedAmount(shop.getId(), newOwed);
player.sendMessage( player.sendMessage(
Component.text("withdrew " + withdrawn + " " + formatMaterial(priceItem), NamedTextColor.GREEN) 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) { private void openShopGui(Player player, Shop shop, Block containerBlock) {
Trade trade = shop.getTrade(); Trade trade = shop.getTrade();
// create fake inventory showing only product items // create title
Inventory shopInventory = Bukkit.createInventory( Component title = Component.text("shop: " + trade.priceQuantity() + " " + formatMaterial(trade.priceItem()) +
new ShopInventoryHolder(shop), "" + trade.productQuantity() + " " + formatMaterial(trade.productItem()));
27,
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); Inventory realInventory = getContainerInventory(containerBlock);
if (realInventory == null) { if (realInventory == null) {
return; 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; int shopSlot = 0;
for (ItemStack item : realInventory.getContents()) { for (ItemStack item : realInventory.getContents()) {
if (shopSlot >= 27) if (shopSlot >= invSize)
break; break;
if (item != null && item.getType() == trade.productItem()) { if (item != null && trade.matchesProduct(item.getType())) {
shopInventory.setItem(shopSlot++, item.clone()); shopInventory.setItem(shopSlot++, item.clone());
} }
} }
viewingShop.put(player, shop); viewingShop.put(player, shop);
player.openInventory(shopInventory); 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 @EventHandler
@@ -202,20 +266,24 @@ public class ChestInteractionListener implements Listener {
// if clicked in the shop inventory area // if clicked in the shop inventory area
if (event.getRawSlot() < event.getInventory().getSize()) { if (event.getRawSlot() < event.getInventory().getSize()) {
ItemStack clicked = event.getCurrentItem(); 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 // determine quantity based on click type
int units = 1; int units = 1;
if (event.isShiftClick()) { if (event.isShiftClick()) {
// shift-click: calculate max units based on items clicked // shift-click: calculate max units based on items clicked
units = clicked.getAmount() / shop.getTrade().productQuantity(); units = clicked.getAmount() / trade.productQuantity();
if (units < 1) if (units < 1)
units = 1; units = 1;
} }
// open confirmation GUI // open confirmation gui
player.closeInventory(); player.closeInventory();
openConfirmationGui(player, shop, units); 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; 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 * 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) { 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 // 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 }) { for (BlockFace face : new BlockFace[] { BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST }) {
Block adjacent = containerBlock.getRelative(face); 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) { private Inventory getContainerInventory(Block block) {
if (block.getState() instanceof Chest chest) { if (block.getState() instanceof Chest chest) {
return chest.getInventory(); return chest.getInventory(); // returns DoubleChestInventory if double chest
} else if (block.getState() instanceof Barrel barrel) { } else if (block.getState() instanceof Barrel barrel) {
return barrel.getInventory(); return barrel.getInventory();
} }

View File

@@ -1,5 +1,7 @@
package party.cybsec.oyeshops.listener; package party.cybsec.oyeshops.listener;
import party.cybsec.oyeshops.gui.SetupDialog;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.event.HoverEvent;
@@ -114,6 +116,21 @@ public class ShopActivationListener implements Listener {
: ""; : "";
} }
// 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);
return;
}
// parse trade // parse trade
Trade trade = parser.parse(lines); Trade trade = parser.parse(lines);
if (trade == null) { if (trade == null) {
@@ -132,7 +149,7 @@ public class ShopActivationListener implements Listener {
// trigger confirmation instead of immediate creation // trigger confirmation instead of immediate creation
PendingActivation activation = new PendingActivation(player.getUniqueId(), block.getLocation(), trade, PendingActivation activation = new PendingActivation(player.getUniqueId(), block.getLocation(), trade,
System.currentTimeMillis()); System.currentTimeMillis(), false);
plugin.getActivationManager().add(player.getUniqueId(), activation); plugin.getActivationManager().add(player.getUniqueId(), activation);
sendConfirmationMessage(player, trade); sendConfirmationMessage(player, trade);
@@ -177,27 +194,37 @@ public class ShopActivationListener implements Listener {
Block block = signLocation.getBlock(); Block block = signLocation.getBlock();
// verify it's still a sign // 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)); player.sendMessage(Component.text("activation failed: sign is gone", NamedTextColor.RED));
return; return;
} }
Trade trade = activation.trade(); Trade trade = activation.trade();
long createdAt = System.currentTimeMillis(); 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, () -> { plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
try { try {
// cleanup potential existing shop to avoid unique constraint error
plugin.getShopRepository().deleteShopByLocation(signLocation);
int shopId = plugin.getShopRepository().createShop(shop); int shopId = plugin.getShopRepository().createShop(shop);
plugin.getLogger().info("DEBUG: created shop id " + shopId + " at " + signLocation);
plugin.getServer().getScheduler().runTask(plugin, () -> { plugin.getServer().getScheduler().runTask(plugin, () -> {
// re-verify sign on main thread // re-verify sign on main thread
if (!(signLocation.getBlock().getState() instanceof Sign finalSign)) if (!(signLocation.getBlock().getState() instanceof Sign finalSign)) {
plugin.getLogger().info("DEBUG: sign missing at " + signLocation);
return; return;
}
Shop registeredShop = new Shop(shopId, signLocation, player.getUniqueId(), trade, 0, true, Shop registeredShop = new Shop(shopId, signLocation, player.getUniqueId(), trade, 0, true,
createdAt); createdAt, null, activation.cosmeticSign(), null);
plugin.getShopRegistry().register(registeredShop); plugin.getShopRegistry().register(registeredShop);
rewriteSignLines(finalSign, trade); plugin.getLogger().info("DEBUG: registered shop " + shopId + " in registry");
rewriteSignLines(finalSign, registeredShop);
player.sendMessage(Component.text("shop #" + shopId + " initialized!", NamedTextColor.GREEN)); player.sendMessage(Component.text("shop #" + shopId + " initialized!", NamedTextColor.GREEN));
}); });
} catch (SQLException e) { } catch (SQLException e) {
@@ -209,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 pricePart = trade.priceQuantity() + " " + abbreviateMaterial(trade.priceItem());
String productPart = trade.productQuantity() + " " + abbreviateMaterial(trade.productItem()); String productPart = trade.productQuantity() + " " + abbreviateMaterial(trade.productItem());
sign.line(0, Component.text(pricePart)); sign.line(0, Component.text(pricePart));
@@ -339,7 +372,7 @@ public class ShopActivationListener implements Listener {
return null; return null;
} }
private boolean isContainer(Material material) { public static boolean isContainer(Material material) {
return material == Material.CHEST || material == Material.TRAPPED_CHEST || material == Material.BARREL; return material == Material.CHEST || material == Material.TRAPPED_CHEST || material == Material.BARREL;
} }

View File

@@ -10,7 +10,8 @@ public record PendingActivation(
UUID owner, UUID owner,
Location location, Location location,
Trade trade, Trade trade,
long createdAt) { long createdAt,
boolean cosmeticSign) {
/** /**
* check if this activation has expired * check if this activation has expired
@@ -28,6 +29,6 @@ public record PendingActivation(
trade.productQuantity(), trade.productQuantity(),
trade.priceItem(), trade.priceItem(),
trade.priceQuantity()); 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 int owedAmount;
private boolean enabled; private boolean enabled;
private final long createdAt; 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, 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.id = id;
this.signLocation = signLocation; this.signLocation = signLocation;
this.owner = owner; this.owner = owner;
@@ -25,6 +28,9 @@ public class Shop {
this.owedAmount = owedAmount; this.owedAmount = owedAmount;
this.enabled = enabled; this.enabled = enabled;
this.createdAt = createdAt; this.createdAt = createdAt;
this.customTitle = customTitle;
this.cosmeticSign = cosmeticSign;
this.disc = disc;
} }
public int getId() { public int getId() {
@@ -63,6 +69,30 @@ public class Shop {
return createdAt; 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 * get chest location from sign location
*/ */

View File

@@ -4,7 +4,7 @@ import org.bukkit.Material;
/** /**
* immutable trade definition * 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( public record Trade(
Material priceItem, Material priceItem,
@@ -12,38 +12,46 @@ public record Trade(
Material productItem, Material productItem,
int productQuantity) { int productQuantity) {
// primary constructor
public Trade { 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) { if (priceQuantity <= 0 && priceQuantity != -1) {
throw new IllegalArgumentException("price quantity must be positive or -1 for auto"); throw new IllegalArgumentException("price quantity must be positive or -1 for auto");
} }
if (productQuantity <= 0 && productQuantity != -1) { if (productQuantity <= 0 && productQuantity != -1) {
throw new IllegalArgumentException("product quantity must be positive or -1 for auto"); 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) { if (priceQuantity != -1 && productQuantity != -1 && priceItem == productItem && priceItem != Material.AIR) {
throw new IllegalArgumentException("price and product must be different materials"); 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() { public boolean isAutoProduct() {
return productQuantity == -1; 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() { public boolean isAutoPrice() {
return priceQuantity == -1; return priceQuantity == -1;
} }
/** /**
* check if this trade needs any AUTO detection * check if this trade needs any auto detection
*/ */
public boolean isAutoDetect() { public boolean isAutoDetect() {
return isAutoProduct() || isAutoPrice(); 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 // short forms
Map.entry("dia", "diamond"), Map.entry("dia", "diamond"),
Map.entry("d", "diamond"),
Map.entry("dias", "diamond"), Map.entry("dias", "diamond"),
Map.entry("em", "emerald"), Map.entry("em", "emerald"),
Map.entry("ems", "emerald"), Map.entry("ems", "emerald"),
@@ -133,6 +134,7 @@ public class MaterialAliasRegistry {
Map.entry("netherrack", "netherrack"), Map.entry("netherrack", "netherrack"),
Map.entry("endstone", "end_stone"), Map.entry("endstone", "end_stone"),
Map.entry("end stone", "end_stone"), Map.entry("end stone", "end_stone"),
Map.entry("bamboo block", "bamboo_block"),
// food // food
Map.entry("steak", "cooked_beef"), Map.entry("steak", "cooked_beef"),
@@ -202,13 +204,13 @@ public class MaterialAliasRegistry {
/** /**
* resolve material from normalized text * resolve material from normalized text
* tries multiple strategies: * priority order:
* 1. exact alias match (longest first) * 1. exact full text match (space to underscore) - handles "bamboo block" ->
* 2. word-by-word alias match * BAMBOO_BLOCK
* 3. space-to-underscore conversion for direct enum match * 2. exact alias match (longest first for multi-word aliases)
* 4. direct material enum match * 3. direct material enum match for each word
* 5. with _INGOT/_BLOCK suffixes * 4. with _INGOT/_BLOCK suffixes
* 6. strip trailing 's' for plurals * 5. strip trailing 's' for plurals
*/ */
public Material resolve(String text) { public Material resolve(String text) {
text = text.toLowerCase().trim(); text = text.toLowerCase().trim();
@@ -217,13 +219,25 @@ public class MaterialAliasRegistry {
return null; 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; String longestMatch = null;
Material longestMaterial = null; Material longestMaterial = null;
for (Map.Entry<String, Material> entry : aliases.entrySet()) { for (Map.Entry<String, Material> entry : aliases.entrySet()) {
String alias = entry.getKey(); 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()) { if (longestMatch == null || alias.length() > longestMatch.length()) {
longestMatch = alias; longestMatch = alias;
longestMaterial = entry.getValue(); longestMaterial = entry.getValue();
@@ -235,30 +249,19 @@ public class MaterialAliasRegistry {
return longestMaterial; return longestMaterial;
} }
// 2. try word-by-word alias match // 3. try each word directly as material
String[] words = text.split("\\s+"); String[] words = materialText.split("\\s+");
for (String word : words) { for (String word : words) {
if (aliases.containsKey(word)) { if (aliases.containsKey(word)) {
return aliases.get(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 { try {
return Material.valueOf(word.toUpperCase()); return Material.valueOf(word.toUpperCase());
} catch (IllegalArgumentException ignored) { } catch (IllegalArgumentException ignored) {
} }
// 5. try with common suffixes // 4. try with common suffixes
try { try {
return Material.valueOf(word.toUpperCase() + "_INGOT"); return Material.valueOf(word.toUpperCase() + "_INGOT");
} catch (IllegalArgumentException ignored) { } catch (IllegalArgumentException ignored) {
@@ -269,7 +272,7 @@ public class MaterialAliasRegistry {
} catch (IllegalArgumentException ignored) { } catch (IllegalArgumentException ignored) {
} }
// 6. try stripping trailing 's' for plurals // 5. try stripping trailing 's' for plurals
if (word.endsWith("s") && word.length() > 1) { if (word.endsWith("s") && word.length() > 1) {
String singular = word.substring(0, word.length() - 1); String singular = word.substring(0, word.length() - 1);
try { 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()) { for (Material material : Material.values()) {
String materialName = material.name().toLowerCase(); String materialName = material.name().toLowerCase();
String materialSpaced = materialName.replace("_", " "); String materialSpaced = materialName.replace("_", " ");
if (text.contains(materialSpaced) || text.contains(materialName)) { if (materialText.equals(materialSpaced) || materialText.equals(materialName)) {
if (longestMatch == null || materialName.length() > longestMatch.length()) { return material; // exact match
longestMatch = materialName; }
longestMaterial = material;
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,19 +50,14 @@ public class SignParser {
/** /**
* parse sign lines into a trade * parse sign lines into a trade
* *
* @return trade if valid, null if invalid or ambiguous * @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) { public Trade parse(String[] lines) {
// concatenate all lines with spaces // concatenate all lines with spaces
String fullText = String.join(" ", lines); String fullText = String.join(" ", lines);
// REQUIREMENT: must contain "." to be parsed as a shop
if (!fullText.contains(".")) {
return null;
}
// normalize text // normalize text
String normalized = normalize(fullText); String normalized = normalize(fullText);
@@ -79,6 +74,7 @@ public class SignParser {
return null; return null;
} }
// check for auto detection
boolean isAuto = productSection.toLowerCase().contains(AUTO_KEYWORD); boolean isAuto = productSection.toLowerCase().contains(AUTO_KEYWORD);
ItemQuantity product; ItemQuantity product;
if (isAuto) { if (isAuto) {
@@ -259,4 +255,8 @@ public class SignParser {
private record ItemQuantity(Material material, int quantity) { private record ItemQuantity(Material material, int quantity) {
} }
public Material parseMaterial(String name) {
return aliasRegistry.resolve(name);
}
} }

View File

@@ -97,6 +97,25 @@ public class ShopRegistry {
shopsById.clear(); 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 * create location key for map lookup
*/ */

View File

@@ -97,22 +97,7 @@ public class TransactionManager {
return Result.INVENTORY_FULL; return Result.INVENTORY_FULL;
} }
// update owed amount in database finalizeTransaction(buyer, shop, units, totalPrice);
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);
}
return Result.SUCCESS; return Result.SUCCESS;
} catch (SQLException e) { } 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 * check if inventory has required items
*/ */
@@ -185,7 +188,7 @@ public class TransactionManager {
/** /**
* get the shop's container inventory * get the shop's container inventory
*/ */
private Inventory getShopInventory(Shop shop) { public Inventory getShopInventory(Shop shop) {
Location signLoc = shop.getSignLocation(); Location signLoc = shop.getSignLocation();
Block signBlock = signLoc.getBlock(); Block signBlock = signLoc.getBlock();

View File

@@ -1,29 +1,11 @@
# hopper protection # oyeShops configuration
hoppers:
allow-product-output: true
block-price-input: true
# transaction history # transaction settings
history: transactions:
max-transactions-per-shop: 100 auto-prune: false
auto-prune: true max-per-shop: 100
# material aliases # custom material aliases (add your own shortcuts)
aliases: aliases:
# common abbreviations # example:
dia: diamond # myalias: 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