v1.3.1: NBT preservation, GUI previews, and global placement toggle

This commit is contained in:
2026-02-08 10:59:02 -05:00
parent 84b4471fcb
commit 58b7d44d8f
31 changed files with 1204 additions and 443 deletions

View File

@@ -3,7 +3,7 @@ plugins {
}
group = "party.cybsec"
version = "1.3.0"
version = "1.3.1"
description = "deterministic item-for-item chest barter"
repositories {

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

@@ -48,13 +48,15 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
case "notify" -> handleNotifyToggle(sender);
case "info" -> handleInfo(sender);
case "help" -> handleHelp(sender);
case "setup" -> handleSetup(sender);
case "config" -> handleConfig(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);
case "toggleplacement", "toggle-placement" -> handleTogglePlacement(sender);
default -> handleHelp(sender);
}
@@ -93,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)));
}
}
@@ -222,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) {
@@ -235,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(
@@ -247,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) {
@@ -275,7 +284,7 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
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;
}
@@ -285,18 +294,25 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
return;
}
try {
int shopId = Integer.parseInt(args[1]);
Shop shop = plugin.getShopRegistry().getShopById(shopId);
String target = args[1];
Shop shop = plugin.getShopRegistry().getShopByName(target);
if (shop == null) {
sender.sendMessage(Component.text("shop not found: " + shopId, NamedTextColor.RED));
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();
@@ -315,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;
}
@@ -325,18 +341,25 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
return;
}
try {
int shopId = Integer.parseInt(args[1]);
Shop shop = plugin.getShopRegistry().getShopById(shopId);
String target = args[1];
Shop shop = plugin.getShopRegistry().getShopByName(target);
if (shop == null) {
sender.sendMessage(Component.text("shop not found: " + shopId, NamedTextColor.RED));
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();
@@ -350,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();
@@ -379,7 +409,8 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
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) {
@@ -388,20 +419,61 @@ 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) {
private void handleSetup(CommandSender sender, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("players only", NamedTextColor.RED));
return;
@@ -412,6 +484,25 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
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));
@@ -427,7 +518,7 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
return;
}
if (!PermissionManager.isAdmin(player)) {
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(
@@ -437,39 +528,110 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
}
}
SetupDialog.open(player, block, plugin);
SetupDialog.open(player, block, plugin, shopName);
}
private void handleConfig(CommandSender sender) {
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", NamedTextColor.RED));
player.sendMessage(
Component.text("you must look at a shop sign or its container, or use /oyes config <name>",
NamedTextColor.RED));
return;
}
Shop shop = null;
if (block.getBlockData() instanceof WallSign) {
shop = plugin.getShopRegistry().getShop(block.getLocation());
} else if (party.cybsec.oyeshops.listener.ShopActivationListener.isContainer(block.getType())) {
// try to find shop on any of the adjacent wall signs
shop = plugin.getShopRegistry().getShopByContainer(block);
}
}
if (shop == null) {
player.sendMessage(Component.text("that is not a part of any shop", NamedTextColor.RED));
return;
}
if (!shop.getOwner().equals(player.getUniqueId()) && !PermissionManager.isAdmin(player)) {
player.sendMessage(Component.text("you do not own this shop", NamedTextColor.RED));
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

@@ -37,4 +37,13 @@ public class ConfigManager {
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

@@ -109,10 +109,18 @@ public class DatabaseManager {
}
try {
stmt.execute("alter table shops add column disc text");
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

@@ -30,8 +30,8 @@ public class ShopRepository {
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, custom_title,
cosmetic_sign, disc)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
cosmetic_sign, disc, name, contributors)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql,
@@ -52,6 +52,8 @@ public class ShopRepository {
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();
@@ -175,14 +177,16 @@ public class ShopRepository {
*/
public void updateShopConfig(Shop shop) throws SQLException {
String sql = """
update shops set custom_title = ?, disc = ?
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.setInt(3, shop.getId());
stmt.setString(3, shop.getName());
stmt.setString(4, serializeContributors(shop.getContributors()));
stmt.setInt(5, shop.getId());
stmt.executeUpdate();
}
}
@@ -251,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"));
@@ -280,7 +315,7 @@ 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

@@ -15,8 +15,10 @@ 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
@@ -32,17 +34,34 @@ public class ConfigDialog {
*/
public static void open(Player player, Shop shop, OyeShopsPlugin plugin) {
Dialog dialog = Dialog.create(builder -> builder.empty()
.base(DialogBase.builder(Component.text("shop #" + shop.getId() + " config", NamedTextColor.GOLD))
.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() : "")
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")
.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(
@@ -50,46 +69,137 @@ public class ConfigDialog {
.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");
// 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",
if (newName == null || newName.isEmpty()) {
p.sendMessage(Component.text(
"shop name cannot be empty",
NamedTextColor.RED));
open(p, shop, plugin); // reopen dialog
open(p, shop, plugin);
return;
}
shop.setCustomTitle(title.isEmpty() ? null : title);
shop.setDisc(disc.isEmpty() || disc.equals("none") ? null : disc);
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;
}
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
// 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, () -> {
plugin.getShopRepository()
.updateShopConfig(
shop);
plugin.getServer()
.getScheduler()
.runTask(plugin, () -> {
p.sendMessage(
Component.text("shop config saved", NamedTextColor.GREEN));
Component.text("shop config saved",
NamedTextColor.GREEN));
});
} catch (SQLException e) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
p.sendMessage(Component.text("database error", NamedTextColor.RED));
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)))
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));
.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

@@ -24,10 +24,16 @@ import java.util.List;
*/
public class SetupDialog {
public static void open(Player player, Block signBlock, OyeShopsPlugin plugin) {
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)",
@@ -41,22 +47,13 @@ public class SetupDialog {
// price (buying)
DialogInput.text("price_item",
Component.text("what do you want? (e.g. diamond)",
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(),
// cosmetic sign toggle
DialogInput.bool("cosmetic_sign",
Component.text("cosmetic sign? (don't rewrite text)",
NamedTextColor.AQUA))
.initial(false)
.onTrue("enabled")
.onFalse("disabled")
.build()))
.build())
.type(DialogType.confirmation(
@@ -65,6 +62,7 @@ public class SetupDialog {
.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
@@ -73,6 +71,36 @@ public class SetupDialog {
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 {
@@ -82,7 +110,7 @@ public class SetupDialog {
p.sendMessage(Component.text(
"invalid price quantity",
NamedTextColor.RED));
open(p, signBlock, plugin);
open(p, signBlock, plugin, newShopName);
return;
}
@@ -94,11 +122,11 @@ public class SetupDialog {
"invalid payment item: "
+ priceStr,
NamedTextColor.RED));
open(p, signBlock, plugin);
open(p, signBlock, plugin, newShopName);
return;
}
// 3. Regular parsing logic
// 3. parse product qty
int productQty;
try {
productQty = Integer.parseInt(
@@ -107,38 +135,36 @@ public class SetupDialog {
p.sendMessage(Component.text(
"invalid product quantity",
NamedTextColor.RED));
open(p, signBlock, plugin);
open(p, signBlock, plugin, newShopName);
return;
}
Material productMat = plugin
.getSignParser()
Material productMat = plugin.getSignParser()
.parseMaterial(productStr);
if (productMat == null) {
p.sendMessage(Component.text(
"invalid product: "
+ productStr,
NamedTextColor.RED));
open(p, signBlock, plugin);
open(p, signBlock, plugin, newShopName);
return;
}
Trade trade = new Trade(priceMat, priceQty,
productMat, productQty);
boolean cosmeticSign = view
.getBoolean("cosmetic_sign");
PendingActivation activation = new PendingActivation(
p.getUniqueId(),
signBlock.getLocation(),
trade,
System.currentTimeMillis(),
cosmeticSign);
true); // cosmeticSign always
// true
// 4. finalize
plugin.getServer().getScheduler()
.runTask(plugin, () -> {
plugin.getShopActivationListener()
.finalizeShop(p, activation);
.finalizeShop(p, activation,
newShopName);
});
}, ClickCallback.Options.builder().uses(1).build()))
.build(),

View File

@@ -24,7 +24,6 @@ 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;
@@ -40,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;
@@ -63,8 +65,7 @@ public class ChestInteractionListener implements Listener {
return;
}
// check if there's a shop sign attached to this container or its double chest
// partner
// check if there's a shop sign attached to this container
Shop shop = findShopForContainer(block);
if (shop == null) {
return; // not a shop container
@@ -79,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;
}
@@ -173,18 +176,13 @@ public class ChestInteractionListener implements Listener {
title = Component.text(shop.getCustomTitle());
}
// get real container inventory (handles double chests correctly)
// get real container inventory
Inventory realInventory = getContainerInventory(containerBlock);
if (realInventory == null) {
return;
}
// determine inventory size based on container type
int invSize = realInventory.getSize();
if (invSize > 54)
invSize = 54; // cap at double chest
if (invSize < 27)
invSize = 27; // minimum single chest
int invSize = Math.min(54, Math.max(27, realInventory.getSize()));
// create fake inventory showing only product items
Inventory shopInventory = Bukkit.createInventory(
@@ -192,17 +190,26 @@ public class ChestInteractionListener implements Listener {
invSize,
title);
// copy matching items to fake inventory
int shopSlot = 0;
// populate with units (productQuantity per slot)
int totalStock = 0;
for (ItemStack item : realInventory.getContents()) {
if (shopSlot >= invSize)
break;
if (item != null && trade.matchesProduct(item.getType())) {
shopInventory.setItem(shopSlot++, item.clone());
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
@@ -256,97 +263,147 @@ 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();
Trade trade = shop.getTrade();
if (clicked != null && trade.matchesProduct(clicked.getType())) {
// determine quantity based on click type
int units = 1;
if (event.isShiftClick()) {
// shift-click: calculate max units based on items clicked
units = clicked.getAmount() / trade.productQuantity();
if (units < 1)
units = 1;
}
// open confirmation gui
player.closeInventory();
openConfirmationGui(player, shop, units);
}
} else {
// clicked in player inventory - play negative feedback sound
player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1.0f, 1.0f);
}
ShopSession session = activeSessions.get(player);
if (session == null) {
return;
}
InventoryHolder holder = event.getInventory().getHolder();
if (!(holder instanceof ShopInventoryHolder)) {
return;
}
// handle confirmation GUI clicks
if (holder instanceof ConfirmationGui confirmGui) {
event.setCancelled(true);
Shop shop = confirmGui.getShop();
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;
}
// 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));
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;
}
// 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) {
// stop disc when closing shop
Shop shop = viewingShop.remove(player);
if (shop != null) {
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 (handles double chests)

View File

@@ -39,8 +39,7 @@ public class LoginListener implements Listener {
return;
}
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
// find all shops owned by player
// 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())) {
@@ -51,16 +50,10 @@ public class LoginListener implements Listener {
}
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.",
Component.text("notification: " + lowStockShops.size() + " of your shops are low on stock.",
NamedTextColor.YELLOW));
}
});
}
});
}
private boolean isLowStock(Shop shop) {

View File

@@ -92,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));
@@ -127,7 +127,7 @@ public class ShopActivationListener implements Listener {
// clear sign text to avoid "setup" staying on the sign
event.line(0, Component.text(""));
SetupDialog.open(player, block, plugin);
SetupDialog.open(player, block, plugin, "shop-" + (plugin.getShopRegistry().getAllShops().size() + 1));
return;
}
@@ -158,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));
@@ -170,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);
}
@@ -189,7 +199,7 @@ 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();
@@ -201,8 +211,9 @@ public class ShopActivationListener implements Listener {
Trade trade = activation.trade();
long createdAt = System.currentTimeMillis();
Shop shop = new Shop(-1, signLocation, player.getUniqueId(), trade, 0, true, createdAt, null,
activation.cosmeticSign(), null);
// 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 {
@@ -210,22 +221,24 @@ public class ShopActivationListener implements Listener {
plugin.getShopRepository().deleteShopByLocation(signLocation);
int shopId = plugin.getShopRepository().createShop(shop);
plugin.getLogger().info("DEBUG: created shop id " + shopId + " at " + signLocation);
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, null, activation.cosmeticSign(), null);
Shop registeredShop = new Shop(shopId, shopName, signLocation, player.getUniqueId(),
new ArrayList<>(), trade, 0, true, createdAt, null, true, null);
plugin.getShopRegistry().register(registeredShop);
plugin.getLogger().info("DEBUG: registered shop " + shopId + " in registry");
rewriteSignLines(finalSign, registeredShop);
player.sendMessage(Component.text("shop #" + shopId + " initialized!", NamedTextColor.GREEN));
// 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, () -> {
@@ -237,20 +250,7 @@ public class ShopActivationListener implements Listener {
}
public void rewriteSignLines(Sign sign, Shop shop) {
if (shop.isCosmeticSign()) {
return;
}
Trade trade = shop.getTrade();
String pricePart = trade.priceQuantity() + " " + abbreviateMaterial(trade.priceItem());
String productPart = trade.productQuantity() + " " + abbreviateMaterial(trade.productItem());
sign.line(0, Component.text(pricePart));
sign.line(1, Component.text("for."));
sign.line(2, Component.text(productPart));
sign.line(3, Component.text(""));
sign.update();
// REMOVED: Sign should just be left alone as requested
}
/**
@@ -355,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();

View File

@@ -2,6 +2,7 @@ package party.cybsec.oyeshops.model;
import org.bukkit.Location;
import java.util.List;
import java.util.UUID;
/**
@@ -9,8 +10,10 @@ 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;
@@ -19,11 +22,13 @@ public class Shop {
private boolean cosmeticSign;
private String disc;
public Shop(int id, Location signLocation, UUID owner, Trade trade, int owedAmount, boolean enabled,
long createdAt, String customTitle, boolean cosmeticSign, String disc) {
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;
@@ -33,6 +38,18 @@ public class Shop {
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() {
return id;
}
@@ -97,8 +114,6 @@ public class Shop {
* 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

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

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
@@ -130,7 +164,7 @@ public class TransactionManager {
/**
* 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) {
@@ -144,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 {
@@ -161,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;

View File

@@ -5,6 +5,9 @@ transactions:
auto-prune: false
max-per-shop: 100
# whether to require players to have placed the container themselves this session
require-placement: true
# custom material aliases (add your own shortcuts)
aliases:
# example:

View File

@@ -1,5 +1,5 @@
name: oyeShops
version: 1.3.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: