forgot to do small contribs lmao

This commit is contained in:
2026-02-04 19:39:37 -05:00
commit 4251641bc6
36 changed files with 4455 additions and 0 deletions

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# gradle
.gradle/
build/
out/
# intellij
.idea/
*.iml
*.iws
*.ipr
# eclipse
.classpath
.project
.settings/
# vscode
.vscode/
# macos
.DS_Store
# plugin output
*.jar
# database
*.db
*.db-journal
# logs
logs/
*.log

187
README.md Normal file
View File

@@ -0,0 +1,187 @@
# oyeShops
deterministic item-for-item chest barter plugin for minecraft paper 1.21.11
## core identity
- **plugin name**: oyeShops
- **purpose**: deterministic item-for-item chest barter using sign text
- **no economy**, no currency, no ml, no heuristics
- **explicit opt-in only**
- **deterministic and reversible**
## features
### permission-gated shops
- only players with `oyeshops.create` can create shops
- only players with `oyeshops.use` can buy from shops
- ops bypass all permission checks
### sign-based activation
shops activate when all conditions are met:
- wall sign attached to chest
- sign text contains at least one "." character
- sign text parses cleanly into exactly one trade
- player has `oyeshops.create` permission
### deterministic parsing
sign parser is strict and deterministic:
- normalizes text (lowercase, strip punctuation)
- resolves material aliases from config
- detects quantities (explicit numbers or implicit: a/one/each → 1)
- requires directional keywords (for/per/costs/= or get/gives/buy/selling)
- ambiguity equals no shop - no auto-fixing
### atomic transactions
- buyer inventory verified before transaction
- chest stock verified before transaction
- all steps succeed or all rollback
- crash-safe via sqlite
### profit withdrawal
- sellers withdraw owed items via `/oyeshops withdraw`
- partial fills allowed if inventory full
- remainder stays owed in database
### admin tools
- `/oyeshops inspect` - toggle inspect mode
- right-click shops while inspecting to view details
- `/oyeshops enable <id>` - enable shop
- `/oyeshops disable <id>` - disable shop
- `/oyeshops unregister <id>` - delete shop
- `/oyeshops reload` - reload config
## building
### requirements
- java 21 or higher
- gradle (wrapper included)
### build commands
```bash
# build plugin jar
./gradlew build
# output jar location
build/libs/oyeShops-1.0.0.jar
```
## installation
1. build the plugin (see above)
2. copy `oyeShops-1.0.0.jar` to your server's `plugins/` directory
3. restart server
4. configure permissions in your permission plugin
5. optionally edit `plugins/oyeShops/config.yml`
## permissions
```yaml
oyeshops.create # allows placing valid shop signs (default: false)
oyeshops.use # allows buying from shops (default: false)
oyeshops.withdraw # allows withdrawing owed items (default: false)
oyeshops.inspect # allows admin inspection (default: op)
oyeshops.admin # full control (default: op)
oyeshops.break.override # allows breaking any shop (default: op)
```
## usage
### creating a shop
1. place a chest
2. attach a wall sign to the chest
3. write trade on sign, for example:
- `5 diamonds`
- `for`
- `1 netherite`
- (blank line)
this creates a shop that sells 1 netherite_ingot for 5 diamonds
### sign format examples
```
5 dia for 1 iron
→ 5 diamonds for 1 iron_ingot
1 emerald per cobble
→ 1 emerald for 1 cobblestone
10 stone = 1 diamond
→ 10 stone for 1 diamond
buy 1 pick for 5 ems
→ 5 emeralds for 1 diamond_pickaxe
```
### buying from a shop
1. right-click the shop chest
2. click any item in the fake chest gui
3. confirmation gui opens showing the trade
4. click green pane to confirm, red pane to cancel
### withdrawing profits
```bash
/oyeshops withdraw
```
withdraws all owed items from your shops into your inventory
## configuration
`plugins/oyeShops/config.yml`:
```yaml
# hopper protection
hoppers:
allow-product-output: true # allow hoppers to extract product items
block-price-input: true # block hoppers from inserting price items
# transaction history
history:
max-transactions-per-shop: 100 # max transactions to keep per shop
auto-prune: true # automatically prune old transactions
# material aliases
aliases:
dia: diamond
dias: diamond
iron: iron_ingot
gold: gold_ingot
emerald: emerald
ems: emerald
# add more as needed
```
## database
shops and transactions are stored in `plugins/oyeShops/oyeshops.db` (sqlite)
- crash-safe
- atomic updates
- bounded transaction history
## failure philosophy
- **ambiguity equals no shop**
- **no auto-fixing**
- **no warnings unless inspected**
- **deterministic parser output**
if a sign doesn't parse cleanly, it simply won't activate as a shop. no error messages, no warnings.
## non-goals
- no economy integration
- no pricing gui
- no machine learning
- no market mechanics
- no abstraction creep
## license
created by cybsec (party.cybsec)

39
build.gradle.kts Normal file
View File

@@ -0,0 +1,39 @@
plugins {
java
}
group = "party.cybsec"
version = "1.0.0"
description = "deterministic item-for-item chest barter"
repositories {
mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/")
}
dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT")
implementation("org.xerial:sqlite-jdbc:3.47.1.0")
}
java {
toolchain.languageVersion.set(JavaLanguageVersion.of(21))
}
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
}
tasks.jar {
archiveBaseName.set("oyeShops")
archiveVersion.set(project.version.toString())
}
tasks.processResources {
val props = mapOf("version" to version)
inputs.properties(props)
filteringCharset = "UTF-8"
filesMatching("plugin.yml") {
expand(props)
}
}

19
build.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/bash
# build script for oyeShops plugin
export JAVA_HOME=/opt/homebrew/opt/openjdk@21
export PATH="$JAVA_HOME/bin:$PATH"
echo "building oyeShops plugin..."
./gradlew clean build
if [ $? -eq 0 ]; then
echo ""
echo "✓ build successful!"
echo "jar location: build/libs/oyeShops-1.0.0.jar"
ls -lh build/libs/*.jar
else
echo ""
echo "✗ build failed"
exit 1
fi

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

1
settings.gradle.kts Normal file
View File

@@ -0,0 +1 @@
rootProject.name = "oyeShops"

View File

@@ -0,0 +1,182 @@
package party.cybsec.oyeshops;
import org.bukkit.plugin.java.JavaPlugin;
import party.cybsec.oyeshops.admin.InspectModeManager;
import party.cybsec.oyeshops.admin.SpoofManager;
import party.cybsec.oyeshops.command.AdminCommands;
import party.cybsec.oyeshops.config.ConfigManager;
import party.cybsec.oyeshops.database.DatabaseManager;
import party.cybsec.oyeshops.database.PlayerPreferenceRepository;
import party.cybsec.oyeshops.database.ShopRepository;
import party.cybsec.oyeshops.database.TransactionRepository;
import party.cybsec.oyeshops.listener.*;
import party.cybsec.oyeshops.model.Shop;
import party.cybsec.oyeshops.parser.MaterialAliasRegistry;
import party.cybsec.oyeshops.parser.SignParser;
import party.cybsec.oyeshops.registry.ShopRegistry;
import party.cybsec.oyeshops.transaction.TransactionManager;
import java.sql.SQLException;
import java.util.List;
/**
* main plugin class
*/
public class OyeShopsPlugin extends JavaPlugin {
// managers
private ConfigManager configManager;
private DatabaseManager databaseManager;
private ShopRepository shopRepository;
private TransactionRepository transactionRepository;
private PlayerPreferenceRepository playerPreferenceRepository;
private ShopRegistry shopRegistry;
private MaterialAliasRegistry aliasRegistry;
private SignParser signParser;
private TransactionManager transactionManager;
private InspectModeManager inspectModeManager;
private SpoofManager spoofManager;
private party.cybsec.oyeshops.manager.ActivationManager activationManager;
private party.cybsec.oyeshops.manager.ContainerMemoryManager containerMemoryManager;
private ShopActivationListener shopActivationListener;
@Override
public void onEnable() {
getLogger().info("initializing oyeShops...");
try {
// initialize config
configManager = new ConfigManager(this);
// initialize database
databaseManager = new DatabaseManager(getDataFolder());
databaseManager.initialize();
// initialize repositories
shopRepository = new ShopRepository(databaseManager);
transactionRepository = new TransactionRepository(databaseManager);
playerPreferenceRepository = new PlayerPreferenceRepository(databaseManager);
// load player preferences cache
playerPreferenceRepository.loadPreferences();
// initialize registry
shopRegistry = new ShopRegistry();
// initialize parsers
aliasRegistry = new MaterialAliasRegistry(configManager.getConfig().getConfigurationSection("aliases"));
signParser = new SignParser(aliasRegistry);
// initialize managers
transactionManager = new TransactionManager(this);
inspectModeManager = new InspectModeManager();
spoofManager = new SpoofManager();
activationManager = new party.cybsec.oyeshops.manager.ActivationManager();
containerMemoryManager = new party.cybsec.oyeshops.manager.ContainerMemoryManager();
shopActivationListener = new ShopActivationListener(this, signParser);
// load existing shops from database
loadShops();
// register listeners
registerListeners();
// register commands
registerCommands();
getLogger().info("oyeShops enabled successfully!");
} catch (SQLException e) {
getLogger().severe("failed to initialize database: " + e.getMessage());
e.printStackTrace();
getServer().getPluginManager().disablePlugin(this);
}
}
@Override
public void onDisable() {
getLogger().info("shutting down oyeShops...");
// close database connection
if (databaseManager != null) {
databaseManager.close();
}
getLogger().info("oyeShops disabled");
}
private void loadShops() throws SQLException {
List<Shop> shops = shopRepository.loadAllShops();
for (Shop shop : shops) {
shopRegistry.register(shop);
}
getLogger().info("loaded " + shops.size() + " shops from database");
}
private void registerListeners() {
getServer().getPluginManager().registerEvents(shopActivationListener, this);
getServer().getPluginManager().registerEvents(new ShopProtectionListener(this), this);
getServer().getPluginManager().registerEvents(new ChestInteractionListener(this), this);
getServer().getPluginManager().registerEvents(new InspectListener(this), this);
getServer().getPluginManager().registerEvents(new LoginListener(this), this);
getServer().getPluginManager().registerEvents(new ContainerPlacementListener(this), this);
}
private void registerCommands() {
AdminCommands adminCommands = new AdminCommands(this);
getCommand("oyeshops").setExecutor(adminCommands);
getCommand("oyeshops").setTabCompleter(adminCommands);
}
// getters for managers
public ConfigManager getConfigManager() {
return configManager;
}
public ShopRepository getShopRepository() {
return shopRepository;
}
public TransactionRepository getTransactionRepository() {
return transactionRepository;
}
public PlayerPreferenceRepository getPlayerPreferenceRepository() {
return playerPreferenceRepository;
}
public ShopRegistry getShopRegistry() {
return shopRegistry;
}
public TransactionManager getTransactionManager() {
return transactionManager;
}
public InspectModeManager getInspectModeManager() {
return inspectModeManager;
}
public SpoofManager getSpoofManager() {
return spoofManager;
}
public party.cybsec.oyeshops.manager.ActivationManager getActivationManager() {
return activationManager;
}
public party.cybsec.oyeshops.manager.ContainerMemoryManager getContainerMemoryManager() {
return containerMemoryManager;
}
public ShopActivationListener getShopActivationListener() {
return shopActivationListener;
}
public SignParser getSignParser() {
return signParser;
}
public MaterialAliasRegistry getAliasRegistry() {
return aliasRegistry;
}
}

View File

@@ -0,0 +1,51 @@
package party.cybsec.oyeshops.admin;
import org.bukkit.entity.Player;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
/**
* toggles inspect mode for admins
*/
public class InspectModeManager {
private final Set<UUID> inspectMode = new HashSet<>();
/**
* toggle inspect mode for player
*
* @return true if now inspecting, false if stopped
*/
public boolean toggle(Player player) {
UUID uuid = player.getUniqueId();
if (inspectMode.contains(uuid)) {
inspectMode.remove(uuid);
return false;
} else {
inspectMode.add(uuid);
return true;
}
}
/**
* check if player is in inspect mode
*/
public boolean isInspecting(Player player) {
return inspectMode.contains(player.getUniqueId());
}
/**
* enable inspect mode
*/
public void enable(Player player) {
inspectMode.add(player.getUniqueId());
}
/**
* disable inspect mode
*/
public void disable(Player player) {
inspectMode.remove(player.getUniqueId());
}
}

View File

@@ -0,0 +1,46 @@
package party.cybsec.oyeshops.admin;
import org.bukkit.entity.Player;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
/**
* manages admin spoof mode
* when spoofing, admin is treated as non-owner for their shops
* allows testing purchases from own shops
*/
public class SpoofManager {
private final Set<UUID> spoofing = new HashSet<>();
/**
* check if player is in spoof mode
*/
public boolean isSpoofing(Player player) {
return spoofing.contains(player.getUniqueId());
}
/**
* toggle spoof mode for player
*
* @return true if now spoofing, false if stopped spoofing
*/
public boolean toggle(Player player) {
UUID uuid = player.getUniqueId();
if (spoofing.contains(uuid)) {
spoofing.remove(uuid);
return false;
} else {
spoofing.add(uuid);
return true;
}
}
/**
* disable spoof mode for player
*/
public void disable(Player player) {
spoofing.remove(player.getUniqueId());
}
}

View File

@@ -0,0 +1,368 @@
package party.cybsec.oyeshops.command;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import party.cybsec.oyeshops.OyeShopsPlugin;
import party.cybsec.oyeshops.model.PendingActivation;
import party.cybsec.oyeshops.model.Shop;
import party.cybsec.oyeshops.permission.PermissionManager;
import party.cybsec.oyeshops.listener.ShopActivationListener;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
/**
* handle /oyeshops commands for both admins and players
*/
public class AdminCommands implements CommandExecutor, TabCompleter {
private final OyeShopsPlugin plugin;
public AdminCommands(OyeShopsPlugin plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (args.length == 0) {
sendHelp(sender);
return true;
}
String subCommand = args[0].toLowerCase();
switch (subCommand) {
case "on", "enable" -> handleEnable(sender, args);
case "off", "disable" -> handleDisable(sender, args);
case "toggle" -> handleToggle(sender);
case "notify" -> handleNotifyToggle(sender);
case "info" -> handleInfo(sender);
case "reload" -> handleReload(sender);
case "inspect", "i" -> handleInspect(sender);
case "spoof", "s" -> handleSpoof(sender);
case "unregister", "delete", "remove" -> handleUnregister(sender, args);
case "_activate" -> handleActivate(sender, args);
default -> sendHelp(sender);
}
return true;
}
private void sendHelp(CommandSender sender) {
sender.sendMessage(Component.text("=== oyeshops commands ===", NamedTextColor.GOLD));
sender.sendMessage(Component.text("/oyeshops on", NamedTextColor.YELLOW)
.append(Component.text(" - enable shop creation", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops off", NamedTextColor.YELLOW)
.append(Component.text(" - disable shop creation", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops toggle", NamedTextColor.YELLOW)
.append(Component.text(" - toggle shop creation", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops notify", NamedTextColor.YELLOW)
.append(Component.text(" - toggle low stock login notifications", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops info", NamedTextColor.YELLOW)
.append(Component.text(" - plugin information", NamedTextColor.GRAY)));
if (PermissionManager.isAdmin(sender)) {
sender.sendMessage(Component.text("/oyeshops reload", NamedTextColor.YELLOW)
.append(Component.text(" - reload config", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops inspect", NamedTextColor.YELLOW)
.append(Component.text(" - toggle inspect mode", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops spoof", NamedTextColor.YELLOW)
.append(Component.text(" - toggle spoof mode", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops enable <id>", NamedTextColor.YELLOW)
.append(Component.text(" - enable a shop", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops disable <id>", NamedTextColor.YELLOW)
.append(Component.text(" - disable a shop", NamedTextColor.GRAY)));
sender.sendMessage(Component.text("/oyeshops unregister <id>", NamedTextColor.YELLOW)
.append(Component.text(" - delete a shop", NamedTextColor.GRAY)));
}
}
private void handleInfo(CommandSender sender) {
sender.sendMessage(Component.text("cybsec made this plugin", NamedTextColor.GRAY));
}
private void handleReload(CommandSender sender) {
if (!PermissionManager.isAdmin(sender)) {
sender.sendMessage(Component.text("no permission", NamedTextColor.RED));
return;
}
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
plugin.getConfigManager().reload();
plugin.getServer().getScheduler().runTask(plugin, () -> {
sender.sendMessage(Component.text("config reloaded", NamedTextColor.GREEN));
});
});
}
private void handleInspect(CommandSender sender) {
if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("players only", NamedTextColor.RED));
return;
}
if (!PermissionManager.canInspect(player)) {
sender.sendMessage(Component.text("no permission", NamedTextColor.RED));
return;
}
boolean nowInspecting = plugin.getInspectModeManager().toggle(player);
if (nowInspecting) {
player.sendMessage(
Component.text("inspect mode enabled - right-click shops to view info", NamedTextColor.GREEN));
} else {
player.sendMessage(Component.text("inspect mode disabled", NamedTextColor.YELLOW));
}
}
private void handleSpoof(CommandSender sender) {
if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("players only", NamedTextColor.RED));
return;
}
if (!PermissionManager.isAdmin(player)) {
sender.sendMessage(Component.text("no permission", NamedTextColor.RED));
return;
}
boolean nowSpoofing = plugin.getSpoofManager().toggle(player);
if (nowSpoofing) {
player.sendMessage(
Component.text("spoof mode enabled - you can now buy from your own shops", NamedTextColor.GREEN));
} else {
player.sendMessage(Component.text("spoof mode disabled", NamedTextColor.YELLOW));
}
}
private void handleToggle(CommandSender sender) {
if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("players only", NamedTextColor.RED));
return;
}
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
try {
boolean nowEnabled = plugin.getPlayerPreferenceRepository().toggleShops(player.getUniqueId());
plugin.getServer().getScheduler().runTask(plugin, () -> {
if (nowEnabled) {
player.sendMessage(
Component.text("shop creation enabled. you can now make chest shops!",
NamedTextColor.GREEN));
} else {
player.sendMessage(
Component.text("shop creation disabled. oyeshops will now ignore your signs.",
NamedTextColor.YELLOW));
}
});
} catch (SQLException e) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
player.sendMessage(Component.text("database error", NamedTextColor.RED));
});
e.printStackTrace();
}
});
}
private void handleNotifyToggle(CommandSender sender) {
if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("players only", NamedTextColor.RED));
return;
}
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
try {
boolean nowEnabled = plugin.getPlayerPreferenceRepository().toggleNotify(player.getUniqueId());
plugin.getServer().getScheduler().runTask(plugin, () -> {
if (nowEnabled) {
player.sendMessage(
Component.text(
"stock notifications enabled. you will be notified of low stock on login.",
NamedTextColor.GREEN));
} else {
player.sendMessage(Component.text("stock notifications disabled.", NamedTextColor.YELLOW));
}
});
} catch (SQLException e) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
player.sendMessage(Component.text("database error", NamedTextColor.RED));
});
e.printStackTrace();
}
});
}
private void handleActivate(CommandSender sender, String[] args) {
if (!(sender instanceof Player player))
return;
if (args.length < 2)
return;
String action = args[1].toLowerCase();
PendingActivation activation = plugin.getActivationManager().getAndRemove(player.getUniqueId());
if (activation == null) {
player.sendMessage(Component.text("activation expired or not found", NamedTextColor.RED));
return;
}
switch (action) {
case "accept" -> {
finalizeShop(player, activation);
}
case "invert" -> {
finalizeShop(player, activation.invert());
}
case "cancel" -> {
player.sendMessage(
Component.text("shop creation cancelled. sign remains as vanilla.", NamedTextColor.YELLOW));
}
}
}
private void finalizeShop(Player player, PendingActivation activation) {
plugin.getShopActivationListener().finalizeShop(player, activation);
}
private void handleEnable(CommandSender sender, String[] args) {
if (args.length < 2) {
if (sender instanceof Player player) {
try {
plugin.getPlayerPreferenceRepository().enableShops(player.getUniqueId());
player.sendMessage(Component.text("shop creation enabled. you can now make chest shops!",
NamedTextColor.GREEN));
} catch (SQLException e) {
player.sendMessage(Component.text("database error", NamedTextColor.RED));
e.printStackTrace();
}
} else {
sender.sendMessage(Component.text("usage: /oyeshops enable <id>", NamedTextColor.RED));
}
return;
}
if (!PermissionManager.isAdmin(sender)) {
sender.sendMessage(Component.text("no permission", NamedTextColor.RED));
return;
}
try {
int shopId = Integer.parseInt(args[1]);
Shop shop = plugin.getShopRegistry().getShopById(shopId);
if (shop == null) {
sender.sendMessage(Component.text("shop not found: " + shopId, NamedTextColor.RED));
return;
}
shop.setEnabled(true);
plugin.getShopRepository().setEnabled(shopId, true);
sender.sendMessage(Component.text("shop #" + shopId + " enabled", NamedTextColor.GREEN));
} catch (NumberFormatException e) {
sender.sendMessage(Component.text("invalid shop id", NamedTextColor.RED));
} catch (SQLException e) {
sender.sendMessage(Component.text("database error", NamedTextColor.RED));
e.printStackTrace();
}
}
private void handleDisable(CommandSender sender, String[] args) {
if (args.length < 2) {
if (sender instanceof Player player) {
try {
plugin.getPlayerPreferenceRepository().disableShops(player.getUniqueId());
player.sendMessage(Component.text("shop creation disabled. oyeshops will now ignore your signs.",
NamedTextColor.YELLOW));
} catch (SQLException e) {
player.sendMessage(Component.text("database error", NamedTextColor.RED));
e.printStackTrace();
}
} else {
sender.sendMessage(Component.text("usage: /oyeshops disable <id>", NamedTextColor.RED));
}
return;
}
if (!PermissionManager.isAdmin(sender)) {
sender.sendMessage(Component.text("no permission", NamedTextColor.RED));
return;
}
try {
int shopId = Integer.parseInt(args[1]);
Shop shop = plugin.getShopRegistry().getShopById(shopId);
if (shop == null) {
sender.sendMessage(Component.text("shop not found: " + shopId, NamedTextColor.RED));
return;
}
shop.setEnabled(false);
plugin.getShopRepository().setEnabled(shopId, false);
sender.sendMessage(Component.text("shop #" + shopId + " disabled", NamedTextColor.YELLOW));
} catch (NumberFormatException e) {
sender.sendMessage(Component.text("invalid shop id", NamedTextColor.RED));
} catch (SQLException e) {
sender.sendMessage(Component.text("database error", NamedTextColor.RED));
e.printStackTrace();
}
}
private void handleUnregister(CommandSender sender, String[] args) {
if (!PermissionManager.isAdmin(sender)) {
sender.sendMessage(Component.text("no permission", NamedTextColor.RED));
return;
}
if (args.length < 2) {
sender.sendMessage(Component.text("usage: /oyeshops unregister <id>", NamedTextColor.RED));
return;
}
try {
int shopId = Integer.parseInt(args[1]);
Shop shop = plugin.getShopRegistry().getShopById(shopId);
if (shop == null) {
sender.sendMessage(Component.text("shop not found: " + shopId, NamedTextColor.RED));
return;
}
plugin.getShopRegistry().unregister(shop);
plugin.getShopRepository().deleteShop(shopId);
sender.sendMessage(Component.text("shop #" + shopId + " deleted", NamedTextColor.RED));
} catch (NumberFormatException e) {
sender.sendMessage(Component.text("invalid shop id", NamedTextColor.RED));
} catch (SQLException e) {
sender.sendMessage(Component.text("database error", NamedTextColor.RED));
e.printStackTrace();
}
}
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
List<String> completions = new ArrayList<>();
if (args.length == 1) {
List<String> subCommands = new ArrayList<>(List.of("on", "off", "toggle", "notify", "enable", "disable"));
if (PermissionManager.isAdmin(sender)) {
subCommands.addAll(List.of("reload", "inspect", "spoof", "unregister"));
}
String partial = args[0].toLowerCase();
for (String sub : subCommands) {
if (sub.startsWith(partial))
completions.add(sub);
}
} else if (args.length == 2) {
String subCommand = args[0].toLowerCase();
if (PermissionManager.isAdmin(sender) && (subCommand.equals("enable") || subCommand.equals("disable")
|| subCommand.equals("unregister"))) {
String partial = args[1];
for (Shop shop : plugin.getShopRegistry().getAllShops()) {
String id = String.valueOf(shop.getId());
if (id.startsWith(partial))
completions.add(id);
}
}
}
return completions;
}
}

View File

@@ -0,0 +1,43 @@
package party.cybsec.oyeshops.config;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.plugin.Plugin;
/**
* configuration loading and management
*/
public class ConfigManager {
private final Plugin plugin;
private FileConfiguration config;
public ConfigManager(Plugin plugin) {
this.plugin = plugin;
reload();
}
public void reload() {
plugin.saveDefaultConfig();
plugin.reloadConfig();
this.config = plugin.getConfig();
}
public boolean isAllowProductOutput() {
return config.getBoolean("hoppers.allow-product-output", true);
}
public boolean isBlockPriceInput() {
return config.getBoolean("hoppers.block-price-input", true);
}
public int getMaxTransactionsPerShop() {
return config.getInt("history.max-transactions-per-shop", 100);
}
public boolean isAutoPrune() {
return config.getBoolean("history.auto-prune", true);
}
public FileConfiguration getConfig() {
return config;
}
}

View File

@@ -0,0 +1,102 @@
package party.cybsec.oyeshops.database;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;
/**
* sqlite database initialization and connection management
*/
public class DatabaseManager {
private final File dataFolder;
private Connection connection;
public DatabaseManager(File dataFolder) {
this.dataFolder = dataFolder;
}
public void initialize() throws SQLException {
if (!dataFolder.exists()) {
dataFolder.mkdirs();
}
File dbFile = new File(dataFolder, "oyeshops.db");
String url = "jdbc:sqlite:" + dbFile.getAbsolutePath();
connection = DriverManager.getConnection(url);
createTables();
}
private void createTables() throws SQLException {
try (Statement stmt = connection.createStatement()) {
// shops table
stmt.execute("""
create table if not exists shops (
shop_id integer primary key autoincrement,
world_uuid text not null,
sign_x integer not null,
sign_y integer not null,
sign_z integer not null,
owner_uuid text not null,
price_item text not null,
price_quantity integer not null,
product_item text not null,
product_quantity integer not null,
owed_amount integer not null default 0,
enabled boolean not null default true,
created_at integer not null,
unique(world_uuid, sign_x, sign_y, sign_z)
)
""");
// transaction history table
stmt.execute("""
create table if not exists transactions (
transaction_id integer primary key autoincrement,
shop_id integer not null,
buyer_uuid text not null,
quantity_traded integer not null,
timestamp integer not null,
foreign key (shop_id) references shops(shop_id) on delete cascade
)
""");
// player preferences table (opt-in for shop creation)
stmt.execute("""
create table if not exists player_preferences (
player_uuid text primary key,
shops_enabled boolean not null default false,
notify_low_stock boolean not null default false,
enabled_at integer
)
""");
// indexes
stmt.execute("""
create index if not exists idx_shop_location
on shops(world_uuid, sign_x, sign_y, sign_z)
""");
stmt.execute("""
create index if not exists idx_transaction_shop
on transactions(shop_id, timestamp desc)
""");
}
}
public Connection getConnection() {
return connection;
}
public void close() {
if (connection != null) {
try {
connection.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}

View File

@@ -0,0 +1,146 @@
package party.cybsec.oyeshops.database;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
/**
* manages player opt-in preferences for shop creation and notifications
* shops are disabled by default - players must explicitly enable
* stock notifications are disabled by default
*/
public class PlayerPreferenceRepository {
private final DatabaseManager dbManager;
// in-memory cache for performance
private final Set<UUID> enabledPlayers = new HashSet<>();
private final Set<UUID> notifyPlayers = new HashSet<>();
public PlayerPreferenceRepository(DatabaseManager dbManager) {
this.dbManager = dbManager;
}
/**
* load all player preferences into cache
*/
public void loadPreferences() throws SQLException {
String sql = "select player_uuid, shops_enabled, notify_low_stock from player_preferences";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
UUID uuid = UUID.fromString(rs.getString("player_uuid"));
if (rs.getBoolean("shops_enabled")) {
enabledPlayers.add(uuid);
}
if (rs.getBoolean("notify_low_stock")) {
notifyPlayers.add(uuid);
}
}
}
}
/**
* check if player has shops enabled (opt-in)
*/
public boolean isShopsEnabled(UUID playerUuid) {
return enabledPlayers.contains(playerUuid);
}
/**
* check if player has stock notifications enabled
*/
public boolean isNotifyEnabled(UUID playerUuid) {
return notifyPlayers.contains(playerUuid);
}
/**
* enable shops for player
*/
public void enableShops(UUID playerUuid) throws SQLException {
String sql = """
insert into player_preferences (player_uuid, shops_enabled, enabled_at)
values (?, true, ?)
on conflict(player_uuid) do update set shops_enabled = true, enabled_at = ?
""";
long now = System.currentTimeMillis();
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setString(1, playerUuid.toString());
stmt.setLong(2, now);
stmt.setLong(3, now);
stmt.executeUpdate();
}
enabledPlayers.add(playerUuid);
}
/**
* disable shops for player
*/
public void disableShops(UUID playerUuid) throws SQLException {
String sql = """
insert into player_preferences (player_uuid, shops_enabled, enabled_at)
values (?, false, null)
on conflict(player_uuid) do update set shops_enabled = false
""";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setString(1, playerUuid.toString());
stmt.executeUpdate();
}
enabledPlayers.remove(playerUuid);
}
/**
* toggle shops for player
*
* @return true if now enabled, false if now disabled
*/
public boolean toggleShops(UUID playerUuid) throws SQLException {
if (isShopsEnabled(playerUuid)) {
disableShops(playerUuid);
return false;
} else {
enableShops(playerUuid);
return true;
}
}
/**
* set stock notification preference
*/
public void setNotifyEnabled(UUID playerUuid, boolean enabled) throws SQLException {
String sql = """
insert into player_preferences (player_uuid, notify_low_stock)
values (?, ?)
on conflict(player_uuid) do update set notify_low_stock = ?
""";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setString(1, playerUuid.toString());
stmt.setBoolean(2, enabled);
stmt.setBoolean(3, enabled);
stmt.executeUpdate();
}
if (enabled) {
notifyPlayers.add(playerUuid);
} else {
notifyPlayers.remove(playerUuid);
}
}
/**
* toggle notification preference
*/
public boolean toggleNotify(UUID playerUuid) throws SQLException {
boolean newState = !isNotifyEnabled(playerUuid);
setNotifyEnabled(playerUuid, newState);
return newState;
}
}

View File

@@ -0,0 +1,246 @@
package party.cybsec.oyeshops.database;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import party.cybsec.oyeshops.model.Shop;
import party.cybsec.oyeshops.model.Trade;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* crud operations for shops
*/
public class ShopRepository {
private final DatabaseManager dbManager;
public ShopRepository(DatabaseManager dbManager) {
this.dbManager = dbManager;
}
/**
* create new shop in database
*/
public int createShop(Shop shop) throws SQLException {
String sql = """
insert into shops (world_uuid, sign_x, sign_y, sign_z, owner_uuid,
price_item, price_quantity, product_item, product_quantity,
owed_amount, enabled, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql,
Statement.RETURN_GENERATED_KEYS)) {
Location loc = shop.getSignLocation();
stmt.setString(1, loc.getWorld().getUID().toString());
stmt.setInt(2, loc.getBlockX());
stmt.setInt(3, loc.getBlockY());
stmt.setInt(4, loc.getBlockZ());
stmt.setString(5, shop.getOwner().toString());
stmt.setString(6, shop.getTrade().priceItem().name());
stmt.setInt(7, shop.getTrade().priceQuantity());
stmt.setString(8, shop.getTrade().productItem().name());
stmt.setInt(9, shop.getTrade().productQuantity());
stmt.setInt(10, shop.getOwedAmount());
stmt.setBoolean(11, shop.isEnabled());
stmt.setLong(12, shop.getCreatedAt());
stmt.executeUpdate();
try (ResultSet rs = stmt.getGeneratedKeys()) {
if (rs.next()) {
return rs.getInt(1);
}
}
}
throw new SQLException("failed to create shop, no id generated");
}
/**
* get shop by sign location
*/
public Shop getShop(Location location) throws SQLException {
String sql = """
select * from shops
where world_uuid = ? and sign_x = ? and sign_y = ? and sign_z = ?
""";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setString(1, location.getWorld().getUID().toString());
stmt.setInt(2, location.getBlockX());
stmt.setInt(3, location.getBlockY());
stmt.setInt(4, location.getBlockZ());
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return shopFromResultSet(rs);
}
}
}
return null;
}
/**
* get shop by id
*/
public Shop getShopById(int shopId) throws SQLException {
String sql = "select * from shops where shop_id = ?";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setInt(1, shopId);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return shopFromResultSet(rs);
}
}
}
return null;
}
/**
* update owed amount atomically
*/
public void updateOwedAmount(int shopId, int amount) throws SQLException {
String sql = "update shops set owed_amount = ? where shop_id = ?";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setInt(1, amount);
stmt.setInt(2, shopId);
stmt.executeUpdate();
}
}
/**
* add to owed amount atomically
*/
public void addOwedAmount(int shopId, int delta) throws SQLException {
String sql = "update shops set owed_amount = owed_amount + ? where shop_id = ?";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setInt(1, delta);
stmt.setInt(2, shopId);
stmt.executeUpdate();
}
}
/**
* enable shop
*/
public void enableShop(int shopId) throws SQLException {
String sql = "update shops set enabled = true where shop_id = ?";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setInt(1, shopId);
stmt.executeUpdate();
}
}
/**
* disable shop
*/
public void disableShop(int shopId) throws SQLException {
String sql = "update shops set enabled = false where shop_id = ?";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setInt(1, shopId);
stmt.executeUpdate();
}
}
/**
* set shop enabled state
*/
public void setEnabled(int shopId, boolean enabled) throws SQLException {
if (enabled) {
enableShop(shopId);
} else {
disableShop(shopId);
}
}
/**
* delete shop
*/
public void deleteShop(int shopId) throws SQLException {
String sql = "delete from shops where shop_id = ?";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setInt(1, shopId);
stmt.executeUpdate();
}
}
/**
* get all shops owned by player
*/
public List<Shop> getShopsByOwner(UUID ownerUuid) throws SQLException {
String sql = "select * from shops where owner_uuid = ?";
List<Shop> shops = new ArrayList<>();
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setString(1, ownerUuid.toString());
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
shops.add(shopFromResultSet(rs));
}
}
}
return shops;
}
/**
* load all shops from database
*/
public List<Shop> loadAllShops() throws SQLException {
String sql = "select * from shops";
List<Shop> shops = new ArrayList<>();
try (Statement stmt = dbManager.getConnection().createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
while (rs.next()) {
shops.add(shopFromResultSet(rs));
}
}
return shops;
}
/**
* convert result set to shop object
*/
private Shop shopFromResultSet(ResultSet rs) throws SQLException {
int id = rs.getInt("shop_id");
UUID worldUuid = UUID.fromString(rs.getString("world_uuid"));
int x = rs.getInt("sign_x");
int y = rs.getInt("sign_y");
int z = rs.getInt("sign_z");
UUID ownerUuid = UUID.fromString(rs.getString("owner_uuid"));
Material priceItem = Material.valueOf(rs.getString("price_item"));
int priceQty = rs.getInt("price_quantity");
Material productItem = Material.valueOf(rs.getString("product_item"));
int productQty = rs.getInt("product_quantity");
int owedAmount = rs.getInt("owed_amount");
boolean enabled = rs.getBoolean("enabled");
long createdAt = rs.getLong("created_at");
World world = Bukkit.getWorld(worldUuid);
if (world == null) {
throw new SQLException("world not found: " + worldUuid);
}
Location location = new Location(world, x, y, z);
Trade trade = new Trade(priceItem, priceQty, productItem, productQty);
return new Shop(id, location, ownerUuid, trade, owedAmount, enabled, createdAt);
}
}

View File

@@ -0,0 +1,120 @@
package party.cybsec.oyeshops.database;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* transaction history operations
*/
public class TransactionRepository {
private final DatabaseManager dbManager;
public TransactionRepository(DatabaseManager dbManager) {
this.dbManager = dbManager;
}
/**
* record a transaction
*/
public void recordTransaction(int shopId, UUID buyerUuid, int quantityTraded) throws SQLException {
String sql = """
insert into transactions (shop_id, buyer_uuid, quantity_traded, timestamp)
values (?, ?, ?, ?)
""";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setInt(1, shopId);
stmt.setString(2, buyerUuid.toString());
stmt.setInt(3, quantityTraded);
stmt.setLong(4, System.currentTimeMillis());
stmt.executeUpdate();
}
}
/**
* get recent transactions for a shop
*/
public List<Transaction> getRecentTransactions(int shopId, int limit) throws SQLException {
String sql = """
select * from transactions
where shop_id = ?
order by timestamp desc
limit ?
""";
List<Transaction> transactions = new ArrayList<>();
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setInt(1, shopId);
stmt.setInt(2, limit);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
transactions.add(new Transaction(
rs.getInt("transaction_id"),
rs.getInt("shop_id"),
UUID.fromString(rs.getString("buyer_uuid")),
rs.getInt("quantity_traded"),
rs.getLong("timestamp")));
}
}
}
return transactions;
}
/**
* prune old transactions, keeping only the most recent
*/
public void pruneOldTransactions(int shopId, int keepCount) throws SQLException {
String sql = """
delete from transactions
where shop_id = ?
and transaction_id not in (
select transaction_id from transactions
where shop_id = ?
order by timestamp desc
limit ?
)
""";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setInt(1, shopId);
stmt.setInt(2, shopId);
stmt.setInt(3, keepCount);
stmt.executeUpdate();
}
}
/**
* prune old transactions (alias)
*/
public void pruneTransactions(int shopId, int keepCount) throws SQLException {
pruneOldTransactions(shopId, keepCount);
}
/**
* clear all transaction history for a shop
*/
public void clearHistory(int shopId) throws SQLException {
String sql = "delete from transactions where shop_id = ?";
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
stmt.setInt(1, shopId);
stmt.executeUpdate();
}
}
/**
* transaction record
*/
public record Transaction(
int transactionId,
int shopId,
UUID buyerUuid,
int quantityTraded,
long timestamp) {
}
}

View File

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

View File

@@ -0,0 +1,324 @@
package party.cybsec.oyeshops.listener;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.block.Barrel;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.Chest;
import org.bukkit.block.data.type.WallSign;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack;
import party.cybsec.oyeshops.OyeShopsPlugin;
import party.cybsec.oyeshops.gui.ConfirmationGui;
import party.cybsec.oyeshops.model.Shop;
import party.cybsec.oyeshops.model.Trade;
import party.cybsec.oyeshops.permission.PermissionManager;
import party.cybsec.oyeshops.transaction.TransactionManager;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
/**
* handles chest/barrel interactions for shop purchases and owner withdrawals
*/
public class ChestInteractionListener implements Listener {
private final OyeShopsPlugin plugin;
// track players viewing fake shop inventories
private final Map<Player, Shop> viewingShop = new HashMap<>();
public ChestInteractionListener(OyeShopsPlugin plugin) {
this.plugin = plugin;
}
@EventHandler(priority = EventPriority.HIGH)
public void onPlayerInteract(PlayerInteractEvent event) {
if (event.getAction() != Action.RIGHT_CLICK_BLOCK) {
return;
}
Block block = event.getClickedBlock();
if (block == null) {
return;
}
// check if this is a container (chest or barrel)
if (!isContainer(block.getType())) {
return;
}
// check if there's a shop sign attached
Shop shop = findShopForContainer(block);
if (shop == null) {
return; // not a shop container
}
Player player = event.getPlayer();
// check if shop is enabled
if (!shop.isEnabled()) {
player.sendMessage(Component.text("this shop is disabled", NamedTextColor.RED));
event.setCancelled(true);
return;
}
// check if player is owner (unless spoofing)
boolean isOwner = shop.getOwner().equals(player.getUniqueId())
&& !plugin.getSpoofManager().isSpoofing(player);
if (isOwner) {
// owner interaction - check for owed items and dispense
if (shop.getOwedAmount() > 0) {
withdrawOwedItems(player, shop);
}
// let owner open the chest normally - don't cancel event
return;
}
// buyer interaction - must have permission
if (!PermissionManager.canUse(player)) {
player.sendMessage(Component.text("you don't have permission to use shops", NamedTextColor.RED));
event.setCancelled(true);
return;
}
// cancel normal chest opening
event.setCancelled(true);
// open shop GUI
openShopGui(player, shop, block);
}
/**
* withdraw owed items to owner's inventory
*/
private void withdrawOwedItems(Player player, Shop shop) {
int owed = shop.getOwedAmount();
if (owed <= 0) {
return;
}
Material priceItem = shop.getTrade().priceItem();
int maxStack = priceItem.getMaxStackSize();
int withdrawn = 0;
int remaining = owed;
while (remaining > 0) {
int toGive = Math.min(remaining, maxStack);
ItemStack stack = new ItemStack(priceItem, toGive);
HashMap<Integer, ItemStack> overflow = player.getInventory().addItem(stack);
if (overflow.isEmpty()) {
withdrawn += toGive;
remaining -= toGive;
} else {
// inventory full - stop
int notAdded = overflow.values().stream().mapToInt(ItemStack::getAmount).sum();
withdrawn += (toGive - notAdded);
break;
}
}
if (withdrawn > 0) {
try {
// update database
int newOwed = owed - withdrawn;
shop.setOwedAmount(newOwed);
plugin.getShopRepository().updateOwedAmount(shop.getId(), -withdrawn);
player.sendMessage(
Component.text("withdrew " + withdrawn + " " + formatMaterial(priceItem), NamedTextColor.GREEN)
.append(Component.text(newOwed > 0 ? " (" + newOwed + " remaining)" : "",
NamedTextColor.GRAY)));
} catch (SQLException e) {
e.printStackTrace();
player.sendMessage(Component.text("database error during withdrawal", NamedTextColor.RED));
}
} else {
player.sendMessage(Component.text("inventory full - could not withdraw", NamedTextColor.RED));
}
}
/**
* open shop GUI for buyer
*/
private void openShopGui(Player player, Shop shop, Block containerBlock) {
Trade trade = shop.getTrade();
// create fake inventory showing only product items
Inventory shopInventory = Bukkit.createInventory(
new ShopInventoryHolder(shop),
27,
Component.text("shop: " + trade.priceQuantity() + " " + formatMaterial(trade.priceItem()) +
"" + trade.productQuantity() + " " + formatMaterial(trade.productItem())));
// get real container inventory
Inventory realInventory = getContainerInventory(containerBlock);
if (realInventory == null) {
return;
}
// copy only product items to fake inventory (first 27 found)
int shopSlot = 0;
for (ItemStack item : realInventory.getContents()) {
if (shopSlot >= 27)
break;
if (item != null && item.getType() == trade.productItem()) {
shopInventory.setItem(shopSlot++, item.clone());
}
}
viewingShop.put(player, shop);
player.openInventory(shopInventory);
}
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
if (!(event.getWhoClicked() instanceof Player player)) {
return;
}
InventoryHolder holder = event.getInventory().getHolder();
// handle shop inventory clicks
if (holder instanceof ShopInventoryHolder shopHolder) {
event.setCancelled(true);
Shop shop = shopHolder.shop();
// if clicked in the shop inventory area
if (event.getRawSlot() < event.getInventory().getSize()) {
ItemStack clicked = event.getCurrentItem();
if (clicked != null && clicked.getType() == shop.getTrade().productItem()) {
// determine quantity based on click type
int units = 1;
if (event.isShiftClick()) {
// shift-click: calculate max units based on items clicked
units = clicked.getAmount() / shop.getTrade().productQuantity();
if (units < 1)
units = 1;
}
// open confirmation GUI
player.closeInventory();
openConfirmationGui(player, shop, units);
}
}
return;
}
// handle confirmation GUI clicks
if (holder instanceof ConfirmationGui confirmGui) {
event.setCancelled(true);
Shop shop = confirmGui.getShop();
int slot = event.getRawSlot();
// green pane = confirm (slot 11-15 or specifically slot 11)
if (slot == 11 || slot == 12 || slot == 13) {
player.closeInventory();
executeTransaction(player, shop, confirmGui.getUnits());
}
// red pane = cancel (slot 15 or right side)
else if (slot == 14 || slot == 15 || slot == 16) {
player.closeInventory();
player.sendMessage(Component.text("purchase cancelled", NamedTextColor.YELLOW));
}
}
}
/**
* open confirmation GUI
*/
private void openConfirmationGui(Player player, Shop shop, int units) {
ConfirmationGui gui = new ConfirmationGui(shop, units);
player.openInventory(gui.getInventory());
}
/**
* execute the transaction
*/
private void executeTransaction(Player player, Shop shop, int units) {
TransactionManager.Result result = plugin.getTransactionManager().execute(player, shop, units);
switch (result) {
case SUCCESS -> player.sendMessage(Component.text("purchase complete!", NamedTextColor.GREEN));
case INSUFFICIENT_FUNDS ->
player.sendMessage(Component.text("you don't have enough items to pay", NamedTextColor.RED));
case INSUFFICIENT_STOCK -> player.sendMessage(Component.text("shop is out of stock", NamedTextColor.RED));
case INVENTORY_FULL -> player.sendMessage(Component.text("your inventory is full", NamedTextColor.RED));
case DATABASE_ERROR ->
player.sendMessage(Component.text("transaction failed - database error", NamedTextColor.RED));
case SHOP_DISABLED -> player.sendMessage(Component.text("this shop is disabled", NamedTextColor.RED));
}
}
/**
* find shop attached to a container block
*/
private Shop findShopForContainer(Block containerBlock) {
// check all adjacent blocks for a wall sign pointing at this container
for (BlockFace face : new BlockFace[] { BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST }) {
Block adjacent = containerBlock.getRelative(face);
if (adjacent.getBlockData() instanceof WallSign wallSign) {
// check if the sign is facing away from the container (attached to it)
if (wallSign.getFacing().getOppositeFace() == face.getOppositeFace()) {
Shop shop = plugin.getShopRegistry().getShop(adjacent.getLocation());
if (shop != null) {
return shop;
}
}
}
}
return null;
}
/**
* get container inventory
*/
private Inventory getContainerInventory(Block block) {
if (block.getState() instanceof Chest chest) {
return chest.getInventory();
} else if (block.getState() instanceof Barrel barrel) {
return barrel.getInventory();
}
return null;
}
/**
* check if material is a container
*/
private boolean isContainer(Material material) {
return material == Material.CHEST || material == Material.TRAPPED_CHEST || material == Material.BARREL;
}
/**
* format material name for display
*/
private String formatMaterial(Material material) {
return material.name().toLowerCase().replace("_", " ");
}
/**
* holder for shop inventory
*/
public record ShopInventoryHolder(Shop shop) implements InventoryHolder {
@Override
public Inventory getInventory() {
return null;
}
}
}

View File

@@ -0,0 +1,63 @@
package party.cybsec.oyeshops.listener;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockExplodeEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.entity.EntityExplodeEvent;
import party.cybsec.oyeshops.OyeShopsPlugin;
import java.util.List;
/**
* monitors block placement to track container ownership for shop creation
*/
public class ContainerPlacementListener implements Listener {
private final OyeShopsPlugin plugin;
public ContainerPlacementListener(OyeShopsPlugin plugin) {
this.plugin = plugin;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPlace(BlockPlaceEvent event) {
Block block = event.getBlock();
if (isContainer(block.getType())) {
plugin.getContainerMemoryManager().recordPlacement(block.getLocation(), event.getPlayer().getUniqueId());
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBreak(BlockBreakEvent event) {
Block block = event.getBlock();
if (isContainer(block.getType())) {
plugin.getContainerMemoryManager().removePlacement(block.getLocation());
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockExplode(BlockExplodeEvent event) {
handleExplosion(event.blockList());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onEntityExplode(EntityExplodeEvent event) {
handleExplosion(event.blockList());
}
private void handleExplosion(List<Block> blocks) {
for (Block block : blocks) {
if (isContainer(block.getType())) {
plugin.getContainerMemoryManager().removePlacement(block.getLocation());
}
}
}
private boolean isContainer(Material material) {
return material == Material.CHEST || material == Material.TRAPPED_CHEST || material == Material.BARREL;
}
}

View File

@@ -0,0 +1,180 @@
package party.cybsec.oyeshops.listener;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerInteractEvent;
import party.cybsec.oyeshops.OyeShopsPlugin;
import party.cybsec.oyeshops.database.TransactionRepository;
import party.cybsec.oyeshops.model.Shop;
import java.sql.SQLException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
/**
* handles right-click inspection while in inspect mode
*/
public class InspectListener implements Listener {
private final OyeShopsPlugin plugin;
private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public InspectListener(OyeShopsPlugin plugin) {
this.plugin = plugin;
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onPlayerInteract(PlayerInteractEvent event) {
if (event.getAction() != org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK) {
return;
}
Player player = event.getPlayer();
// check if player is in inspect mode
if (!plugin.getInspectModeManager().isInspecting(player)) {
return;
}
Block block = event.getClickedBlock();
if (block == null) {
return;
}
// check if this is a shop sign
Shop shop = plugin.getShopRegistry().getShop(block.getLocation());
if (shop == null) {
return;
}
// cancel interaction
event.setCancelled(true);
// display shop info
displayShopInfo(player, shop);
}
private void displayShopInfo(Player player, Shop shop) {
player.sendMessage(Component.text("=== shop #" + shop.getId() + " ===", NamedTextColor.GOLD));
// owner
String ownerName = Bukkit.getOfflinePlayer(shop.getOwner()).getName();
if (ownerName == null) {
ownerName = shop.getOwner().toString();
}
player.sendMessage(Component.text("owner: ", NamedTextColor.GRAY)
.append(Component.text(ownerName, NamedTextColor.WHITE)));
// status
String status = shop.isEnabled() ? "enabled" : "disabled";
NamedTextColor statusColor = shop.isEnabled() ? NamedTextColor.GREEN : NamedTextColor.RED;
player.sendMessage(Component.text("status: ", NamedTextColor.GRAY)
.append(Component.text(status, statusColor)));
// trade
player.sendMessage(Component.text("trade: ", NamedTextColor.GRAY)
.append(Component.text(
shop.getTrade().priceQuantity() + " " + shop.getTrade().priceItem().name().toLowerCase() +
"" +
shop.getTrade().productQuantity() + " "
+ shop.getTrade().productItem().name().toLowerCase(),
NamedTextColor.YELLOW)));
// owed amount
player.sendMessage(Component.text("owed: ", NamedTextColor.GRAY)
.append(Component.text(
shop.getOwedAmount() + " " + shop.getTrade().priceItem().name().toLowerCase(),
NamedTextColor.GREEN)));
// stock (check chest)
int stock = getStock(shop);
int units = stock / shop.getTrade().productQuantity();
player.sendMessage(Component.text("stock: ", NamedTextColor.GRAY)
.append(Component.text(
stock + " " + shop.getTrade().productItem().name().toLowerCase() +
" (" + units + " units available)",
NamedTextColor.AQUA)));
// created
String createdDate = dateFormat.format(new Date(shop.getCreatedAt()));
player.sendMessage(Component.text("created: ", NamedTextColor.GRAY)
.append(Component.text(createdDate, NamedTextColor.WHITE)));
// recent transactions
try {
List<TransactionRepository.Transaction> transactions = plugin.getTransactionRepository()
.getRecentTransactions(shop.getId(), 10);
if (!transactions.isEmpty()) {
player.sendMessage(Component.text("recent transactions (last 10):", NamedTextColor.GOLD));
for (TransactionRepository.Transaction tx : transactions) {
String buyerName = Bukkit.getOfflinePlayer(tx.buyerUuid()).getName();
if (buyerName == null) {
buyerName = tx.buyerUuid().toString();
}
String txDate = dateFormat.format(new Date(tx.timestamp()));
player.sendMessage(Component.text(" - ", NamedTextColor.GRAY)
.append(Component.text(buyerName, NamedTextColor.WHITE))
.append(Component.text(" bought ", NamedTextColor.GRAY))
.append(Component.text(tx.quantityTraded() + " units", NamedTextColor.YELLOW))
.append(Component.text(" (" + txDate + ")", NamedTextColor.DARK_GRAY)));
}
}
} catch (SQLException e) {
e.printStackTrace();
}
// action buttons (clickable text)
player.sendMessage(Component.text(""));
player.sendMessage(
Component.text("[enable]", NamedTextColor.GREEN)
.append(Component.text(" ", NamedTextColor.WHITE))
.append(Component.text("[disable]", NamedTextColor.RED))
.append(Component.text(" ", NamedTextColor.WHITE))
.append(Component.text("[unregister]", NamedTextColor.DARK_RED))
.append(Component.text(" ", NamedTextColor.WHITE))
.append(Component.text("[clear history]", NamedTextColor.YELLOW)));
player.sendMessage(Component.text("use /oyeshops <action> " + shop.getId(), NamedTextColor.GRAY));
}
/**
* get current stock from chest or barrel
*/
private int getStock(Shop shop) {
Block signBlock = shop.getSignLocation().getBlock();
org.bukkit.block.data.BlockData blockData = signBlock.getBlockData();
if (!(blockData instanceof org.bukkit.block.data.type.WallSign wallSign)) {
return 0;
}
org.bukkit.block.BlockFace attachedFace = wallSign.getFacing().getOppositeFace();
Block containerBlock = signBlock.getRelative(attachedFace);
org.bukkit.inventory.Inventory inventory = null;
if (containerBlock.getState() instanceof org.bukkit.block.Chest chest) {
inventory = chest.getInventory();
} else if (containerBlock.getState() instanceof org.bukkit.block.Barrel barrel) {
inventory = barrel.getInventory();
}
if (inventory == null) {
return 0;
}
int count = 0;
for (org.bukkit.inventory.ItemStack item : inventory.getContents()) {
if (item != null && item.getType() == shop.getTrade().productItem()) {
count += item.getAmount();
}
}
return count;
}
}

View File

@@ -0,0 +1,97 @@
package party.cybsec.oyeshops.listener;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Material;
import org.bukkit.block.Barrel;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.Chest;
import org.bukkit.block.data.type.WallSign;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import party.cybsec.oyeshops.OyeShopsPlugin;
import party.cybsec.oyeshops.model.Shop;
import java.util.ArrayList;
import java.util.List;
/**
* alerts players on login if their shops are low on stock
*/
public class LoginListener implements Listener {
private final OyeShopsPlugin plugin;
public LoginListener(OyeShopsPlugin plugin) {
this.plugin = plugin;
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
// check if user wants notifications
if (!plugin.getPlayerPreferenceRepository().isNotifyEnabled(player.getUniqueId())) {
return;
}
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
// find all shops owned by player
List<Shop> lowStockShops = new ArrayList<>();
for (Shop shop : plugin.getShopRegistry().getAllShops()) {
if (shop.getOwner().equals(player.getUniqueId())) {
if (isLowStock(shop)) {
lowStockShops.add(shop);
}
}
}
if (!lowStockShops.isEmpty()) {
final int count = lowStockShops.size();
plugin.getServer().getScheduler().runTask(plugin, () -> {
if (player.isOnline()) {
player.sendMessage(
Component.text("notification: " + count + " of your shops are low on stock.",
NamedTextColor.YELLOW));
}
});
}
});
}
private boolean isLowStock(Shop shop) {
Block signBlock = shop.getSignLocation().getBlock();
if (!(signBlock.getBlockData() instanceof WallSign wallSign))
return false;
BlockFace attachedFace = wallSign.getFacing().getOppositeFace();
Block attachedBlock = signBlock.getRelative(attachedFace);
Inventory inventory = getContainerInventory(attachedBlock);
if (inventory == null)
return true; // container gone? count as low stock warning
Material product = shop.getTrade().productItem();
int count = 0;
for (ItemStack item : inventory.getContents()) {
if (item != null && item.getType() == product) {
count += item.getAmount();
}
}
// threshold: less than 1 transaction or less than 10 items
return count < shop.getTrade().productQuantity() || count < 10;
}
private Inventory getContainerInventory(Block block) {
if (block.getState() instanceof Chest chest)
return chest.getInventory();
if (block.getState() instanceof Barrel barrel)
return barrel.getInventory();
return null;
}
}

View File

@@ -0,0 +1,392 @@
package party.cybsec.oyeshops.listener;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Barrel;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.Chest;
import org.bukkit.block.Sign;
import org.bukkit.block.DoubleChest;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.WallSign;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.SignChangeEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.DoubleChestInventory;
import party.cybsec.oyeshops.OyeShopsPlugin;
import party.cybsec.oyeshops.model.PendingActivation;
import party.cybsec.oyeshops.model.Shop;
import party.cybsec.oyeshops.model.Trade;
import party.cybsec.oyeshops.parser.SignParser;
import party.cybsec.oyeshops.permission.PermissionManager;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* listens for sign placement and validates shop activation
* now uses a confirmation flow via chat
*/
public class ShopActivationListener implements Listener {
private final OyeShopsPlugin plugin;
private final SignParser parser;
public ShopActivationListener(OyeShopsPlugin plugin, SignParser parser) {
this.plugin = plugin;
this.parser = parser;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onSignChange(SignChangeEvent event) {
Player player = event.getPlayer();
Block block = event.getBlock();
// check if player has opted into shop creation (disabled by default)
if (!plugin.getPlayerPreferenceRepository().isShopsEnabled(player.getUniqueId())) {
return;
}
// check permission
if (!PermissionManager.canCreate(player)) {
return;
}
// check if wall sign
BlockData blockData = block.getBlockData();
if (!(blockData instanceof WallSign wallSign)) {
return;
}
// get attached container (chest or barrel)
BlockFace attachedFace = wallSign.getFacing().getOppositeFace();
Block attachedBlock = block.getRelative(attachedFace);
if (!isContainer(attachedBlock.getType())) {
return;
}
// --- protection checks ---
// 1. check if container is already part of another player's shop
Shop existingShop = findAnyShopOnContainer(attachedBlock);
if (existingShop != null && !existingShop.getOwner().equals(player.getUniqueId())
&& !PermissionManager.isAdmin(player)) {
player.sendMessage(Component.text("this container belongs to another player's shop", NamedTextColor.RED));
return;
}
// 2. session-based ownership check
if (!PermissionManager.isAdmin(player)) {
UUID sessionOwner = plugin.getContainerMemoryManager().getOwner(attachedBlock.getLocation());
if (sessionOwner == null) {
// block was placed before the server started or by no one
player.sendMessage(Component.text("you can only create shops on containers you placed this session",
NamedTextColor.RED));
return;
}
if (!sessionOwner.equals(player.getUniqueId())) {
player.sendMessage(Component.text("this container was placed by another player", NamedTextColor.RED));
return;
}
}
// get sign text
String[] lines = new String[4];
for (int i = 0; i < 4; i++) {
Component lineComponent = event.line(i);
lines[i] = lineComponent != null
? net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
.serialize(lineComponent)
: "";
}
// parse trade
Trade trade = parser.parse(lines);
if (trade == null) {
return;
}
// handle auto detection for both sides
if (trade.isAutoDetect()) {
trade = detectAutoTrade(trade, attachedBlock, player);
if (trade == null) {
player.sendMessage(Component.text("auto detection failed: could not determine items from container",
NamedTextColor.RED));
return;
}
}
// trigger confirmation instead of immediate creation
PendingActivation activation = new PendingActivation(player.getUniqueId(), block.getLocation(), trade,
System.currentTimeMillis());
plugin.getActivationManager().add(player.getUniqueId(), activation);
sendConfirmationMessage(player, trade);
}
private void sendConfirmationMessage(Player player, Trade trade) {
String priceText = trade.priceQuantity() + " " + formatMaterial(trade.priceItem());
String productText = trade.productQuantity() + " " + formatMaterial(trade.productItem());
// clear display: buyer pays vs buyer gets
player.sendMessage(Component.text("shop detected!", NamedTextColor.GOLD));
player.sendMessage(Component.text("buyer pays: ", NamedTextColor.GRAY)
.append(Component.text(priceText, NamedTextColor.GREEN)));
player.sendMessage(Component.text("buyer gets: ", NamedTextColor.GRAY)
.append(Component.text(productText, NamedTextColor.YELLOW)));
Component buttons = Component.text(" ")
.append(Component.text("[accept]", NamedTextColor.GREEN, TextDecoration.BOLD)
.hoverEvent(HoverEvent.showText(
Component.text("create shop exactly as shown above", NamedTextColor.WHITE)))
.clickEvent(ClickEvent.runCommand("/oyes _activate accept")))
.append(Component.text(" "))
.append(Component.text("[invert]", NamedTextColor.GOLD, TextDecoration.BOLD)
.hoverEvent(HoverEvent.showText(Component.text(
"swap: buyer pays " + productText + " and gets " + priceText, NamedTextColor.WHITE)))
.clickEvent(ClickEvent.runCommand("/oyes _activate invert")))
.append(Component.text(" "))
.append(Component.text("[cancel]", NamedTextColor.RED, TextDecoration.BOLD)
.hoverEvent(HoverEvent.showText(
Component.text("ignore sign. no shop created.", NamedTextColor.WHITE)))
.clickEvent(ClickEvent.runCommand("/oyes _activate cancel")));
player.sendMessage(buttons);
}
/**
* complete the shop creation process
* called from AdminCommands when user clicks [accept] or [invert]
*/
public void finalizeShop(Player player, PendingActivation activation) {
Location signLocation = activation.location();
Block block = signLocation.getBlock();
// verify it's still a sign
if (!(block.getState() instanceof Sign sign)) {
player.sendMessage(Component.text("activation failed: sign is gone", NamedTextColor.RED));
return;
}
Trade trade = activation.trade();
long createdAt = System.currentTimeMillis();
Shop shop = new Shop(-1, signLocation, player.getUniqueId(), trade, 0, true, createdAt);
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
try {
int shopId = plugin.getShopRepository().createShop(shop);
plugin.getServer().getScheduler().runTask(plugin, () -> {
// re-verify sign on main thread
if (!(signLocation.getBlock().getState() instanceof Sign finalSign))
return;
Shop registeredShop = new Shop(shopId, signLocation, player.getUniqueId(), trade, 0, true,
createdAt);
plugin.getShopRegistry().register(registeredShop);
rewriteSignLines(finalSign, trade);
player.sendMessage(Component.text("shop #" + shopId + " initialized!", NamedTextColor.GREEN));
});
} catch (SQLException e) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
player.sendMessage(Component.text("failed to create shop: database error", NamedTextColor.RED));
});
e.printStackTrace();
}
});
}
private void rewriteSignLines(Sign sign, Trade trade) {
String pricePart = trade.priceQuantity() + " " + abbreviateMaterial(trade.priceItem());
String productPart = trade.productQuantity() + " " + abbreviateMaterial(trade.productItem());
sign.line(0, Component.text(pricePart));
sign.line(1, Component.text("for."));
sign.line(2, Component.text(productPart));
sign.line(3, Component.text(""));
sign.update();
}
/**
* detect auto trade from chest contents
* can detect material and quantity for either side (or both)
*/
private Trade detectAutoTrade(Trade baseTrade, Block containerBlock, Player player) {
Inventory inventory = getContainerInventory(containerBlock);
if (inventory == null)
return null;
Map<Material, Map<Integer, Integer>> counts = new HashMap<>();
for (ItemStack item : inventory.getContents()) {
if (item == null || item.getType() == Material.AIR)
continue;
counts.computeIfAbsent(item.getType(), k -> new HashMap<>())
.merge(item.getAmount(), 1, Integer::sum);
}
if (counts.isEmpty())
return null;
Material priceItem = baseTrade.priceItem();
int priceQty = baseTrade.priceQuantity();
Material productItem = baseTrade.productItem();
int productQty = baseTrade.productQuantity();
// if product needs detection
if (productQty == -1) {
Material bestMat = null;
int bestQty = -1;
int bestScore = -1;
for (Map.Entry<Material, Map<Integer, Integer>> entry : counts.entrySet()) {
Material mat = entry.getKey();
// skip if it's the price item (unless it's unknown)
if (priceItem != null && mat == priceItem)
continue;
int qty = findMostCommonQuantity(entry.getValue());
int score = entry.getValue().getOrDefault(qty, 0);
if (score > bestScore) {
bestScore = score;
bestQty = qty;
bestMat = mat;
}
}
if (bestMat != null) {
productItem = bestMat;
productQty = bestQty;
}
}
// if price needs detection (e.g. "AUTO for 10 dirt")
if (priceQty == -1) {
Material bestMat = null;
int bestQty = -1;
int bestScore = -1;
for (Map.Entry<Material, Map<Integer, Integer>> entry : counts.entrySet()) {
Material mat = entry.getKey();
// skip if it's the product item
if (productItem != null && mat == productItem)
continue;
int qty = findMostCommonQuantity(entry.getValue());
int score = entry.getValue().getOrDefault(qty, 0);
if (score > bestScore) {
bestScore = score;
bestQty = qty;
bestMat = mat;
}
}
if (bestMat != null) {
priceItem = bestMat;
priceQty = bestQty;
}
}
// final checks
if (priceItem == null || priceItem == Material.AIR || priceQty <= 0)
return null;
if (productItem == null || productItem == Material.AIR || productQty <= 0)
return null;
if (priceItem == productItem)
return null;
return new Trade(priceItem, priceQty, productItem, productQty);
}
private int findMostCommonQuantity(Map<Integer, Integer> counts) {
int bestQuantity = -1;
int bestCount = -1;
for (Map.Entry<Integer, Integer> entry : counts.entrySet()) {
if (entry.getValue() > bestCount) {
bestCount = entry.getValue();
bestQuantity = entry.getKey();
}
}
return bestQuantity;
}
private String abbreviateMaterial(Material material) {
String name = material.name().toLowerCase().replace("_", " ");
name = name.replace("diamond", "dia").replace("emerald", "em").replace("netherite", "neth")
.replace("ingot", "").replace("block", "blk").replace("pickaxe", "pick")
.replace("chestplate", "chest").replace("leggings", "legs");
name = name.trim();
return name.length() > 14 ? name.substring(0, 14) : name;
}
private Inventory getContainerInventory(Block block) {
if (block.getState() instanceof Chest chest)
return chest.getInventory();
if (block.getState() instanceof Barrel barrel)
return barrel.getInventory();
return null;
}
private boolean isContainer(Material material) {
return material == Material.CHEST || material == Material.TRAPPED_CHEST || material == Material.BARREL;
}
private String formatMaterial(Material material) {
return material.name().toLowerCase().replace("_", " ");
}
/**
* check if ANY shop exists on this container (or its other half)
*/
private Shop findAnyShopOnContainer(Block containerBlock) {
List<Block> parts = getFullContainer(containerBlock);
for (Block part : parts) {
for (BlockFace face : new BlockFace[] { BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST,
BlockFace.WEST }) {
Block adjacent = part.getRelative(face);
if (adjacent.getBlockData() instanceof WallSign wallSign) {
if (wallSign.getFacing().getOppositeFace() == face.getOppositeFace()) {
Shop shop = plugin.getShopRegistry().getShop(adjacent.getLocation());
if (shop != null)
return shop;
}
}
}
}
return null;
}
/**
* get all blocks belonging to this container (handles double chests)
*/
private List<Block> getFullContainer(Block block) {
List<Block> blocks = new ArrayList<>();
blocks.add(block);
if (block.getState() instanceof Chest chest) {
Inventory inv = chest.getInventory();
if (inv instanceof DoubleChestInventory dci) {
DoubleChest dc = dci.getHolder();
if (dc != null) {
blocks.clear();
if (dc.getLeftSide() instanceof Chest left)
blocks.add(left.getBlock());
if (dc.getRightSide() instanceof Chest right)
blocks.add(right.getBlock());
}
}
}
return blocks;
}
}

View File

@@ -0,0 +1,118 @@
package party.cybsec.oyeshops.listener;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import party.cybsec.oyeshops.OyeShopsPlugin;
import party.cybsec.oyeshops.model.Shop;
import party.cybsec.oyeshops.permission.PermissionManager;
import java.sql.SQLException;
/**
* protects shop signs and chests from unauthorized breaking
*/
public class ShopProtectionListener implements Listener {
private final OyeShopsPlugin plugin;
public ShopProtectionListener(OyeShopsPlugin plugin) {
this.plugin = plugin;
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockBreak(BlockBreakEvent event) {
Block block = event.getBlock();
Player player = event.getPlayer();
// check if this block is a shop sign
Shop shop = plugin.getShopRegistry().getShop(block.getLocation());
if (shop != null) {
// this is a shop sign
if (canBreakShop(player, shop)) {
// authorized - unregister shop
unregisterShop(shop);
} else {
// unauthorized - cancel break
event.setCancelled(true);
}
return;
}
// check if this block is a shop chest
// we need to check all registered shops to see if any have this chest
// for performance, we'll check if the broken block is a chest first
if (!isChest(block.getType())) {
return;
}
// find shop with this chest location
Shop chestShop = findShopByChest(block);
if (chestShop != null) {
if (canBreakShop(player, chestShop)) {
// authorized - unregister shop
unregisterShop(chestShop);
} else {
// unauthorized - cancel break
event.setCancelled(true);
}
}
}
/**
* check if player can break shop
*/
private boolean canBreakShop(Player player, Shop shop) {
// owner can break
if (player.getUniqueId().equals(shop.getOwner())) {
return true;
}
// admin or break override can break
return PermissionManager.isAdmin(player) || PermissionManager.canBreakOverride(player);
}
/**
* unregister shop from registry and database
*/
private void unregisterShop(Shop shop) {
plugin.getShopRegistry().unregister(shop.getId());
try {
plugin.getShopRepository().deleteShop(shop.getId());
} catch (SQLException e) {
e.printStackTrace();
}
}
/**
* find shop by chest location
*/
private Shop findShopByChest(Block chestBlock) {
// check all adjacent blocks for wall signs
for (org.bukkit.block.BlockFace face : new org.bukkit.block.BlockFace[] {
org.bukkit.block.BlockFace.NORTH,
org.bukkit.block.BlockFace.SOUTH,
org.bukkit.block.BlockFace.EAST,
org.bukkit.block.BlockFace.WEST
}) {
Block adjacent = chestBlock.getRelative(face);
Shop shop = plugin.getShopRegistry().getShop(adjacent.getLocation());
if (shop != null) {
return shop;
}
}
return null;
}
/**
* check if material is a container (chest or barrel)
*/
private boolean isChest(org.bukkit.Material material) {
return material == org.bukkit.Material.CHEST
|| material == org.bukkit.Material.TRAPPED_CHEST
|| material == org.bukkit.Material.BARREL;
}
}

View File

@@ -0,0 +1,51 @@
package party.cybsec.oyeshops.manager;
import party.cybsec.oyeshops.model.PendingActivation;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* manages shops awaiting confirmation
*/
public class ActivationManager {
private final Map<UUID, PendingActivation> pending = new ConcurrentHashMap<>();
private static final long TIMEOUT_MS = 60 * 1000; // 60 seconds
/**
* register a pending activation for a player
*/
public void add(UUID playerUuid, PendingActivation activation) {
pending.put(playerUuid, activation);
}
/**
* get and remove pending activation for a player
*/
public PendingActivation getAndRemove(UUID playerUuid) {
PendingActivation activation = pending.remove(playerUuid);
if (activation != null && activation.isExpired(TIMEOUT_MS)) {
return null;
}
return activation;
}
/**
* remove a pending activation
*/
public void remove(UUID playerUuid) {
pending.remove(playerUuid);
}
/**
* check if player has a pending activation
*/
public boolean hasPending(UUID playerUuid) {
PendingActivation activation = pending.get(playerUuid);
if (activation != null && activation.isExpired(TIMEOUT_MS)) {
pending.remove(playerUuid);
return false;
}
return activation != null;
}
}

View File

@@ -0,0 +1,33 @@
package party.cybsec.oyeshops.manager;
import org.bukkit.Location;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* tracks which player placed which container during this session
*/
public class ContainerMemoryManager {
private final Map<String, UUID> placements = new ConcurrentHashMap<>();
public void recordPlacement(Location location, UUID playerUuid) {
placements.put(locationKey(location), playerUuid);
}
public void removePlacement(Location location) {
placements.remove(locationKey(location));
}
public UUID getOwner(Location location) {
return placements.get(locationKey(location));
}
public boolean isSessionPlaced(Location location) {
return placements.containsKey(locationKey(location));
}
private String locationKey(Location loc) {
return loc.getWorld().getUID() + ":" + loc.getBlockX() + ":" + loc.getBlockY() + ":" + loc.getBlockZ();
}
}

View File

@@ -0,0 +1,33 @@
package party.cybsec.oyeshops.model;
import org.bukkit.Location;
import java.util.UUID;
/**
* data for a shop awaiting player confirmation
*/
public record PendingActivation(
UUID owner,
Location location,
Trade trade,
long createdAt) {
/**
* check if this activation has expired
*/
public boolean isExpired(long timeoutMs) {
return System.currentTimeMillis() - createdAt > timeoutMs;
}
/**
* get an inverted version of this activation (swaps price/product)
*/
public PendingActivation invert() {
Trade invertedTrade = new Trade(
trade.productItem(),
trade.productQuantity(),
trade.priceItem(),
trade.priceQuantity());
return new PendingActivation(owner, location, invertedTrade, createdAt);
}
}

View File

@@ -0,0 +1,74 @@
package party.cybsec.oyeshops.model;
import org.bukkit.Location;
import java.util.UUID;
/**
* shop data model
*/
public class Shop {
private final int id;
private final Location signLocation;
private final UUID owner;
private final Trade trade;
private int owedAmount;
private boolean enabled;
private final long createdAt;
public Shop(int id, Location signLocation, UUID owner, Trade trade, int owedAmount, boolean enabled,
long createdAt) {
this.id = id;
this.signLocation = signLocation;
this.owner = owner;
this.trade = trade;
this.owedAmount = owedAmount;
this.enabled = enabled;
this.createdAt = createdAt;
}
public int getId() {
return id;
}
public Location getSignLocation() {
return signLocation;
}
public UUID getOwner() {
return owner;
}
public Trade getTrade() {
return trade;
}
public int getOwedAmount() {
return owedAmount;
}
public void setOwedAmount(int owedAmount) {
this.owedAmount = owedAmount;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public long getCreatedAt() {
return createdAt;
}
/**
* get chest location from sign location
*/
public Location getChestLocation() {
// chest is attached to the sign
// we'll determine this from the sign's attached block face
return signLocation.clone(); // placeholder - will be properly implemented
}
}

View File

@@ -0,0 +1,49 @@
package party.cybsec.oyeshops.model;
import org.bukkit.Material;
/**
* immutable trade definition
* quantity of -1 indicates AUTO detection needed for that side
*/
public record Trade(
Material priceItem,
int priceQuantity,
Material productItem,
int productQuantity) {
public Trade {
// allow -1 for AUTO detection, but otherwise must be positive
if (priceQuantity <= 0 && priceQuantity != -1) {
throw new IllegalArgumentException("price quantity must be positive or -1 for auto");
}
if (productQuantity <= 0 && productQuantity != -1) {
throw new IllegalArgumentException("product quantity must be positive or -1 for auto");
}
// for AUTO, materials might be AIR (unknown)
if (priceQuantity != -1 && productQuantity != -1 && priceItem == productItem && priceItem != Material.AIR) {
throw new IllegalArgumentException("price and product must be different materials");
}
}
/**
* check if this trade needs AUTO detection on the product side
*/
public boolean isAutoProduct() {
return productQuantity == -1;
}
/**
* check if this trade needs AUTO detection on the price side
*/
public boolean isAutoPrice() {
return priceQuantity == -1;
}
/**
* check if this trade needs any AUTO detection
*/
public boolean isAutoDetect() {
return isAutoProduct() || isAutoPrice();
}
}

View File

@@ -0,0 +1,297 @@
package party.cybsec.oyeshops.parser;
import org.bukkit.Material;
import org.bukkit.configuration.ConfigurationSection;
import java.util.*;
/**
* material name alias resolution system
* handles underscores, plurals, and common abbreviations
*/
public class MaterialAliasRegistry {
private final Map<String, Material> aliases = new HashMap<>();
// common aliases built-in
private static final Map<String, String> COMMON_ALIASES = Map.ofEntries(
// plurals
Map.entry("diamonds", "diamond"),
Map.entry("emeralds", "emerald"),
Map.entry("stones", "stone"),
Map.entry("dirts", "dirt"),
Map.entry("cobblestones", "cobblestone"),
Map.entry("coals", "coal"),
Map.entry("irons", "iron_ingot"),
Map.entry("golds", "gold_ingot"),
Map.entry("coppers", "copper_ingot"),
Map.entry("netherites", "netherite_ingot"),
// short forms
Map.entry("dia", "diamond"),
Map.entry("dias", "diamond"),
Map.entry("em", "emerald"),
Map.entry("ems", "emerald"),
Map.entry("iron", "iron_ingot"),
Map.entry("gold", "gold_ingot"),
Map.entry("copper", "copper_ingot"),
Map.entry("netherite", "netherite_ingot"),
Map.entry("cobble", "cobblestone"),
Map.entry("lapis", "lapis_lazuli"),
Map.entry("redite", "redstone"),
Map.entry("quartz", "quartz"),
// ores
Map.entry("iron ore", "iron_ore"),
Map.entry("gold ore", "gold_ore"),
Map.entry("diamond ore", "diamond_ore"),
Map.entry("coal ore", "coal_ore"),
Map.entry("copper ore", "copper_ore"),
Map.entry("emerald ore", "emerald_ore"),
Map.entry("lapis ore", "lapis_ore"),
Map.entry("redstone ore", "redstone_ore"),
// tools - pickaxes
Map.entry("diamond pick", "diamond_pickaxe"),
Map.entry("diamond pickaxe", "diamond_pickaxe"),
Map.entry("iron pick", "iron_pickaxe"),
Map.entry("iron pickaxe", "iron_pickaxe"),
Map.entry("stone pick", "stone_pickaxe"),
Map.entry("stone pickaxe", "stone_pickaxe"),
Map.entry("wooden pick", "wooden_pickaxe"),
Map.entry("wooden pickaxe", "wooden_pickaxe"),
Map.entry("gold pick", "golden_pickaxe"),
Map.entry("golden pick", "golden_pickaxe"),
Map.entry("golden pickaxe", "golden_pickaxe"),
Map.entry("netherite pick", "netherite_pickaxe"),
Map.entry("netherite pickaxe", "netherite_pickaxe"),
// tools - swords
Map.entry("diamond sword", "diamond_sword"),
Map.entry("iron sword", "iron_sword"),
Map.entry("stone sword", "stone_sword"),
Map.entry("wooden sword", "wooden_sword"),
Map.entry("golden sword", "golden_sword"),
Map.entry("gold sword", "golden_sword"),
Map.entry("netherite sword", "netherite_sword"),
// tools - axes
Map.entry("diamond axe", "diamond_axe"),
Map.entry("iron axe", "iron_axe"),
Map.entry("stone axe", "stone_axe"),
Map.entry("wooden axe", "wooden_axe"),
Map.entry("golden axe", "golden_axe"),
Map.entry("gold axe", "golden_axe"),
Map.entry("netherite axe", "netherite_axe"),
// tools - shovels
Map.entry("diamond shovel", "diamond_shovel"),
Map.entry("iron shovel", "iron_shovel"),
Map.entry("stone shovel", "stone_shovel"),
Map.entry("wooden shovel", "wooden_shovel"),
Map.entry("golden shovel", "golden_shovel"),
Map.entry("gold shovel", "golden_shovel"),
Map.entry("netherite shovel", "netherite_shovel"),
// tools - hoes
Map.entry("diamond hoe", "diamond_hoe"),
Map.entry("iron hoe", "iron_hoe"),
Map.entry("stone hoe", "stone_hoe"),
Map.entry("wooden hoe", "wooden_hoe"),
Map.entry("golden hoe", "golden_hoe"),
Map.entry("gold hoe", "golden_hoe"),
Map.entry("netherite hoe", "netherite_hoe"),
// armor
Map.entry("diamond helmet", "diamond_helmet"),
Map.entry("diamond chestplate", "diamond_chestplate"),
Map.entry("diamond leggings", "diamond_leggings"),
Map.entry("diamond boots", "diamond_boots"),
Map.entry("iron helmet", "iron_helmet"),
Map.entry("iron chestplate", "iron_chestplate"),
Map.entry("iron leggings", "iron_leggings"),
Map.entry("iron boots", "iron_boots"),
Map.entry("netherite helmet", "netherite_helmet"),
Map.entry("netherite chestplate", "netherite_chestplate"),
Map.entry("netherite leggings", "netherite_leggings"),
Map.entry("netherite boots", "netherite_boots"),
// common blocks
Map.entry("oak log", "oak_log"),
Map.entry("oak plank", "oak_planks"),
Map.entry("oak planks", "oak_planks"),
Map.entry("oak wood", "oak_log"),
Map.entry("spruce log", "spruce_log"),
Map.entry("birch log", "birch_log"),
Map.entry("jungle log", "jungle_log"),
Map.entry("acacia log", "acacia_log"),
Map.entry("dark oak log", "dark_oak_log"),
Map.entry("glass", "glass"),
Map.entry("sand", "sand"),
Map.entry("gravel", "gravel"),
Map.entry("obsidian", "obsidian"),
Map.entry("glowstone", "glowstone"),
Map.entry("netherrack", "netherrack"),
Map.entry("endstone", "end_stone"),
Map.entry("end stone", "end_stone"),
// food
Map.entry("steak", "cooked_beef"),
Map.entry("cooked steak", "cooked_beef"),
Map.entry("porkchop", "cooked_porkchop"),
Map.entry("cooked porkchop", "cooked_porkchop"),
Map.entry("chicken", "cooked_chicken"),
Map.entry("cooked chicken", "cooked_chicken"),
Map.entry("bread", "bread"),
Map.entry("apple", "apple"),
Map.entry("golden apple", "golden_apple"),
Map.entry("gapple", "golden_apple"),
Map.entry("notch apple", "enchanted_golden_apple"),
Map.entry("enchanted golden apple", "enchanted_golden_apple"),
Map.entry("god apple", "enchanted_golden_apple"),
// misc
Map.entry("ender pearl", "ender_pearl"),
Map.entry("pearl", "ender_pearl"),
Map.entry("enderpearl", "ender_pearl"),
Map.entry("blaze rod", "blaze_rod"),
Map.entry("slime ball", "slime_ball"),
Map.entry("slimeball", "slime_ball"),
Map.entry("ghast tear", "ghast_tear"),
Map.entry("nether star", "nether_star"),
Map.entry("totem", "totem_of_undying"),
Map.entry("elytra", "elytra"),
Map.entry("shulker", "shulker_box"),
Map.entry("shulker box", "shulker_box"),
Map.entry("book", "book"),
Map.entry("exp bottle", "experience_bottle"),
Map.entry("xp bottle", "experience_bottle"),
Map.entry("bottle o enchanting", "experience_bottle"));
public MaterialAliasRegistry(ConfigurationSection aliasSection) {
// load built-in aliases first
for (Map.Entry<String, String> entry : COMMON_ALIASES.entrySet()) {
try {
Material material = Material.valueOf(entry.getValue().toUpperCase());
aliases.put(entry.getKey().toLowerCase(), material);
} catch (IllegalArgumentException e) {
// ignore invalid built-in aliases
}
}
// then load config aliases (override built-ins)
loadAliases(aliasSection);
}
private void loadAliases(ConfigurationSection section) {
if (section == null) {
return;
}
for (String alias : section.getKeys(false)) {
String materialName = section.getString(alias);
if (materialName != null) {
try {
Material material = Material.valueOf(materialName.toUpperCase());
aliases.put(alias.toLowerCase(), material);
} catch (IllegalArgumentException e) {
// invalid material name in config, skip
}
}
}
}
/**
* resolve material from normalized text
* tries multiple strategies:
* 1. exact alias match (longest first)
* 2. word-by-word alias match
* 3. space-to-underscore conversion for direct enum match
* 4. direct material enum match
* 5. with _INGOT/_BLOCK suffixes
* 6. strip trailing 's' for plurals
*/
public Material resolve(String text) {
text = text.toLowerCase().trim();
if (text.isEmpty()) {
return null;
}
// 1. try longest alias match first (for multi-word aliases)
String longestMatch = null;
Material longestMaterial = null;
for (Map.Entry<String, Material> entry : aliases.entrySet()) {
String alias = entry.getKey();
if (text.contains(alias)) {
if (longestMatch == null || alias.length() > longestMatch.length()) {
longestMatch = alias;
longestMaterial = entry.getValue();
}
}
}
if (longestMaterial != null) {
return longestMaterial;
}
// 2. try word-by-word alias match
String[] words = text.split("\\s+");
for (String word : words) {
if (aliases.containsKey(word)) {
return aliases.get(word);
}
}
// 3. try space-to-underscore conversion for multi-word materials
// e.g., "netherite pickaxe" -> "netherite_pickaxe"
String underscored = text.replace(" ", "_");
try {
return Material.valueOf(underscored.toUpperCase());
} catch (IllegalArgumentException ignored) {
}
// 4. try each word directly as material
for (String word : words) {
try {
return Material.valueOf(word.toUpperCase());
} catch (IllegalArgumentException ignored) {
}
// 5. try with common suffixes
try {
return Material.valueOf(word.toUpperCase() + "_INGOT");
} catch (IllegalArgumentException ignored) {
}
try {
return Material.valueOf(word.toUpperCase() + "_BLOCK");
} catch (IllegalArgumentException ignored) {
}
// 6. try stripping trailing 's' for plurals
if (word.endsWith("s") && word.length() > 1) {
String singular = word.substring(0, word.length() - 1);
try {
return Material.valueOf(singular.toUpperCase());
} catch (IllegalArgumentException ignored) {
}
}
}
// 7. try the whole text with underscores for complex names
for (Material material : Material.values()) {
String materialName = material.name().toLowerCase();
String materialSpaced = materialName.replace("_", " ");
if (text.contains(materialSpaced) || text.contains(materialName)) {
if (longestMatch == null || materialName.length() > longestMatch.length()) {
longestMatch = materialName;
longestMaterial = material;
}
}
}
return longestMaterial;
}
}

View File

@@ -0,0 +1,262 @@
package party.cybsec.oyeshops.parser;
import org.bukkit.Material;
import party.cybsec.oyeshops.model.Trade;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* deterministic sign text parser
* requires "." in sign text
* ambiguity equals no shop
*/
public class SignParser {
private final MaterialAliasRegistry aliasRegistry;
// directional keywords - price comes before, product comes after
private static final Set<String> COST_INDICATORS = Set.of(
"for", "per", "costs", "cost", "price", "=", "->", "=>", ">", "");
private static final Set<String> SELL_INDICATORS = Set.of(
"get", "gets", "gives", "buy", "buys", "selling", "trades", "exchanges");
// quantity patterns
private static final Pattern NUMBER_PATTERN = Pattern.compile("\\d+");
private static final Map<String, Integer> WORD_QUANTITIES = Map.ofEntries(
Map.entry("a", 1),
Map.entry("an", 1),
Map.entry("one", 1),
Map.entry("each", 1),
Map.entry("two", 2),
Map.entry("three", 3),
Map.entry("four", 4),
Map.entry("five", 5),
Map.entry("six", 6),
Map.entry("seven", 7),
Map.entry("eight", 8),
Map.entry("nine", 9),
Map.entry("ten", 10),
Map.entry("dozen", 12),
Map.entry("half", 32),
Map.entry("stack", 64));
// special keyword for auto-detection
public static final String AUTO_KEYWORD = "auto";
public SignParser(MaterialAliasRegistry aliasRegistry) {
this.aliasRegistry = aliasRegistry;
}
/**
* parse sign lines into a trade
*
* @return trade if valid, null if invalid or ambiguous
* trade with quantity -1 means AUTO detection needed
*/
public Trade parse(String[] lines) {
// concatenate all lines with spaces
String fullText = String.join(" ", lines);
// REQUIREMENT: must contain "." to be parsed as a shop
if (!fullText.contains(".")) {
return null;
}
// normalize text
String normalized = normalize(fullText);
// find directional keywords
DirectionalInfo directional = findDirectionalKeywords(normalized);
if (directional != null) {
// section-based parsing (original logic)
String priceSection = normalized.substring(0, directional.keywordStart).trim();
String productSection = normalized.substring(directional.keywordEnd).trim();
ItemQuantity price = parseItemQuantity(priceSection);
if (price == null) {
return null;
}
boolean isAuto = productSection.toLowerCase().contains(AUTO_KEYWORD);
ItemQuantity product;
if (isAuto) {
Material productMaterial = aliasRegistry.resolve(productSection.replace(AUTO_KEYWORD, "").trim());
product = new ItemQuantity(productMaterial, -1);
} else {
product = parseItemQuantity(productSection);
if (product == null) {
return null;
}
}
if (product.material != null && price.material == product.material) {
return null;
}
return new Trade(price.material, price.quantity, product.material != null ? product.material : Material.AIR,
product.quantity);
} else {
// fallback: line-by-line parsing for keyword-less format (e.g. "1 dirt" \n "1
// diamond")
// we look for two distinct item-quantity pairs in the normalized text
List<ItemQuantity> pairs = findMultipleItemQuantities(normalized);
if (pairs.size() == 2) {
// assume first is price, second is product
ItemQuantity price = pairs.get(0);
ItemQuantity product = pairs.get(1);
if (price.material != null && product.material != null && price.material != product.material) {
return new Trade(price.material, price.quantity, product.material, product.quantity);
}
}
}
return null;
}
/**
* find multiple item-quantity pairs in a string
*/
private List<ItemQuantity> findMultipleItemQuantities(String text) {
List<ItemQuantity> results = new ArrayList<>();
String[] words = text.split("\\s+");
// simple heuristic: find clusters of [quantity] [material]
for (int i = 0; i < words.length; i++) {
String word = words[i];
// is this a quantity?
Integer qty = null;
if (NUMBER_PATTERN.matcher(word).matches()) {
qty = Integer.parseInt(word);
} else if (WORD_QUANTITIES.containsKey(word)) {
qty = WORD_QUANTITIES.get(word);
}
if (qty != null && i + 1 < words.length) {
// check if next word is a material
Material mat = aliasRegistry.resolve(words[i + 1]);
if (mat != null) {
results.add(new ItemQuantity(mat, qty));
i++; // skip material word
}
} else {
// maybe it's just a material (quantity 1)
Material mat = aliasRegistry.resolve(word);
if (mat != null) {
// avoid duplicates/overlaps
results.add(new ItemQuantity(mat, 1));
}
}
}
return results;
}
/**
* normalize sign text
* removes punctuation except underscores (for material names)
* keeps "." check before calling this
*/
private String normalize(String text) {
text = text.toLowerCase();
// replace common arrow symbols with spaces
text = text.replace("->", " for ");
text = text.replace("=>", " for ");
text = text.replace("", " for ");
text = text.replace(">", " for ");
// replace punctuation with spaces (keep underscores)
text = text.replaceAll("[^a-z0-9_\\s]", " ");
// normalize whitespace
text = text.replaceAll("\\s+", " ").trim();
return text;
}
/**
* find directional keywords and split point
*/
private DirectionalInfo findDirectionalKeywords(String text) {
String[] tokens = text.split("\\s+");
int currentPos = 0;
// first pass: look for cost indicators
for (String token : tokens) {
if (COST_INDICATORS.contains(token)) {
int keywordEnd = currentPos + token.length();
if (keywordEnd < text.length() && text.charAt(keywordEnd) == ' ') {
keywordEnd++;
}
return new DirectionalInfo(currentPos, keywordEnd, token.equals("per") || token.equals("each"), token);
}
currentPos += token.length() + 1;
}
// second pass: look for sell indicators
currentPos = 0;
for (String token : tokens) {
if (SELL_INDICATORS.contains(token)) {
int keywordEnd = currentPos + token.length();
if (keywordEnd < text.length() && text.charAt(keywordEnd) == ' ') {
keywordEnd++;
}
return new DirectionalInfo(currentPos, keywordEnd, false, token);
}
currentPos += token.length() + 1;
}
return null;
}
/**
* parse item and quantity from text section
*/
private ItemQuantity parseItemQuantity(String section) {
section = section.trim();
if (section.isEmpty()) {
return null;
}
// find quantity
Integer quantity = null;
// look for explicit number first
Matcher matcher = NUMBER_PATTERN.matcher(section);
if (matcher.find()) {
quantity = Integer.parseInt(matcher.group());
}
// look for word quantities if no explicit number
if (quantity == null) {
String[] words = section.split("\\s+");
for (String word : words) {
if (WORD_QUANTITIES.containsKey(word)) {
quantity = WORD_QUANTITIES.get(word);
break;
}
}
}
// default to 1 if no quantity specified
if (quantity == null) {
quantity = 1;
}
// find material
Material material = aliasRegistry.resolve(section);
if (material == null) {
return null;
}
return new ItemQuantity(material, quantity);
}
private record DirectionalInfo(int keywordStart, int keywordEnd, boolean isPerUnit, String keyword) {
}
private record ItemQuantity(Material material, int quantity) {
}
}

View File

@@ -0,0 +1,60 @@
package party.cybsec.oyeshops.permission;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/**
* centralized permission checking with op bypass
*/
public class PermissionManager {
public static final String CREATE = "oyeshops.create";
public static final String USE = "oyeshops.use";
public static final String INSPECT = "oyeshops.inspect";
public static final String ADMIN = "oyeshops.admin";
public static final String BREAK_OVERRIDE = "oyeshops.break.override";
/**
* check if player has permission or is op
*/
public static boolean has(Player player, String permission) {
return player.isOp() || player.hasPermission(permission);
}
/**
* check if sender has permission (for non-player senders)
*/
public static boolean has(CommandSender sender, String permission) {
if (sender instanceof Player player) {
return has(player, permission);
}
return sender.hasPermission(permission);
}
public static boolean canCreate(Player player) {
return has(player, CREATE);
}
public static boolean canUse(Player player) {
return has(player, USE);
}
public static boolean canInspect(Player player) {
return has(player, INSPECT) || has(player, ADMIN);
}
public static boolean isAdmin(Player player) {
return has(player, ADMIN);
}
public static boolean isAdmin(CommandSender sender) {
if (sender instanceof Player player) {
return isAdmin(player);
}
return sender.hasPermission(ADMIN);
}
public static boolean canBreakOverride(Player player) {
return has(player, BREAK_OVERRIDE) || has(player, ADMIN);
}
}

View File

@@ -0,0 +1,106 @@
package party.cybsec.oyeshops.registry;
import org.bukkit.Location;
import party.cybsec.oyeshops.model.Shop;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* in-memory shop cache
* synchronized access, backed by sqlite
*/
public class ShopRegistry {
private final Map<String, Shop> shopsByLocation = new ConcurrentHashMap<>();
private final Map<Integer, Shop> shopsById = new ConcurrentHashMap<>();
/**
* register a shop
*/
public void register(Shop shop) {
String key = locationKey(shop.getSignLocation());
shopsByLocation.put(key, shop);
shopsById.put(shop.getId(), shop);
}
/**
* unregister a shop by location
*/
public void unregister(Location location) {
String key = locationKey(location);
Shop shop = shopsByLocation.remove(key);
if (shop != null) {
shopsById.remove(shop.getId());
}
}
/**
* unregister a shop by id
*/
public void unregister(int shopId) {
Shop shop = shopsById.remove(shopId);
if (shop != null) {
String key = locationKey(shop.getSignLocation());
shopsByLocation.remove(key);
}
}
/**
* unregister a shop
*/
public void unregister(Shop shop) {
unregister(shop.getId());
}
/**
* get shop by location
*/
public Shop getShop(Location location) {
String key = locationKey(location);
return shopsByLocation.get(key);
}
/**
* get shop by id
*/
public Shop getShop(int shopId) {
return shopsById.get(shopId);
}
/**
* get shop by id (alias)
*/
public Shop getShopById(int shopId) {
return shopsById.get(shopId);
}
/**
* get all shops
*/
public Collection<Shop> getAllShops() {
return shopsById.values();
}
/**
* check if location has a shop
*/
public boolean hasShop(Location location) {
return shopsByLocation.containsKey(locationKey(location));
}
/**
* clear all shops
*/
public void clear() {
shopsByLocation.clear();
shopsById.clear();
}
/**
* create location key for map lookup
*/
private String locationKey(Location loc) {
return loc.getWorld().getUID() + ":" + loc.getBlockX() + ":" + loc.getBlockY() + ":" + loc.getBlockZ();
}
}

View File

@@ -0,0 +1,207 @@
package party.cybsec.oyeshops.transaction;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Barrel;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.Chest;
import org.bukkit.block.data.type.WallSign;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import party.cybsec.oyeshops.OyeShopsPlugin;
import party.cybsec.oyeshops.model.Shop;
import party.cybsec.oyeshops.model.Trade;
import java.sql.SQLException;
import java.util.HashMap;
/**
* handles atomic shop transactions with rollback
*/
public class TransactionManager {
private final OyeShopsPlugin plugin;
public TransactionManager(OyeShopsPlugin plugin) {
this.plugin = plugin;
}
public enum Result {
SUCCESS,
INSUFFICIENT_FUNDS,
INSUFFICIENT_STOCK,
INVENTORY_FULL,
DATABASE_ERROR,
SHOP_DISABLED
}
/**
* execute a transaction for the given number of units
*/
public Result execute(Player buyer, Shop shop, int units) {
if (!shop.isEnabled()) {
return Result.SHOP_DISABLED;
}
Trade trade = shop.getTrade();
int totalPrice = trade.priceQuantity() * units;
int totalProduct = trade.productQuantity() * units;
// get chest inventory
Inventory chestInventory = getShopInventory(shop);
if (chestInventory == null) {
return Result.DATABASE_ERROR;
}
// verify buyer has required items
if (!hasItems(buyer.getInventory(), trade.priceItem(), totalPrice)) {
return Result.INSUFFICIENT_FUNDS;
}
// verify chest has required stock
if (!hasItems(chestInventory, trade.productItem(), totalProduct)) {
return Result.INSUFFICIENT_STOCK;
}
// take snapshots for rollback
ItemStack[] buyerSnapshot = buyer.getInventory().getContents().clone();
for (int i = 0; i < buyerSnapshot.length; i++) {
if (buyerSnapshot[i] != null) {
buyerSnapshot[i] = buyerSnapshot[i].clone();
}
}
ItemStack[] chestSnapshot = chestInventory.getContents().clone();
for (int i = 0; i < chestSnapshot.length; i++) {
if (chestSnapshot[i] != null) {
chestSnapshot[i] = chestSnapshot[i].clone();
}
}
try {
// remove price items from buyer
removeItems(buyer.getInventory(), trade.priceItem(), totalPrice);
// remove product items from chest
removeItems(chestInventory, trade.productItem(), totalProduct);
// add product items to buyer
HashMap<Integer, ItemStack> overflow = buyer.getInventory().addItem(
createItemStacks(trade.productItem(), totalProduct));
if (!overflow.isEmpty()) {
// rollback
buyer.getInventory().setContents(buyerSnapshot);
chestInventory.setContents(chestSnapshot);
return Result.INVENTORY_FULL;
}
// update owed amount in database
plugin.getShopRepository().updateOwedAmount(shop.getId(), totalPrice);
shop.setOwedAmount(shop.getOwedAmount() + totalPrice);
// record transaction
plugin.getTransactionRepository().recordTransaction(
shop.getId(),
buyer.getUniqueId(),
units);
// prune old transactions if configured
if (plugin.getConfigManager().isAutoPrune()) {
int maxHistory = plugin.getConfigManager().getMaxTransactionsPerShop();
plugin.getTransactionRepository().pruneTransactions(shop.getId(), maxHistory);
}
return Result.SUCCESS;
} catch (SQLException e) {
// rollback on database error
buyer.getInventory().setContents(buyerSnapshot);
chestInventory.setContents(chestSnapshot);
e.printStackTrace();
return Result.DATABASE_ERROR;
}
}
/**
* check if inventory has required items
*/
private boolean hasItems(Inventory inventory, Material material, int amount) {
int count = 0;
for (ItemStack item : inventory.getContents()) {
if (item != null && item.getType() == material) {
count += item.getAmount();
if (count >= amount) {
return true;
}
}
}
return count >= amount;
}
/**
* remove items from inventory
*/
private void removeItems(Inventory inventory, Material material, int amount) {
int remaining = amount;
for (int i = 0; i < inventory.getSize() && remaining > 0; i++) {
ItemStack item = inventory.getItem(i);
if (item != null && item.getType() == material) {
int toRemove = Math.min(item.getAmount(), remaining);
if (toRemove == item.getAmount()) {
inventory.setItem(i, null);
} else {
item.setAmount(item.getAmount() - toRemove);
}
remaining -= toRemove;
}
}
}
/**
* create item stacks for given total amount
*/
private ItemStack[] createItemStacks(Material material, int totalAmount) {
int maxStack = material.getMaxStackSize();
int fullStacks = totalAmount / maxStack;
int remainder = totalAmount % maxStack;
int arraySize = fullStacks + (remainder > 0 ? 1 : 0);
ItemStack[] stacks = new ItemStack[arraySize];
for (int i = 0; i < fullStacks; i++) {
stacks[i] = new ItemStack(material, maxStack);
}
if (remainder > 0) {
stacks[fullStacks] = new ItemStack(material, remainder);
}
return stacks;
}
/**
* get the shop's container inventory
*/
private Inventory getShopInventory(Shop shop) {
Location signLoc = shop.getSignLocation();
Block signBlock = signLoc.getBlock();
if (!(signBlock.getBlockData() instanceof WallSign wallSign)) {
return null;
}
BlockFace attachedFace = wallSign.getFacing().getOppositeFace();
Block containerBlock = signBlock.getRelative(attachedFace);
if (containerBlock.getState() instanceof Chest chest) {
return chest.getInventory();
} else if (containerBlock.getState() instanceof Barrel barrel) {
return barrel.getInventory();
}
return null;
}
}

View File

@@ -0,0 +1,29 @@
# hopper protection
hoppers:
allow-product-output: true
block-price-input: true
# transaction history
history:
max-transactions-per-shop: 100
auto-prune: true
# material aliases
aliases:
# common abbreviations
dia: diamond
dias: diamond
iron: iron_ingot
gold: gold_ingot
emerald: emerald
ems: emerald
# blocks
stone: stone
dirt: dirt
cobble: cobblestone
# tools
pick: diamond_pickaxe
sword: diamond_sword
axe: diamond_axe

View File

@@ -0,0 +1,34 @@
name: oyeShops
version: 1.0.0
main: party.cybsec.oyeshops.OyeShopsPlugin
api-version: '1.21'
description: deterministic item-for-item chest barter
author: cybsec
website: https://party.cybsec
commands:
oyeshops:
description: oyeShops commands
usage: /oyeshops <on|off|toggle|reload|inspect|spoof|enable|disable|unregister>
aliases: [oyes, oshop]
permissions:
oyeshops.create:
description: allows placing valid shop signs (requires opt-in)
default: true
oyeshops.use:
description: allows buying from shops
default: true
oyeshops.inspect:
description: allows admin inspection via inspect mode
default: op
oyeshops.admin:
description: full control including spoof, reload, shop management
default: op
oyeshops.break.override:
description: allows breaking any shop sign or chest/barrel
default: op