12 Commits

39 changed files with 2100 additions and 446 deletions

1
.gitignore vendored
View File

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

View File

@@ -3,16 +3,17 @@ plugins {
}
group = "party.cybsec"
version = "1.0.0"
version = "1.3.1"
description = "deterministic item-for-item chest barter"
repositories {
mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/")
maven("https://repo.papermc.io/repository/maven-snapshots/")
}
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")
}

View File

@@ -0,0 +1,57 @@
# oyeOwner Integration Guide for AI Assistants
This document provides concise instructions for integrating the **oyeOwner** API into other Bukkit/Spigot plugins.
## 1. Dependency Configuration (Maven)
Add the `oyeOwner` project as a dependency in your `pom.xml`.
```xml
<dependency>
<groupId>party.cybsec</groupId>
<artifactId>oyeOwner</artifactId>
<version>1.0-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
```
## 2. Plugin Configuration (plugin.yml)
Add `oyeOwner` as a dependency to ensure it loads before your plugin.
```yaml
depend: [oyeOwner]
```
## 3. Java API Usage
### Accessing the API
The API is accessible via a static getter in the main class: `party.cybsec.OyeOwner.getAPI()`.
### Sync Lookup (Blocking)
Use this if you are already in an asynchronous task or if a tiny delay is acceptable.
```java
import org.bukkit.block.Block;
import party.cybsec.OyeOwner;
// Returns String username or null
String owner = OyeOwner.getAPI().getBlockOwner(block);
```
### Async Lookup (Non-blocking)
Recommended for use on the main thread to avoid lag.
```java
import org.bukkit.block.Block;
import party.cybsec.OyeOwner;
OyeOwner.getAPI().getBlockOwnerAsync(block).thenAccept(owner -> {
if (owner != null) {
// Player name found: owner
} else {
// No ownership data found
}
});
```
## 4. Summary of Capabilities
- **Lookback Period**: 60 days.
- **Action Tracked**: Block Placement (Action ID 1).
- **Core Engine**: Powered by CoreProtect with a reflection-based safe hook.

73
oyeOwner/pom.xml Normal file
View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>party.cybsec</groupId>
<artifactId>oyeOwner</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>oyeOwner</name>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<repository>
<id>spigotmc-repo</id>
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
</repository>
<repository>
<id>sonatype</id>
<url>https://oss.sonatype.org/content/groups/public/</url>
</repository>
<repository>
<id>playpro</id>
<url>https://maven.playpro.com</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.spigotmc</groupId>
<artifactId>spigot-api</artifactId>
<version>1.21.1-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>net.coreprotect</groupId>
<artifactId>coreprotect</artifactId>
<version>23.1</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
</plugin>
</plugins>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
</build>
</project>

View File

@@ -0,0 +1,56 @@
package party.cybsec;
import net.coreprotect.CoreProtectAPI;
import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
import java.lang.reflect.Method;
public class CoreProtectHook {
private final OyeOwner plugin;
public CoreProtectHook(OyeOwner plugin) {
this.plugin = plugin;
}
public CoreProtectAPI getCoreProtect() {
Plugin cpPlugin = Bukkit.getPluginManager().getPlugin("CoreProtect");
// Check that CoreProtect is loaded and enabled
if (cpPlugin == null || !cpPlugin.isEnabled()) {
return null;
}
// Use reflection to get the API to avoid NoClassDefFoundError at load time
try {
// Verify it's the right class name
if (!cpPlugin.getClass().getName().equals("net.coreprotect.CoreProtect")) {
return null;
}
Method getAPIMethod = cpPlugin.getClass().getMethod("getAPI");
Object apiObject = getAPIMethod.invoke(cpPlugin);
if (apiObject instanceof CoreProtectAPI) {
CoreProtectAPI api = (CoreProtectAPI) apiObject;
// Check that the API is enabled
if (!api.isEnabled()) {
return null;
}
// Check that a compatible version of the API is loaded
if (api.APIVersion() < 11) {
return null;
}
return api;
}
} catch (Exception e) {
plugin.getLogger().warning("Failed to hook into CoreProtect API via reflection: " + e.getMessage());
}
return null;
}
}

View File

@@ -0,0 +1,50 @@
package party.cybsec;
import party.cybsec.command.WhoCommand;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.logging.Logger;
public class OyeOwner extends JavaPlugin {
private static OyeOwner instance;
private CoreProtectHook coreProtectHook;
private OyeOwnerAPI api;
@Override
public void onEnable() {
instance = this;
Logger logger = getLogger();
logger.info("oyeOwner is enabling...");
this.coreProtectHook = new CoreProtectHook(this);
this.api = new OyeOwnerAPI(this);
if (coreProtectHook.getCoreProtect() == null) {
logger.severe("CoreProtect not found or incompatible! Disabling oyeOwner.");
getServer().getPluginManager().disablePlugin(this);
return;
}
getCommand("who").setExecutor(new WhoCommand(this));
logger.info("oyeOwner enabled successfully.");
}
@Override
public void onDisable() {
getLogger().info("oyeOwner disabled.");
}
public CoreProtectHook getCoreProtectHook() {
return coreProtectHook;
}
public OyeOwnerAPI getOyeAPI() {
return api;
}
public static OyeOwnerAPI getAPI() {
return instance.api;
}
}

View File

@@ -0,0 +1,59 @@
package party.cybsec;
import net.coreprotect.CoreProtectAPI;
import org.bukkit.block.Block;
import java.util.List;
import java.util.concurrent.CompletableFuture;
public class OyeOwnerAPI {
private final OyeOwner plugin;
public OyeOwnerAPI(OyeOwner plugin) {
this.plugin = plugin;
}
/**
* Get the owner (player who placed) of a block.
* Searches back 60 days.
*
* @param block The block to check.
* @return The username of the player who placed the block, or null if not
* found/error.
*/
public String getBlockOwner(Block block) {
CoreProtectAPI api = plugin.getCoreProtectHook().getCoreProtect();
if (api == null || block == null) {
return null;
}
// 60 days in seconds
int time = 60 * 24 * 60 * 60;
List<String[]> lookup = api.blockLookup(block, time);
if (lookup == null || lookup.isEmpty()) {
return null;
}
for (String[] result : lookup) {
CoreProtectAPI.ParseResult parsed = api.parseResult(result);
// Action ID 1 is "Placement"
if (parsed.getActionId() == 1) {
return parsed.getPlayer();
}
}
return null;
}
/**
* Get the owner of a block asynchronously.
*
* @param block The block to check.
* @return A CompletableFuture containing the username or null.
*/
public CompletableFuture<String> getBlockOwnerAsync(Block block) {
return CompletableFuture.supplyAsync(() -> getBlockOwner(block));
}
}

View File

@@ -0,0 +1,47 @@
package party.cybsec.command;
import party.cybsec.OyeOwner;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
public class WhoCommand implements CommandExecutor {
private final OyeOwner plugin;
public WhoCommand(OyeOwner plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player)) {
sender.sendMessage(ChatColor.RED + "Only players can use this command.");
return true;
}
Player player = (Player) sender;
Block targetBlock = player.getTargetBlockExact(5);
if (targetBlock == null || targetBlock.getType() == Material.AIR) {
player.sendMessage(ChatColor.RED + "You must be looking at a block.");
return true;
}
String owner = plugin.getOyeAPI().getBlockOwner(targetBlock);
if (owner != null) {
player.sendMessage(ChatColor.DARK_AQUA + "--- Block Owner Info ---");
player.sendMessage(ChatColor.GOLD + "user: " + ChatColor.WHITE + owner);
player.sendMessage(ChatColor.DARK_AQUA + "------------------------");
return true;
}
player.sendMessage(ChatColor.GRAY + "No placement records found for this block.");
return true;
}
}

View File

@@ -0,0 +1,14 @@
name: oyeOwner
version: '1.0'
main: party.cybsec.OyeOwner
api-version: '1.21'
depend: [CoreProtect]
commands:
who:
description: check who placed the block you are looking at
permission: oyeowner.use
usage: /<command>
permissions:
oyeowner.use:
description: Allows player to use the /who command
default: op

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,14 @@
name: oyeOwner
version: '1.0'
main: party.cybsec.OyeOwner
api-version: '1.21'
depend: [CoreProtect]
commands:
who:
description: check who placed the block you are looking at
permission: oyeowner.use
usage: /<command>
permissions:
oyeowner.use:
description: Allows player to use the /who command
default: op

View File

@@ -0,0 +1,3 @@
artifactId=oyeOwner
groupId=party.cybsec
version=1.0-SNAPSHOT

View File

@@ -0,0 +1,4 @@
party/cybsec/command/WhoCommand.class
party/cybsec/OyeOwner.class
party/cybsec/CoreProtectHook.class
party/cybsec/OyeOwnerAPI.class

View File

@@ -0,0 +1,4 @@
/Users/jacktotonchi/oyeOwner/src/main/java/party/cybsec/CoreProtectHook.java
/Users/jacktotonchi/oyeOwner/src/main/java/party/cybsec/OyeOwner.java
/Users/jacktotonchi/oyeOwner/src/main/java/party/cybsec/OyeOwnerAPI.java
/Users/jacktotonchi/oyeOwner/src/main/java/party/cybsec/command/WhoCommand.java

View File

@@ -11,7 +11,12 @@ import party.cybsec.oyeshops.OyeShopsPlugin;
import party.cybsec.oyeshops.model.PendingActivation;
import party.cybsec.oyeshops.model.Shop;
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.util.ArrayList;
@@ -42,12 +47,17 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
case "toggle" -> handleToggle(sender);
case "notify" -> handleNotifyToggle(sender);
case "info" -> handleInfo(sender);
case "help" -> handleHelp(sender);
case "setup" -> handleSetup(sender, args);
case "config" -> handleConfig(sender, args);
case "reload" -> handleReload(sender);
case "inspect", "i" -> handleInspect(sender);
case "spoof", "s" -> handleSpoof(sender);
case "unregister", "delete", "remove" -> handleUnregister(sender, args);
case "tpshop" -> handleTpShop(sender, args);
case "_activate" -> handleActivate(sender, args);
default -> sendHelp(sender);
case "toggleplacement", "toggle-placement" -> handleTogglePlacement(sender);
default -> handleHelp(sender);
}
return true;
@@ -55,6 +65,12 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
private void sendHelp(CommandSender sender) {
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)
.append(Component.text(" - enable shop creation", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops off", NamedTextColor.YELLOW)
@@ -79,6 +95,10 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
.append(Component.text(" - disable a shop", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops unregister <id>", NamedTextColor.YELLOW)
.append(Component.text(" - delete a shop", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops tpshop <owner> <shop>", NamedTextColor.YELLOW)
.append(Component.text(" - teleport to a shop", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops toggle-placement", NamedTextColor.YELLOW)
.append(Component.text(" - toggle container placement requirement", NamedTextColor.GRAY)));
}
}
@@ -86,6 +106,14 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
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) {
if (!PermissionManager.isAdmin(sender)) {
sender.sendMessage(Component.text("no permission", NamedTextColor.RED));
@@ -200,10 +228,13 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
private void handleActivate(CommandSender sender, String[] args) {
if (!(sender instanceof Player player))
return;
if (args.length < 2)
if (args.length < 3) {
player.sendMessage(Component.text("usage: /oyes _activate <action> <name>", NamedTextColor.RED));
return;
}
String action = args[1].toLowerCase();
String name = args[2];
PendingActivation activation = plugin.getActivationManager().getAndRemove(player.getUniqueId());
if (activation == null) {
@@ -213,10 +244,10 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
switch (action) {
case "accept" -> {
finalizeShop(player, activation);
finalizeShop(player, activation, name);
}
case "invert" -> {
finalizeShop(player, activation.invert());
finalizeShop(player, activation.invert(), name);
}
case "cancel" -> {
player.sendMessage(
@@ -225,8 +256,8 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
}
}
private void finalizeShop(Player player, PendingActivation activation) {
plugin.getShopActivationListener().finalizeShop(player, activation);
private void finalizeShop(Player player, PendingActivation activation, String shopName) {
plugin.getShopActivationListener().finalizeShop(player, activation, shopName);
}
private void handleEnable(CommandSender sender, String[] args) {
@@ -236,12 +267,24 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
plugin.getPlayerPreferenceRepository().enableShops(player.getUniqueId());
player.sendMessage(Component.text("shop creation enabled. you can now make chest shops!",
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) {
player.sendMessage(Component.text("database error", NamedTextColor.RED));
e.printStackTrace();
}
} else {
sender.sendMessage(Component.text("usage: /oyeshops enable <id>", NamedTextColor.RED));
sender.sendMessage(Component.text("usage: /oyeshops enable <id/name>", NamedTextColor.RED));
}
return;
}
@@ -251,18 +294,25 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
return;
}
try {
int shopId = Integer.parseInt(args[1]);
Shop shop = plugin.getShopRegistry().getShopById(shopId);
if (shop == null) {
sender.sendMessage(Component.text("shop not found: " + shopId, NamedTextColor.RED));
return;
String target = args[1];
Shop shop = plugin.getShopRegistry().getShopByName(target);
if (shop == null) {
try {
int id = Integer.parseInt(target);
shop = plugin.getShopRegistry().getShopById(id);
} catch (NumberFormatException ignored) {
}
}
if (shop == null) {
sender.sendMessage(Component.text("shop not found: " + target, NamedTextColor.RED));
return;
}
try {
shop.setEnabled(true);
plugin.getShopRepository().setEnabled(shopId, true);
sender.sendMessage(Component.text("shop #" + shopId + " enabled", NamedTextColor.GREEN));
} catch (NumberFormatException e) {
sender.sendMessage(Component.text("invalid shop id", NamedTextColor.RED));
plugin.getShopRepository().setEnabled(shop.getId(), true);
sender.sendMessage(Component.text("shop '" + shop.getName() + "' enabled", NamedTextColor.GREEN));
} catch (SQLException e) {
sender.sendMessage(Component.text("database error", NamedTextColor.RED));
e.printStackTrace();
@@ -281,7 +331,7 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
e.printStackTrace();
}
} else {
sender.sendMessage(Component.text("usage: /oyeshops disable <id>", NamedTextColor.RED));
sender.sendMessage(Component.text("usage: /oyeshops disable <id/name>", NamedTextColor.RED));
}
return;
}
@@ -291,18 +341,25 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
return;
}
try {
int shopId = Integer.parseInt(args[1]);
Shop shop = plugin.getShopRegistry().getShopById(shopId);
if (shop == null) {
sender.sendMessage(Component.text("shop not found: " + shopId, NamedTextColor.RED));
return;
String target = args[1];
Shop shop = plugin.getShopRegistry().getShopByName(target);
if (shop == null) {
try {
int id = Integer.parseInt(target);
shop = plugin.getShopRegistry().getShopById(id);
} catch (NumberFormatException ignored) {
}
}
if (shop == null) {
sender.sendMessage(Component.text("shop not found: " + target, NamedTextColor.RED));
return;
}
try {
shop.setEnabled(false);
plugin.getShopRepository().setEnabled(shopId, false);
sender.sendMessage(Component.text("shop #" + shopId + " disabled", NamedTextColor.YELLOW));
} catch (NumberFormatException e) {
sender.sendMessage(Component.text("invalid shop id", NamedTextColor.RED));
plugin.getShopRepository().setEnabled(shop.getId(), false);
sender.sendMessage(Component.text("shop '" + shop.getName() + "' disabled", NamedTextColor.YELLOW));
} catch (SQLException e) {
sender.sendMessage(Component.text("database error", NamedTextColor.RED));
e.printStackTrace();
@@ -316,22 +373,29 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
}
if (args.length < 2) {
sender.sendMessage(Component.text("usage: /oyeshops unregister <id>", NamedTextColor.RED));
sender.sendMessage(Component.text("usage: /oyeshops unregister <id/name>", NamedTextColor.RED));
return;
}
String target = args[1];
Shop shop = plugin.getShopRegistry().getShopByName(target);
if (shop == null) {
try {
int id = Integer.parseInt(target);
shop = plugin.getShopRegistry().getShopById(id);
} catch (NumberFormatException ignored) {
}
}
if (shop == null) {
sender.sendMessage(Component.text("shop not found: " + target, NamedTextColor.RED));
return;
}
try {
int shopId = Integer.parseInt(args[1]);
Shop shop = plugin.getShopRegistry().getShopById(shopId);
if (shop == null) {
sender.sendMessage(Component.text("shop not found: " + shopId, NamedTextColor.RED));
return;
}
plugin.getShopRegistry().unregister(shop);
plugin.getShopRepository().deleteShop(shopId);
sender.sendMessage(Component.text("shop #" + shopId + " deleted", NamedTextColor.RED));
} catch (NumberFormatException e) {
sender.sendMessage(Component.text("invalid shop id", NamedTextColor.RED));
plugin.getShopRepository().deleteShop(shop.getId());
sender.sendMessage(Component.text("shop '" + shop.getName() + "' deleted", NamedTextColor.RED));
} catch (SQLException e) {
sender.sendMessage(Component.text("database error", NamedTextColor.RED));
e.printStackTrace();
@@ -342,9 +406,11 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
List<String> completions = new ArrayList<>();
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)) {
subCommands.addAll(List.of("reload", "inspect", "spoof", "unregister"));
subCommands.addAll(List.of("reload", "inspect", "spoof", "unregister", "tpshop", "toggle-placement",
"toggleplacement"));
}
String partial = args[0].toLowerCase();
for (String sub : subCommands) {
@@ -353,16 +419,219 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
}
} else if (args.length == 2) {
String subCommand = args[0].toLowerCase();
if (PermissionManager.isAdmin(sender) && (subCommand.equals("enable") || subCommand.equals("disable")
|| subCommand.equals("unregister"))) {
String partial = args[1];
if (subCommand.equals("config") && sender instanceof Player player) {
String partial = args[1].toLowerCase();
for (Shop shop : plugin.getShopRegistry().getAllShops()) {
if (shop.getOwner().equals(player.getUniqueId())
|| shop.getContributors().contains(player.getUniqueId())) {
if (shop.getName().toLowerCase().startsWith(partial)) {
completions.add(shop.getName());
}
}
}
} else if (PermissionManager.isAdmin(sender) && (subCommand.equals("enable") || subCommand.equals("disable")
|| subCommand.equals("unregister"))) {
String partial = args[1].toLowerCase();
for (Shop shop : plugin.getShopRegistry().getAllShops()) {
if (shop.getName().toLowerCase().startsWith(partial)) {
completions.add(shop.getName());
}
String id = String.valueOf(shop.getId());
if (id.startsWith(partial))
completions.add(id);
}
} else if (subCommand.equals("tpshop") && PermissionManager.isAdmin(sender)) {
String partial = args[1].toLowerCase();
java.util.Set<String> owners = new java.util.HashSet<>();
for (Shop shop : plugin.getShopRegistry().getAllShops()) {
org.bukkit.OfflinePlayer owner = org.bukkit.Bukkit.getOfflinePlayer(shop.getOwner());
if (owner.getName() != null) {
owners.add(owner.getName());
}
}
for (String name : owners) {
if (name.toLowerCase().startsWith(partial)) {
completions.add(name);
}
}
}
} else if (args.length == 3) {
String subCommand = args[0].toLowerCase();
if (subCommand.equals("tpshop") && PermissionManager.isAdmin(sender)) {
String ownerName = args[1];
String partial = args[2].toLowerCase();
for (Shop shop : plugin.getShopRegistry().getAllShops()) {
org.bukkit.OfflinePlayer owner = org.bukkit.Bukkit.getOfflinePlayer(shop.getOwner());
if (ownerName.equalsIgnoreCase(owner.getName())) {
if (shop.getName().toLowerCase().startsWith(partial)) {
completions.add(shop.getName());
}
}
}
}
}
return completions;
}
private void handleSetup(CommandSender sender, String[] args) {
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;
}
if (args.length < 2) {
player.sendMessage(Component.text("usage: /oyes setup <name>", NamedTextColor.RED));
player.sendMessage(Component.text("example: /oyes setup myEpicBambooShop", NamedTextColor.GREEN));
return;
}
String shopName = args[1];
if (shopName.equalsIgnoreCase("myEpicBambooShop")) {
player.sendMessage(
Component.text("hey! that's cybsec's shop! choose a different name", NamedTextColor.RED));
return;
}
if (plugin.getShopRegistry().getShopByOwnerAndName(player.getUniqueId(), shopName) != null) {
player.sendMessage(Component.text("you already have a shop with that name!", 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 (plugin.getConfigManager().isRequirePlacement() && !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, shopName);
}
private void handleTogglePlacement(CommandSender sender) {
if (!PermissionManager.isAdmin(sender)) {
sender.sendMessage(Component.text("you don't have permission to do this", NamedTextColor.RED));
return;
}
boolean current = plugin.getConfigManager().isRequirePlacement();
boolean newValue = !current;
plugin.getConfigManager().setRequirePlacement(newValue);
sender.sendMessage(Component.text("global container placement requirement is now ", NamedTextColor.GREEN)
.append(Component.text(newValue ? "ENABLED" : "DISABLED",
newValue ? NamedTextColor.YELLOW : NamedTextColor.RED,
net.kyori.adventure.text.format.TextDecoration.BOLD)));
}
private void handleConfig(CommandSender sender, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("players only", NamedTextColor.RED));
return;
}
Shop shop = null;
if (args.length > 1) {
// remote config by name (lookup by owner)
shop = plugin.getShopRegistry().getShopByOwnerAndName(player.getUniqueId(), args[1]);
if (shop == null && PermissionManager.isAdmin(player)) {
// admins can lookup any shop by name if not found in their own
shop = plugin.getShopRegistry().getShopByName(args[1]);
}
if (shop == null) {
player.sendMessage(Component.text("shop not found: " + args[1], NamedTextColor.RED));
return;
}
} else {
// target block config
Block block = player.getTargetBlockExact(5);
if (block == null) {
player.sendMessage(
Component.text("you must look at a shop sign or its container, or use /oyes config <name>",
NamedTextColor.RED));
return;
}
if (block.getBlockData() instanceof WallSign) {
shop = plugin.getShopRegistry().getShop(block.getLocation());
} else if (party.cybsec.oyeshops.listener.ShopActivationListener.isContainer(block.getType())) {
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()) && !shop.getContributors().contains(player.getUniqueId())
&& !PermissionManager.isAdmin(player)) {
player.sendMessage(Component.text("you do not own or contribute to this shop", NamedTextColor.RED));
return;
}
ConfigDialog.open(player, shop, plugin);
}
private void handleTpShop(CommandSender sender, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("players only", NamedTextColor.RED));
return;
}
if (!PermissionManager.isAdmin(player)) {
player.sendMessage(Component.text("no permission", NamedTextColor.RED));
return;
}
if (args.length < 3) {
player.sendMessage(Component.text("usage: /oyes tpshop <ownerName> <shopName>", NamedTextColor.RED));
return;
}
String ownerName = args[1];
String shopName = args[2];
Shop targetShop = null;
for (Shop shop : plugin.getShopRegistry().getAllShops()) {
org.bukkit.OfflinePlayer owner = org.bukkit.Bukkit.getOfflinePlayer(shop.getOwner());
if (ownerName.equalsIgnoreCase(owner.getName()) && shopName.equalsIgnoreCase(shop.getName())) {
targetShop = shop;
break;
}
}
if (targetShop == null) {
player.sendMessage(Component.text("shop not found for that owner and name", NamedTextColor.RED));
return;
}
player.teleport(targetShop.getSignLocation().clone().add(0.5, 0, 0.5));
player.sendMessage(
Component.text("teleporting to shop '" + shopName + "' by " + ownerName, NamedTextColor.GREEN));
}
}

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,32 @@ 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);
}
public boolean isRequirePlacement() {
return config.getBoolean("require-placement", true);
}
public void setRequirePlacement(boolean require) {
config.set("require-placement", require);
save();
}
}

View File

@@ -69,10 +69,58 @@ public class DatabaseManager {
player_uuid text primary key,
shops_enabled boolean not null default false,
notify_low_stock boolean not null default false,
seen_intro boolean not null default false,
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 name text");
} catch (SQLException ignored) {
}
try {
stmt.execute("alter table shops add column contributors text");
} catch (SQLException ignored) {
}
// migration: set name to shop_id for existing shops where name is null
stmt.execute("update shops set name = cast(shop_id as text) where name is null");
// indexes
stmt.execute("""
create index if not exists idx_shop_location

View File

@@ -15,9 +15,9 @@ import java.util.UUID;
public class PlayerPreferenceRepository {
private final DatabaseManager dbManager;
// in-memory cache for performance
private final Set<UUID> enabledPlayers = new HashSet<>();
private final Set<UUID> notifyPlayers = new HashSet<>();
private final Set<UUID> seenIntroPlayers = new HashSet<>();
public PlayerPreferenceRepository(DatabaseManager dbManager) {
this.dbManager = dbManager;
@@ -27,7 +27,7 @@ public class PlayerPreferenceRepository {
* load all player preferences into cache
*/
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);
ResultSet rs = stmt.executeQuery()) {
@@ -39,6 +39,9 @@ public class PlayerPreferenceRepository {
if (rs.getBoolean("notify_low_stock")) {
notifyPlayers.add(uuid);
}
if (rs.getBoolean("seen_intro")) {
seenIntroPlayers.add(uuid);
}
}
}
}
@@ -57,6 +60,31 @@ public class PlayerPreferenceRepository {
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
*/

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, name, contributors)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql,
@@ -48,6 +49,11 @@ 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.setString(16, shop.getName());
stmt.setString(17, serializeContributors(shop.getContributors()));
stmt.executeUpdate();
@@ -166,6 +172,25 @@ public class ShopRepository {
}
}
/**
* update shop configuration fields
*/
public void updateShopConfig(Shop shop) throws SQLException {
String sql = """
update shops set custom_title = ?, disc = ?, name = ?, contributors = ?
where shop_id = ?
""";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setString(1, shop.getCustomTitle());
stmt.setString(2, shop.getDisc());
stmt.setString(3, shop.getName());
stmt.setString(4, serializeContributors(shop.getContributors()));
stmt.setInt(5, shop.getId());
stmt.executeUpdate();
}
}
/**
* delete shop
*/
@@ -178,6 +203,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
*/
@@ -215,16 +255,47 @@ public class ShopRepository {
return shops;
}
/**
* serialize list of UUIDs to comma-separated string
*/
private String serializeContributors(List<UUID> contributors) {
if (contributors == null || contributors.isEmpty())
return "";
List<String> uuids = new ArrayList<>();
for (UUID uuid : contributors) {
uuids.add(uuid.toString());
}
return String.join(",", uuids);
}
/**
* deserialize comma-separated string to list of UUIDs
*/
private List<UUID> deserializeContributors(String data) {
List<UUID> list = new ArrayList<>();
if (data == null || data.isEmpty())
return list;
for (String s : data.split(",")) {
try {
list.add(UUID.fromString(s));
} catch (IllegalArgumentException ignored) {
}
}
return list;
}
/**
* convert result set to shop object
*/
private Shop shopFromResultSet(ResultSet rs) throws SQLException {
int id = rs.getInt("shop_id");
String name = rs.getString("name");
UUID worldUuid = UUID.fromString(rs.getString("world_uuid"));
int x = rs.getInt("sign_x");
int y = rs.getInt("sign_y");
int z = rs.getInt("sign_z");
UUID ownerUuid = UUID.fromString(rs.getString("owner_uuid"));
List<UUID> contributors = deserializeContributors(rs.getString("contributors"));
Material priceItem = Material.valueOf(rs.getString("price_item"));
int priceQty = rs.getInt("price_quantity");
Material productItem = Material.valueOf(rs.getString("product_item"));
@@ -232,6 +303,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) {
@@ -241,6 +315,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, name, location, ownerUuid, contributors, trade, owedAmount, enabled, createdAt,
customTitle, cosmeticSign, disc);
}
}

View File

@@ -0,0 +1,205 @@
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.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* 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("config: " + shop.getName(), NamedTextColor.GOLD))
.inputs(List.of(
DialogInput.text("name",
Component.text("rename shop",
NamedTextColor.YELLOW))
.initial(shop.getName())
.build(),
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(),
DialogInput.text("contributors",
Component.text("contributors (comma separated names)",
NamedTextColor.LIGHT_PURPLE))
.initial(getContributorNames(
shop.getContributors()))
.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 newName = view.getText("name");
String title = view.getText("custom_title");
String disc = view.getText("disc");
String contributorsStr = view
.getText("contributors");
if (newName == null || newName.isEmpty()) {
p.sendMessage(Component.text(
"shop name cannot be empty",
NamedTextColor.RED));
open(p, shop, plugin);
return;
}
if (newName.equalsIgnoreCase(
"myEpicBambooShop")) {
p.sendMessage(Component.text(
"hey! that's cybsec's shop! choose a different name",
NamedTextColor.RED));
open(p, shop, plugin);
return;
}
// validate name uniqueness if changed (player
// scoped)
if (!newName.equalsIgnoreCase(shop.getName())) {
if (plugin.getShopRegistry()
.getShopByOwnerAndName(
shop.getOwner(),
newName) != null) {
p.sendMessage(Component.text(
"you already have a shop with that name!",
NamedTextColor.RED));
open(p, shop, plugin);
return;
}
}
// 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);
return;
}
shop.setName(newName);
shop.setCustomTitle(
title.isEmpty() ? null : title);
shop.setDisc(disc.isEmpty()
|| disc.equals("none") ? null
: disc);
// resolve contributors
List<UUID> contributorUuids = resolveContributors(
contributorsStr);
shop.getContributors().clear();
shop.getContributors().addAll(contributorUuids);
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);
}
private static String getContributorNames(List<UUID> uuids) {
if (uuids == null || uuids.isEmpty())
return "";
List<String> names = new ArrayList<>();
for (UUID uuid : uuids) {
org.bukkit.OfflinePlayer op = org.bukkit.Bukkit.getOfflinePlayer(uuid);
if (op.getName() != null) {
names.add(op.getName());
} else {
names.add(uuid.toString());
}
}
return String.join(", ", names);
}
private static List<UUID> resolveContributors(String data) {
List<UUID> list = new ArrayList<>();
if (data == null || data.isEmpty())
return list;
for (String part : data.split(",")) {
String name = part.trim();
if (name.isEmpty())
continue;
// try to resolve by name
org.bukkit.OfflinePlayer op = org.bukkit.Bukkit.getOfflinePlayer(name);
list.add(op.getUniqueId());
}
return list;
}
}

View File

@@ -1,116 +0,0 @@
package party.cybsec.oyeshops.gui;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import party.cybsec.oyeshops.model.Shop;
import party.cybsec.oyeshops.model.Trade;
import java.util.ArrayList;
import java.util.List;
/**
* confirmation GUI for shop purchases
* supports batch purchases with units
*/
public class ConfirmationGui implements InventoryHolder {
private final Shop shop;
private final int units;
private final Inventory inventory;
public ConfirmationGui(Shop shop, int units) {
this.shop = shop;
this.units = Math.max(1, units);
this.inventory = createInventory();
}
private Inventory createInventory() {
Trade trade = shop.getTrade();
int totalPrice = trade.priceQuantity() * units;
int totalProduct = trade.productQuantity() * units;
String title = units > 1
? "confirm: " + units + " units"
: "confirm purchase";
Inventory inv = Bukkit.createInventory(this, 27, Component.text(title));
// fill with gray glass
ItemStack filler = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
ItemMeta fillerMeta = filler.getItemMeta();
fillerMeta.displayName(Component.text(" "));
filler.setItemMeta(fillerMeta);
for (int i = 0; i < 27; i++) {
inv.setItem(i, filler.clone());
}
// price item display (left side - slot 3)
ItemStack priceDisplay = new ItemStack(trade.priceItem(), Math.min(totalPrice, 64));
ItemMeta priceMeta = priceDisplay.getItemMeta();
priceMeta.displayName(Component.text("you pay:", NamedTextColor.RED));
List<Component> priceLore = new ArrayList<>();
priceLore.add(Component.text(totalPrice + " " + formatMaterial(trade.priceItem()), NamedTextColor.WHITE));
if (units > 1) {
priceLore.add(Component.text("(" + trade.priceQuantity() + " × " + units + " units)", NamedTextColor.GRAY));
}
priceMeta.lore(priceLore);
priceDisplay.setItemMeta(priceMeta);
inv.setItem(3, priceDisplay);
// product item display (right side - slot 5)
ItemStack productDisplay = new ItemStack(trade.productItem(), Math.min(totalProduct, 64));
ItemMeta productMeta = productDisplay.getItemMeta();
productMeta.displayName(Component.text("you get:", NamedTextColor.GREEN));
List<Component> productLore = new ArrayList<>();
productLore.add(Component.text(totalProduct + " " + formatMaterial(trade.productItem()), NamedTextColor.WHITE));
if (units > 1) {
productLore.add(
Component.text("(" + trade.productQuantity() + " × " + units + " units)", NamedTextColor.GRAY));
}
productMeta.lore(productLore);
productDisplay.setItemMeta(productMeta);
inv.setItem(5, productDisplay);
// confirm button (green - bottom left)
ItemStack confirm = new ItemStack(Material.LIME_STAINED_GLASS_PANE);
ItemMeta confirmMeta = confirm.getItemMeta();
confirmMeta.displayName(Component.text("CONFIRM", NamedTextColor.GREEN));
confirmMeta.lore(List.of(Component.text("click to complete purchase", NamedTextColor.GRAY)));
confirm.setItemMeta(confirmMeta);
inv.setItem(11, confirm);
inv.setItem(12, confirm.clone());
// cancel button (red - bottom right)
ItemStack cancel = new ItemStack(Material.RED_STAINED_GLASS_PANE);
ItemMeta cancelMeta = cancel.getItemMeta();
cancelMeta.displayName(Component.text("CANCEL", NamedTextColor.RED));
cancelMeta.lore(List.of(Component.text("click to cancel", NamedTextColor.GRAY)));
cancel.setItemMeta(cancelMeta);
inv.setItem(14, cancel);
inv.setItem(15, cancel.clone());
return inv;
}
private String formatMaterial(Material material) {
return material.name().toLowerCase().replace("_", " ");
}
public Shop getShop() {
return shop;
}
public int getUnits() {
return units;
}
@Override
public Inventory getInventory() {
return inventory;
}
}

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,183 @@
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, String shopName) {
Dialog dialog = Dialog.create(builder -> builder.empty()
.base(DialogBase.builder(Component.text("shop setup wizard", NamedTextColor.GOLD))
.inputs(List.of(
// shop name
DialogInput.text("shop_name",
Component.text("shop name (unique)",
NamedTextColor.YELLOW))
.initial(shopName)
.build(),
// 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? (prop: diamond)",
NamedTextColor.GREEN))
.build(),
DialogInput.text("price_qty",
Component.text("how many? (e.g. 10)",
NamedTextColor.GREEN))
.initial("1")
.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 newShopName = view.getText("shop_name");
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;
// shop name validation
if (newShopName == null
|| newShopName.isEmpty()) {
p.sendMessage(Component.text(
"shop name cannot be empty",
NamedTextColor.RED));
open(p, signBlock, plugin, shopName);
return;
}
if (newShopName.equalsIgnoreCase(
"myEpicBambooShop")) {
p.sendMessage(Component.text(
"hey! that's cybsec's shop! choose a different name",
NamedTextColor.RED));
open(p, signBlock, plugin, newShopName);
return;
}
if (plugin.getShopRegistry()
.getShopByOwnerAndName(
p.getUniqueId(),
newShopName) != null) {
p.sendMessage(Component.text(
"you already have a shop with that name!",
NamedTextColor.RED));
open(p, signBlock, plugin, newShopName);
return;
}
// 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, newShopName);
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, newShopName);
return;
}
// 3. parse product qty
int productQty;
try {
productQty = Integer.parseInt(
productQtyStr);
} catch (NumberFormatException e) {
p.sendMessage(Component.text(
"invalid product quantity",
NamedTextColor.RED));
open(p, signBlock, plugin, newShopName);
return;
}
Material productMat = plugin.getSignParser()
.parseMaterial(productStr);
if (productMat == null) {
p.sendMessage(Component.text(
"invalid product: "
+ productStr,
NamedTextColor.RED));
open(p, signBlock, plugin, newShopName);
return;
}
Trade trade = new Trade(priceMat, priceQty,
productMat, productQty);
PendingActivation activation = new PendingActivation(
p.getUniqueId(),
signBlock.getLocation(),
trade,
System.currentTimeMillis(),
true); // cosmeticSign always
// true
// 4. finalize
plugin.getServer().getScheduler()
.runTask(plugin, () -> {
plugin.getShopActivationListener()
.finalizeShop(p, activation,
newShopName);
});
}, 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 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,12 +17,13 @@ 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;
import party.cybsec.oyeshops.OyeShopsPlugin;
import party.cybsec.oyeshops.gui.ConfirmationGui;
import party.cybsec.oyeshops.model.Shop;
import party.cybsec.oyeshops.model.Trade;
import party.cybsec.oyeshops.permission.PermissionManager;
@@ -36,8 +39,11 @@ import java.util.Map;
public class ChestInteractionListener implements Listener {
private final OyeShopsPlugin plugin;
// track players viewing fake shop inventories
private final Map<Player, Shop> viewingShop = new HashMap<>();
// track active shop sessions for players
private final Map<Player, ShopSession> activeSessions = new HashMap<>();
public record ShopSession(Shop shop, int unitsTraded, Inventory realInventory) {
}
public ChestInteractionListener(OyeShopsPlugin plugin) {
this.plugin = plugin;
@@ -59,7 +65,7 @@ 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
Shop shop = findShopForContainer(block);
if (shop == null) {
return; // not a shop container
@@ -74,16 +80,18 @@ public class ChestInteractionListener implements Listener {
return;
}
// check if player is owner (unless spoofing)
// check if player is owner or contributor (unless spoofing)
boolean isOwner = shop.getOwner().equals(player.getUniqueId())
&& !plugin.getSpoofManager().isSpoofing(player);
boolean isContributor = shop.getContributors().contains(player.getUniqueId())
&& !plugin.getSpoofManager().isSpoofing(player);
if (isOwner) {
// owner interaction - check for owed items and dispense
if (isOwner || isContributor) {
// owner/contributor interaction - check for owed items and dispense
if (shop.getOwedAmount() > 0) {
withdrawOwedItems(player, shop);
}
// let owner open the chest normally - don't cancel event
// let them open the chest normally
return;
}
@@ -138,7 +146,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,17 +162,19 @@ 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()));
// respect custom title if set
if (shop.getCustomTitle() != null && !shop.getCustomTitle().isEmpty()) {
title = Component.text(shop.getCustomTitle());
}
// get real container inventory
Inventory realInventory = getContainerInventory(containerBlock);
@@ -172,18 +182,79 @@ public class ChestInteractionListener implements Listener {
return;
}
// copy only product items to fake inventory (first 27 found)
int shopSlot = 0;
int invSize = Math.min(54, Math.max(27, realInventory.getSize()));
// create fake inventory showing only product items
Inventory shopInventory = Bukkit.createInventory(
new ShopInventoryHolder(shop),
invSize,
title);
// populate with units (productQuantity per slot)
int totalStock = 0;
for (ItemStack item : realInventory.getContents()) {
if (shopSlot >= 27)
break;
if (item != null && item.getType() == trade.productItem()) {
shopInventory.setItem(shopSlot++, item.clone());
if (item != null && trade.matchesProduct(item.getType())) {
totalStock += item.getAmount();
}
}
viewingShop.put(player, shop);
int unitsAvailable = totalStock / trade.productQuantity();
int slotsToFill = Math.min(invSize, unitsAvailable);
// get representative item with NBT for display
ItemStack displayItem = plugin.getTransactionManager().getRepresentativeItem(realInventory, trade.productItem(),
trade.productQuantity());
for (int i = 0; i < slotsToFill; i++) {
shopInventory.setItem(i, displayItem);
}
activeSessions.put(player, new ShopSession(shop, 0, realInventory));
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
@@ -192,84 +263,189 @@ public class ChestInteractionListener implements Listener {
return;
}
InventoryHolder holder = event.getInventory().getHolder();
// handle shop inventory clicks
if (holder instanceof ShopInventoryHolder shopHolder) {
event.setCancelled(true);
Shop shop = shopHolder.shop();
// 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()) {
// 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();
if (units < 1)
units = 1;
}
// open confirmation GUI
player.closeInventory();
openConfirmationGui(player, shop, units);
}
}
ShopSession session = activeSessions.get(player);
if (session == null) {
return;
}
// handle confirmation GUI clicks
if (holder instanceof ConfirmationGui confirmGui) {
event.setCancelled(true);
Shop shop = confirmGui.getShop();
InventoryHolder holder = event.getInventory().getHolder();
if (!(holder instanceof ShopInventoryHolder)) {
return;
}
int slot = event.getRawSlot();
event.setCancelled(true);
int slot = event.getRawSlot();
if (slot >= event.getInventory().getSize()) {
return; // Clicked player inventory
}
// green pane = confirm (slot 11-15 or specifically slot 11)
if (slot == 11 || slot == 12 || slot == 13) {
player.closeInventory();
executeTransaction(player, shop, confirmGui.getUnits());
ItemStack clicked = event.getCurrentItem();
if (clicked == null || clicked.getType() == Material.AIR) {
return;
}
Shop shop = session.shop();
Trade trade = shop.getTrade();
TransactionManager tm = plugin.getTransactionManager();
// Case 1: Clicked a Product (to BUY)
if (trade.matchesProduct(clicked.getType())) {
// 1. Check if player has payment
if (!tm.hasItems(player.getInventory(), trade.priceItem(), trade.priceQuantity())) {
player.sendMessage(Component.text("you don't have enough to pay!", NamedTextColor.RED));
player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1.0f, 1.0f);
return;
}
// red pane = cancel (slot 15 or right side)
else if (slot == 14 || slot == 15 || slot == 16) {
player.closeInventory();
player.sendMessage(Component.text("purchase cancelled", NamedTextColor.YELLOW));
// 2. Check if player has space for product
if (!tm.hasSpace(player.getInventory(), trade.productItem(), trade.productQuantity())) {
player.sendMessage(Component.text("your inventory is full!", NamedTextColor.RED));
player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1.0f, 1.0f);
return;
}
// 3. Remove product from REAL chest (NBT PRESERVED)
if (tm.hasItems(session.realInventory(), trade.productItem(), trade.productQuantity())) {
ItemStack[] products = tm.removeItemsAndReturn(session.realInventory(), trade.productItem(),
trade.productQuantity());
// 4. Give product to Player
player.getInventory().addItem(products);
// 5. Remove payment from Player (NBT preserved if needed)
tm.removeItems(player.getInventory(), trade.priceItem(), trade.priceQuantity());
// Success!
player.playSound(player.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f, 1.0f);
// Show "Payment" in GUI slot to allow reversal
ItemStack paymentPlaceholder = new ItemStack(trade.priceItem(), trade.priceQuantity());
event.getInventory().setItem(slot, paymentPlaceholder);
activeSessions.put(player, new ShopSession(shop, session.unitsTraded() + 1, session.realInventory()));
} else {
player.sendMessage(Component.text("shop is out of stock!", NamedTextColor.RED));
player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1.0f, 1.0f);
}
}
// Case 2: Clicked a Payment (to REFUND)
else if (clicked.getType() == trade.priceItem()) {
// 1. Check if player has product to return
if (!tm.hasItems(player.getInventory(), trade.productItem(), trade.productQuantity())) {
player.sendMessage(Component.text("you don't have the product to return!", NamedTextColor.RED));
player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1.0f, 1.0f);
return;
}
// 2. Check if chest has space for product
if (!tm.hasSpace(session.realInventory(), trade.productItem(), trade.productQuantity())) {
player.sendMessage(Component.text("the shop chest is full! cannot return!", NamedTextColor.RED));
player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1.0f, 1.0f);
return;
}
// 3. Return product from Player -> Chest (NBT PRESERVED)
ItemStack[] products = tm.removeItemsAndReturn(player.getInventory(), trade.productItem(),
trade.productQuantity());
session.realInventory().addItem(products);
// 4. Return payment to Player
tm.removeItems(session.realInventory(), trade.priceItem(), trade.priceQuantity()); // Not used currently but
// for completeness
player.getInventory().addItem(tm.createItemStacks(trade.priceItem(), trade.priceQuantity()));
// Success refund!
player.playSound(player.getLocation(), Sound.BLOCK_CHEST_CLOSE, 1.0f, 1.2f);
// Restore Product to GUI slot (with NBT)
ItemStack productPreview = tm.getRepresentativeItem(session.realInventory(), trade.productItem(),
trade.productQuantity());
event.getInventory().setItem(slot, productPreview);
activeSessions.put(player, new ShopSession(shop, session.unitsTraded() - 1, session.realInventory()));
}
}
@EventHandler
public void onInventoryClose(InventoryCloseEvent event) {
if (!(event.getPlayer() instanceof Player player)) {
return;
}
ShopSession session = activeSessions.remove(player);
if (session == null) {
return;
}
Shop shop = session.shop();
// Finalize transaction in database if any units were traded
if (session.unitsTraded() > 0) {
try {
int totalPaid = session.unitsTraded() * shop.getTrade().priceQuantity();
plugin.getShopRepository().updateOwedAmount(shop.getId(), shop.getOwedAmount() + totalPaid);
shop.setOwedAmount(shop.getOwedAmount() + totalPaid);
plugin.getTransactionRepository().recordTransaction(
shop.getId(),
player.getUniqueId(),
session.unitsTraded());
player.sendMessage(Component.text("transaction finalized!", NamedTextColor.GREEN));
} catch (SQLException e) {
e.printStackTrace();
player.sendMessage(Component.text("error saving transaction to database", NamedTextColor.RED));
}
}
// stop disc
String discName = shop.getDisc();
if (discName != null && !discName.isEmpty()) {
stopDisc(player, discName);
}
}
/**
* open confirmation GUI
*/
private void openConfirmationGui(Player player, Shop shop, int units) {
ConfirmationGui gui = new ConfirmationGui(shop, units);
player.openInventory(gui.getInventory());
}
/**
* execute the transaction
*/
private void executeTransaction(Player player, Shop shop, int units) {
TransactionManager.Result result = plugin.getTransactionManager().execute(player, shop, units);
switch (result) {
case SUCCESS -> player.sendMessage(Component.text("purchase complete!", NamedTextColor.GREEN));
case INSUFFICIENT_FUNDS ->
player.sendMessage(Component.text("you don't have enough items to pay", NamedTextColor.RED));
case INSUFFICIENT_STOCK -> player.sendMessage(Component.text("shop is out of stock", NamedTextColor.RED));
case INVENTORY_FULL -> player.sendMessage(Component.text("your inventory is full", NamedTextColor.RED));
case DATABASE_ERROR ->
player.sendMessage(Component.text("transaction failed - database error", NamedTextColor.RED));
case SHOP_DISABLED -> player.sendMessage(Component.text("this shop is disabled", NamedTextColor.RED));
}
}
/**
* 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 +463,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

@@ -39,28 +39,21 @@ public class LoginListener implements Listener {
return;
}
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
// find all shops owned by player
List<Shop> lowStockShops = new ArrayList<>();
for (Shop shop : plugin.getShopRegistry().getAllShops()) {
if (shop.getOwner().equals(player.getUniqueId())) {
if (isLowStock(shop)) {
lowStockShops.add(shop);
}
// find all shops owned by player and check stock on main thread
List<Shop> lowStockShops = new ArrayList<>();
for (Shop shop : plugin.getShopRegistry().getAllShops()) {
if (shop.getOwner().equals(player.getUniqueId())) {
if (isLowStock(shop)) {
lowStockShops.add(shop);
}
}
}
if (!lowStockShops.isEmpty()) {
final int count = lowStockShops.size();
plugin.getServer().getScheduler().runTask(plugin, () -> {
if (player.isOnline()) {
player.sendMessage(
Component.text("notification: " + count + " of your shops are low on stock.",
NamedTextColor.YELLOW));
}
});
}
});
if (!lowStockShops.isEmpty()) {
player.sendMessage(
Component.text("notification: " + lowStockShops.size() + " of your shops are low on stock.",
NamedTextColor.YELLOW));
}
}
private boolean isLowStock(Shop shop) {

View File

@@ -1,5 +1,7 @@
package party.cybsec.oyeshops.listener;
import party.cybsec.oyeshops.gui.SetupDialog;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
@@ -90,9 +92,9 @@ public class ShopActivationListener implements Listener {
}
// 2. session-based ownership check
if (!PermissionManager.isAdmin(player)) {
if (plugin.getConfigManager().isRequirePlacement() && !PermissionManager.isAdmin(player)) {
UUID sessionOwner = plugin.getContainerMemoryManager().getOwner(attachedBlock.getLocation());
if (sessionOwner == null) {
if (sessionOwner == null || !sessionOwner.equals(player.getUniqueId())) {
// block was placed before the server started or by no one
player.sendMessage(Component.text("you can only create shops on containers you placed this session",
NamedTextColor.RED));
@@ -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, "shop-" + (plugin.getShopRegistry().getAllShops().size() + 1));
return;
}
// parse trade
Trade trade = parser.parse(lines);
if (trade == null) {
@@ -132,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);
@@ -141,6 +158,16 @@ public class ShopActivationListener implements Listener {
private void sendConfirmationMessage(Player player, Trade trade) {
String priceText = trade.priceQuantity() + " " + formatMaterial(trade.priceItem());
String productText = trade.productQuantity() + " " + formatMaterial(trade.productItem());
String shopName = "shop-" + (plugin.getShopRegistry().getAllShops().size() + 1);
// check uniqueness for default name (player scoped)
if (plugin.getShopRegistry().getShopByOwnerAndName(player.getUniqueId(), shopName) != null) {
int i = 2;
while (plugin.getShopRegistry().getShopByOwnerAndName(player.getUniqueId(), shopName + "-" + i) != null) {
i++;
}
shopName = shopName + "-" + i;
}
// clear display: buyer pays vs buyer gets
player.sendMessage(Component.text("shop detected!", NamedTextColor.GOLD));
@@ -153,17 +180,17 @@ public class ShopActivationListener implements Listener {
.append(Component.text("[accept]", NamedTextColor.GREEN, TextDecoration.BOLD)
.hoverEvent(HoverEvent.showText(
Component.text("create shop exactly as shown above", NamedTextColor.WHITE)))
.clickEvent(ClickEvent.runCommand("/oyes _activate accept")))
.clickEvent(ClickEvent.runCommand("/oyes _activate accept " + shopName)))
.append(Component.text(" "))
.append(Component.text("[invert]", NamedTextColor.GOLD, TextDecoration.BOLD)
.hoverEvent(HoverEvent.showText(Component.text(
"swap: buyer pays " + productText + " and gets " + priceText, NamedTextColor.WHITE)))
.clickEvent(ClickEvent.runCommand("/oyes _activate invert")))
.clickEvent(ClickEvent.runCommand("/oyes _activate invert " + shopName)))
.append(Component.text(" "))
.append(Component.text("[cancel]", NamedTextColor.RED, TextDecoration.BOLD)
.hoverEvent(HoverEvent.showText(
Component.text("ignore sign. no shop created.", NamedTextColor.WHITE)))
.clickEvent(ClickEvent.runCommand("/oyes _activate cancel")));
.clickEvent(ClickEvent.runCommand("/oyes _activate cancel " + shopName)));
player.sendMessage(buttons);
}
@@ -172,33 +199,46 @@ public class ShopActivationListener implements Listener {
* complete the shop creation process
* called from AdminCommands when user clicks [accept] or [invert]
*/
public void finalizeShop(Player player, PendingActivation activation) {
public void finalizeShop(Player player, PendingActivation activation, String shopName) {
Location signLocation = activation.location();
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);
// default cosmeticSign to true as requested
Shop shop = new Shop(-1, shopName, signLocation, player.getUniqueId(), new ArrayList<>(), trade, 0, true,
createdAt, null, true, null);
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
try {
// cleanup potential existing shop to avoid unique constraint error
plugin.getShopRepository().deleteShopByLocation(signLocation);
int shopId = plugin.getShopRepository().createShop(shop);
plugin.getLogger().info("DEBUG: created shop id " + shopId + " (" + shopName + ") at " + signLocation);
plugin.getServer().getScheduler().runTask(plugin, () -> {
// re-verify sign on main thread
if (!(signLocation.getBlock().getState() instanceof Sign finalSign))
if (!(signLocation.getBlock().getState() instanceof Sign)) {
plugin.getLogger().info("DEBUG: sign missing at " + signLocation);
return;
}
Shop registeredShop = new Shop(shopId, signLocation, player.getUniqueId(), trade, 0, true,
createdAt);
Shop registeredShop = new Shop(shopId, shopName, signLocation, player.getUniqueId(),
new ArrayList<>(), trade, 0, true, createdAt, null, true, null);
plugin.getShopRegistry().register(registeredShop);
rewriteSignLines(finalSign, trade);
player.sendMessage(Component.text("shop #" + shopId + " initialized!", NamedTextColor.GREEN));
plugin.getLogger().info("DEBUG: registered shop " + shopId + " in registry");
// rewriteSignLines(finalSign, registeredShop); // REMOVED: Sign should just be
// left alone
player.sendMessage(Component.text("shop '" + shopName + "' (#" + shopId + ") initialized!",
NamedTextColor.GREEN));
});
} catch (SQLException e) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
@@ -209,15 +249,8 @@ public class ShopActivationListener implements Listener {
});
}
private void rewriteSignLines(Sign sign, Trade trade) {
String pricePart = trade.priceQuantity() + " " + abbreviateMaterial(trade.priceItem());
String productPart = trade.productQuantity() + " " + abbreviateMaterial(trade.productItem());
sign.line(0, Component.text(pricePart));
sign.line(1, Component.text("for."));
sign.line(2, Component.text(productPart));
sign.line(3, Component.text(""));
sign.update();
public void rewriteSignLines(Sign sign, Shop shop) {
// REMOVED: Sign should just be left alone as requested
}
/**
@@ -322,15 +355,6 @@ public class ShopActivationListener implements Listener {
return bestQuantity;
}
private String abbreviateMaterial(Material material) {
String name = material.name().toLowerCase().replace("_", " ");
name = name.replace("diamond", "dia").replace("emerald", "em").replace("netherite", "neth")
.replace("ingot", "").replace("block", "blk").replace("pickaxe", "pick")
.replace("chestplate", "chest").replace("leggings", "legs");
name = name.trim();
return name.length() > 14 ? name.substring(0, 14) : name;
}
private Inventory getContainerInventory(Block block) {
if (block.getState() instanceof Chest chest)
return chest.getInventory();
@@ -339,7 +363,7 @@ public class ShopActivationListener implements Listener {
return null;
}
private boolean isContainer(Material material) {
public static boolean isContainer(Material material) {
return material == Material.CHEST || material == Material.TRAPPED_CHEST || material == Material.BARREL;
}

View File

@@ -1,11 +1,16 @@
package party.cybsec.oyeshops.listener;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.Chest;
import org.bukkit.block.DoubleChest;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.inventory.DoubleChestInventory;
import org.bukkit.inventory.Inventory;
import party.cybsec.oyeshops.OyeShopsPlugin;
import party.cybsec.oyeshops.model.Shop;
import party.cybsec.oyeshops.permission.PermissionManager;
@@ -49,7 +54,7 @@ public class ShopProtectionListener implements Listener {
return;
}
// find shop with this chest location
// find shop with this chest location (handles double chests)
Shop chestShop = findShopByChest(block);
if (chestShop != null) {
if (canBreakShop(player, chestShop)) {
@@ -88,17 +93,54 @@ public class ShopProtectionListener implements Listener {
}
/**
* find shop by chest location
* find shop by chest location (handles double chests)
*/
private Shop findShopByChest(Block chestBlock) {
// first check this block directly
Shop shop = findShopOnBlock(chestBlock);
if (shop != null) {
return shop;
}
// if this is a chest, check the other half of a double chest
if (chestBlock.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 attached to a specific block
*/
private Shop findShopOnBlock(Block containerBlock) {
// check all adjacent blocks for wall signs
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
for (BlockFace face : new BlockFace[] {
BlockFace.NORTH,
BlockFace.SOUTH,
BlockFace.EAST,
BlockFace.WEST
}) {
Block adjacent = chestBlock.getRelative(face);
Block adjacent = containerBlock.getRelative(face);
Shop shop = plugin.getShopRegistry().getShop(adjacent.getLocation());
if (shop != null) {
return shop;

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

@@ -2,6 +2,7 @@ package party.cybsec.oyeshops.model;
import org.bukkit.Location;
import java.util.List;
import java.util.UUID;
/**
@@ -9,22 +10,44 @@ import java.util.UUID;
*/
public class Shop {
private final int id;
private String name;
private final Location signLocation;
private final UUID owner;
private final List<UUID> contributors;
private final Trade trade;
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) {
public Shop(int id, String name, Location signLocation, UUID owner, List<UUID> contributors, Trade trade,
int owedAmount, boolean enabled, long createdAt, String customTitle, boolean cosmeticSign, String disc) {
this.id = id;
this.name = name;
this.signLocation = signLocation;
this.owner = owner;
this.contributors = contributors;
this.trade = trade;
this.owedAmount = owedAmount;
this.enabled = enabled;
this.createdAt = createdAt;
this.customTitle = customTitle;
this.cosmeticSign = cosmeticSign;
this.disc = disc;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public List<UUID> getContributors() {
return contributors;
}
public int getId() {
@@ -63,12 +86,34 @@ 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
*/
public Location getChestLocation() {
// chest is attached to the sign
// we'll determine this from the sign's attached block face
return signLocation.clone(); // placeholder - will be properly implemented
return signLocation.clone();
}
}

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

@@ -52,17 +52,12 @@ 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
String fullText = String.join(" ", lines);
// REQUIREMENT: must contain "." to be parsed as a shop
if (!fullText.contains(".")) {
return null;
}
// normalize text
String normalized = normalize(fullText);
@@ -79,6 +74,7 @@ public class SignParser {
return null;
}
// check for auto detection
boolean isAuto = productSection.toLowerCase().contains(AUTO_KEYWORD);
ItemQuantity product;
if (isAuto) {
@@ -259,4 +255,8 @@ public class SignParser {
private record ItemQuantity(Material material, int quantity) {
}
public Material parseMaterial(String name) {
return aliasRegistry.resolve(name);
}
}

View File

@@ -75,6 +75,34 @@ public class ShopRegistry {
return shopsById.get(shopId);
}
/**
* get shop by name (first match)
*/
public Shop getShopByName(String name) {
if (name == null)
return null;
for (Shop shop : shopsById.values()) {
if (name.equalsIgnoreCase(shop.getName())) {
return shop;
}
}
return null;
}
/**
* get shop by owner and name
*/
public Shop getShopByOwnerAndName(java.util.UUID owner, String name) {
if (name == null || owner == null)
return null;
for (Shop shop : shopsById.values()) {
if (owner.equals(shop.getOwner()) && name.equalsIgnoreCase(shop.getName())) {
return shop;
}
}
return null;
}
/**
* get all shops
*/
@@ -97,6 +125,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

@@ -33,7 +33,41 @@ public class TransactionManager {
INSUFFICIENT_STOCK,
INVENTORY_FULL,
DATABASE_ERROR,
SHOP_DISABLED
SHOP_DISABLED,
INVALID_AMOUNT
}
/**
* check if inventory has enough space for a material and amount
*/
public boolean hasSpace(Inventory inventory, Material material, int amount) {
int space = 0;
int maxStack = material.getMaxStackSize();
for (ItemStack item : inventory.getStorageContents()) {
if (item == null || item.getType() == Material.AIR) {
space += maxStack;
} else if (item.getType() == material) {
space += (maxStack - item.getAmount());
}
if (space >= amount)
return true;
}
return space >= amount;
}
/**
* transfers items from one inventory to another atomically
* returns true if successful
*/
public boolean transferItems(Inventory from, Inventory to, Material material, int amount) {
if (!hasItems(from, material, amount) || !hasSpace(to, material, amount)) {
return false;
}
ItemStack[] items = removeItemsAndReturn(from, material, amount);
to.addItem(items);
return true;
}
/**
@@ -80,15 +114,15 @@ public class TransactionManager {
}
try {
// remove price items from buyer
removeItems(buyer.getInventory(), trade.priceItem(), totalPrice);
// remove price items from buyer (NBT preserved if needed, though usually
// currency)
removeItemsAndReturn(buyer.getInventory(), trade.priceItem(), totalPrice);
// remove product items from chest
removeItems(chestInventory, trade.productItem(), totalProduct);
// remove product items from chest (NBT CRITICAL HERE)
ItemStack[] productItems = removeItemsAndReturn(chestInventory, trade.productItem(), totalProduct);
// add product items to buyer
HashMap<Integer, ItemStack> overflow = buyer.getInventory().addItem(
createItemStacks(trade.productItem(), totalProduct));
HashMap<Integer, ItemStack> overflow = buyer.getInventory().addItem(productItems);
if (!overflow.isEmpty()) {
// rollback
@@ -97,22 +131,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,10 +143,28 @@ 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
*/
private boolean hasItems(Inventory inventory, Material material, int amount) {
public boolean hasItems(Inventory inventory, Material material, int amount) {
int count = 0;
for (ItemStack item : inventory.getContents()) {
if (item != null && item.getType() == material) {
@@ -141,15 +178,20 @@ public class TransactionManager {
}
/**
* remove items from inventory
* remove items from inventory and return them (preserving NBT)
*/
private void removeItems(Inventory inventory, Material material, int amount) {
public ItemStack[] removeItemsAndReturn(Inventory inventory, Material material, int amount) {
int remaining = amount;
java.util.List<ItemStack> removed = new java.util.ArrayList<>();
for (int i = 0; i < inventory.getSize() && remaining > 0; i++) {
ItemStack item = inventory.getItem(i);
if (item != null && item.getType() == material) {
int toRemove = Math.min(item.getAmount(), remaining);
ItemStack clone = item.clone();
clone.setAmount(toRemove);
removed.add(clone);
if (toRemove == item.getAmount()) {
inventory.setItem(i, null);
} else {
@@ -158,12 +200,32 @@ public class TransactionManager {
remaining -= toRemove;
}
}
return removed.toArray(new ItemStack[0]);
}
/**
* create item stacks for given total amount
* remove items from inventory
*/
private ItemStack[] createItemStacks(Material material, int totalAmount) {
public void removeItems(Inventory inventory, Material material, int amount) {
removeItemsAndReturn(inventory, material, amount);
}
/**
* get a sample item from inventory for display (preserving NBT)
*/
public ItemStack getRepresentativeItem(Inventory inventory, Material material, int amount) {
for (ItemStack item : inventory.getContents()) {
if (item != null && item.getType() == material) {
ItemStack preview = item.clone();
preview.setAmount(amount);
return preview;
}
}
// fallback to blank material if not found
return new ItemStack(material, amount);
}
public ItemStack[] createItemStacks(Material material, int totalAmount) {
int maxStack = material.getMaxStackSize();
int fullStacks = totalAmount / maxStack;
int remainder = totalAmount % maxStack;
@@ -185,7 +247,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,14 @@
# 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
# whether to require players to have placed the container themselves this session
require-placement: true
# 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

View File

@@ -1,5 +1,5 @@
name: oyeShops
version: 1.0.0
version: 1.3.1
main: party.cybsec.oyeshops.OyeShopsPlugin
api-version: '1.21'
description: deterministic item-for-item chest barter
@@ -9,7 +9,7 @@ website: https://party.cybsec
commands:
oyeshops:
description: oyeShops commands
usage: /oyeshops <on|off|toggle|reload|inspect|spoof|enable|disable|unregister>
usage: /oyeshops <on|off|toggle|reload|setup|config|tpshop|toggle-placement>
aliases: [oyes, oshop]
permissions: