commit 4251641bc6b5b24b307ddf1f6f23784a32590dec Author: cybsec Date: Wed Feb 4 19:39:37 2026 -0500 forgot to do small contribs lmao diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b20d3b3 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a627919 --- /dev/null +++ b/README.md @@ -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 ` - enable shop +- `/oyeshops disable ` - disable shop +- `/oyeshops unregister ` - 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) diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..fb8bb18 --- /dev/null +++ b/build.gradle.kts @@ -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 { + 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) + } +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..057e5dc --- /dev/null +++ b/build.sh @@ -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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..2271762 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "oyeShops" diff --git a/src/main/java/party/cybsec/oyeshops/OyeShopsPlugin.java b/src/main/java/party/cybsec/oyeshops/OyeShopsPlugin.java new file mode 100644 index 0000000..4cb3aa6 --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/OyeShopsPlugin.java @@ -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 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; + } +} diff --git a/src/main/java/party/cybsec/oyeshops/admin/InspectModeManager.java b/src/main/java/party/cybsec/oyeshops/admin/InspectModeManager.java new file mode 100644 index 0000000..044412e --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/admin/InspectModeManager.java @@ -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 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()); + } +} diff --git a/src/main/java/party/cybsec/oyeshops/admin/SpoofManager.java b/src/main/java/party/cybsec/oyeshops/admin/SpoofManager.java new file mode 100644 index 0000000..d6ca213 --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/admin/SpoofManager.java @@ -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 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()); + } +} diff --git a/src/main/java/party/cybsec/oyeshops/command/AdminCommands.java b/src/main/java/party/cybsec/oyeshops/command/AdminCommands.java new file mode 100644 index 0000000..423050b --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/command/AdminCommands.java @@ -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 ", NamedTextColor.YELLOW) + .append(Component.text(" - enable a shop", NamedTextColor.GRAY))); + sender.sendMessage(Component.text("/oyeshops disable ", NamedTextColor.YELLOW) + .append(Component.text(" - disable a shop", NamedTextColor.GRAY))); + sender.sendMessage(Component.text("/oyeshops unregister ", 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 ", 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 ", 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 ", 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 onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + List completions = new ArrayList<>(); + if (args.length == 1) { + List 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; + } +} diff --git a/src/main/java/party/cybsec/oyeshops/config/ConfigManager.java b/src/main/java/party/cybsec/oyeshops/config/ConfigManager.java new file mode 100644 index 0000000..f54496c --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/config/ConfigManager.java @@ -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; + } +} diff --git a/src/main/java/party/cybsec/oyeshops/database/DatabaseManager.java b/src/main/java/party/cybsec/oyeshops/database/DatabaseManager.java new file mode 100644 index 0000000..79763cd --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/database/DatabaseManager.java @@ -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(); + } + } + } +} diff --git a/src/main/java/party/cybsec/oyeshops/database/PlayerPreferenceRepository.java b/src/main/java/party/cybsec/oyeshops/database/PlayerPreferenceRepository.java new file mode 100644 index 0000000..bd66f49 --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/database/PlayerPreferenceRepository.java @@ -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 enabledPlayers = new HashSet<>(); + private final Set 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; + } +} diff --git a/src/main/java/party/cybsec/oyeshops/database/ShopRepository.java b/src/main/java/party/cybsec/oyeshops/database/ShopRepository.java new file mode 100644 index 0000000..29ab6ec --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/database/ShopRepository.java @@ -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 getShopsByOwner(UUID ownerUuid) throws SQLException { + String sql = "select * from shops where owner_uuid = ?"; + List 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 loadAllShops() throws SQLException { + String sql = "select * from shops"; + List 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); + } +} diff --git a/src/main/java/party/cybsec/oyeshops/database/TransactionRepository.java b/src/main/java/party/cybsec/oyeshops/database/TransactionRepository.java new file mode 100644 index 0000000..8628649 --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/database/TransactionRepository.java @@ -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 getRecentTransactions(int shopId, int limit) throws SQLException { + String sql = """ + select * from transactions + where shop_id = ? + order by timestamp desc + limit ? + """; + + List 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) { + } +} diff --git a/src/main/java/party/cybsec/oyeshops/gui/ConfirmationGui.java b/src/main/java/party/cybsec/oyeshops/gui/ConfirmationGui.java new file mode 100644 index 0000000..9ecf4cb --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/gui/ConfirmationGui.java @@ -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 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 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; + } +} diff --git a/src/main/java/party/cybsec/oyeshops/listener/ChestInteractionListener.java b/src/main/java/party/cybsec/oyeshops/listener/ChestInteractionListener.java new file mode 100644 index 0000000..e35b846 --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/listener/ChestInteractionListener.java @@ -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 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 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; + } + } +} diff --git a/src/main/java/party/cybsec/oyeshops/listener/ContainerPlacementListener.java b/src/main/java/party/cybsec/oyeshops/listener/ContainerPlacementListener.java new file mode 100644 index 0000000..ee166e4 --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/listener/ContainerPlacementListener.java @@ -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 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; + } +} diff --git a/src/main/java/party/cybsec/oyeshops/listener/InspectListener.java b/src/main/java/party/cybsec/oyeshops/listener/InspectListener.java new file mode 100644 index 0000000..7e46667 --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/listener/InspectListener.java @@ -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 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 " + 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; + } +} diff --git a/src/main/java/party/cybsec/oyeshops/listener/LoginListener.java b/src/main/java/party/cybsec/oyeshops/listener/LoginListener.java new file mode 100644 index 0000000..19cadb2 --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/listener/LoginListener.java @@ -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 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; + } +} diff --git a/src/main/java/party/cybsec/oyeshops/listener/ShopActivationListener.java b/src/main/java/party/cybsec/oyeshops/listener/ShopActivationListener.java new file mode 100644 index 0000000..f5345ab --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/listener/ShopActivationListener.java @@ -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> 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> 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> 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 counts) { + int bestQuantity = -1; + int bestCount = -1; + for (Map.Entry 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 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 getFullContainer(Block block) { + List 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; + } +} diff --git a/src/main/java/party/cybsec/oyeshops/listener/ShopProtectionListener.java b/src/main/java/party/cybsec/oyeshops/listener/ShopProtectionListener.java new file mode 100644 index 0000000..dca7b65 --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/listener/ShopProtectionListener.java @@ -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; + } +} diff --git a/src/main/java/party/cybsec/oyeshops/manager/ActivationManager.java b/src/main/java/party/cybsec/oyeshops/manager/ActivationManager.java new file mode 100644 index 0000000..6cd3e51 --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/manager/ActivationManager.java @@ -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 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; + } +} diff --git a/src/main/java/party/cybsec/oyeshops/manager/ContainerMemoryManager.java b/src/main/java/party/cybsec/oyeshops/manager/ContainerMemoryManager.java new file mode 100644 index 0000000..f893e8a --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/manager/ContainerMemoryManager.java @@ -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 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(); + } +} diff --git a/src/main/java/party/cybsec/oyeshops/model/PendingActivation.java b/src/main/java/party/cybsec/oyeshops/model/PendingActivation.java new file mode 100644 index 0000000..c88ca82 --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/model/PendingActivation.java @@ -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); + } +} diff --git a/src/main/java/party/cybsec/oyeshops/model/Shop.java b/src/main/java/party/cybsec/oyeshops/model/Shop.java new file mode 100644 index 0000000..38ab3ef --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/model/Shop.java @@ -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 + } +} diff --git a/src/main/java/party/cybsec/oyeshops/model/Trade.java b/src/main/java/party/cybsec/oyeshops/model/Trade.java new file mode 100644 index 0000000..d93a056 --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/model/Trade.java @@ -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(); + } +} diff --git a/src/main/java/party/cybsec/oyeshops/parser/MaterialAliasRegistry.java b/src/main/java/party/cybsec/oyeshops/parser/MaterialAliasRegistry.java new file mode 100644 index 0000000..0857849 --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/parser/MaterialAliasRegistry.java @@ -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 aliases = new HashMap<>(); + + // common aliases built-in + private static final Map 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 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 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; + } +} diff --git a/src/main/java/party/cybsec/oyeshops/parser/SignParser.java b/src/main/java/party/cybsec/oyeshops/parser/SignParser.java new file mode 100644 index 0000000..12c1eb3 --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/parser/SignParser.java @@ -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 COST_INDICATORS = Set.of( + "for", "per", "costs", "cost", "price", "=", "->", "=>", ">", "→"); + private static final Set 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 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 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 findMultipleItemQuantities(String text) { + List 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) { + } +} diff --git a/src/main/java/party/cybsec/oyeshops/permission/PermissionManager.java b/src/main/java/party/cybsec/oyeshops/permission/PermissionManager.java new file mode 100644 index 0000000..8ef451b --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/permission/PermissionManager.java @@ -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); + } +} diff --git a/src/main/java/party/cybsec/oyeshops/registry/ShopRegistry.java b/src/main/java/party/cybsec/oyeshops/registry/ShopRegistry.java new file mode 100644 index 0000000..993413d --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/registry/ShopRegistry.java @@ -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 shopsByLocation = new ConcurrentHashMap<>(); + private final Map 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 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(); + } +} diff --git a/src/main/java/party/cybsec/oyeshops/transaction/TransactionManager.java b/src/main/java/party/cybsec/oyeshops/transaction/TransactionManager.java new file mode 100644 index 0000000..d1fcf59 --- /dev/null +++ b/src/main/java/party/cybsec/oyeshops/transaction/TransactionManager.java @@ -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 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; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..0a8bd45 --- /dev/null +++ b/src/main/resources/config.yml @@ -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 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..20a32cd --- /dev/null +++ b/src/main/resources/plugin.yml @@ -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 + 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