4 Commits

37 changed files with 1664 additions and 499 deletions

View File

@@ -3,7 +3,7 @@ plugins {
} }
group = "party.cybsec" group = "party.cybsec"
version = "1.0.0" version = "1.3.1"
description = "deterministic item-for-item chest barter" description = "deterministic item-for-item chest barter"
repositories { 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

@@ -13,6 +13,7 @@ import party.cybsec.oyeshops.model.Shop;
import party.cybsec.oyeshops.permission.PermissionManager; import party.cybsec.oyeshops.permission.PermissionManager;
import party.cybsec.oyeshops.gui.HelpBook; import party.cybsec.oyeshops.gui.HelpBook;
import party.cybsec.oyeshops.gui.SetupDialog; import party.cybsec.oyeshops.gui.SetupDialog;
import party.cybsec.oyeshops.gui.ConfigDialog;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.block.BlockFace; import org.bukkit.block.BlockFace;
import org.bukkit.block.data.type.WallSign; import org.bukkit.block.data.type.WallSign;
@@ -47,12 +48,15 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
case "notify" -> handleNotifyToggle(sender); case "notify" -> handleNotifyToggle(sender);
case "info" -> handleInfo(sender); case "info" -> handleInfo(sender);
case "help" -> handleHelp(sender); case "help" -> handleHelp(sender);
case "setup" -> handleSetup(sender); case "setup" -> handleSetup(sender, args);
case "config" -> handleConfig(sender, args);
case "reload" -> handleReload(sender); case "reload" -> handleReload(sender);
case "inspect", "i" -> handleInspect(sender); case "inspect", "i" -> handleInspect(sender);
case "spoof", "s" -> handleSpoof(sender); case "spoof", "s" -> handleSpoof(sender);
case "unregister", "delete", "remove" -> handleUnregister(sender, args); case "unregister", "delete", "remove" -> handleUnregister(sender, args);
case "tpshop" -> handleTpShop(sender, args);
case "_activate" -> handleActivate(sender, args); case "_activate" -> handleActivate(sender, args);
case "toggleplacement", "toggle-placement" -> handleTogglePlacement(sender);
default -> handleHelp(sender); default -> handleHelp(sender);
} }
@@ -65,6 +69,8 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
.append(Component.text(" - open interactive guide", NamedTextColor.GRAY))); .append(Component.text(" - open interactive guide", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops setup", NamedTextColor.YELLOW) sender.sendMessage(Component.text("/oyeshops setup", NamedTextColor.YELLOW)
.append(Component.text(" - open shop wizard", NamedTextColor.GRAY))); .append(Component.text(" - open shop wizard", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops config", NamedTextColor.YELLOW)
.append(Component.text(" - configure looking shop", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops on", NamedTextColor.YELLOW) sender.sendMessage(Component.text("/oyeshops on", NamedTextColor.YELLOW)
.append(Component.text(" - enable shop creation", NamedTextColor.GRAY))); .append(Component.text(" - enable shop creation", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops off", NamedTextColor.YELLOW) sender.sendMessage(Component.text("/oyeshops off", NamedTextColor.YELLOW)
@@ -89,6 +95,10 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
.append(Component.text(" - disable a shop", NamedTextColor.GRAY))); .append(Component.text(" - disable a shop", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops unregister <id>", NamedTextColor.YELLOW) sender.sendMessage(Component.text("/oyeshops unregister <id>", NamedTextColor.YELLOW)
.append(Component.text(" - delete a shop", NamedTextColor.GRAY))); .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)));
} }
} }
@@ -218,10 +228,13 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
private void handleActivate(CommandSender sender, String[] args) { private void handleActivate(CommandSender sender, String[] args) {
if (!(sender instanceof Player player)) if (!(sender instanceof Player player))
return; return;
if (args.length < 2) if (args.length < 3) {
player.sendMessage(Component.text("usage: /oyes _activate <action> <name>", NamedTextColor.RED));
return; return;
}
String action = args[1].toLowerCase(); String action = args[1].toLowerCase();
String name = args[2];
PendingActivation activation = plugin.getActivationManager().getAndRemove(player.getUniqueId()); PendingActivation activation = plugin.getActivationManager().getAndRemove(player.getUniqueId());
if (activation == null) { if (activation == null) {
@@ -231,10 +244,10 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
switch (action) { switch (action) {
case "accept" -> { case "accept" -> {
finalizeShop(player, activation); finalizeShop(player, activation, name);
} }
case "invert" -> { case "invert" -> {
finalizeShop(player, activation.invert()); finalizeShop(player, activation.invert(), name);
} }
case "cancel" -> { case "cancel" -> {
player.sendMessage( player.sendMessage(
@@ -243,8 +256,8 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
} }
} }
private void finalizeShop(Player player, PendingActivation activation) { private void finalizeShop(Player player, PendingActivation activation, String shopName) {
plugin.getShopActivationListener().finalizeShop(player, activation); plugin.getShopActivationListener().finalizeShop(player, activation, shopName);
} }
private void handleEnable(CommandSender sender, String[] args) { private void handleEnable(CommandSender sender, String[] args) {
@@ -271,7 +284,7 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
e.printStackTrace(); e.printStackTrace();
} }
} else { } else {
sender.sendMessage(Component.text("usage: /oyeshops enable <id>", NamedTextColor.RED)); sender.sendMessage(Component.text("usage: /oyeshops enable <id/name>", NamedTextColor.RED));
} }
return; return;
} }
@@ -281,18 +294,25 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
return; return;
} }
try { String target = args[1];
int shopId = Integer.parseInt(args[1]); Shop shop = plugin.getShopRegistry().getShopByName(target);
Shop shop = plugin.getShopRegistry().getShopById(shopId);
if (shop == null) { 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; return;
} }
try {
shop.setEnabled(true); shop.setEnabled(true);
plugin.getShopRepository().setEnabled(shopId, true); plugin.getShopRepository().setEnabled(shop.getId(), true);
sender.sendMessage(Component.text("shop #" + shopId + " enabled", NamedTextColor.GREEN)); sender.sendMessage(Component.text("shop '" + shop.getName() + "' enabled", NamedTextColor.GREEN));
} catch (NumberFormatException e) {
sender.sendMessage(Component.text("invalid shop id", NamedTextColor.RED));
} catch (SQLException e) { } catch (SQLException e) {
sender.sendMessage(Component.text("database error", NamedTextColor.RED)); sender.sendMessage(Component.text("database error", NamedTextColor.RED));
e.printStackTrace(); e.printStackTrace();
@@ -311,7 +331,7 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
e.printStackTrace(); e.printStackTrace();
} }
} else { } else {
sender.sendMessage(Component.text("usage: /oyeshops disable <id>", NamedTextColor.RED)); sender.sendMessage(Component.text("usage: /oyeshops disable <id/name>", NamedTextColor.RED));
} }
return; return;
} }
@@ -321,18 +341,25 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
return; return;
} }
try { String target = args[1];
int shopId = Integer.parseInt(args[1]); Shop shop = plugin.getShopRegistry().getShopByName(target);
Shop shop = plugin.getShopRegistry().getShopById(shopId);
if (shop == null) { 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; return;
} }
try {
shop.setEnabled(false); shop.setEnabled(false);
plugin.getShopRepository().setEnabled(shopId, false); plugin.getShopRepository().setEnabled(shop.getId(), false);
sender.sendMessage(Component.text("shop #" + shopId + " disabled", NamedTextColor.YELLOW)); sender.sendMessage(Component.text("shop '" + shop.getName() + "' disabled", NamedTextColor.YELLOW));
} catch (NumberFormatException e) {
sender.sendMessage(Component.text("invalid shop id", NamedTextColor.RED));
} catch (SQLException e) { } catch (SQLException e) {
sender.sendMessage(Component.text("database error", NamedTextColor.RED)); sender.sendMessage(Component.text("database error", NamedTextColor.RED));
e.printStackTrace(); e.printStackTrace();
@@ -346,22 +373,29 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
} }
if (args.length < 2) { 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; return;
} }
try { 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.getShopRegistry().unregister(shop);
plugin.getShopRepository().deleteShop(shopId); plugin.getShopRepository().deleteShop(shop.getId());
sender.sendMessage(Component.text("shop #" + shopId + " deleted", NamedTextColor.RED)); sender.sendMessage(Component.text("shop '" + shop.getName() + "' deleted", NamedTextColor.RED));
} catch (NumberFormatException e) {
sender.sendMessage(Component.text("invalid shop id", NamedTextColor.RED));
} catch (SQLException e) { } catch (SQLException e) {
sender.sendMessage(Component.text("database error", NamedTextColor.RED)); sender.sendMessage(Component.text("database error", NamedTextColor.RED));
e.printStackTrace(); e.printStackTrace();
@@ -373,9 +407,10 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
List<String> completions = new ArrayList<>(); List<String> completions = new ArrayList<>();
if (args.length == 1) { if (args.length == 1) {
List<String> subCommands = new ArrayList<>( List<String> subCommands = new ArrayList<>(
List.of("help", "setup", "on", "off", "toggle", "notify", "info", "enable", "disable")); List.of("help", "setup", "config", "on", "off", "toggle", "notify", "info", "enable", "disable"));
if (PermissionManager.isAdmin(sender)) { 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(); String partial = args[0].toLowerCase();
for (String sub : subCommands) { for (String sub : subCommands) {
@@ -384,20 +419,61 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
} }
} else if (args.length == 2) { } else if (args.length == 2) {
String subCommand = args[0].toLowerCase(); String subCommand = args[0].toLowerCase();
if (PermissionManager.isAdmin(sender) && (subCommand.equals("enable") || subCommand.equals("disable") if (subCommand.equals("config") && sender instanceof Player player) {
|| subCommand.equals("unregister"))) { String partial = args[1].toLowerCase();
String partial = args[1];
for (Shop shop : plugin.getShopRegistry().getAllShops()) { 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()); String id = String.valueOf(shop.getId());
if (id.startsWith(partial)) if (id.startsWith(partial))
completions.add(id); 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; return completions;
} }
private void handleSetup(CommandSender sender) { private void handleSetup(CommandSender sender, String[] args) {
if (!(sender instanceof Player player)) { if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("players only", NamedTextColor.RED)); sender.sendMessage(Component.text("players only", NamedTextColor.RED));
return; return;
@@ -408,6 +484,25 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
return; 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); Block block = player.getTargetBlockExact(5);
if (block == null || !(block.getBlockData() instanceof WallSign wallSign)) { 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)); player.sendMessage(Component.text("you must look at a wall sign to use the wizard", NamedTextColor.RED));
@@ -423,7 +518,7 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
return; return;
} }
if (!PermissionManager.isAdmin(player)) { if (plugin.getConfigManager().isRequirePlacement() && !PermissionManager.isAdmin(player)) {
java.util.UUID sessionOwner = plugin.getContainerMemoryManager().getOwner(attachedBlock.getLocation()); java.util.UUID sessionOwner = plugin.getContainerMemoryManager().getOwner(attachedBlock.getLocation());
if (sessionOwner == null || !sessionOwner.equals(player.getUniqueId())) { if (sessionOwner == null || !sessionOwner.equals(player.getUniqueId())) {
player.sendMessage( player.sendMessage(
@@ -433,6 +528,110 @@ public class AdminCommands implements CommandExecutor, TabCompleter {
} }
} }
SetupDialog.open(player, block, plugin); 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; package party.cybsec.oyeshops.config;
import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.plugin.Plugin; import party.cybsec.oyeshops.OyeShopsPlugin;
/** /**
* configuration loading and management * manages plugin configuration
*/ */
public class ConfigManager { public class ConfigManager {
private final Plugin plugin; private final OyeShopsPlugin plugin;
private FileConfiguration config; private FileConfiguration config;
public ConfigManager(Plugin plugin) { public ConfigManager(OyeShopsPlugin plugin) {
this.plugin = plugin; this.plugin = plugin;
reload(); reload();
} }
@@ -18,26 +18,32 @@ public class ConfigManager {
public void reload() { public void reload() {
plugin.saveDefaultConfig(); plugin.saveDefaultConfig();
plugin.reloadConfig(); plugin.reloadConfig();
this.config = plugin.getConfig(); config = plugin.getConfig();
}
public boolean isAllowProductOutput() {
return config.getBoolean("hoppers.allow-product-output", true);
}
public boolean isBlockPriceInput() {
return config.getBoolean("hoppers.block-price-input", true);
}
public int getMaxTransactionsPerShop() {
return config.getInt("history.max-transactions-per-shop", 100);
}
public boolean isAutoPrune() {
return config.getBoolean("history.auto-prune", true);
} }
public FileConfiguration getConfig() { public FileConfiguration getConfig() {
return config; return config;
} }
public void save() {
plugin.saveConfig();
}
// transaction settings
public boolean isAutoPrune() {
return config.getBoolean("transactions.auto-prune", false);
}
public int getMaxTransactionsPerShop() {
return config.getInt("transactions.max-per-shop", 100);
}
public boolean isRequirePlacement() {
return config.getBoolean("require-placement", true);
}
public void setRequirePlacement(boolean require) {
config.set("require-placement", require);
save();
}
} }

View File

@@ -93,6 +93,33 @@ public class DatabaseManager {
stmt.execute("alter table shops add column created_at integer not null default 0"); stmt.execute("alter table shops add column created_at integer not null default 0");
} catch (SQLException ignored) { } 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 // indexes
stmt.execute(""" stmt.execute("""

View File

@@ -29,8 +29,9 @@ public class ShopRepository {
String sql = """ String sql = """
insert into shops (world_uuid, sign_x, sign_y, sign_z, owner_uuid, insert into shops (world_uuid, sign_x, sign_y, sign_z, owner_uuid,
price_item, price_quantity, product_item, product_quantity, price_item, price_quantity, product_item, product_quantity,
owed_amount, enabled, created_at) owed_amount, enabled, created_at, custom_title,
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) cosmetic_sign, disc, name, contributors)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""; """;
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql, try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql,
@@ -48,6 +49,11 @@ public class ShopRepository {
stmt.setInt(10, shop.getOwedAmount()); stmt.setInt(10, shop.getOwedAmount());
stmt.setBoolean(11, shop.isEnabled()); stmt.setBoolean(11, shop.isEnabled());
stmt.setLong(12, shop.getCreatedAt()); stmt.setLong(12, shop.getCreatedAt());
stmt.setString(13, shop.getCustomTitle());
stmt.setBoolean(14, shop.isCosmeticSign());
stmt.setString(15, shop.getDisc());
stmt.setString(16, shop.getName());
stmt.setString(17, serializeContributors(shop.getContributors()));
stmt.executeUpdate(); 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 * delete shop
*/ */
@@ -230,16 +255,47 @@ public class ShopRepository {
return shops; 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 * convert result set to shop object
*/ */
private Shop shopFromResultSet(ResultSet rs) throws SQLException { private Shop shopFromResultSet(ResultSet rs) throws SQLException {
int id = rs.getInt("shop_id"); int id = rs.getInt("shop_id");
String name = rs.getString("name");
UUID worldUuid = UUID.fromString(rs.getString("world_uuid")); UUID worldUuid = UUID.fromString(rs.getString("world_uuid"));
int x = rs.getInt("sign_x"); int x = rs.getInt("sign_x");
int y = rs.getInt("sign_y"); int y = rs.getInt("sign_y");
int z = rs.getInt("sign_z"); int z = rs.getInt("sign_z");
UUID ownerUuid = UUID.fromString(rs.getString("owner_uuid")); UUID ownerUuid = UUID.fromString(rs.getString("owner_uuid"));
List<UUID> contributors = deserializeContributors(rs.getString("contributors"));
Material priceItem = Material.valueOf(rs.getString("price_item")); Material priceItem = Material.valueOf(rs.getString("price_item"));
int priceQty = rs.getInt("price_quantity"); int priceQty = rs.getInt("price_quantity");
Material productItem = Material.valueOf(rs.getString("product_item")); Material productItem = Material.valueOf(rs.getString("product_item"));
@@ -247,6 +303,9 @@ public class ShopRepository {
int owedAmount = rs.getInt("owed_amount"); int owedAmount = rs.getInt("owed_amount");
boolean enabled = rs.getBoolean("enabled"); boolean enabled = rs.getBoolean("enabled");
long createdAt = rs.getLong("created_at"); long createdAt = rs.getLong("created_at");
String customTitle = rs.getString("custom_title");
boolean cosmeticSign = rs.getBoolean("cosmetic_sign");
String disc = rs.getString("disc");
World world = Bukkit.getWorld(worldUuid); World world = Bukkit.getWorld(worldUuid);
if (world == null) { if (world == null) {
@@ -256,6 +315,8 @@ public class ShopRepository {
Location location = new Location(world, x, y, z); Location location = new Location(world, x, y, z);
Trade trade = new Trade(priceItem, priceQty, productItem, productQty); Trade trade = new Trade(priceItem, priceQty, productItem, productQty);
return new Shop(id, location, ownerUuid, trade, owedAmount, enabled, createdAt); return new Shop(id, 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

@@ -2,8 +2,6 @@ package party.cybsec.oyeshops.gui;
import net.kyori.adventure.inventory.Book; import net.kyori.adventure.inventory.Book;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration; import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
@@ -32,10 +30,7 @@ public class HelpBook {
.append(Component.text("steps to start:", NamedTextColor.GRAY)) .append(Component.text("steps to start:", NamedTextColor.GRAY))
.append(Component.newline()) .append(Component.newline())
.append(Component.text("1. type ", NamedTextColor.BLACK)) .append(Component.text("1. type ", NamedTextColor.BLACK))
.append(Component.text("/oyes on", NamedTextColor.BLUE) .append(Component.text("/oyes on", NamedTextColor.BLUE))
.clickEvent(ClickEvent.runCommand("/oyes on"))
.hoverEvent(HoverEvent.showText(Component.text("click to enable shops",
NamedTextColor.GRAY))))
.append(Component.newline()) .append(Component.newline())
.append(Component.text("2. place a chest", NamedTextColor.BLACK)) .append(Component.text("2. place a chest", NamedTextColor.BLACK))
.append(Component.newline()) .append(Component.newline())
@@ -53,10 +48,7 @@ public class HelpBook {
.append(Component.text("1. look at a sign", NamedTextColor.GRAY)) .append(Component.text("1. look at a sign", NamedTextColor.GRAY))
.append(Component.newline()) .append(Component.newline())
.append(Component.text("2. type ", NamedTextColor.GRAY)) .append(Component.text("2. type ", NamedTextColor.GRAY))
.append(Component.text("/oyes setup", NamedTextColor.BLUE) .append(Component.text("/oyes setup", NamedTextColor.BLUE))
.clickEvent(ClickEvent.runCommand("/oyes setup"))
.hoverEvent(HoverEvent.showText(Component.text("click to start wizard",
NamedTextColor.GRAY))))
.append(Component.newline()) .append(Component.newline())
.append(Component.newline()) .append(Component.newline())
.append(Component.text("...or turn the page", NamedTextColor.DARK_GRAY)) .append(Component.text("...or turn the page", NamedTextColor.DARK_GRAY))
@@ -150,18 +142,12 @@ public class HelpBook {
.append(Component.text("commands", NamedTextColor.GOLD, TextDecoration.BOLD)) .append(Component.text("commands", NamedTextColor.GOLD, TextDecoration.BOLD))
.append(Component.newline()) .append(Component.newline())
.append(Component.newline()) .append(Component.newline())
.append(Component.text("/oyes notify", NamedTextColor.BLUE) .append(Component.text("/oyes notify", NamedTextColor.BLUE))
.clickEvent(ClickEvent.runCommand("/oyes notify"))
.hoverEvent(HoverEvent.showText(Component.text("click to toggle alerts",
NamedTextColor.GRAY))))
.append(Component.newline()) .append(Component.newline())
.append(Component.text("get low stock alerts.", NamedTextColor.DARK_GRAY)) .append(Component.text("get low stock alerts.", NamedTextColor.DARK_GRAY))
.append(Component.newline()) .append(Component.newline())
.append(Component.newline()) .append(Component.newline())
.append(Component.text("/oyes info", NamedTextColor.BLUE) .append(Component.text("/oyes info", NamedTextColor.BLUE))
.clickEvent(ClickEvent.runCommand("/oyes info"))
.hoverEvent(HoverEvent.showText(
Component.text("click for info", NamedTextColor.GRAY))))
.append(Component.newline()) .append(Component.newline())
.append(Component.text("plugin info.", NamedTextColor.DARK_GRAY)) .append(Component.text("plugin info.", NamedTextColor.DARK_GRAY))
.build()); .build());

View File

@@ -24,24 +24,30 @@ import java.util.List;
*/ */
public class SetupDialog { 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() Dialog dialog = Dialog.create(builder -> builder.empty()
.base(DialogBase.builder(Component.text("shop setup wizard", NamedTextColor.GOLD)) .base(DialogBase.builder(Component.text("shop setup wizard", NamedTextColor.GOLD))
.inputs(List.of( .inputs(List.of(
// shop name
DialogInput.text("shop_name",
Component.text("shop name (unique)",
NamedTextColor.YELLOW))
.initial(shopName)
.build(),
// product (selling) // product (selling)
DialogInput.text("product_item", DialogInput.text("product_item",
Component.text("what are you selling? (e.g. oak log)", Component.text("selling what? (e.g. dirt)",
NamedTextColor.YELLOW)) NamedTextColor.YELLOW))
.build(), .build(),
DialogInput.text("product_qty", DialogInput.text("product_qty",
Component.text("how many per purchase? (e.g. 64)", Component.text("how many? (e.g. 1)",
NamedTextColor.YELLOW)) NamedTextColor.YELLOW))
.initial("1") .initial("1")
.build(), .build(),
// price (buying) // price (buying)
DialogInput.text("price_item", DialogInput.text("price_item",
Component.text("what do you want? (e.g. diamond)", Component.text("what do you want? (prop: diamond)",
NamedTextColor.GREEN)) NamedTextColor.GREEN))
.build(), .build(),
DialogInput.text("price_qty", DialogInput.text("price_qty",
@@ -56,6 +62,7 @@ public class SetupDialog {
.tooltip(Component .tooltip(Component
.text("click to confirm trade details")) .text("click to confirm trade details"))
.action(DialogAction.customClick((view, audience) -> { .action(DialogAction.customClick((view, audience) -> {
String newShopName = view.getText("shop_name");
String productStr = view String productStr = view
.getText("product_item"); .getText("product_item");
String productQtyStr = view String productQtyStr = view
@@ -64,43 +71,50 @@ public class SetupDialog {
String priceQtyStr = view.getText("price_qty"); String priceQtyStr = view.getText("price_qty");
Player p = (Player) audience; Player p = (Player) audience;
// 1. parse quantities // shop name validation
int productQty; 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; int priceQty;
try { try {
productQty = Integer.parseInt(
productQtyStr);
priceQty = Integer priceQty = Integer
.parseInt(priceQtyStr); .parseInt(priceQtyStr);
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
p.sendMessage(Component.text( p.sendMessage(Component.text(
"invalid quantity provided", "invalid price quantity",
NamedTextColor.RED)); NamedTextColor.RED));
// reopen open(p, signBlock, plugin, newShopName);
open(p, signBlock, plugin);
return;
}
if (productQty <= 0 || priceQty <= 0) {
p.sendMessage(Component.text(
"quantities must be positive",
NamedTextColor.RED));
open(p, signBlock, plugin);
return;
}
// 2. parse materials
Material productMat = plugin.getSignParser()
.parseMaterial(productStr);
if (productMat == null) {
p.sendMessage(Component.text(
"invalid product item: "
+ productStr,
NamedTextColor.RED));
open(p, signBlock, plugin);
return; return;
} }
// 2. parse price material
Material priceMat = plugin.getSignParser() Material priceMat = plugin.getSignParser()
.parseMaterial(priceStr); .parseMaterial(priceStr);
if (priceMat == null) { if (priceMat == null) {
@@ -108,27 +122,49 @@ public class SetupDialog {
"invalid payment item: " "invalid payment item: "
+ priceStr, + priceStr,
NamedTextColor.RED)); NamedTextColor.RED));
open(p, signBlock, plugin); open(p, signBlock, plugin, newShopName);
return; return;
} }
// 3. create trade & activation // 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, Trade trade = new Trade(priceMat, priceQty,
productMat, productQty); productMat, productQty);
PendingActivation activation = new PendingActivation( PendingActivation activation = new PendingActivation(
p.getUniqueId(), p.getUniqueId(),
signBlock.getLocation(), signBlock.getLocation(),
trade, trade,
System.currentTimeMillis()); System.currentTimeMillis(),
true); // cosmeticSign always
// true
// 4. finalize shop immediately // 4. finalize
plugin.getLogger().info(
"DEBUG: SetupDialog creating shop at "
+ signBlock.getLocation());
plugin.getServer().getScheduler() plugin.getServer().getScheduler()
.runTask(plugin, () -> { .runTask(plugin, () -> {
plugin.getShopActivationListener() plugin.getShopActivationListener()
.finalizeShop(p, activation); .finalizeShop(p, activation,
newShopName);
}); });
}, ClickCallback.Options.builder().uses(1).build())) }, ClickCallback.Options.builder().uses(1).build()))
.build(), .build(),

View File

@@ -4,10 +4,12 @@ import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.Sound;
import org.bukkit.block.Barrel; import org.bukkit.block.Barrel;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.block.BlockFace; import org.bukkit.block.BlockFace;
import org.bukkit.block.Chest; import org.bukkit.block.Chest;
import org.bukkit.block.DoubleChest;
import org.bukkit.block.data.type.WallSign; import org.bukkit.block.data.type.WallSign;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
@@ -15,12 +17,13 @@ import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.block.Action; import org.bukkit.event.block.Action;
import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.DoubleChestInventory;
import org.bukkit.inventory.Inventory; import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder; import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import party.cybsec.oyeshops.OyeShopsPlugin; import party.cybsec.oyeshops.OyeShopsPlugin;
import party.cybsec.oyeshops.gui.ConfirmationGui;
import party.cybsec.oyeshops.model.Shop; import party.cybsec.oyeshops.model.Shop;
import party.cybsec.oyeshops.model.Trade; import party.cybsec.oyeshops.model.Trade;
import party.cybsec.oyeshops.permission.PermissionManager; import party.cybsec.oyeshops.permission.PermissionManager;
@@ -36,8 +39,11 @@ import java.util.Map;
public class ChestInteractionListener implements Listener { public class ChestInteractionListener implements Listener {
private final OyeShopsPlugin plugin; private final OyeShopsPlugin plugin;
// track players viewing fake shop inventories // track active shop sessions for players
private final Map<Player, Shop> viewingShop = new HashMap<>(); private final Map<Player, ShopSession> activeSessions = new HashMap<>();
public record ShopSession(Shop shop, int unitsTraded, Inventory realInventory) {
}
public ChestInteractionListener(OyeShopsPlugin plugin) { public ChestInteractionListener(OyeShopsPlugin plugin) {
this.plugin = plugin; this.plugin = plugin;
@@ -59,7 +65,7 @@ public class ChestInteractionListener implements Listener {
return; return;
} }
// check if there's a shop sign attached // check if there's a shop sign attached to this container
Shop shop = findShopForContainer(block); Shop shop = findShopForContainer(block);
if (shop == null) { if (shop == null) {
return; // not a shop container return; // not a shop container
@@ -74,16 +80,18 @@ public class ChestInteractionListener implements Listener {
return; return;
} }
// check if player is owner (unless spoofing) // check if player is owner or contributor (unless spoofing)
boolean isOwner = shop.getOwner().equals(player.getUniqueId()) boolean isOwner = shop.getOwner().equals(player.getUniqueId())
&& !plugin.getSpoofManager().isSpoofing(player); && !plugin.getSpoofManager().isSpoofing(player);
boolean isContributor = shop.getContributors().contains(player.getUniqueId())
&& !plugin.getSpoofManager().isSpoofing(player);
if (isOwner) { if (isOwner || isContributor) {
// owner interaction - check for owed items and dispense // owner/contributor interaction - check for owed items and dispense
if (shop.getOwedAmount() > 0) { if (shop.getOwedAmount() > 0) {
withdrawOwedItems(player, shop); withdrawOwedItems(player, shop);
} }
// let owner open the chest normally - don't cancel event // let them open the chest normally
return; return;
} }
@@ -138,7 +146,7 @@ public class ChestInteractionListener implements Listener {
// update database // update database
int newOwed = owed - withdrawn; int newOwed = owed - withdrawn;
shop.setOwedAmount(newOwed); shop.setOwedAmount(newOwed);
plugin.getShopRepository().updateOwedAmount(shop.getId(), -withdrawn); plugin.getShopRepository().updateOwedAmount(shop.getId(), newOwed);
player.sendMessage( player.sendMessage(
Component.text("withdrew " + withdrawn + " " + formatMaterial(priceItem), NamedTextColor.GREEN) Component.text("withdrew " + withdrawn + " " + formatMaterial(priceItem), NamedTextColor.GREEN)
@@ -154,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) { private void openShopGui(Player player, Shop shop, Block containerBlock) {
Trade trade = shop.getTrade(); Trade trade = shop.getTrade();
// create fake inventory showing only product items // create title
Inventory shopInventory = Bukkit.createInventory( Component title = Component.text("shop: " + trade.priceQuantity() + " " + formatMaterial(trade.priceItem()) +
new ShopInventoryHolder(shop), "" + trade.productQuantity() + " " + formatMaterial(trade.productItem()));
27,
Component.text("shop: " + trade.priceQuantity() + " " + formatMaterial(trade.priceItem()) + // respect custom title if set
"" + trade.productQuantity() + " " + formatMaterial(trade.productItem()))); if (shop.getCustomTitle() != null && !shop.getCustomTitle().isEmpty()) {
title = Component.text(shop.getCustomTitle());
}
// get real container inventory // get real container inventory
Inventory realInventory = getContainerInventory(containerBlock); Inventory realInventory = getContainerInventory(containerBlock);
@@ -172,18 +182,79 @@ public class ChestInteractionListener implements Listener {
return; return;
} }
// copy only product items to fake inventory (first 27 found) int invSize = Math.min(54, Math.max(27, realInventory.getSize()));
int shopSlot = 0;
// 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()) { for (ItemStack item : realInventory.getContents()) {
if (shopSlot >= 27) if (item != null && trade.matchesProduct(item.getType())) {
break; totalStock += item.getAmount();
if (item != null && item.getType() == trade.productItem()) {
shopInventory.setItem(shopSlot++, item.clone());
} }
} }
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); player.openInventory(shopInventory);
// play shop owner's configured disc if set
String discName = shop.getDisc();
if (discName != null && !discName.isEmpty()) {
playDisc(player, discName);
}
}
/**
* play a music disc for a player
*/
private void playDisc(Player player, String discName) {
Sound discSound = getDiscSound(discName);
if (discSound != null) {
player.playSound(player.getLocation(), discSound, 1.0f, 1.0f);
}
}
/**
* stop a music disc for a player
*/
private void stopDisc(Player player, String discName) {
Sound discSound = getDiscSound(discName);
if (discSound != null) {
player.stopSound(discSound);
}
}
/**
* get Sound enum for disc name
*/
private Sound getDiscSound(String discName) {
return switch (discName.toLowerCase()) {
case "blocks" -> Sound.MUSIC_DISC_BLOCKS;
case "chirp" -> Sound.MUSIC_DISC_CHIRP;
case "far" -> Sound.MUSIC_DISC_FAR;
case "mall" -> Sound.MUSIC_DISC_MALL;
case "mellohi" -> Sound.MUSIC_DISC_MELLOHI;
case "stal" -> Sound.MUSIC_DISC_STAL;
case "strad" -> Sound.MUSIC_DISC_STRAD;
case "ward" -> Sound.MUSIC_DISC_WARD;
case "wait" -> Sound.MUSIC_DISC_WAIT;
default -> null;
};
} }
@EventHandler @EventHandler
@@ -192,84 +263,189 @@ public class ChestInteractionListener implements Listener {
return; return;
} }
InventoryHolder holder = event.getInventory().getHolder(); ShopSession session = activeSessions.get(player);
if (session == null) {
// handle shop inventory clicks return;
if (holder instanceof ShopInventoryHolder shopHolder) { }
event.setCancelled(true);
Shop shop = shopHolder.shop(); InventoryHolder holder = event.getInventory().getHolder();
if (!(holder instanceof ShopInventoryHolder)) {
// 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);
}
}
return; return;
} }
// handle confirmation GUI clicks
if (holder instanceof ConfirmationGui confirmGui) {
event.setCancelled(true); event.setCancelled(true);
Shop shop = confirmGui.getShop();
int slot = event.getRawSlot(); int slot = event.getRawSlot();
if (slot >= event.getInventory().getSize()) {
return; // Clicked player inventory
}
// green pane = confirm (slot 11-15 or specifically slot 11) ItemStack clicked = event.getCurrentItem();
if (slot == 11 || slot == 12 || slot == 13) { if (clicked == null || clicked.getType() == Material.AIR) {
player.closeInventory(); return;
executeTransaction(player, shop, confirmGui.getUnits());
} }
// red pane = cancel (slot 15 or right side)
else if (slot == 14 || slot == 15 || slot == 16) { Shop shop = session.shop();
player.closeInventory(); Trade trade = shop.getTrade();
player.sendMessage(Component.text("purchase cancelled", NamedTextColor.YELLOW)); 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)) {
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 * find shop attached to a container block (handles double chests)
*/
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
*/ */
private Shop findShopForContainer(Block containerBlock) { private Shop findShopForContainer(Block containerBlock) {
// first check this block directly
Shop shop = findShopOnBlock(containerBlock);
if (shop != null) {
return shop;
}
// if this is a chest, check the other half of a double chest
if (containerBlock.getState() instanceof Chest chest) {
Inventory inv = chest.getInventory();
if (inv instanceof DoubleChestInventory doubleInv) {
DoubleChest doubleChest = doubleInv.getHolder();
if (doubleChest != null) {
// check both sides
Chest left = (Chest) doubleChest.getLeftSide();
Chest right = (Chest) doubleChest.getRightSide();
if (left != null) {
shop = findShopOnBlock(left.getBlock());
if (shop != null)
return shop;
}
if (right != null) {
shop = findShopOnBlock(right.getBlock());
if (shop != null)
return shop;
}
}
}
}
return null;
}
/**
* find shop sign on a specific block
*/
private Shop findShopOnBlock(Block containerBlock) {
// check all adjacent blocks for a wall sign pointing at this container // check all adjacent blocks for a wall sign pointing at this container
for (BlockFace face : new BlockFace[] { BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST }) { for (BlockFace face : new BlockFace[] { BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST }) {
Block adjacent = containerBlock.getRelative(face); Block adjacent = containerBlock.getRelative(face);
@@ -287,11 +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) { private Inventory getContainerInventory(Block block) {
if (block.getState() instanceof Chest chest) { if (block.getState() instanceof Chest chest) {
return chest.getInventory(); return chest.getInventory(); // returns DoubleChestInventory if double chest
} else if (block.getState() instanceof Barrel barrel) { } else if (block.getState() instanceof Barrel barrel) {
return barrel.getInventory(); return barrel.getInventory();
} }

View File

@@ -39,8 +39,7 @@ public class LoginListener implements Listener {
return; return;
} }
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { // find all shops owned by player and check stock on main thread
// find all shops owned by player
List<Shop> lowStockShops = new ArrayList<>(); List<Shop> lowStockShops = new ArrayList<>();
for (Shop shop : plugin.getShopRegistry().getAllShops()) { for (Shop shop : plugin.getShopRegistry().getAllShops()) {
if (shop.getOwner().equals(player.getUniqueId())) { if (shop.getOwner().equals(player.getUniqueId())) {
@@ -51,16 +50,10 @@ public class LoginListener implements Listener {
} }
if (!lowStockShops.isEmpty()) { if (!lowStockShops.isEmpty()) {
final int count = lowStockShops.size();
plugin.getServer().getScheduler().runTask(plugin, () -> {
if (player.isOnline()) {
player.sendMessage( 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)); NamedTextColor.YELLOW));
} }
});
}
});
} }
private boolean isLowStock(Shop shop) { private boolean isLowStock(Shop shop) {

View File

@@ -92,9 +92,9 @@ public class ShopActivationListener implements Listener {
} }
// 2. session-based ownership check // 2. session-based ownership check
if (!PermissionManager.isAdmin(player)) { if (plugin.getConfigManager().isRequirePlacement() && !PermissionManager.isAdmin(player)) {
UUID sessionOwner = plugin.getContainerMemoryManager().getOwner(attachedBlock.getLocation()); 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 // 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", player.sendMessage(Component.text("you can only create shops on containers you placed this session",
NamedTextColor.RED)); NamedTextColor.RED));
@@ -116,11 +116,18 @@ public class ShopActivationListener implements Listener {
: ""; : "";
} }
// 3. check for setup wizard // check for setup wizard
if (lines[0].equalsIgnoreCase("setup") && lines[1].isEmpty() && lines[2].isEmpty() && lines[3].isEmpty()) { 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 // clear sign text to avoid "setup" staying on the sign
event.line(0, Component.text("")); event.line(0, Component.text(""));
SetupDialog.open(player, block, plugin); SetupDialog.open(player, block, plugin, "shop-" + (plugin.getShopRegistry().getAllShops().size() + 1));
return; return;
} }
@@ -142,7 +149,7 @@ public class ShopActivationListener implements Listener {
// trigger confirmation instead of immediate creation // trigger confirmation instead of immediate creation
PendingActivation activation = new PendingActivation(player.getUniqueId(), block.getLocation(), trade, PendingActivation activation = new PendingActivation(player.getUniqueId(), block.getLocation(), trade,
System.currentTimeMillis()); System.currentTimeMillis(), false);
plugin.getActivationManager().add(player.getUniqueId(), activation); plugin.getActivationManager().add(player.getUniqueId(), activation);
sendConfirmationMessage(player, trade); sendConfirmationMessage(player, trade);
@@ -151,6 +158,16 @@ public class ShopActivationListener implements Listener {
private void sendConfirmationMessage(Player player, Trade trade) { private void sendConfirmationMessage(Player player, Trade trade) {
String priceText = trade.priceQuantity() + " " + formatMaterial(trade.priceItem()); String priceText = trade.priceQuantity() + " " + formatMaterial(trade.priceItem());
String productText = trade.productQuantity() + " " + formatMaterial(trade.productItem()); 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 // clear display: buyer pays vs buyer gets
player.sendMessage(Component.text("shop detected!", NamedTextColor.GOLD)); player.sendMessage(Component.text("shop detected!", NamedTextColor.GOLD));
@@ -163,17 +180,17 @@ public class ShopActivationListener implements Listener {
.append(Component.text("[accept]", NamedTextColor.GREEN, TextDecoration.BOLD) .append(Component.text("[accept]", NamedTextColor.GREEN, TextDecoration.BOLD)
.hoverEvent(HoverEvent.showText( .hoverEvent(HoverEvent.showText(
Component.text("create shop exactly as shown above", NamedTextColor.WHITE))) 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(" "))
.append(Component.text("[invert]", NamedTextColor.GOLD, TextDecoration.BOLD) .append(Component.text("[invert]", NamedTextColor.GOLD, TextDecoration.BOLD)
.hoverEvent(HoverEvent.showText(Component.text( .hoverEvent(HoverEvent.showText(Component.text(
"swap: buyer pays " + productText + " and gets " + priceText, NamedTextColor.WHITE))) "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(" "))
.append(Component.text("[cancel]", NamedTextColor.RED, TextDecoration.BOLD) .append(Component.text("[cancel]", NamedTextColor.RED, TextDecoration.BOLD)
.hoverEvent(HoverEvent.showText( .hoverEvent(HoverEvent.showText(
Component.text("ignore sign. no shop created.", NamedTextColor.WHITE))) 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); player.sendMessage(buttons);
} }
@@ -182,19 +199,21 @@ public class ShopActivationListener implements Listener {
* complete the shop creation process * complete the shop creation process
* called from AdminCommands when user clicks [accept] or [invert] * 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(); Location signLocation = activation.location();
Block block = signLocation.getBlock(); Block block = signLocation.getBlock();
// verify it's still a sign // verify it's still a sign
if (!(block.getState() instanceof Sign sign)) { if (!(block.getState() instanceof Sign)) {
player.sendMessage(Component.text("activation failed: sign is gone", NamedTextColor.RED)); player.sendMessage(Component.text("activation failed: sign is gone", NamedTextColor.RED));
return; return;
} }
Trade trade = activation.trade(); Trade trade = activation.trade();
long createdAt = System.currentTimeMillis(); long createdAt = System.currentTimeMillis();
Shop shop = new Shop(-1, signLocation, player.getUniqueId(), trade, 0, true, createdAt); // 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, () -> { plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
try { try {
@@ -202,22 +221,24 @@ public class ShopActivationListener implements Listener {
plugin.getShopRepository().deleteShopByLocation(signLocation); plugin.getShopRepository().deleteShopByLocation(signLocation);
int shopId = plugin.getShopRepository().createShop(shop); int shopId = plugin.getShopRepository().createShop(shop);
plugin.getLogger().info("DEBUG: created shop id " + shopId + " at " + signLocation); plugin.getLogger().info("DEBUG: created shop id " + shopId + " (" + shopName + ") at " + signLocation);
plugin.getServer().getScheduler().runTask(plugin, () -> { plugin.getServer().getScheduler().runTask(plugin, () -> {
// re-verify sign on main thread // re-verify sign on main thread
if (!(signLocation.getBlock().getState() instanceof Sign finalSign)) { if (!(signLocation.getBlock().getState() instanceof Sign)) {
plugin.getLogger().info("DEBUG: sign missing at " + signLocation); plugin.getLogger().info("DEBUG: sign missing at " + signLocation);
return; return;
} }
Shop registeredShop = new Shop(shopId, signLocation, player.getUniqueId(), trade, 0, true, Shop registeredShop = new Shop(shopId, shopName, signLocation, player.getUniqueId(),
createdAt); new ArrayList<>(), trade, 0, true, createdAt, null, true, null);
plugin.getShopRegistry().register(registeredShop); plugin.getShopRegistry().register(registeredShop);
plugin.getLogger().info("DEBUG: registered shop " + shopId + " in registry"); plugin.getLogger().info("DEBUG: registered shop " + shopId + " in registry");
rewriteSignLines(finalSign, trade); // rewriteSignLines(finalSign, registeredShop); // REMOVED: Sign should just be
player.sendMessage(Component.text("shop #" + shopId + " initialized!", NamedTextColor.GREEN)); // left alone
player.sendMessage(Component.text("shop '" + shopName + "' (#" + shopId + ") initialized!",
NamedTextColor.GREEN));
}); });
} catch (SQLException e) { } catch (SQLException e) {
plugin.getServer().getScheduler().runTask(plugin, () -> { plugin.getServer().getScheduler().runTask(plugin, () -> {
@@ -228,15 +249,8 @@ public class ShopActivationListener implements Listener {
}); });
} }
private void rewriteSignLines(Sign sign, Trade trade) { public void rewriteSignLines(Sign sign, Shop shop) {
String pricePart = trade.priceQuantity() + " " + abbreviateMaterial(trade.priceItem()); // REMOVED: Sign should just be left alone as requested
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();
} }
/** /**
@@ -341,15 +355,6 @@ public class ShopActivationListener implements Listener {
return bestQuantity; 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) { private Inventory getContainerInventory(Block block) {
if (block.getState() instanceof Chest chest) if (block.getState() instanceof Chest chest)
return chest.getInventory(); return chest.getInventory();

View File

@@ -1,11 +1,16 @@
package party.cybsec.oyeshops.listener; package party.cybsec.oyeshops.listener;
import org.bukkit.block.Block; 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.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority; import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent; 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.OyeShopsPlugin;
import party.cybsec.oyeshops.model.Shop; import party.cybsec.oyeshops.model.Shop;
import party.cybsec.oyeshops.permission.PermissionManager; import party.cybsec.oyeshops.permission.PermissionManager;
@@ -49,7 +54,7 @@ public class ShopProtectionListener implements Listener {
return; return;
} }
// find shop with this chest location // find shop with this chest location (handles double chests)
Shop chestShop = findShopByChest(block); Shop chestShop = findShopByChest(block);
if (chestShop != null) { if (chestShop != null) {
if (canBreakShop(player, chestShop)) { 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) { 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 // check all adjacent blocks for wall signs
for (org.bukkit.block.BlockFace face : new org.bukkit.block.BlockFace[] { for (BlockFace face : new BlockFace[] {
org.bukkit.block.BlockFace.NORTH, BlockFace.NORTH,
org.bukkit.block.BlockFace.SOUTH, BlockFace.SOUTH,
org.bukkit.block.BlockFace.EAST, BlockFace.EAST,
org.bukkit.block.BlockFace.WEST BlockFace.WEST
}) { }) {
Block adjacent = chestBlock.getRelative(face); Block adjacent = containerBlock.getRelative(face);
Shop shop = plugin.getShopRegistry().getShop(adjacent.getLocation()); Shop shop = plugin.getShopRegistry().getShop(adjacent.getLocation());
if (shop != null) { if (shop != null) {
return shop; return shop;

View File

@@ -10,7 +10,8 @@ public record PendingActivation(
UUID owner, UUID owner,
Location location, Location location,
Trade trade, Trade trade,
long createdAt) { long createdAt,
boolean cosmeticSign) {
/** /**
* check if this activation has expired * check if this activation has expired
@@ -28,6 +29,6 @@ public record PendingActivation(
trade.productQuantity(), trade.productQuantity(),
trade.priceItem(), trade.priceItem(),
trade.priceQuantity()); trade.priceQuantity());
return new PendingActivation(owner, location, invertedTrade, createdAt); return new PendingActivation(owner, location, invertedTrade, createdAt, cosmeticSign);
} }
} }

View File

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

View File

@@ -4,7 +4,7 @@ import org.bukkit.Material;
/** /**
* immutable trade definition * immutable trade definition
* quantity of -1 indicates AUTO detection needed for that side * quantity of -1 indicates auto detection needed for that side
*/ */
public record Trade( public record Trade(
Material priceItem, Material priceItem,
@@ -12,38 +12,46 @@ public record Trade(
Material productItem, Material productItem,
int productQuantity) { int productQuantity) {
// primary constructor
public Trade { public Trade {
// allow -1 for AUTO detection, but otherwise must be positive // allow -1 for auto detection, but otherwise must be positive
if (priceQuantity <= 0 && priceQuantity != -1) { if (priceQuantity <= 0 && priceQuantity != -1) {
throw new IllegalArgumentException("price quantity must be positive or -1 for auto"); throw new IllegalArgumentException("price quantity must be positive or -1 for auto");
} }
if (productQuantity <= 0 && productQuantity != -1) { if (productQuantity <= 0 && productQuantity != -1) {
throw new IllegalArgumentException("product quantity must be positive or -1 for auto"); throw new IllegalArgumentException("product quantity must be positive or -1 for auto");
} }
// for AUTO, materials might be AIR (unknown) // for auto, materials might be air (unknown)
if (priceQuantity != -1 && productQuantity != -1 && priceItem == productItem && priceItem != Material.AIR) { if (priceQuantity != -1 && productQuantity != -1 && priceItem == productItem && priceItem != Material.AIR) {
throw new IllegalArgumentException("price and product must be different materials"); throw new IllegalArgumentException("price and product must be different materials");
} }
} }
/** /**
* check if this trade needs AUTO detection on the product side * check if this trade needs auto detection on the product side
*/ */
public boolean isAutoProduct() { public boolean isAutoProduct() {
return productQuantity == -1; return productQuantity == -1;
} }
/** /**
* check if this trade needs AUTO detection on the price side * check if this trade needs auto detection on the price side
*/ */
public boolean isAutoPrice() { public boolean isAutoPrice() {
return priceQuantity == -1; return priceQuantity == -1;
} }
/** /**
* check if this trade needs any AUTO detection * check if this trade needs any auto detection
*/ */
public boolean isAutoDetect() { public boolean isAutoDetect() {
return isAutoProduct() || isAutoPrice(); return isAutoProduct() || isAutoPrice();
} }
/**
* check if a material matches this trade's product
*/
public boolean matchesProduct(Material material) {
return productItem == material;
}
} }

View File

@@ -28,6 +28,7 @@ public class MaterialAliasRegistry {
// short forms // short forms
Map.entry("dia", "diamond"), Map.entry("dia", "diamond"),
Map.entry("d", "diamond"),
Map.entry("dias", "diamond"), Map.entry("dias", "diamond"),
Map.entry("em", "emerald"), Map.entry("em", "emerald"),
Map.entry("ems", "emerald"), Map.entry("ems", "emerald"),
@@ -133,6 +134,7 @@ public class MaterialAliasRegistry {
Map.entry("netherrack", "netherrack"), Map.entry("netherrack", "netherrack"),
Map.entry("endstone", "end_stone"), Map.entry("endstone", "end_stone"),
Map.entry("end stone", "end_stone"), Map.entry("end stone", "end_stone"),
Map.entry("bamboo block", "bamboo_block"),
// food // food
Map.entry("steak", "cooked_beef"), Map.entry("steak", "cooked_beef"),
@@ -202,13 +204,13 @@ public class MaterialAliasRegistry {
/** /**
* resolve material from normalized text * resolve material from normalized text
* tries multiple strategies: * priority order:
* 1. exact alias match (longest first) * 1. exact full text match (space to underscore) - handles "bamboo block" ->
* 2. word-by-word alias match * BAMBOO_BLOCK
* 3. space-to-underscore conversion for direct enum match * 2. exact alias match (longest first for multi-word aliases)
* 4. direct material enum match * 3. direct material enum match for each word
* 5. with _INGOT/_BLOCK suffixes * 4. with _INGOT/_BLOCK suffixes
* 6. strip trailing 's' for plurals * 5. strip trailing 's' for plurals
*/ */
public Material resolve(String text) { public Material resolve(String text) {
text = text.toLowerCase().trim(); text = text.toLowerCase().trim();
@@ -217,13 +219,25 @@ public class MaterialAliasRegistry {
return null; return null;
} }
// 1. try longest alias match first (for multi-word aliases) // remove numbers and extra whitespace for material matching
String materialText = text.replaceAll("\\d+", "").trim().replaceAll("\\s+", " ");
// 1. try exact full text match with underscore conversion first
// this handles "bamboo block" -> "bamboo_block" -> BAMBOO_BLOCK
String underscored = materialText.replace(" ", "_");
try {
return Material.valueOf(underscored.toUpperCase());
} catch (IllegalArgumentException ignored) {
}
// 2. try exact alias match (longest first for multi-word aliases)
String longestMatch = null; String longestMatch = null;
Material longestMaterial = null; Material longestMaterial = null;
for (Map.Entry<String, Material> entry : aliases.entrySet()) { for (Map.Entry<String, Material> entry : aliases.entrySet()) {
String alias = entry.getKey(); String alias = entry.getKey();
if (text.contains(alias)) { // check if the text equals or contains the alias
if (materialText.equals(alias) || materialText.contains(alias)) {
if (longestMatch == null || alias.length() > longestMatch.length()) { if (longestMatch == null || alias.length() > longestMatch.length()) {
longestMatch = alias; longestMatch = alias;
longestMaterial = entry.getValue(); longestMaterial = entry.getValue();
@@ -235,30 +249,19 @@ public class MaterialAliasRegistry {
return longestMaterial; return longestMaterial;
} }
// 2. try word-by-word alias match // 3. try each word directly as material
String[] words = text.split("\\s+"); String[] words = materialText.split("\\s+");
for (String word : words) { for (String word : words) {
if (aliases.containsKey(word)) { if (aliases.containsKey(word)) {
return aliases.get(word); return aliases.get(word);
} }
}
// 3. try space-to-underscore conversion for multi-word materials
// e.g., "netherite pickaxe" -> "netherite_pickaxe"
String underscored = text.replace(" ", "_");
try {
return Material.valueOf(underscored.toUpperCase());
} catch (IllegalArgumentException ignored) {
}
// 4. try each word directly as material
for (String word : words) {
try { try {
return Material.valueOf(word.toUpperCase()); return Material.valueOf(word.toUpperCase());
} catch (IllegalArgumentException ignored) { } catch (IllegalArgumentException ignored) {
} }
// 5. try with common suffixes // 4. try with common suffixes
try { try {
return Material.valueOf(word.toUpperCase() + "_INGOT"); return Material.valueOf(word.toUpperCase() + "_INGOT");
} catch (IllegalArgumentException ignored) { } catch (IllegalArgumentException ignored) {
@@ -269,7 +272,7 @@ public class MaterialAliasRegistry {
} catch (IllegalArgumentException ignored) { } catch (IllegalArgumentException ignored) {
} }
// 6. try stripping trailing 's' for plurals // 5. try stripping trailing 's' for plurals
if (word.endsWith("s") && word.length() > 1) { if (word.endsWith("s") && word.length() > 1) {
String singular = word.substring(0, word.length() - 1); String singular = word.substring(0, word.length() - 1);
try { try {
@@ -279,19 +282,30 @@ public class MaterialAliasRegistry {
} }
} }
// 7. try the whole text with underscores for complex names // 6. last resort: scan all materials for longest match
Material bestMatch = null;
int bestLength = 0;
for (Material material : Material.values()) { for (Material material : Material.values()) {
String materialName = material.name().toLowerCase(); String materialName = material.name().toLowerCase();
String materialSpaced = materialName.replace("_", " "); String materialSpaced = materialName.replace("_", " ");
if (text.contains(materialSpaced) || text.contains(materialName)) { if (materialText.equals(materialSpaced) || materialText.equals(materialName)) {
if (longestMatch == null || materialName.length() > longestMatch.length()) { return material; // exact match
longestMatch = materialName; }
longestMaterial = material;
if (materialText.contains(materialSpaced) || materialText.contains(materialName)) {
if (materialName.length() > bestLength) {
bestLength = materialName.length();
bestMatch = material;
} }
} }
} }
return longestMaterial; return bestMatch;
}
public Material parseMaterial(String name) {
return resolve(name);
} }
} }

View File

@@ -52,7 +52,7 @@ public class SignParser {
* parse sign lines into a trade * parse sign lines into a trade
* *
* @return trade if valid, null if invalid or ambiguous * @return trade if valid, null if invalid or ambiguous
* trade with quantity -1 means AUTO detection needed * trade with quantity -1 means auto detection needed
*/ */
public Trade parse(String[] lines) { public Trade parse(String[] lines) {
// concatenate all lines with spaces // concatenate all lines with spaces
@@ -74,6 +74,7 @@ public class SignParser {
return null; return null;
} }
// check for auto detection
boolean isAuto = productSection.toLowerCase().contains(AUTO_KEYWORD); boolean isAuto = productSection.toLowerCase().contains(AUTO_KEYWORD);
ItemQuantity product; ItemQuantity product;
if (isAuto) { if (isAuto) {

View File

@@ -75,6 +75,34 @@ public class ShopRegistry {
return shopsById.get(shopId); 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 * get all shops
*/ */
@@ -97,6 +125,25 @@ public class ShopRegistry {
shopsById.clear(); shopsById.clear();
} }
/**
* find shop by container block (checks adjacent faces for signs)
*/
public Shop getShopByContainer(org.bukkit.block.Block container) {
for (org.bukkit.block.BlockFace face : new org.bukkit.block.BlockFace[] {
org.bukkit.block.BlockFace.NORTH, org.bukkit.block.BlockFace.SOUTH,
org.bukkit.block.BlockFace.EAST, org.bukkit.block.BlockFace.WEST }) {
org.bukkit.block.Block adjacent = container.getRelative(face);
if (adjacent.getBlockData() instanceof org.bukkit.block.data.type.WallSign wallSign) {
if (wallSign.getFacing().getOppositeFace() == face.getOppositeFace()) {
Shop shop = getShop(adjacent.getLocation());
if (shop != null)
return shop;
}
}
}
return null;
}
/** /**
* create location key for map lookup * create location key for map lookup
*/ */

View File

@@ -33,7 +33,41 @@ public class TransactionManager {
INSUFFICIENT_STOCK, INSUFFICIENT_STOCK,
INVENTORY_FULL, INVENTORY_FULL,
DATABASE_ERROR, 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 { try {
// remove price items from buyer // remove price items from buyer (NBT preserved if needed, though usually
removeItems(buyer.getInventory(), trade.priceItem(), totalPrice); // currency)
removeItemsAndReturn(buyer.getInventory(), trade.priceItem(), totalPrice);
// remove product items from chest // remove product items from chest (NBT CRITICAL HERE)
removeItems(chestInventory, trade.productItem(), totalProduct); ItemStack[] productItems = removeItemsAndReturn(chestInventory, trade.productItem(), totalProduct);
// add product items to buyer // add product items to buyer
HashMap<Integer, ItemStack> overflow = buyer.getInventory().addItem( HashMap<Integer, ItemStack> overflow = buyer.getInventory().addItem(productItems);
createItemStacks(trade.productItem(), totalProduct));
if (!overflow.isEmpty()) { if (!overflow.isEmpty()) {
// rollback // rollback
@@ -97,22 +131,7 @@ public class TransactionManager {
return Result.INVENTORY_FULL; return Result.INVENTORY_FULL;
} }
// update owed amount in database finalizeTransaction(buyer, shop, units, totalPrice);
plugin.getShopRepository().updateOwedAmount(shop.getId(), totalPrice);
shop.setOwedAmount(shop.getOwedAmount() + totalPrice);
// record transaction
plugin.getTransactionRepository().recordTransaction(
shop.getId(),
buyer.getUniqueId(),
units);
// prune old transactions if configured
if (plugin.getConfigManager().isAutoPrune()) {
int maxHistory = plugin.getConfigManager().getMaxTransactionsPerShop();
plugin.getTransactionRepository().pruneTransactions(shop.getId(), maxHistory);
}
return Result.SUCCESS; return Result.SUCCESS;
} catch (SQLException e) { } catch (SQLException e) {
@@ -124,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 * 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; int count = 0;
for (ItemStack item : inventory.getContents()) { for (ItemStack item : inventory.getContents()) {
if (item != null && item.getType() == material) { 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; int remaining = amount;
java.util.List<ItemStack> removed = new java.util.ArrayList<>();
for (int i = 0; i < inventory.getSize() && remaining > 0; i++) { for (int i = 0; i < inventory.getSize() && remaining > 0; i++) {
ItemStack item = inventory.getItem(i); ItemStack item = inventory.getItem(i);
if (item != null && item.getType() == material) { if (item != null && item.getType() == material) {
int toRemove = Math.min(item.getAmount(), remaining); int toRemove = Math.min(item.getAmount(), remaining);
ItemStack clone = item.clone();
clone.setAmount(toRemove);
removed.add(clone);
if (toRemove == item.getAmount()) { if (toRemove == item.getAmount()) {
inventory.setItem(i, null); inventory.setItem(i, null);
} else { } else {
@@ -158,12 +200,32 @@ public class TransactionManager {
remaining -= toRemove; 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 maxStack = material.getMaxStackSize();
int fullStacks = totalAmount / maxStack; int fullStacks = totalAmount / maxStack;
int remainder = totalAmount % maxStack; int remainder = totalAmount % maxStack;
@@ -185,7 +247,7 @@ public class TransactionManager {
/** /**
* get the shop's container inventory * get the shop's container inventory
*/ */
private Inventory getShopInventory(Shop shop) { public Inventory getShopInventory(Shop shop) {
Location signLoc = shop.getSignLocation(); Location signLoc = shop.getSignLocation();
Block signBlock = signLoc.getBlock(); Block signBlock = signLoc.getBlock();

View File

@@ -1,29 +1,14 @@
# hopper protection # oyeShops configuration
hoppers:
allow-product-output: true
block-price-input: true
# transaction history # transaction settings
history: transactions:
max-transactions-per-shop: 100 auto-prune: false
auto-prune: true max-per-shop: 100
# material aliases # whether to require players to have placed the container themselves this session
require-placement: true
# custom material aliases (add your own shortcuts)
aliases: aliases:
# common abbreviations # example:
dia: diamond # myalias: diamond
dias: diamond
iron: iron_ingot
gold: gold_ingot
emerald: emerald
ems: emerald
# blocks
stone: stone
dirt: dirt
cobble: cobblestone
# tools
pick: diamond_pickaxe
sword: diamond_sword
axe: diamond_axe

View File

@@ -1,5 +1,5 @@
name: oyeShops name: oyeShops
version: 1.0.0 version: 1.3.1
main: party.cybsec.oyeshops.OyeShopsPlugin main: party.cybsec.oyeshops.OyeShopsPlugin
api-version: '1.21' api-version: '1.21'
description: deterministic item-for-item chest barter description: deterministic item-for-item chest barter
@@ -9,7 +9,7 @@ website: https://party.cybsec
commands: commands:
oyeshops: oyeshops:
description: oyeShops commands 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] aliases: [oyes, oshop]
permissions: permissions: