forgot to do small contribs lmao
This commit is contained in:
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# gradle
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# intellij
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.iws
|
||||||
|
*.ipr
|
||||||
|
|
||||||
|
# eclipse
|
||||||
|
.classpath
|
||||||
|
.project
|
||||||
|
.settings/
|
||||||
|
|
||||||
|
# vscode
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# macos
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# plugin output
|
||||||
|
*.jar
|
||||||
|
|
||||||
|
# database
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
187
README.md
Normal file
187
README.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# oyeShops
|
||||||
|
|
||||||
|
deterministic item-for-item chest barter plugin for minecraft paper 1.21.11
|
||||||
|
|
||||||
|
## core identity
|
||||||
|
|
||||||
|
- **plugin name**: oyeShops
|
||||||
|
- **purpose**: deterministic item-for-item chest barter using sign text
|
||||||
|
- **no economy**, no currency, no ml, no heuristics
|
||||||
|
- **explicit opt-in only**
|
||||||
|
- **deterministic and reversible**
|
||||||
|
|
||||||
|
## features
|
||||||
|
|
||||||
|
### permission-gated shops
|
||||||
|
- only players with `oyeshops.create` can create shops
|
||||||
|
- only players with `oyeshops.use` can buy from shops
|
||||||
|
- ops bypass all permission checks
|
||||||
|
|
||||||
|
### sign-based activation
|
||||||
|
shops activate when all conditions are met:
|
||||||
|
- wall sign attached to chest
|
||||||
|
- sign text contains at least one "." character
|
||||||
|
- sign text parses cleanly into exactly one trade
|
||||||
|
- player has `oyeshops.create` permission
|
||||||
|
|
||||||
|
### deterministic parsing
|
||||||
|
sign parser is strict and deterministic:
|
||||||
|
- normalizes text (lowercase, strip punctuation)
|
||||||
|
- resolves material aliases from config
|
||||||
|
- detects quantities (explicit numbers or implicit: a/one/each → 1)
|
||||||
|
- requires directional keywords (for/per/costs/= or get/gives/buy/selling)
|
||||||
|
- ambiguity equals no shop - no auto-fixing
|
||||||
|
|
||||||
|
### atomic transactions
|
||||||
|
- buyer inventory verified before transaction
|
||||||
|
- chest stock verified before transaction
|
||||||
|
- all steps succeed or all rollback
|
||||||
|
- crash-safe via sqlite
|
||||||
|
|
||||||
|
### profit withdrawal
|
||||||
|
- sellers withdraw owed items via `/oyeshops withdraw`
|
||||||
|
- partial fills allowed if inventory full
|
||||||
|
- remainder stays owed in database
|
||||||
|
|
||||||
|
### admin tools
|
||||||
|
- `/oyeshops inspect` - toggle inspect mode
|
||||||
|
- right-click shops while inspecting to view details
|
||||||
|
- `/oyeshops enable <id>` - enable shop
|
||||||
|
- `/oyeshops disable <id>` - disable shop
|
||||||
|
- `/oyeshops unregister <id>` - delete shop
|
||||||
|
- `/oyeshops reload` - reload config
|
||||||
|
|
||||||
|
## building
|
||||||
|
|
||||||
|
### requirements
|
||||||
|
- java 21 or higher
|
||||||
|
- gradle (wrapper included)
|
||||||
|
|
||||||
|
### build commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# build plugin jar
|
||||||
|
./gradlew build
|
||||||
|
|
||||||
|
# output jar location
|
||||||
|
build/libs/oyeShops-1.0.0.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
## installation
|
||||||
|
|
||||||
|
1. build the plugin (see above)
|
||||||
|
2. copy `oyeShops-1.0.0.jar` to your server's `plugins/` directory
|
||||||
|
3. restart server
|
||||||
|
4. configure permissions in your permission plugin
|
||||||
|
5. optionally edit `plugins/oyeShops/config.yml`
|
||||||
|
|
||||||
|
## permissions
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
oyeshops.create # allows placing valid shop signs (default: false)
|
||||||
|
oyeshops.use # allows buying from shops (default: false)
|
||||||
|
oyeshops.withdraw # allows withdrawing owed items (default: false)
|
||||||
|
oyeshops.inspect # allows admin inspection (default: op)
|
||||||
|
oyeshops.admin # full control (default: op)
|
||||||
|
oyeshops.break.override # allows breaking any shop (default: op)
|
||||||
|
```
|
||||||
|
|
||||||
|
## usage
|
||||||
|
|
||||||
|
### creating a shop
|
||||||
|
|
||||||
|
1. place a chest
|
||||||
|
2. attach a wall sign to the chest
|
||||||
|
3. write trade on sign, for example:
|
||||||
|
- `5 diamonds`
|
||||||
|
- `for`
|
||||||
|
- `1 netherite`
|
||||||
|
- (blank line)
|
||||||
|
|
||||||
|
this creates a shop that sells 1 netherite_ingot for 5 diamonds
|
||||||
|
|
||||||
|
### sign format examples
|
||||||
|
|
||||||
|
```
|
||||||
|
5 dia for 1 iron
|
||||||
|
→ 5 diamonds for 1 iron_ingot
|
||||||
|
|
||||||
|
1 emerald per cobble
|
||||||
|
→ 1 emerald for 1 cobblestone
|
||||||
|
|
||||||
|
10 stone = 1 diamond
|
||||||
|
→ 10 stone for 1 diamond
|
||||||
|
|
||||||
|
buy 1 pick for 5 ems
|
||||||
|
→ 5 emeralds for 1 diamond_pickaxe
|
||||||
|
```
|
||||||
|
|
||||||
|
### buying from a shop
|
||||||
|
|
||||||
|
1. right-click the shop chest
|
||||||
|
2. click any item in the fake chest gui
|
||||||
|
3. confirmation gui opens showing the trade
|
||||||
|
4. click green pane to confirm, red pane to cancel
|
||||||
|
|
||||||
|
### withdrawing profits
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/oyeshops withdraw
|
||||||
|
```
|
||||||
|
|
||||||
|
withdraws all owed items from your shops into your inventory
|
||||||
|
|
||||||
|
## configuration
|
||||||
|
|
||||||
|
`plugins/oyeShops/config.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# hopper protection
|
||||||
|
hoppers:
|
||||||
|
allow-product-output: true # allow hoppers to extract product items
|
||||||
|
block-price-input: true # block hoppers from inserting price items
|
||||||
|
|
||||||
|
# transaction history
|
||||||
|
history:
|
||||||
|
max-transactions-per-shop: 100 # max transactions to keep per shop
|
||||||
|
auto-prune: true # automatically prune old transactions
|
||||||
|
|
||||||
|
# material aliases
|
||||||
|
aliases:
|
||||||
|
dia: diamond
|
||||||
|
dias: diamond
|
||||||
|
iron: iron_ingot
|
||||||
|
gold: gold_ingot
|
||||||
|
emerald: emerald
|
||||||
|
ems: emerald
|
||||||
|
# add more as needed
|
||||||
|
```
|
||||||
|
|
||||||
|
## database
|
||||||
|
|
||||||
|
shops and transactions are stored in `plugins/oyeShops/oyeshops.db` (sqlite)
|
||||||
|
|
||||||
|
- crash-safe
|
||||||
|
- atomic updates
|
||||||
|
- bounded transaction history
|
||||||
|
|
||||||
|
## failure philosophy
|
||||||
|
|
||||||
|
- **ambiguity equals no shop**
|
||||||
|
- **no auto-fixing**
|
||||||
|
- **no warnings unless inspected**
|
||||||
|
- **deterministic parser output**
|
||||||
|
|
||||||
|
if a sign doesn't parse cleanly, it simply won't activate as a shop. no error messages, no warnings.
|
||||||
|
|
||||||
|
## non-goals
|
||||||
|
|
||||||
|
- no economy integration
|
||||||
|
- no pricing gui
|
||||||
|
- no machine learning
|
||||||
|
- no market mechanics
|
||||||
|
- no abstraction creep
|
||||||
|
|
||||||
|
## license
|
||||||
|
|
||||||
|
created by cybsec (party.cybsec)
|
||||||
39
build.gradle.kts
Normal file
39
build.gradle.kts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
plugins {
|
||||||
|
java
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "party.cybsec"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "deterministic item-for-item chest barter"
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
maven("https://repo.papermc.io/repository/maven-public/")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT")
|
||||||
|
implementation("org.xerial:sqlite-jdbc:3.47.1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain.languageVersion.set(JavaLanguageVersion.of(21))
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<JavaCompile> {
|
||||||
|
options.encoding = "UTF-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.jar {
|
||||||
|
archiveBaseName.set("oyeShops")
|
||||||
|
archiveVersion.set(project.version.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.processResources {
|
||||||
|
val props = mapOf("version" to version)
|
||||||
|
inputs.properties(props)
|
||||||
|
filteringCharset = "UTF-8"
|
||||||
|
filesMatching("plugin.yml") {
|
||||||
|
expand(props)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
build.sh
Executable file
19
build.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# build script for oyeShops plugin
|
||||||
|
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@21
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
|
||||||
|
echo "building oyeShops plugin..."
|
||||||
|
./gradlew clean build
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "✓ build successful!"
|
||||||
|
echo "jar location: build/libs/oyeShops-1.0.0.jar"
|
||||||
|
ls -lh build/libs/*.jar
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "✗ build failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
248
gradlew
vendored
Executable file
248
gradlew
vendored
Executable file
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
93
gradlew.bat
vendored
Normal file
93
gradlew.bat
vendored
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
1
settings.gradle.kts
Normal file
1
settings.gradle.kts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = "oyeShops"
|
||||||
182
src/main/java/party/cybsec/oyeshops/OyeShopsPlugin.java
Normal file
182
src/main/java/party/cybsec/oyeshops/OyeShopsPlugin.java
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package party.cybsec.oyeshops;
|
||||||
|
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
import party.cybsec.oyeshops.admin.InspectModeManager;
|
||||||
|
import party.cybsec.oyeshops.admin.SpoofManager;
|
||||||
|
import party.cybsec.oyeshops.command.AdminCommands;
|
||||||
|
import party.cybsec.oyeshops.config.ConfigManager;
|
||||||
|
import party.cybsec.oyeshops.database.DatabaseManager;
|
||||||
|
import party.cybsec.oyeshops.database.PlayerPreferenceRepository;
|
||||||
|
import party.cybsec.oyeshops.database.ShopRepository;
|
||||||
|
import party.cybsec.oyeshops.database.TransactionRepository;
|
||||||
|
import party.cybsec.oyeshops.listener.*;
|
||||||
|
import party.cybsec.oyeshops.model.Shop;
|
||||||
|
import party.cybsec.oyeshops.parser.MaterialAliasRegistry;
|
||||||
|
import party.cybsec.oyeshops.parser.SignParser;
|
||||||
|
import party.cybsec.oyeshops.registry.ShopRegistry;
|
||||||
|
import party.cybsec.oyeshops.transaction.TransactionManager;
|
||||||
|
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* main plugin class
|
||||||
|
*/
|
||||||
|
public class OyeShopsPlugin extends JavaPlugin {
|
||||||
|
// managers
|
||||||
|
private ConfigManager configManager;
|
||||||
|
private DatabaseManager databaseManager;
|
||||||
|
private ShopRepository shopRepository;
|
||||||
|
private TransactionRepository transactionRepository;
|
||||||
|
private PlayerPreferenceRepository playerPreferenceRepository;
|
||||||
|
private ShopRegistry shopRegistry;
|
||||||
|
private MaterialAliasRegistry aliasRegistry;
|
||||||
|
private SignParser signParser;
|
||||||
|
private TransactionManager transactionManager;
|
||||||
|
private InspectModeManager inspectModeManager;
|
||||||
|
private SpoofManager spoofManager;
|
||||||
|
private party.cybsec.oyeshops.manager.ActivationManager activationManager;
|
||||||
|
private party.cybsec.oyeshops.manager.ContainerMemoryManager containerMemoryManager;
|
||||||
|
private ShopActivationListener shopActivationListener;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEnable() {
|
||||||
|
getLogger().info("initializing oyeShops...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// initialize config
|
||||||
|
configManager = new ConfigManager(this);
|
||||||
|
|
||||||
|
// initialize database
|
||||||
|
databaseManager = new DatabaseManager(getDataFolder());
|
||||||
|
databaseManager.initialize();
|
||||||
|
|
||||||
|
// initialize repositories
|
||||||
|
shopRepository = new ShopRepository(databaseManager);
|
||||||
|
transactionRepository = new TransactionRepository(databaseManager);
|
||||||
|
playerPreferenceRepository = new PlayerPreferenceRepository(databaseManager);
|
||||||
|
|
||||||
|
// load player preferences cache
|
||||||
|
playerPreferenceRepository.loadPreferences();
|
||||||
|
|
||||||
|
// initialize registry
|
||||||
|
shopRegistry = new ShopRegistry();
|
||||||
|
|
||||||
|
// initialize parsers
|
||||||
|
aliasRegistry = new MaterialAliasRegistry(configManager.getConfig().getConfigurationSection("aliases"));
|
||||||
|
signParser = new SignParser(aliasRegistry);
|
||||||
|
|
||||||
|
// initialize managers
|
||||||
|
transactionManager = new TransactionManager(this);
|
||||||
|
inspectModeManager = new InspectModeManager();
|
||||||
|
spoofManager = new SpoofManager();
|
||||||
|
activationManager = new party.cybsec.oyeshops.manager.ActivationManager();
|
||||||
|
containerMemoryManager = new party.cybsec.oyeshops.manager.ContainerMemoryManager();
|
||||||
|
shopActivationListener = new ShopActivationListener(this, signParser);
|
||||||
|
|
||||||
|
// load existing shops from database
|
||||||
|
loadShops();
|
||||||
|
|
||||||
|
// register listeners
|
||||||
|
registerListeners();
|
||||||
|
|
||||||
|
// register commands
|
||||||
|
registerCommands();
|
||||||
|
|
||||||
|
getLogger().info("oyeShops enabled successfully!");
|
||||||
|
|
||||||
|
} catch (SQLException e) {
|
||||||
|
getLogger().severe("failed to initialize database: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
getServer().getPluginManager().disablePlugin(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisable() {
|
||||||
|
getLogger().info("shutting down oyeShops...");
|
||||||
|
|
||||||
|
// close database connection
|
||||||
|
if (databaseManager != null) {
|
||||||
|
databaseManager.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
getLogger().info("oyeShops disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadShops() throws SQLException {
|
||||||
|
List<Shop> shops = shopRepository.loadAllShops();
|
||||||
|
for (Shop shop : shops) {
|
||||||
|
shopRegistry.register(shop);
|
||||||
|
}
|
||||||
|
getLogger().info("loaded " + shops.size() + " shops from database");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void registerListeners() {
|
||||||
|
getServer().getPluginManager().registerEvents(shopActivationListener, this);
|
||||||
|
getServer().getPluginManager().registerEvents(new ShopProtectionListener(this), this);
|
||||||
|
getServer().getPluginManager().registerEvents(new ChestInteractionListener(this), this);
|
||||||
|
getServer().getPluginManager().registerEvents(new InspectListener(this), this);
|
||||||
|
getServer().getPluginManager().registerEvents(new LoginListener(this), this);
|
||||||
|
getServer().getPluginManager().registerEvents(new ContainerPlacementListener(this), this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void registerCommands() {
|
||||||
|
AdminCommands adminCommands = new AdminCommands(this);
|
||||||
|
getCommand("oyeshops").setExecutor(adminCommands);
|
||||||
|
getCommand("oyeshops").setTabCompleter(adminCommands);
|
||||||
|
}
|
||||||
|
|
||||||
|
// getters for managers
|
||||||
|
public ConfigManager getConfigManager() {
|
||||||
|
return configManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShopRepository getShopRepository() {
|
||||||
|
return shopRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TransactionRepository getTransactionRepository() {
|
||||||
|
return transactionRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerPreferenceRepository getPlayerPreferenceRepository() {
|
||||||
|
return playerPreferenceRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShopRegistry getShopRegistry() {
|
||||||
|
return shopRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TransactionManager getTransactionManager() {
|
||||||
|
return transactionManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InspectModeManager getInspectModeManager() {
|
||||||
|
return inspectModeManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SpoofManager getSpoofManager() {
|
||||||
|
return spoofManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public party.cybsec.oyeshops.manager.ActivationManager getActivationManager() {
|
||||||
|
return activationManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public party.cybsec.oyeshops.manager.ContainerMemoryManager getContainerMemoryManager() {
|
||||||
|
return containerMemoryManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShopActivationListener getShopActivationListener() {
|
||||||
|
return shopActivationListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SignParser getSignParser() {
|
||||||
|
return signParser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MaterialAliasRegistry getAliasRegistry() {
|
||||||
|
return aliasRegistry;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package party.cybsec.oyeshops.admin;
|
||||||
|
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* toggles inspect mode for admins
|
||||||
|
*/
|
||||||
|
public class InspectModeManager {
|
||||||
|
private final Set<UUID> inspectMode = new HashSet<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* toggle inspect mode for player
|
||||||
|
*
|
||||||
|
* @return true if now inspecting, false if stopped
|
||||||
|
*/
|
||||||
|
public boolean toggle(Player player) {
|
||||||
|
UUID uuid = player.getUniqueId();
|
||||||
|
if (inspectMode.contains(uuid)) {
|
||||||
|
inspectMode.remove(uuid);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
inspectMode.add(uuid);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if player is in inspect mode
|
||||||
|
*/
|
||||||
|
public boolean isInspecting(Player player) {
|
||||||
|
return inspectMode.contains(player.getUniqueId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* enable inspect mode
|
||||||
|
*/
|
||||||
|
public void enable(Player player) {
|
||||||
|
inspectMode.add(player.getUniqueId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* disable inspect mode
|
||||||
|
*/
|
||||||
|
public void disable(Player player) {
|
||||||
|
inspectMode.remove(player.getUniqueId());
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/main/java/party/cybsec/oyeshops/admin/SpoofManager.java
Normal file
46
src/main/java/party/cybsec/oyeshops/admin/SpoofManager.java
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package party.cybsec.oyeshops.admin;
|
||||||
|
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* manages admin spoof mode
|
||||||
|
* when spoofing, admin is treated as non-owner for their shops
|
||||||
|
* allows testing purchases from own shops
|
||||||
|
*/
|
||||||
|
public class SpoofManager {
|
||||||
|
private final Set<UUID> spoofing = new HashSet<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if player is in spoof mode
|
||||||
|
*/
|
||||||
|
public boolean isSpoofing(Player player) {
|
||||||
|
return spoofing.contains(player.getUniqueId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* toggle spoof mode for player
|
||||||
|
*
|
||||||
|
* @return true if now spoofing, false if stopped spoofing
|
||||||
|
*/
|
||||||
|
public boolean toggle(Player player) {
|
||||||
|
UUID uuid = player.getUniqueId();
|
||||||
|
if (spoofing.contains(uuid)) {
|
||||||
|
spoofing.remove(uuid);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
spoofing.add(uuid);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* disable spoof mode for player
|
||||||
|
*/
|
||||||
|
public void disable(Player player) {
|
||||||
|
spoofing.remove(player.getUniqueId());
|
||||||
|
}
|
||||||
|
}
|
||||||
368
src/main/java/party/cybsec/oyeshops/command/AdminCommands.java
Normal file
368
src/main/java/party/cybsec/oyeshops/command/AdminCommands.java
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
package party.cybsec.oyeshops.command;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandExecutor;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.command.TabCompleter;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import party.cybsec.oyeshops.OyeShopsPlugin;
|
||||||
|
import party.cybsec.oyeshops.model.PendingActivation;
|
||||||
|
import party.cybsec.oyeshops.model.Shop;
|
||||||
|
import party.cybsec.oyeshops.permission.PermissionManager;
|
||||||
|
import party.cybsec.oyeshops.listener.ShopActivationListener;
|
||||||
|
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* handle /oyeshops commands for both admins and players
|
||||||
|
*/
|
||||||
|
public class AdminCommands implements CommandExecutor, TabCompleter {
|
||||||
|
private final OyeShopsPlugin plugin;
|
||||||
|
|
||||||
|
public AdminCommands(OyeShopsPlugin plugin) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||||
|
if (args.length == 0) {
|
||||||
|
sendHelp(sender);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String subCommand = args[0].toLowerCase();
|
||||||
|
|
||||||
|
switch (subCommand) {
|
||||||
|
case "on", "enable" -> handleEnable(sender, args);
|
||||||
|
case "off", "disable" -> handleDisable(sender, args);
|
||||||
|
case "toggle" -> handleToggle(sender);
|
||||||
|
case "notify" -> handleNotifyToggle(sender);
|
||||||
|
case "info" -> handleInfo(sender);
|
||||||
|
case "reload" -> handleReload(sender);
|
||||||
|
case "inspect", "i" -> handleInspect(sender);
|
||||||
|
case "spoof", "s" -> handleSpoof(sender);
|
||||||
|
case "unregister", "delete", "remove" -> handleUnregister(sender, args);
|
||||||
|
case "_activate" -> handleActivate(sender, args);
|
||||||
|
default -> sendHelp(sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendHelp(CommandSender sender) {
|
||||||
|
sender.sendMessage(Component.text("=== oyeshops commands ===", NamedTextColor.GOLD));
|
||||||
|
sender.sendMessage(Component.text("/oyeshops on", NamedTextColor.YELLOW)
|
||||||
|
.append(Component.text(" - enable shop creation", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text("/oyeshops off", NamedTextColor.YELLOW)
|
||||||
|
.append(Component.text(" - disable shop creation", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text("/oyeshops toggle", NamedTextColor.YELLOW)
|
||||||
|
.append(Component.text(" - toggle shop creation", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text("/oyeshops notify", NamedTextColor.YELLOW)
|
||||||
|
.append(Component.text(" - toggle low stock login notifications", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text("/oyeshops info", NamedTextColor.YELLOW)
|
||||||
|
.append(Component.text(" - plugin information", NamedTextColor.GRAY)));
|
||||||
|
|
||||||
|
if (PermissionManager.isAdmin(sender)) {
|
||||||
|
sender.sendMessage(Component.text("/oyeshops reload", NamedTextColor.YELLOW)
|
||||||
|
.append(Component.text(" - reload config", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text("/oyeshops inspect", NamedTextColor.YELLOW)
|
||||||
|
.append(Component.text(" - toggle inspect mode", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text("/oyeshops spoof", NamedTextColor.YELLOW)
|
||||||
|
.append(Component.text(" - toggle spoof mode", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text("/oyeshops enable <id>", NamedTextColor.YELLOW)
|
||||||
|
.append(Component.text(" - enable a shop", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text("/oyeshops disable <id>", NamedTextColor.YELLOW)
|
||||||
|
.append(Component.text(" - disable a shop", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text("/oyeshops unregister <id>", NamedTextColor.YELLOW)
|
||||||
|
.append(Component.text(" - delete a shop", NamedTextColor.GRAY)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleInfo(CommandSender sender) {
|
||||||
|
sender.sendMessage(Component.text("cybsec made this plugin", NamedTextColor.GRAY));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleReload(CommandSender sender) {
|
||||||
|
if (!PermissionManager.isAdmin(sender)) {
|
||||||
|
sender.sendMessage(Component.text("no permission", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
plugin.getConfigManager().reload();
|
||||||
|
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||||
|
sender.sendMessage(Component.text("config reloaded", NamedTextColor.GREEN));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleInspect(CommandSender sender) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
sender.sendMessage(Component.text("players only", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PermissionManager.canInspect(player)) {
|
||||||
|
sender.sendMessage(Component.text("no permission", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean nowInspecting = plugin.getInspectModeManager().toggle(player);
|
||||||
|
if (nowInspecting) {
|
||||||
|
player.sendMessage(
|
||||||
|
Component.text("inspect mode enabled - right-click shops to view info", NamedTextColor.GREEN));
|
||||||
|
} else {
|
||||||
|
player.sendMessage(Component.text("inspect mode disabled", NamedTextColor.YELLOW));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleSpoof(CommandSender sender) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
sender.sendMessage(Component.text("players only", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PermissionManager.isAdmin(player)) {
|
||||||
|
sender.sendMessage(Component.text("no permission", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean nowSpoofing = plugin.getSpoofManager().toggle(player);
|
||||||
|
if (nowSpoofing) {
|
||||||
|
player.sendMessage(
|
||||||
|
Component.text("spoof mode enabled - you can now buy from your own shops", NamedTextColor.GREEN));
|
||||||
|
} else {
|
||||||
|
player.sendMessage(Component.text("spoof mode disabled", NamedTextColor.YELLOW));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleToggle(CommandSender sender) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
sender.sendMessage(Component.text("players only", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
boolean nowEnabled = plugin.getPlayerPreferenceRepository().toggleShops(player.getUniqueId());
|
||||||
|
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||||
|
if (nowEnabled) {
|
||||||
|
player.sendMessage(
|
||||||
|
Component.text("shop creation enabled. you can now make chest shops!",
|
||||||
|
NamedTextColor.GREEN));
|
||||||
|
} else {
|
||||||
|
player.sendMessage(
|
||||||
|
Component.text("shop creation disabled. oyeshops will now ignore your signs.",
|
||||||
|
NamedTextColor.YELLOW));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (SQLException e) {
|
||||||
|
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||||
|
player.sendMessage(Component.text("database error", NamedTextColor.RED));
|
||||||
|
});
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleNotifyToggle(CommandSender sender) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
sender.sendMessage(Component.text("players only", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
boolean nowEnabled = plugin.getPlayerPreferenceRepository().toggleNotify(player.getUniqueId());
|
||||||
|
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||||
|
if (nowEnabled) {
|
||||||
|
player.sendMessage(
|
||||||
|
Component.text(
|
||||||
|
"stock notifications enabled. you will be notified of low stock on login.",
|
||||||
|
NamedTextColor.GREEN));
|
||||||
|
} else {
|
||||||
|
player.sendMessage(Component.text("stock notifications disabled.", NamedTextColor.YELLOW));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (SQLException e) {
|
||||||
|
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||||
|
player.sendMessage(Component.text("database error", NamedTextColor.RED));
|
||||||
|
});
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleActivate(CommandSender sender, String[] args) {
|
||||||
|
if (!(sender instanceof Player player))
|
||||||
|
return;
|
||||||
|
if (args.length < 2)
|
||||||
|
return;
|
||||||
|
|
||||||
|
String action = args[1].toLowerCase();
|
||||||
|
PendingActivation activation = plugin.getActivationManager().getAndRemove(player.getUniqueId());
|
||||||
|
|
||||||
|
if (activation == null) {
|
||||||
|
player.sendMessage(Component.text("activation expired or not found", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "accept" -> {
|
||||||
|
finalizeShop(player, activation);
|
||||||
|
}
|
||||||
|
case "invert" -> {
|
||||||
|
finalizeShop(player, activation.invert());
|
||||||
|
}
|
||||||
|
case "cancel" -> {
|
||||||
|
player.sendMessage(
|
||||||
|
Component.text("shop creation cancelled. sign remains as vanilla.", NamedTextColor.YELLOW));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finalizeShop(Player player, PendingActivation activation) {
|
||||||
|
plugin.getShopActivationListener().finalizeShop(player, activation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleEnable(CommandSender sender, String[] args) {
|
||||||
|
if (args.length < 2) {
|
||||||
|
if (sender instanceof Player player) {
|
||||||
|
try {
|
||||||
|
plugin.getPlayerPreferenceRepository().enableShops(player.getUniqueId());
|
||||||
|
player.sendMessage(Component.text("shop creation enabled. you can now make chest shops!",
|
||||||
|
NamedTextColor.GREEN));
|
||||||
|
} catch (SQLException e) {
|
||||||
|
player.sendMessage(Component.text("database error", NamedTextColor.RED));
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sender.sendMessage(Component.text("usage: /oyeshops enable <id>", NamedTextColor.RED));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PermissionManager.isAdmin(sender)) {
|
||||||
|
sender.sendMessage(Component.text("no permission", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
int shopId = Integer.parseInt(args[1]);
|
||||||
|
Shop shop = plugin.getShopRegistry().getShopById(shopId);
|
||||||
|
if (shop == null) {
|
||||||
|
sender.sendMessage(Component.text("shop not found: " + shopId, NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shop.setEnabled(true);
|
||||||
|
plugin.getShopRepository().setEnabled(shopId, true);
|
||||||
|
sender.sendMessage(Component.text("shop #" + shopId + " enabled", NamedTextColor.GREEN));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
sender.sendMessage(Component.text("invalid shop id", NamedTextColor.RED));
|
||||||
|
} catch (SQLException e) {
|
||||||
|
sender.sendMessage(Component.text("database error", NamedTextColor.RED));
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleDisable(CommandSender sender, String[] args) {
|
||||||
|
if (args.length < 2) {
|
||||||
|
if (sender instanceof Player player) {
|
||||||
|
try {
|
||||||
|
plugin.getPlayerPreferenceRepository().disableShops(player.getUniqueId());
|
||||||
|
player.sendMessage(Component.text("shop creation disabled. oyeshops will now ignore your signs.",
|
||||||
|
NamedTextColor.YELLOW));
|
||||||
|
} catch (SQLException e) {
|
||||||
|
player.sendMessage(Component.text("database error", NamedTextColor.RED));
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sender.sendMessage(Component.text("usage: /oyeshops disable <id>", NamedTextColor.RED));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PermissionManager.isAdmin(sender)) {
|
||||||
|
sender.sendMessage(Component.text("no permission", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
int shopId = Integer.parseInt(args[1]);
|
||||||
|
Shop shop = plugin.getShopRegistry().getShopById(shopId);
|
||||||
|
if (shop == null) {
|
||||||
|
sender.sendMessage(Component.text("shop not found: " + shopId, NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shop.setEnabled(false);
|
||||||
|
plugin.getShopRepository().setEnabled(shopId, false);
|
||||||
|
sender.sendMessage(Component.text("shop #" + shopId + " disabled", NamedTextColor.YELLOW));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
sender.sendMessage(Component.text("invalid shop id", NamedTextColor.RED));
|
||||||
|
} catch (SQLException e) {
|
||||||
|
sender.sendMessage(Component.text("database error", NamedTextColor.RED));
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleUnregister(CommandSender sender, String[] args) {
|
||||||
|
if (!PermissionManager.isAdmin(sender)) {
|
||||||
|
sender.sendMessage(Component.text("no permission", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.length < 2) {
|
||||||
|
sender.sendMessage(Component.text("usage: /oyeshops unregister <id>", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
int shopId = Integer.parseInt(args[1]);
|
||||||
|
Shop shop = plugin.getShopRegistry().getShopById(shopId);
|
||||||
|
if (shop == null) {
|
||||||
|
sender.sendMessage(Component.text("shop not found: " + shopId, NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
plugin.getShopRegistry().unregister(shop);
|
||||||
|
plugin.getShopRepository().deleteShop(shopId);
|
||||||
|
sender.sendMessage(Component.text("shop #" + shopId + " deleted", NamedTextColor.RED));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
sender.sendMessage(Component.text("invalid shop id", NamedTextColor.RED));
|
||||||
|
} catch (SQLException e) {
|
||||||
|
sender.sendMessage(Component.text("database error", NamedTextColor.RED));
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||||
|
List<String> completions = new ArrayList<>();
|
||||||
|
if (args.length == 1) {
|
||||||
|
List<String> subCommands = new ArrayList<>(List.of("on", "off", "toggle", "notify", "enable", "disable"));
|
||||||
|
if (PermissionManager.isAdmin(sender)) {
|
||||||
|
subCommands.addAll(List.of("reload", "inspect", "spoof", "unregister"));
|
||||||
|
}
|
||||||
|
String partial = args[0].toLowerCase();
|
||||||
|
for (String sub : subCommands) {
|
||||||
|
if (sub.startsWith(partial))
|
||||||
|
completions.add(sub);
|
||||||
|
}
|
||||||
|
} else if (args.length == 2) {
|
||||||
|
String subCommand = args[0].toLowerCase();
|
||||||
|
if (PermissionManager.isAdmin(sender) && (subCommand.equals("enable") || subCommand.equals("disable")
|
||||||
|
|| subCommand.equals("unregister"))) {
|
||||||
|
String partial = args[1];
|
||||||
|
for (Shop shop : plugin.getShopRegistry().getAllShops()) {
|
||||||
|
String id = String.valueOf(shop.getId());
|
||||||
|
if (id.startsWith(partial))
|
||||||
|
completions.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return completions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package party.cybsec.oyeshops.database;
|
||||||
|
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* manages player opt-in preferences for shop creation and notifications
|
||||||
|
* shops are disabled by default - players must explicitly enable
|
||||||
|
* stock notifications are disabled by default
|
||||||
|
*/
|
||||||
|
public class PlayerPreferenceRepository {
|
||||||
|
private final DatabaseManager dbManager;
|
||||||
|
|
||||||
|
// in-memory cache for performance
|
||||||
|
private final Set<UUID> enabledPlayers = new HashSet<>();
|
||||||
|
private final Set<UUID> notifyPlayers = new HashSet<>();
|
||||||
|
|
||||||
|
public PlayerPreferenceRepository(DatabaseManager dbManager) {
|
||||||
|
this.dbManager = dbManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* load all player preferences into cache
|
||||||
|
*/
|
||||||
|
public void loadPreferences() throws SQLException {
|
||||||
|
String sql = "select player_uuid, shops_enabled, notify_low_stock from player_preferences";
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql);
|
||||||
|
ResultSet rs = stmt.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
UUID uuid = UUID.fromString(rs.getString("player_uuid"));
|
||||||
|
if (rs.getBoolean("shops_enabled")) {
|
||||||
|
enabledPlayers.add(uuid);
|
||||||
|
}
|
||||||
|
if (rs.getBoolean("notify_low_stock")) {
|
||||||
|
notifyPlayers.add(uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if player has shops enabled (opt-in)
|
||||||
|
*/
|
||||||
|
public boolean isShopsEnabled(UUID playerUuid) {
|
||||||
|
return enabledPlayers.contains(playerUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if player has stock notifications enabled
|
||||||
|
*/
|
||||||
|
public boolean isNotifyEnabled(UUID playerUuid) {
|
||||||
|
return notifyPlayers.contains(playerUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* enable shops for player
|
||||||
|
*/
|
||||||
|
public void enableShops(UUID playerUuid) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
insert into player_preferences (player_uuid, shops_enabled, enabled_at)
|
||||||
|
values (?, true, ?)
|
||||||
|
on conflict(player_uuid) do update set shops_enabled = true, enabled_at = ?
|
||||||
|
""";
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
|
||||||
|
stmt.setString(1, playerUuid.toString());
|
||||||
|
stmt.setLong(2, now);
|
||||||
|
stmt.setLong(3, now);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
enabledPlayers.add(playerUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* disable shops for player
|
||||||
|
*/
|
||||||
|
public void disableShops(UUID playerUuid) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
insert into player_preferences (player_uuid, shops_enabled, enabled_at)
|
||||||
|
values (?, false, null)
|
||||||
|
on conflict(player_uuid) do update set shops_enabled = false
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
|
||||||
|
stmt.setString(1, playerUuid.toString());
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
enabledPlayers.remove(playerUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* toggle shops for player
|
||||||
|
*
|
||||||
|
* @return true if now enabled, false if now disabled
|
||||||
|
*/
|
||||||
|
public boolean toggleShops(UUID playerUuid) throws SQLException {
|
||||||
|
if (isShopsEnabled(playerUuid)) {
|
||||||
|
disableShops(playerUuid);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
enableShops(playerUuid);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* set stock notification preference
|
||||||
|
*/
|
||||||
|
public void setNotifyEnabled(UUID playerUuid, boolean enabled) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
insert into player_preferences (player_uuid, notify_low_stock)
|
||||||
|
values (?, ?)
|
||||||
|
on conflict(player_uuid) do update set notify_low_stock = ?
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
|
||||||
|
stmt.setString(1, playerUuid.toString());
|
||||||
|
stmt.setBoolean(2, enabled);
|
||||||
|
stmt.setBoolean(3, enabled);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
notifyPlayers.add(playerUuid);
|
||||||
|
} else {
|
||||||
|
notifyPlayers.remove(playerUuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* toggle notification preference
|
||||||
|
*/
|
||||||
|
public boolean toggleNotify(UUID playerUuid) throws SQLException {
|
||||||
|
boolean newState = !isNotifyEnabled(playerUuid);
|
||||||
|
setNotifyEnabled(playerUuid, newState);
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
}
|
||||||
246
src/main/java/party/cybsec/oyeshops/database/ShopRepository.java
Normal file
246
src/main/java/party/cybsec/oyeshops/database/ShopRepository.java
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
package party.cybsec.oyeshops.database;
|
||||||
|
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import party.cybsec.oyeshops.model.Shop;
|
||||||
|
import party.cybsec.oyeshops.model.Trade;
|
||||||
|
|
||||||
|
import java.sql.*;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* crud operations for shops
|
||||||
|
*/
|
||||||
|
public class ShopRepository {
|
||||||
|
private final DatabaseManager dbManager;
|
||||||
|
|
||||||
|
public ShopRepository(DatabaseManager dbManager) {
|
||||||
|
this.dbManager = dbManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* create new shop in database
|
||||||
|
*/
|
||||||
|
public int createShop(Shop shop) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
insert into shops (world_uuid, sign_x, sign_y, sign_z, owner_uuid,
|
||||||
|
price_item, price_quantity, product_item, product_quantity,
|
||||||
|
owed_amount, enabled, created_at)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql,
|
||||||
|
Statement.RETURN_GENERATED_KEYS)) {
|
||||||
|
Location loc = shop.getSignLocation();
|
||||||
|
stmt.setString(1, loc.getWorld().getUID().toString());
|
||||||
|
stmt.setInt(2, loc.getBlockX());
|
||||||
|
stmt.setInt(3, loc.getBlockY());
|
||||||
|
stmt.setInt(4, loc.getBlockZ());
|
||||||
|
stmt.setString(5, shop.getOwner().toString());
|
||||||
|
stmt.setString(6, shop.getTrade().priceItem().name());
|
||||||
|
stmt.setInt(7, shop.getTrade().priceQuantity());
|
||||||
|
stmt.setString(8, shop.getTrade().productItem().name());
|
||||||
|
stmt.setInt(9, shop.getTrade().productQuantity());
|
||||||
|
stmt.setInt(10, shop.getOwedAmount());
|
||||||
|
stmt.setBoolean(11, shop.isEnabled());
|
||||||
|
stmt.setLong(12, shop.getCreatedAt());
|
||||||
|
|
||||||
|
stmt.executeUpdate();
|
||||||
|
|
||||||
|
try (ResultSet rs = stmt.getGeneratedKeys()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
return rs.getInt(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new SQLException("failed to create shop, no id generated");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get shop by sign location
|
||||||
|
*/
|
||||||
|
public Shop getShop(Location location) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
select * from shops
|
||||||
|
where world_uuid = ? and sign_x = ? and sign_y = ? and sign_z = ?
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
|
||||||
|
stmt.setString(1, location.getWorld().getUID().toString());
|
||||||
|
stmt.setInt(2, location.getBlockX());
|
||||||
|
stmt.setInt(3, location.getBlockY());
|
||||||
|
stmt.setInt(4, location.getBlockZ());
|
||||||
|
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
return shopFromResultSet(rs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get shop by id
|
||||||
|
*/
|
||||||
|
public Shop getShopById(int shopId) throws SQLException {
|
||||||
|
String sql = "select * from shops where shop_id = ?";
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
|
||||||
|
stmt.setInt(1, shopId);
|
||||||
|
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
return shopFromResultSet(rs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* update owed amount atomically
|
||||||
|
*/
|
||||||
|
public void updateOwedAmount(int shopId, int amount) throws SQLException {
|
||||||
|
String sql = "update shops set owed_amount = ? where shop_id = ?";
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
|
||||||
|
stmt.setInt(1, amount);
|
||||||
|
stmt.setInt(2, shopId);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* add to owed amount atomically
|
||||||
|
*/
|
||||||
|
public void addOwedAmount(int shopId, int delta) throws SQLException {
|
||||||
|
String sql = "update shops set owed_amount = owed_amount + ? where shop_id = ?";
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
|
||||||
|
stmt.setInt(1, delta);
|
||||||
|
stmt.setInt(2, shopId);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* enable shop
|
||||||
|
*/
|
||||||
|
public void enableShop(int shopId) throws SQLException {
|
||||||
|
String sql = "update shops set enabled = true where shop_id = ?";
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
|
||||||
|
stmt.setInt(1, shopId);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* disable shop
|
||||||
|
*/
|
||||||
|
public void disableShop(int shopId) throws SQLException {
|
||||||
|
String sql = "update shops set enabled = false where shop_id = ?";
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
|
||||||
|
stmt.setInt(1, shopId);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* set shop enabled state
|
||||||
|
*/
|
||||||
|
public void setEnabled(int shopId, boolean enabled) throws SQLException {
|
||||||
|
if (enabled) {
|
||||||
|
enableShop(shopId);
|
||||||
|
} else {
|
||||||
|
disableShop(shopId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* delete shop
|
||||||
|
*/
|
||||||
|
public void deleteShop(int shopId) throws SQLException {
|
||||||
|
String sql = "delete from shops where shop_id = ?";
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
|
||||||
|
stmt.setInt(1, shopId);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get all shops owned by player
|
||||||
|
*/
|
||||||
|
public List<Shop> getShopsByOwner(UUID ownerUuid) throws SQLException {
|
||||||
|
String sql = "select * from shops where owner_uuid = ?";
|
||||||
|
List<Shop> shops = new ArrayList<>();
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
|
||||||
|
stmt.setString(1, ownerUuid.toString());
|
||||||
|
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
shops.add(shopFromResultSet(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return shops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* load all shops from database
|
||||||
|
*/
|
||||||
|
public List<Shop> loadAllShops() throws SQLException {
|
||||||
|
String sql = "select * from shops";
|
||||||
|
List<Shop> shops = new ArrayList<>();
|
||||||
|
|
||||||
|
try (Statement stmt = dbManager.getConnection().createStatement();
|
||||||
|
ResultSet rs = stmt.executeQuery(sql)) {
|
||||||
|
while (rs.next()) {
|
||||||
|
shops.add(shopFromResultSet(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return shops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* convert result set to shop object
|
||||||
|
*/
|
||||||
|
private Shop shopFromResultSet(ResultSet rs) throws SQLException {
|
||||||
|
int id = rs.getInt("shop_id");
|
||||||
|
UUID worldUuid = UUID.fromString(rs.getString("world_uuid"));
|
||||||
|
int x = rs.getInt("sign_x");
|
||||||
|
int y = rs.getInt("sign_y");
|
||||||
|
int z = rs.getInt("sign_z");
|
||||||
|
UUID ownerUuid = UUID.fromString(rs.getString("owner_uuid"));
|
||||||
|
Material priceItem = Material.valueOf(rs.getString("price_item"));
|
||||||
|
int priceQty = rs.getInt("price_quantity");
|
||||||
|
Material productItem = Material.valueOf(rs.getString("product_item"));
|
||||||
|
int productQty = rs.getInt("product_quantity");
|
||||||
|
int owedAmount = rs.getInt("owed_amount");
|
||||||
|
boolean enabled = rs.getBoolean("enabled");
|
||||||
|
long createdAt = rs.getLong("created_at");
|
||||||
|
|
||||||
|
World world = Bukkit.getWorld(worldUuid);
|
||||||
|
if (world == null) {
|
||||||
|
throw new SQLException("world not found: " + worldUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
Location location = new Location(world, x, y, z);
|
||||||
|
Trade trade = new Trade(priceItem, priceQty, productItem, productQty);
|
||||||
|
|
||||||
|
return new Shop(id, location, ownerUuid, trade, owedAmount, enabled, createdAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package party.cybsec.oyeshops.database;
|
||||||
|
|
||||||
|
import java.sql.*;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* transaction history operations
|
||||||
|
*/
|
||||||
|
public class TransactionRepository {
|
||||||
|
private final DatabaseManager dbManager;
|
||||||
|
|
||||||
|
public TransactionRepository(DatabaseManager dbManager) {
|
||||||
|
this.dbManager = dbManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* record a transaction
|
||||||
|
*/
|
||||||
|
public void recordTransaction(int shopId, UUID buyerUuid, int quantityTraded) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
insert into transactions (shop_id, buyer_uuid, quantity_traded, timestamp)
|
||||||
|
values (?, ?, ?, ?)
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
|
||||||
|
stmt.setInt(1, shopId);
|
||||||
|
stmt.setString(2, buyerUuid.toString());
|
||||||
|
stmt.setInt(3, quantityTraded);
|
||||||
|
stmt.setLong(4, System.currentTimeMillis());
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get recent transactions for a shop
|
||||||
|
*/
|
||||||
|
public List<Transaction> getRecentTransactions(int shopId, int limit) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
select * from transactions
|
||||||
|
where shop_id = ?
|
||||||
|
order by timestamp desc
|
||||||
|
limit ?
|
||||||
|
""";
|
||||||
|
|
||||||
|
List<Transaction> transactions = new ArrayList<>();
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
|
||||||
|
stmt.setInt(1, shopId);
|
||||||
|
stmt.setInt(2, limit);
|
||||||
|
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
transactions.add(new Transaction(
|
||||||
|
rs.getInt("transaction_id"),
|
||||||
|
rs.getInt("shop_id"),
|
||||||
|
UUID.fromString(rs.getString("buyer_uuid")),
|
||||||
|
rs.getInt("quantity_traded"),
|
||||||
|
rs.getLong("timestamp")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* prune old transactions, keeping only the most recent
|
||||||
|
*/
|
||||||
|
public void pruneOldTransactions(int shopId, int keepCount) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
delete from transactions
|
||||||
|
where shop_id = ?
|
||||||
|
and transaction_id not in (
|
||||||
|
select transaction_id from transactions
|
||||||
|
where shop_id = ?
|
||||||
|
order by timestamp desc
|
||||||
|
limit ?
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
|
||||||
|
stmt.setInt(1, shopId);
|
||||||
|
stmt.setInt(2, shopId);
|
||||||
|
stmt.setInt(3, keepCount);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* prune old transactions (alias)
|
||||||
|
*/
|
||||||
|
public void pruneTransactions(int shopId, int keepCount) throws SQLException {
|
||||||
|
pruneOldTransactions(shopId, keepCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* clear all transaction history for a shop
|
||||||
|
*/
|
||||||
|
public void clearHistory(int shopId) throws SQLException {
|
||||||
|
String sql = "delete from transactions where shop_id = ?";
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = dbManager.getConnection().prepareStatement(sql)) {
|
||||||
|
stmt.setInt(1, shopId);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* transaction record
|
||||||
|
*/
|
||||||
|
public record Transaction(
|
||||||
|
int transactionId,
|
||||||
|
int shopId,
|
||||||
|
UUID buyerUuid,
|
||||||
|
int quantityTraded,
|
||||||
|
long timestamp) {
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/main/java/party/cybsec/oyeshops/gui/ConfirmationGui.java
Normal file
116
src/main/java/party/cybsec/oyeshops/gui/ConfirmationGui.java
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package party.cybsec.oyeshops.gui;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.InventoryHolder;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
import party.cybsec.oyeshops.model.Shop;
|
||||||
|
import party.cybsec.oyeshops.model.Trade;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* confirmation GUI for shop purchases
|
||||||
|
* supports batch purchases with units
|
||||||
|
*/
|
||||||
|
public class ConfirmationGui implements InventoryHolder {
|
||||||
|
private final Shop shop;
|
||||||
|
private final int units;
|
||||||
|
private final Inventory inventory;
|
||||||
|
|
||||||
|
public ConfirmationGui(Shop shop, int units) {
|
||||||
|
this.shop = shop;
|
||||||
|
this.units = Math.max(1, units);
|
||||||
|
this.inventory = createInventory();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Inventory createInventory() {
|
||||||
|
Trade trade = shop.getTrade();
|
||||||
|
int totalPrice = trade.priceQuantity() * units;
|
||||||
|
int totalProduct = trade.productQuantity() * units;
|
||||||
|
|
||||||
|
String title = units > 1
|
||||||
|
? "confirm: " + units + " units"
|
||||||
|
: "confirm purchase";
|
||||||
|
|
||||||
|
Inventory inv = Bukkit.createInventory(this, 27, Component.text(title));
|
||||||
|
|
||||||
|
// fill with gray glass
|
||||||
|
ItemStack filler = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
|
||||||
|
ItemMeta fillerMeta = filler.getItemMeta();
|
||||||
|
fillerMeta.displayName(Component.text(" "));
|
||||||
|
filler.setItemMeta(fillerMeta);
|
||||||
|
for (int i = 0; i < 27; i++) {
|
||||||
|
inv.setItem(i, filler.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// price item display (left side - slot 3)
|
||||||
|
ItemStack priceDisplay = new ItemStack(trade.priceItem(), Math.min(totalPrice, 64));
|
||||||
|
ItemMeta priceMeta = priceDisplay.getItemMeta();
|
||||||
|
priceMeta.displayName(Component.text("you pay:", NamedTextColor.RED));
|
||||||
|
List<Component> priceLore = new ArrayList<>();
|
||||||
|
priceLore.add(Component.text(totalPrice + " " + formatMaterial(trade.priceItem()), NamedTextColor.WHITE));
|
||||||
|
if (units > 1) {
|
||||||
|
priceLore.add(Component.text("(" + trade.priceQuantity() + " × " + units + " units)", NamedTextColor.GRAY));
|
||||||
|
}
|
||||||
|
priceMeta.lore(priceLore);
|
||||||
|
priceDisplay.setItemMeta(priceMeta);
|
||||||
|
inv.setItem(3, priceDisplay);
|
||||||
|
|
||||||
|
// product item display (right side - slot 5)
|
||||||
|
ItemStack productDisplay = new ItemStack(trade.productItem(), Math.min(totalProduct, 64));
|
||||||
|
ItemMeta productMeta = productDisplay.getItemMeta();
|
||||||
|
productMeta.displayName(Component.text("you get:", NamedTextColor.GREEN));
|
||||||
|
List<Component> productLore = new ArrayList<>();
|
||||||
|
productLore.add(Component.text(totalProduct + " " + formatMaterial(trade.productItem()), NamedTextColor.WHITE));
|
||||||
|
if (units > 1) {
|
||||||
|
productLore.add(
|
||||||
|
Component.text("(" + trade.productQuantity() + " × " + units + " units)", NamedTextColor.GRAY));
|
||||||
|
}
|
||||||
|
productMeta.lore(productLore);
|
||||||
|
productDisplay.setItemMeta(productMeta);
|
||||||
|
inv.setItem(5, productDisplay);
|
||||||
|
|
||||||
|
// confirm button (green - bottom left)
|
||||||
|
ItemStack confirm = new ItemStack(Material.LIME_STAINED_GLASS_PANE);
|
||||||
|
ItemMeta confirmMeta = confirm.getItemMeta();
|
||||||
|
confirmMeta.displayName(Component.text("CONFIRM", NamedTextColor.GREEN));
|
||||||
|
confirmMeta.lore(List.of(Component.text("click to complete purchase", NamedTextColor.GRAY)));
|
||||||
|
confirm.setItemMeta(confirmMeta);
|
||||||
|
inv.setItem(11, confirm);
|
||||||
|
inv.setItem(12, confirm.clone());
|
||||||
|
|
||||||
|
// cancel button (red - bottom right)
|
||||||
|
ItemStack cancel = new ItemStack(Material.RED_STAINED_GLASS_PANE);
|
||||||
|
ItemMeta cancelMeta = cancel.getItemMeta();
|
||||||
|
cancelMeta.displayName(Component.text("CANCEL", NamedTextColor.RED));
|
||||||
|
cancelMeta.lore(List.of(Component.text("click to cancel", NamedTextColor.GRAY)));
|
||||||
|
cancel.setItemMeta(cancelMeta);
|
||||||
|
inv.setItem(14, cancel);
|
||||||
|
inv.setItem(15, cancel.clone());
|
||||||
|
|
||||||
|
return inv;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatMaterial(Material material) {
|
||||||
|
return material.name().toLowerCase().replace("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Shop getShop() {
|
||||||
|
return shop;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getUnits() {
|
||||||
|
return units;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Inventory getInventory() {
|
||||||
|
return inventory;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
package party.cybsec.oyeshops.listener;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.block.Barrel;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.block.BlockFace;
|
||||||
|
import org.bukkit.block.Chest;
|
||||||
|
import org.bukkit.block.data.type.WallSign;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.block.Action;
|
||||||
|
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||||
|
import org.bukkit.event.player.PlayerInteractEvent;
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.InventoryHolder;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import party.cybsec.oyeshops.OyeShopsPlugin;
|
||||||
|
import party.cybsec.oyeshops.gui.ConfirmationGui;
|
||||||
|
import party.cybsec.oyeshops.model.Shop;
|
||||||
|
import party.cybsec.oyeshops.model.Trade;
|
||||||
|
import party.cybsec.oyeshops.permission.PermissionManager;
|
||||||
|
import party.cybsec.oyeshops.transaction.TransactionManager;
|
||||||
|
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* handles chest/barrel interactions for shop purchases and owner withdrawals
|
||||||
|
*/
|
||||||
|
public class ChestInteractionListener implements Listener {
|
||||||
|
private final OyeShopsPlugin plugin;
|
||||||
|
|
||||||
|
// track players viewing fake shop inventories
|
||||||
|
private final Map<Player, Shop> viewingShop = new HashMap<>();
|
||||||
|
|
||||||
|
public ChestInteractionListener(OyeShopsPlugin plugin) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH)
|
||||||
|
public void onPlayerInteract(PlayerInteractEvent event) {
|
||||||
|
if (event.getAction() != Action.RIGHT_CLICK_BLOCK) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Block block = event.getClickedBlock();
|
||||||
|
if (block == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if this is a container (chest or barrel)
|
||||||
|
if (!isContainer(block.getType())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if there's a shop sign attached
|
||||||
|
Shop shop = findShopForContainer(block);
|
||||||
|
if (shop == null) {
|
||||||
|
return; // not a shop container
|
||||||
|
}
|
||||||
|
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
|
||||||
|
// check if shop is enabled
|
||||||
|
if (!shop.isEnabled()) {
|
||||||
|
player.sendMessage(Component.text("this shop is disabled", NamedTextColor.RED));
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if player is owner (unless spoofing)
|
||||||
|
boolean isOwner = shop.getOwner().equals(player.getUniqueId())
|
||||||
|
&& !plugin.getSpoofManager().isSpoofing(player);
|
||||||
|
|
||||||
|
if (isOwner) {
|
||||||
|
// owner interaction - check for owed items and dispense
|
||||||
|
if (shop.getOwedAmount() > 0) {
|
||||||
|
withdrawOwedItems(player, shop);
|
||||||
|
}
|
||||||
|
// let owner open the chest normally - don't cancel event
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// buyer interaction - must have permission
|
||||||
|
if (!PermissionManager.canUse(player)) {
|
||||||
|
player.sendMessage(Component.text("you don't have permission to use shops", NamedTextColor.RED));
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancel normal chest opening
|
||||||
|
event.setCancelled(true);
|
||||||
|
|
||||||
|
// open shop GUI
|
||||||
|
openShopGui(player, shop, block);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* withdraw owed items to owner's inventory
|
||||||
|
*/
|
||||||
|
private void withdrawOwedItems(Player player, Shop shop) {
|
||||||
|
int owed = shop.getOwedAmount();
|
||||||
|
if (owed <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Material priceItem = shop.getTrade().priceItem();
|
||||||
|
int maxStack = priceItem.getMaxStackSize();
|
||||||
|
|
||||||
|
int withdrawn = 0;
|
||||||
|
int remaining = owed;
|
||||||
|
|
||||||
|
while (remaining > 0) {
|
||||||
|
int toGive = Math.min(remaining, maxStack);
|
||||||
|
ItemStack stack = new ItemStack(priceItem, toGive);
|
||||||
|
|
||||||
|
HashMap<Integer, ItemStack> overflow = player.getInventory().addItem(stack);
|
||||||
|
|
||||||
|
if (overflow.isEmpty()) {
|
||||||
|
withdrawn += toGive;
|
||||||
|
remaining -= toGive;
|
||||||
|
} else {
|
||||||
|
// inventory full - stop
|
||||||
|
int notAdded = overflow.values().stream().mapToInt(ItemStack::getAmount).sum();
|
||||||
|
withdrawn += (toGive - notAdded);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withdrawn > 0) {
|
||||||
|
try {
|
||||||
|
// update database
|
||||||
|
int newOwed = owed - withdrawn;
|
||||||
|
shop.setOwedAmount(newOwed);
|
||||||
|
plugin.getShopRepository().updateOwedAmount(shop.getId(), -withdrawn);
|
||||||
|
|
||||||
|
player.sendMessage(
|
||||||
|
Component.text("withdrew " + withdrawn + " " + formatMaterial(priceItem), NamedTextColor.GREEN)
|
||||||
|
.append(Component.text(newOwed > 0 ? " (" + newOwed + " remaining)" : "",
|
||||||
|
NamedTextColor.GRAY)));
|
||||||
|
} catch (SQLException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
player.sendMessage(Component.text("database error during withdrawal", NamedTextColor.RED));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
player.sendMessage(Component.text("inventory full - could not withdraw", NamedTextColor.RED));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* open shop GUI for buyer
|
||||||
|
*/
|
||||||
|
private void openShopGui(Player player, Shop shop, Block containerBlock) {
|
||||||
|
Trade trade = shop.getTrade();
|
||||||
|
|
||||||
|
// create fake inventory showing only product items
|
||||||
|
Inventory shopInventory = Bukkit.createInventory(
|
||||||
|
new ShopInventoryHolder(shop),
|
||||||
|
27,
|
||||||
|
Component.text("shop: " + trade.priceQuantity() + " " + formatMaterial(trade.priceItem()) +
|
||||||
|
" → " + trade.productQuantity() + " " + formatMaterial(trade.productItem())));
|
||||||
|
|
||||||
|
// get real container inventory
|
||||||
|
Inventory realInventory = getContainerInventory(containerBlock);
|
||||||
|
if (realInventory == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy only product items to fake inventory (first 27 found)
|
||||||
|
int shopSlot = 0;
|
||||||
|
for (ItemStack item : realInventory.getContents()) {
|
||||||
|
if (shopSlot >= 27)
|
||||||
|
break;
|
||||||
|
if (item != null && item.getType() == trade.productItem()) {
|
||||||
|
shopInventory.setItem(shopSlot++, item.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewingShop.put(player, shop);
|
||||||
|
player.openInventory(shopInventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onInventoryClick(InventoryClickEvent event) {
|
||||||
|
if (!(event.getWhoClicked() instanceof Player player)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
InventoryHolder holder = event.getInventory().getHolder();
|
||||||
|
|
||||||
|
// handle shop inventory clicks
|
||||||
|
if (holder instanceof ShopInventoryHolder shopHolder) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
Shop shop = shopHolder.shop();
|
||||||
|
|
||||||
|
// if clicked in the shop inventory area
|
||||||
|
if (event.getRawSlot() < event.getInventory().getSize()) {
|
||||||
|
ItemStack clicked = event.getCurrentItem();
|
||||||
|
if (clicked != null && clicked.getType() == shop.getTrade().productItem()) {
|
||||||
|
// determine quantity based on click type
|
||||||
|
int units = 1;
|
||||||
|
if (event.isShiftClick()) {
|
||||||
|
// shift-click: calculate max units based on items clicked
|
||||||
|
units = clicked.getAmount() / shop.getTrade().productQuantity();
|
||||||
|
if (units < 1)
|
||||||
|
units = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// open confirmation GUI
|
||||||
|
player.closeInventory();
|
||||||
|
openConfirmationGui(player, shop, units);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle confirmation GUI clicks
|
||||||
|
if (holder instanceof ConfirmationGui confirmGui) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
Shop shop = confirmGui.getShop();
|
||||||
|
|
||||||
|
int slot = event.getRawSlot();
|
||||||
|
|
||||||
|
// green pane = confirm (slot 11-15 or specifically slot 11)
|
||||||
|
if (slot == 11 || slot == 12 || slot == 13) {
|
||||||
|
player.closeInventory();
|
||||||
|
executeTransaction(player, shop, confirmGui.getUnits());
|
||||||
|
}
|
||||||
|
// red pane = cancel (slot 15 or right side)
|
||||||
|
else if (slot == 14 || slot == 15 || slot == 16) {
|
||||||
|
player.closeInventory();
|
||||||
|
player.sendMessage(Component.text("purchase cancelled", NamedTextColor.YELLOW));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* open confirmation GUI
|
||||||
|
*/
|
||||||
|
private void openConfirmationGui(Player player, Shop shop, int units) {
|
||||||
|
ConfirmationGui gui = new ConfirmationGui(shop, units);
|
||||||
|
player.openInventory(gui.getInventory());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* execute the transaction
|
||||||
|
*/
|
||||||
|
private void executeTransaction(Player player, Shop shop, int units) {
|
||||||
|
TransactionManager.Result result = plugin.getTransactionManager().execute(player, shop, units);
|
||||||
|
|
||||||
|
switch (result) {
|
||||||
|
case SUCCESS -> player.sendMessage(Component.text("purchase complete!", NamedTextColor.GREEN));
|
||||||
|
case INSUFFICIENT_FUNDS ->
|
||||||
|
player.sendMessage(Component.text("you don't have enough items to pay", NamedTextColor.RED));
|
||||||
|
case INSUFFICIENT_STOCK -> player.sendMessage(Component.text("shop is out of stock", NamedTextColor.RED));
|
||||||
|
case INVENTORY_FULL -> player.sendMessage(Component.text("your inventory is full", NamedTextColor.RED));
|
||||||
|
case DATABASE_ERROR ->
|
||||||
|
player.sendMessage(Component.text("transaction failed - database error", NamedTextColor.RED));
|
||||||
|
case SHOP_DISABLED -> player.sendMessage(Component.text("this shop is disabled", NamedTextColor.RED));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* find shop attached to a container block
|
||||||
|
*/
|
||||||
|
private Shop findShopForContainer(Block containerBlock) {
|
||||||
|
// check all adjacent blocks for a wall sign pointing at this container
|
||||||
|
for (BlockFace face : new BlockFace[] { BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST }) {
|
||||||
|
Block adjacent = containerBlock.getRelative(face);
|
||||||
|
if (adjacent.getBlockData() instanceof WallSign wallSign) {
|
||||||
|
// check if the sign is facing away from the container (attached to it)
|
||||||
|
if (wallSign.getFacing().getOppositeFace() == face.getOppositeFace()) {
|
||||||
|
Shop shop = plugin.getShopRegistry().getShop(adjacent.getLocation());
|
||||||
|
if (shop != null) {
|
||||||
|
return shop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get container inventory
|
||||||
|
*/
|
||||||
|
private Inventory getContainerInventory(Block block) {
|
||||||
|
if (block.getState() instanceof Chest chest) {
|
||||||
|
return chest.getInventory();
|
||||||
|
} else if (block.getState() instanceof Barrel barrel) {
|
||||||
|
return barrel.getInventory();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if material is a container
|
||||||
|
*/
|
||||||
|
private boolean isContainer(Material material) {
|
||||||
|
return material == Material.CHEST || material == Material.TRAPPED_CHEST || material == Material.BARREL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* format material name for display
|
||||||
|
*/
|
||||||
|
private String formatMaterial(Material material) {
|
||||||
|
return material.name().toLowerCase().replace("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* holder for shop inventory
|
||||||
|
*/
|
||||||
|
public record ShopInventoryHolder(Shop shop) implements InventoryHolder {
|
||||||
|
@Override
|
||||||
|
public Inventory getInventory() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package party.cybsec.oyeshops.listener;
|
||||||
|
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.block.BlockBreakEvent;
|
||||||
|
import org.bukkit.event.block.BlockExplodeEvent;
|
||||||
|
import org.bukkit.event.block.BlockPlaceEvent;
|
||||||
|
import org.bukkit.event.entity.EntityExplodeEvent;
|
||||||
|
import party.cybsec.oyeshops.OyeShopsPlugin;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* monitors block placement to track container ownership for shop creation
|
||||||
|
*/
|
||||||
|
public class ContainerPlacementListener implements Listener {
|
||||||
|
private final OyeShopsPlugin plugin;
|
||||||
|
|
||||||
|
public ContainerPlacementListener(OyeShopsPlugin plugin) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||||
|
public void onPlace(BlockPlaceEvent event) {
|
||||||
|
Block block = event.getBlock();
|
||||||
|
if (isContainer(block.getType())) {
|
||||||
|
plugin.getContainerMemoryManager().recordPlacement(block.getLocation(), event.getPlayer().getUniqueId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||||
|
public void onBreak(BlockBreakEvent event) {
|
||||||
|
Block block = event.getBlock();
|
||||||
|
if (isContainer(block.getType())) {
|
||||||
|
plugin.getContainerMemoryManager().removePlacement(block.getLocation());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||||
|
public void onBlockExplode(BlockExplodeEvent event) {
|
||||||
|
handleExplosion(event.blockList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||||
|
public void onEntityExplode(EntityExplodeEvent event) {
|
||||||
|
handleExplosion(event.blockList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleExplosion(List<Block> blocks) {
|
||||||
|
for (Block block : blocks) {
|
||||||
|
if (isContainer(block.getType())) {
|
||||||
|
plugin.getContainerMemoryManager().removePlacement(block.getLocation());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isContainer(Material material) {
|
||||||
|
return material == Material.CHEST || material == Material.TRAPPED_CHEST || material == Material.BARREL;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package party.cybsec.oyeshops.listener;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.player.PlayerInteractEvent;
|
||||||
|
import party.cybsec.oyeshops.OyeShopsPlugin;
|
||||||
|
import party.cybsec.oyeshops.database.TransactionRepository;
|
||||||
|
import party.cybsec.oyeshops.model.Shop;
|
||||||
|
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* handles right-click inspection while in inspect mode
|
||||||
|
*/
|
||||||
|
public class InspectListener implements Listener {
|
||||||
|
private final OyeShopsPlugin plugin;
|
||||||
|
private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
public InspectListener(OyeShopsPlugin plugin) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGHEST)
|
||||||
|
public void onPlayerInteract(PlayerInteractEvent event) {
|
||||||
|
if (event.getAction() != org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
|
||||||
|
// check if player is in inspect mode
|
||||||
|
if (!plugin.getInspectModeManager().isInspecting(player)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Block block = event.getClickedBlock();
|
||||||
|
if (block == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if this is a shop sign
|
||||||
|
Shop shop = plugin.getShopRegistry().getShop(block.getLocation());
|
||||||
|
if (shop == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancel interaction
|
||||||
|
event.setCancelled(true);
|
||||||
|
|
||||||
|
// display shop info
|
||||||
|
displayShopInfo(player, shop);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void displayShopInfo(Player player, Shop shop) {
|
||||||
|
player.sendMessage(Component.text("=== shop #" + shop.getId() + " ===", NamedTextColor.GOLD));
|
||||||
|
|
||||||
|
// owner
|
||||||
|
String ownerName = Bukkit.getOfflinePlayer(shop.getOwner()).getName();
|
||||||
|
if (ownerName == null) {
|
||||||
|
ownerName = shop.getOwner().toString();
|
||||||
|
}
|
||||||
|
player.sendMessage(Component.text("owner: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(ownerName, NamedTextColor.WHITE)));
|
||||||
|
|
||||||
|
// status
|
||||||
|
String status = shop.isEnabled() ? "enabled" : "disabled";
|
||||||
|
NamedTextColor statusColor = shop.isEnabled() ? NamedTextColor.GREEN : NamedTextColor.RED;
|
||||||
|
player.sendMessage(Component.text("status: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(status, statusColor)));
|
||||||
|
|
||||||
|
// trade
|
||||||
|
player.sendMessage(Component.text("trade: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(
|
||||||
|
shop.getTrade().priceQuantity() + " " + shop.getTrade().priceItem().name().toLowerCase() +
|
||||||
|
" → " +
|
||||||
|
shop.getTrade().productQuantity() + " "
|
||||||
|
+ shop.getTrade().productItem().name().toLowerCase(),
|
||||||
|
NamedTextColor.YELLOW)));
|
||||||
|
|
||||||
|
// owed amount
|
||||||
|
player.sendMessage(Component.text("owed: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(
|
||||||
|
shop.getOwedAmount() + " " + shop.getTrade().priceItem().name().toLowerCase(),
|
||||||
|
NamedTextColor.GREEN)));
|
||||||
|
|
||||||
|
// stock (check chest)
|
||||||
|
int stock = getStock(shop);
|
||||||
|
int units = stock / shop.getTrade().productQuantity();
|
||||||
|
player.sendMessage(Component.text("stock: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(
|
||||||
|
stock + " " + shop.getTrade().productItem().name().toLowerCase() +
|
||||||
|
" (" + units + " units available)",
|
||||||
|
NamedTextColor.AQUA)));
|
||||||
|
|
||||||
|
// created
|
||||||
|
String createdDate = dateFormat.format(new Date(shop.getCreatedAt()));
|
||||||
|
player.sendMessage(Component.text("created: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(createdDate, NamedTextColor.WHITE)));
|
||||||
|
|
||||||
|
// recent transactions
|
||||||
|
try {
|
||||||
|
List<TransactionRepository.Transaction> transactions = plugin.getTransactionRepository()
|
||||||
|
.getRecentTransactions(shop.getId(), 10);
|
||||||
|
|
||||||
|
if (!transactions.isEmpty()) {
|
||||||
|
player.sendMessage(Component.text("recent transactions (last 10):", NamedTextColor.GOLD));
|
||||||
|
for (TransactionRepository.Transaction tx : transactions) {
|
||||||
|
String buyerName = Bukkit.getOfflinePlayer(tx.buyerUuid()).getName();
|
||||||
|
if (buyerName == null) {
|
||||||
|
buyerName = tx.buyerUuid().toString();
|
||||||
|
}
|
||||||
|
String txDate = dateFormat.format(new Date(tx.timestamp()));
|
||||||
|
player.sendMessage(Component.text(" - ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(buyerName, NamedTextColor.WHITE))
|
||||||
|
.append(Component.text(" bought ", NamedTextColor.GRAY))
|
||||||
|
.append(Component.text(tx.quantityTraded() + " units", NamedTextColor.YELLOW))
|
||||||
|
.append(Component.text(" (" + txDate + ")", NamedTextColor.DARK_GRAY)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
// action buttons (clickable text)
|
||||||
|
player.sendMessage(Component.text(""));
|
||||||
|
player.sendMessage(
|
||||||
|
Component.text("[enable]", NamedTextColor.GREEN)
|
||||||
|
.append(Component.text(" ", NamedTextColor.WHITE))
|
||||||
|
.append(Component.text("[disable]", NamedTextColor.RED))
|
||||||
|
.append(Component.text(" ", NamedTextColor.WHITE))
|
||||||
|
.append(Component.text("[unregister]", NamedTextColor.DARK_RED))
|
||||||
|
.append(Component.text(" ", NamedTextColor.WHITE))
|
||||||
|
.append(Component.text("[clear history]", NamedTextColor.YELLOW)));
|
||||||
|
player.sendMessage(Component.text("use /oyeshops <action> " + shop.getId(), NamedTextColor.GRAY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get current stock from chest or barrel
|
||||||
|
*/
|
||||||
|
private int getStock(Shop shop) {
|
||||||
|
Block signBlock = shop.getSignLocation().getBlock();
|
||||||
|
org.bukkit.block.data.BlockData blockData = signBlock.getBlockData();
|
||||||
|
|
||||||
|
if (!(blockData instanceof org.bukkit.block.data.type.WallSign wallSign)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
org.bukkit.block.BlockFace attachedFace = wallSign.getFacing().getOppositeFace();
|
||||||
|
Block containerBlock = signBlock.getRelative(attachedFace);
|
||||||
|
|
||||||
|
org.bukkit.inventory.Inventory inventory = null;
|
||||||
|
|
||||||
|
if (containerBlock.getState() instanceof org.bukkit.block.Chest chest) {
|
||||||
|
inventory = chest.getInventory();
|
||||||
|
} else if (containerBlock.getState() instanceof org.bukkit.block.Barrel barrel) {
|
||||||
|
inventory = barrel.getInventory();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inventory == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
for (org.bukkit.inventory.ItemStack item : inventory.getContents()) {
|
||||||
|
if (item != null && item.getType() == shop.getTrade().productItem()) {
|
||||||
|
count += item.getAmount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package party.cybsec.oyeshops.listener;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.block.Barrel;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.block.BlockFace;
|
||||||
|
import org.bukkit.block.Chest;
|
||||||
|
import org.bukkit.block.data.type.WallSign;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.player.PlayerJoinEvent;
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import party.cybsec.oyeshops.OyeShopsPlugin;
|
||||||
|
import party.cybsec.oyeshops.model.Shop;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* alerts players on login if their shops are low on stock
|
||||||
|
*/
|
||||||
|
public class LoginListener implements Listener {
|
||||||
|
private final OyeShopsPlugin plugin;
|
||||||
|
|
||||||
|
public LoginListener(OyeShopsPlugin plugin) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onJoin(PlayerJoinEvent event) {
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
|
||||||
|
// check if user wants notifications
|
||||||
|
if (!plugin.getPlayerPreferenceRepository().isNotifyEnabled(player.getUniqueId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
// find all shops owned by player
|
||||||
|
List<Shop> lowStockShops = new ArrayList<>();
|
||||||
|
for (Shop shop : plugin.getShopRegistry().getAllShops()) {
|
||||||
|
if (shop.getOwner().equals(player.getUniqueId())) {
|
||||||
|
if (isLowStock(shop)) {
|
||||||
|
lowStockShops.add(shop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lowStockShops.isEmpty()) {
|
||||||
|
final int count = lowStockShops.size();
|
||||||
|
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||||
|
if (player.isOnline()) {
|
||||||
|
player.sendMessage(
|
||||||
|
Component.text("notification: " + count + " of your shops are low on stock.",
|
||||||
|
NamedTextColor.YELLOW));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isLowStock(Shop shop) {
|
||||||
|
Block signBlock = shop.getSignLocation().getBlock();
|
||||||
|
if (!(signBlock.getBlockData() instanceof WallSign wallSign))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
BlockFace attachedFace = wallSign.getFacing().getOppositeFace();
|
||||||
|
Block attachedBlock = signBlock.getRelative(attachedFace);
|
||||||
|
|
||||||
|
Inventory inventory = getContainerInventory(attachedBlock);
|
||||||
|
if (inventory == null)
|
||||||
|
return true; // container gone? count as low stock warning
|
||||||
|
|
||||||
|
Material product = shop.getTrade().productItem();
|
||||||
|
int count = 0;
|
||||||
|
for (ItemStack item : inventory.getContents()) {
|
||||||
|
if (item != null && item.getType() == product) {
|
||||||
|
count += item.getAmount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// threshold: less than 1 transaction or less than 10 items
|
||||||
|
return count < shop.getTrade().productQuantity() || count < 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Inventory getContainerInventory(Block block) {
|
||||||
|
if (block.getState() instanceof Chest chest)
|
||||||
|
return chest.getInventory();
|
||||||
|
if (block.getState() instanceof Barrel barrel)
|
||||||
|
return barrel.getInventory();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
package party.cybsec.oyeshops.listener;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.event.ClickEvent;
|
||||||
|
import net.kyori.adventure.text.event.HoverEvent;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import net.kyori.adventure.text.format.TextDecoration;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.block.Barrel;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.block.BlockFace;
|
||||||
|
import org.bukkit.block.Chest;
|
||||||
|
import org.bukkit.block.Sign;
|
||||||
|
import org.bukkit.block.DoubleChest;
|
||||||
|
import org.bukkit.block.data.BlockData;
|
||||||
|
import org.bukkit.block.data.type.WallSign;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.block.SignChangeEvent;
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.DoubleChestInventory;
|
||||||
|
import party.cybsec.oyeshops.OyeShopsPlugin;
|
||||||
|
import party.cybsec.oyeshops.model.PendingActivation;
|
||||||
|
import party.cybsec.oyeshops.model.Shop;
|
||||||
|
import party.cybsec.oyeshops.model.Trade;
|
||||||
|
import party.cybsec.oyeshops.parser.SignParser;
|
||||||
|
import party.cybsec.oyeshops.permission.PermissionManager;
|
||||||
|
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* listens for sign placement and validates shop activation
|
||||||
|
* now uses a confirmation flow via chat
|
||||||
|
*/
|
||||||
|
public class ShopActivationListener implements Listener {
|
||||||
|
private final OyeShopsPlugin plugin;
|
||||||
|
private final SignParser parser;
|
||||||
|
|
||||||
|
public ShopActivationListener(OyeShopsPlugin plugin, SignParser parser) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.parser = parser;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||||
|
public void onSignChange(SignChangeEvent event) {
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
Block block = event.getBlock();
|
||||||
|
|
||||||
|
// check if player has opted into shop creation (disabled by default)
|
||||||
|
if (!plugin.getPlayerPreferenceRepository().isShopsEnabled(player.getUniqueId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check permission
|
||||||
|
if (!PermissionManager.canCreate(player)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if wall sign
|
||||||
|
BlockData blockData = block.getBlockData();
|
||||||
|
if (!(blockData instanceof WallSign wallSign)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get attached container (chest or barrel)
|
||||||
|
BlockFace attachedFace = wallSign.getFacing().getOppositeFace();
|
||||||
|
Block attachedBlock = block.getRelative(attachedFace);
|
||||||
|
|
||||||
|
if (!isContainer(attachedBlock.getType())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- protection checks ---
|
||||||
|
|
||||||
|
// 1. check if container is already part of another player's shop
|
||||||
|
Shop existingShop = findAnyShopOnContainer(attachedBlock);
|
||||||
|
if (existingShop != null && !existingShop.getOwner().equals(player.getUniqueId())
|
||||||
|
&& !PermissionManager.isAdmin(player)) {
|
||||||
|
player.sendMessage(Component.text("this container belongs to another player's shop", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. session-based ownership check
|
||||||
|
if (!PermissionManager.isAdmin(player)) {
|
||||||
|
UUID sessionOwner = plugin.getContainerMemoryManager().getOwner(attachedBlock.getLocation());
|
||||||
|
if (sessionOwner == null) {
|
||||||
|
// block was placed before the server started or by no one
|
||||||
|
player.sendMessage(Component.text("you can only create shops on containers you placed this session",
|
||||||
|
NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sessionOwner.equals(player.getUniqueId())) {
|
||||||
|
player.sendMessage(Component.text("this container was placed by another player", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get sign text
|
||||||
|
String[] lines = new String[4];
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
Component lineComponent = event.line(i);
|
||||||
|
lines[i] = lineComponent != null
|
||||||
|
? net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
|
||||||
|
.serialize(lineComponent)
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse trade
|
||||||
|
Trade trade = parser.parse(lines);
|
||||||
|
if (trade == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle auto detection for both sides
|
||||||
|
if (trade.isAutoDetect()) {
|
||||||
|
trade = detectAutoTrade(trade, attachedBlock, player);
|
||||||
|
if (trade == null) {
|
||||||
|
player.sendMessage(Component.text("auto detection failed: could not determine items from container",
|
||||||
|
NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trigger confirmation instead of immediate creation
|
||||||
|
PendingActivation activation = new PendingActivation(player.getUniqueId(), block.getLocation(), trade,
|
||||||
|
System.currentTimeMillis());
|
||||||
|
plugin.getActivationManager().add(player.getUniqueId(), activation);
|
||||||
|
|
||||||
|
sendConfirmationMessage(player, trade);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendConfirmationMessage(Player player, Trade trade) {
|
||||||
|
String priceText = trade.priceQuantity() + " " + formatMaterial(trade.priceItem());
|
||||||
|
String productText = trade.productQuantity() + " " + formatMaterial(trade.productItem());
|
||||||
|
|
||||||
|
// clear display: buyer pays vs buyer gets
|
||||||
|
player.sendMessage(Component.text("shop detected!", NamedTextColor.GOLD));
|
||||||
|
player.sendMessage(Component.text("buyer pays: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(priceText, NamedTextColor.GREEN)));
|
||||||
|
player.sendMessage(Component.text("buyer gets: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(productText, NamedTextColor.YELLOW)));
|
||||||
|
|
||||||
|
Component buttons = Component.text(" ")
|
||||||
|
.append(Component.text("[accept]", NamedTextColor.GREEN, TextDecoration.BOLD)
|
||||||
|
.hoverEvent(HoverEvent.showText(
|
||||||
|
Component.text("create shop exactly as shown above", NamedTextColor.WHITE)))
|
||||||
|
.clickEvent(ClickEvent.runCommand("/oyes _activate accept")))
|
||||||
|
.append(Component.text(" "))
|
||||||
|
.append(Component.text("[invert]", NamedTextColor.GOLD, TextDecoration.BOLD)
|
||||||
|
.hoverEvent(HoverEvent.showText(Component.text(
|
||||||
|
"swap: buyer pays " + productText + " and gets " + priceText, NamedTextColor.WHITE)))
|
||||||
|
.clickEvent(ClickEvent.runCommand("/oyes _activate invert")))
|
||||||
|
.append(Component.text(" "))
|
||||||
|
.append(Component.text("[cancel]", NamedTextColor.RED, TextDecoration.BOLD)
|
||||||
|
.hoverEvent(HoverEvent.showText(
|
||||||
|
Component.text("ignore sign. no shop created.", NamedTextColor.WHITE)))
|
||||||
|
.clickEvent(ClickEvent.runCommand("/oyes _activate cancel")));
|
||||||
|
|
||||||
|
player.sendMessage(buttons);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* complete the shop creation process
|
||||||
|
* called from AdminCommands when user clicks [accept] or [invert]
|
||||||
|
*/
|
||||||
|
public void finalizeShop(Player player, PendingActivation activation) {
|
||||||
|
Location signLocation = activation.location();
|
||||||
|
Block block = signLocation.getBlock();
|
||||||
|
|
||||||
|
// verify it's still a sign
|
||||||
|
if (!(block.getState() instanceof Sign sign)) {
|
||||||
|
player.sendMessage(Component.text("activation failed: sign is gone", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Trade trade = activation.trade();
|
||||||
|
long createdAt = System.currentTimeMillis();
|
||||||
|
Shop shop = new Shop(-1, signLocation, player.getUniqueId(), trade, 0, true, createdAt);
|
||||||
|
|
||||||
|
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
int shopId = plugin.getShopRepository().createShop(shop);
|
||||||
|
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||||
|
// re-verify sign on main thread
|
||||||
|
if (!(signLocation.getBlock().getState() instanceof Sign finalSign))
|
||||||
|
return;
|
||||||
|
|
||||||
|
Shop registeredShop = new Shop(shopId, signLocation, player.getUniqueId(), trade, 0, true,
|
||||||
|
createdAt);
|
||||||
|
plugin.getShopRegistry().register(registeredShop);
|
||||||
|
rewriteSignLines(finalSign, trade);
|
||||||
|
player.sendMessage(Component.text("shop #" + shopId + " initialized!", NamedTextColor.GREEN));
|
||||||
|
});
|
||||||
|
} catch (SQLException e) {
|
||||||
|
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||||
|
player.sendMessage(Component.text("failed to create shop: database error", NamedTextColor.RED));
|
||||||
|
});
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rewriteSignLines(Sign sign, Trade trade) {
|
||||||
|
String pricePart = trade.priceQuantity() + " " + abbreviateMaterial(trade.priceItem());
|
||||||
|
String productPart = trade.productQuantity() + " " + abbreviateMaterial(trade.productItem());
|
||||||
|
|
||||||
|
sign.line(0, Component.text(pricePart));
|
||||||
|
sign.line(1, Component.text("for."));
|
||||||
|
sign.line(2, Component.text(productPart));
|
||||||
|
sign.line(3, Component.text(""));
|
||||||
|
sign.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* detect auto trade from chest contents
|
||||||
|
* can detect material and quantity for either side (or both)
|
||||||
|
*/
|
||||||
|
private Trade detectAutoTrade(Trade baseTrade, Block containerBlock, Player player) {
|
||||||
|
Inventory inventory = getContainerInventory(containerBlock);
|
||||||
|
if (inventory == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
Map<Material, Map<Integer, Integer>> counts = new HashMap<>();
|
||||||
|
for (ItemStack item : inventory.getContents()) {
|
||||||
|
if (item == null || item.getType() == Material.AIR)
|
||||||
|
continue;
|
||||||
|
counts.computeIfAbsent(item.getType(), k -> new HashMap<>())
|
||||||
|
.merge(item.getAmount(), 1, Integer::sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counts.isEmpty())
|
||||||
|
return null;
|
||||||
|
|
||||||
|
Material priceItem = baseTrade.priceItem();
|
||||||
|
int priceQty = baseTrade.priceQuantity();
|
||||||
|
Material productItem = baseTrade.productItem();
|
||||||
|
int productQty = baseTrade.productQuantity();
|
||||||
|
|
||||||
|
// if product needs detection
|
||||||
|
if (productQty == -1) {
|
||||||
|
Material bestMat = null;
|
||||||
|
int bestQty = -1;
|
||||||
|
int bestScore = -1;
|
||||||
|
|
||||||
|
for (Map.Entry<Material, Map<Integer, Integer>> entry : counts.entrySet()) {
|
||||||
|
Material mat = entry.getKey();
|
||||||
|
// skip if it's the price item (unless it's unknown)
|
||||||
|
if (priceItem != null && mat == priceItem)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
int qty = findMostCommonQuantity(entry.getValue());
|
||||||
|
int score = entry.getValue().getOrDefault(qty, 0);
|
||||||
|
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestQty = qty;
|
||||||
|
bestMat = mat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestMat != null) {
|
||||||
|
productItem = bestMat;
|
||||||
|
productQty = bestQty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if price needs detection (e.g. "AUTO for 10 dirt")
|
||||||
|
if (priceQty == -1) {
|
||||||
|
Material bestMat = null;
|
||||||
|
int bestQty = -1;
|
||||||
|
int bestScore = -1;
|
||||||
|
|
||||||
|
for (Map.Entry<Material, Map<Integer, Integer>> entry : counts.entrySet()) {
|
||||||
|
Material mat = entry.getKey();
|
||||||
|
// skip if it's the product item
|
||||||
|
if (productItem != null && mat == productItem)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
int qty = findMostCommonQuantity(entry.getValue());
|
||||||
|
int score = entry.getValue().getOrDefault(qty, 0);
|
||||||
|
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestQty = qty;
|
||||||
|
bestMat = mat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestMat != null) {
|
||||||
|
priceItem = bestMat;
|
||||||
|
priceQty = bestQty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// final checks
|
||||||
|
if (priceItem == null || priceItem == Material.AIR || priceQty <= 0)
|
||||||
|
return null;
|
||||||
|
if (productItem == null || productItem == Material.AIR || productQty <= 0)
|
||||||
|
return null;
|
||||||
|
if (priceItem == productItem)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new Trade(priceItem, priceQty, productItem, productQty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int findMostCommonQuantity(Map<Integer, Integer> counts) {
|
||||||
|
int bestQuantity = -1;
|
||||||
|
int bestCount = -1;
|
||||||
|
for (Map.Entry<Integer, Integer> entry : counts.entrySet()) {
|
||||||
|
if (entry.getValue() > bestCount) {
|
||||||
|
bestCount = entry.getValue();
|
||||||
|
bestQuantity = entry.getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestQuantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String abbreviateMaterial(Material material) {
|
||||||
|
String name = material.name().toLowerCase().replace("_", " ");
|
||||||
|
name = name.replace("diamond", "dia").replace("emerald", "em").replace("netherite", "neth")
|
||||||
|
.replace("ingot", "").replace("block", "blk").replace("pickaxe", "pick")
|
||||||
|
.replace("chestplate", "chest").replace("leggings", "legs");
|
||||||
|
name = name.trim();
|
||||||
|
return name.length() > 14 ? name.substring(0, 14) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Inventory getContainerInventory(Block block) {
|
||||||
|
if (block.getState() instanceof Chest chest)
|
||||||
|
return chest.getInventory();
|
||||||
|
if (block.getState() instanceof Barrel barrel)
|
||||||
|
return barrel.getInventory();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isContainer(Material material) {
|
||||||
|
return material == Material.CHEST || material == Material.TRAPPED_CHEST || material == Material.BARREL;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatMaterial(Material material) {
|
||||||
|
return material.name().toLowerCase().replace("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if ANY shop exists on this container (or its other half)
|
||||||
|
*/
|
||||||
|
private Shop findAnyShopOnContainer(Block containerBlock) {
|
||||||
|
List<Block> parts = getFullContainer(containerBlock);
|
||||||
|
for (Block part : parts) {
|
||||||
|
for (BlockFace face : new BlockFace[] { BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST,
|
||||||
|
BlockFace.WEST }) {
|
||||||
|
Block adjacent = part.getRelative(face);
|
||||||
|
if (adjacent.getBlockData() instanceof WallSign wallSign) {
|
||||||
|
if (wallSign.getFacing().getOppositeFace() == face.getOppositeFace()) {
|
||||||
|
Shop shop = plugin.getShopRegistry().getShop(adjacent.getLocation());
|
||||||
|
if (shop != null)
|
||||||
|
return shop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get all blocks belonging to this container (handles double chests)
|
||||||
|
*/
|
||||||
|
private List<Block> getFullContainer(Block block) {
|
||||||
|
List<Block> blocks = new ArrayList<>();
|
||||||
|
blocks.add(block);
|
||||||
|
if (block.getState() instanceof Chest chest) {
|
||||||
|
Inventory inv = chest.getInventory();
|
||||||
|
if (inv instanceof DoubleChestInventory dci) {
|
||||||
|
DoubleChest dc = dci.getHolder();
|
||||||
|
if (dc != null) {
|
||||||
|
blocks.clear();
|
||||||
|
if (dc.getLeftSide() instanceof Chest left)
|
||||||
|
blocks.add(left.getBlock());
|
||||||
|
if (dc.getRightSide() instanceof Chest right)
|
||||||
|
blocks.add(right.getBlock());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package party.cybsec.oyeshops.manager;
|
||||||
|
|
||||||
|
import party.cybsec.oyeshops.model.PendingActivation;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* manages shops awaiting confirmation
|
||||||
|
*/
|
||||||
|
public class ActivationManager {
|
||||||
|
private final Map<UUID, PendingActivation> pending = new ConcurrentHashMap<>();
|
||||||
|
private static final long TIMEOUT_MS = 60 * 1000; // 60 seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* register a pending activation for a player
|
||||||
|
*/
|
||||||
|
public void add(UUID playerUuid, PendingActivation activation) {
|
||||||
|
pending.put(playerUuid, activation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get and remove pending activation for a player
|
||||||
|
*/
|
||||||
|
public PendingActivation getAndRemove(UUID playerUuid) {
|
||||||
|
PendingActivation activation = pending.remove(playerUuid);
|
||||||
|
if (activation != null && activation.isExpired(TIMEOUT_MS)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return activation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* remove a pending activation
|
||||||
|
*/
|
||||||
|
public void remove(UUID playerUuid) {
|
||||||
|
pending.remove(playerUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if player has a pending activation
|
||||||
|
*/
|
||||||
|
public boolean hasPending(UUID playerUuid) {
|
||||||
|
PendingActivation activation = pending.get(playerUuid);
|
||||||
|
if (activation != null && activation.isExpired(TIMEOUT_MS)) {
|
||||||
|
pending.remove(playerUuid);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return activation != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package party.cybsec.oyeshops.manager;
|
||||||
|
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tracks which player placed which container during this session
|
||||||
|
*/
|
||||||
|
public class ContainerMemoryManager {
|
||||||
|
private final Map<String, UUID> placements = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public void recordPlacement(Location location, UUID playerUuid) {
|
||||||
|
placements.put(locationKey(location), playerUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removePlacement(Location location) {
|
||||||
|
placements.remove(locationKey(location));
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getOwner(Location location) {
|
||||||
|
return placements.get(locationKey(location));
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSessionPlaced(Location location) {
|
||||||
|
return placements.containsKey(locationKey(location));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String locationKey(Location loc) {
|
||||||
|
return loc.getWorld().getUID() + ":" + loc.getBlockX() + ":" + loc.getBlockY() + ":" + loc.getBlockZ();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/main/java/party/cybsec/oyeshops/model/Shop.java
Normal file
74
src/main/java/party/cybsec/oyeshops/model/Shop.java
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package party.cybsec.oyeshops.model;
|
||||||
|
|
||||||
|
import org.bukkit.Location;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shop data model
|
||||||
|
*/
|
||||||
|
public class Shop {
|
||||||
|
private final int id;
|
||||||
|
private final Location signLocation;
|
||||||
|
private final UUID owner;
|
||||||
|
private final Trade trade;
|
||||||
|
private int owedAmount;
|
||||||
|
private boolean enabled;
|
||||||
|
private final long createdAt;
|
||||||
|
|
||||||
|
public Shop(int id, Location signLocation, UUID owner, Trade trade, int owedAmount, boolean enabled,
|
||||||
|
long createdAt) {
|
||||||
|
this.id = id;
|
||||||
|
this.signLocation = signLocation;
|
||||||
|
this.owner = owner;
|
||||||
|
this.trade = trade;
|
||||||
|
this.owedAmount = owedAmount;
|
||||||
|
this.enabled = enabled;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Location getSignLocation() {
|
||||||
|
return signLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getOwner() {
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Trade getTrade() {
|
||||||
|
return trade;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getOwedAmount() {
|
||||||
|
return owedAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOwedAmount(int owedAmount) {
|
||||||
|
this.owedAmount = owedAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get chest location from sign location
|
||||||
|
*/
|
||||||
|
public Location getChestLocation() {
|
||||||
|
// chest is attached to the sign
|
||||||
|
// we'll determine this from the sign's attached block face
|
||||||
|
return signLocation.clone(); // placeholder - will be properly implemented
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/main/java/party/cybsec/oyeshops/model/Trade.java
Normal file
49
src/main/java/party/cybsec/oyeshops/model/Trade.java
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package party.cybsec.oyeshops.model;
|
||||||
|
|
||||||
|
import org.bukkit.Material;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* immutable trade definition
|
||||||
|
* quantity of -1 indicates AUTO detection needed for that side
|
||||||
|
*/
|
||||||
|
public record Trade(
|
||||||
|
Material priceItem,
|
||||||
|
int priceQuantity,
|
||||||
|
Material productItem,
|
||||||
|
int productQuantity) {
|
||||||
|
|
||||||
|
public Trade {
|
||||||
|
// allow -1 for AUTO detection, but otherwise must be positive
|
||||||
|
if (priceQuantity <= 0 && priceQuantity != -1) {
|
||||||
|
throw new IllegalArgumentException("price quantity must be positive or -1 for auto");
|
||||||
|
}
|
||||||
|
if (productQuantity <= 0 && productQuantity != -1) {
|
||||||
|
throw new IllegalArgumentException("product quantity must be positive or -1 for auto");
|
||||||
|
}
|
||||||
|
// for AUTO, materials might be AIR (unknown)
|
||||||
|
if (priceQuantity != -1 && productQuantity != -1 && priceItem == productItem && priceItem != Material.AIR) {
|
||||||
|
throw new IllegalArgumentException("price and product must be different materials");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if this trade needs AUTO detection on the product side
|
||||||
|
*/
|
||||||
|
public boolean isAutoProduct() {
|
||||||
|
return productQuantity == -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if this trade needs AUTO detection on the price side
|
||||||
|
*/
|
||||||
|
public boolean isAutoPrice() {
|
||||||
|
return priceQuantity == -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if this trade needs any AUTO detection
|
||||||
|
*/
|
||||||
|
public boolean isAutoDetect() {
|
||||||
|
return isAutoProduct() || isAutoPrice();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package party.cybsec.oyeshops.parser;
|
||||||
|
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.configuration.ConfigurationSection;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* material name alias resolution system
|
||||||
|
* handles underscores, plurals, and common abbreviations
|
||||||
|
*/
|
||||||
|
public class MaterialAliasRegistry {
|
||||||
|
private final Map<String, Material> aliases = new HashMap<>();
|
||||||
|
|
||||||
|
// common aliases built-in
|
||||||
|
private static final Map<String, String> COMMON_ALIASES = Map.ofEntries(
|
||||||
|
// plurals
|
||||||
|
Map.entry("diamonds", "diamond"),
|
||||||
|
Map.entry("emeralds", "emerald"),
|
||||||
|
Map.entry("stones", "stone"),
|
||||||
|
Map.entry("dirts", "dirt"),
|
||||||
|
Map.entry("cobblestones", "cobblestone"),
|
||||||
|
Map.entry("coals", "coal"),
|
||||||
|
Map.entry("irons", "iron_ingot"),
|
||||||
|
Map.entry("golds", "gold_ingot"),
|
||||||
|
Map.entry("coppers", "copper_ingot"),
|
||||||
|
Map.entry("netherites", "netherite_ingot"),
|
||||||
|
|
||||||
|
// short forms
|
||||||
|
Map.entry("dia", "diamond"),
|
||||||
|
Map.entry("dias", "diamond"),
|
||||||
|
Map.entry("em", "emerald"),
|
||||||
|
Map.entry("ems", "emerald"),
|
||||||
|
Map.entry("iron", "iron_ingot"),
|
||||||
|
Map.entry("gold", "gold_ingot"),
|
||||||
|
Map.entry("copper", "copper_ingot"),
|
||||||
|
Map.entry("netherite", "netherite_ingot"),
|
||||||
|
Map.entry("cobble", "cobblestone"),
|
||||||
|
Map.entry("lapis", "lapis_lazuli"),
|
||||||
|
Map.entry("redite", "redstone"),
|
||||||
|
Map.entry("quartz", "quartz"),
|
||||||
|
|
||||||
|
// ores
|
||||||
|
Map.entry("iron ore", "iron_ore"),
|
||||||
|
Map.entry("gold ore", "gold_ore"),
|
||||||
|
Map.entry("diamond ore", "diamond_ore"),
|
||||||
|
Map.entry("coal ore", "coal_ore"),
|
||||||
|
Map.entry("copper ore", "copper_ore"),
|
||||||
|
Map.entry("emerald ore", "emerald_ore"),
|
||||||
|
Map.entry("lapis ore", "lapis_ore"),
|
||||||
|
Map.entry("redstone ore", "redstone_ore"),
|
||||||
|
|
||||||
|
// tools - pickaxes
|
||||||
|
Map.entry("diamond pick", "diamond_pickaxe"),
|
||||||
|
Map.entry("diamond pickaxe", "diamond_pickaxe"),
|
||||||
|
Map.entry("iron pick", "iron_pickaxe"),
|
||||||
|
Map.entry("iron pickaxe", "iron_pickaxe"),
|
||||||
|
Map.entry("stone pick", "stone_pickaxe"),
|
||||||
|
Map.entry("stone pickaxe", "stone_pickaxe"),
|
||||||
|
Map.entry("wooden pick", "wooden_pickaxe"),
|
||||||
|
Map.entry("wooden pickaxe", "wooden_pickaxe"),
|
||||||
|
Map.entry("gold pick", "golden_pickaxe"),
|
||||||
|
Map.entry("golden pick", "golden_pickaxe"),
|
||||||
|
Map.entry("golden pickaxe", "golden_pickaxe"),
|
||||||
|
Map.entry("netherite pick", "netherite_pickaxe"),
|
||||||
|
Map.entry("netherite pickaxe", "netherite_pickaxe"),
|
||||||
|
|
||||||
|
// tools - swords
|
||||||
|
Map.entry("diamond sword", "diamond_sword"),
|
||||||
|
Map.entry("iron sword", "iron_sword"),
|
||||||
|
Map.entry("stone sword", "stone_sword"),
|
||||||
|
Map.entry("wooden sword", "wooden_sword"),
|
||||||
|
Map.entry("golden sword", "golden_sword"),
|
||||||
|
Map.entry("gold sword", "golden_sword"),
|
||||||
|
Map.entry("netherite sword", "netherite_sword"),
|
||||||
|
|
||||||
|
// tools - axes
|
||||||
|
Map.entry("diamond axe", "diamond_axe"),
|
||||||
|
Map.entry("iron axe", "iron_axe"),
|
||||||
|
Map.entry("stone axe", "stone_axe"),
|
||||||
|
Map.entry("wooden axe", "wooden_axe"),
|
||||||
|
Map.entry("golden axe", "golden_axe"),
|
||||||
|
Map.entry("gold axe", "golden_axe"),
|
||||||
|
Map.entry("netherite axe", "netherite_axe"),
|
||||||
|
|
||||||
|
// tools - shovels
|
||||||
|
Map.entry("diamond shovel", "diamond_shovel"),
|
||||||
|
Map.entry("iron shovel", "iron_shovel"),
|
||||||
|
Map.entry("stone shovel", "stone_shovel"),
|
||||||
|
Map.entry("wooden shovel", "wooden_shovel"),
|
||||||
|
Map.entry("golden shovel", "golden_shovel"),
|
||||||
|
Map.entry("gold shovel", "golden_shovel"),
|
||||||
|
Map.entry("netherite shovel", "netherite_shovel"),
|
||||||
|
|
||||||
|
// tools - hoes
|
||||||
|
Map.entry("diamond hoe", "diamond_hoe"),
|
||||||
|
Map.entry("iron hoe", "iron_hoe"),
|
||||||
|
Map.entry("stone hoe", "stone_hoe"),
|
||||||
|
Map.entry("wooden hoe", "wooden_hoe"),
|
||||||
|
Map.entry("golden hoe", "golden_hoe"),
|
||||||
|
Map.entry("gold hoe", "golden_hoe"),
|
||||||
|
Map.entry("netherite hoe", "netherite_hoe"),
|
||||||
|
|
||||||
|
// armor
|
||||||
|
Map.entry("diamond helmet", "diamond_helmet"),
|
||||||
|
Map.entry("diamond chestplate", "diamond_chestplate"),
|
||||||
|
Map.entry("diamond leggings", "diamond_leggings"),
|
||||||
|
Map.entry("diamond boots", "diamond_boots"),
|
||||||
|
Map.entry("iron helmet", "iron_helmet"),
|
||||||
|
Map.entry("iron chestplate", "iron_chestplate"),
|
||||||
|
Map.entry("iron leggings", "iron_leggings"),
|
||||||
|
Map.entry("iron boots", "iron_boots"),
|
||||||
|
Map.entry("netherite helmet", "netherite_helmet"),
|
||||||
|
Map.entry("netherite chestplate", "netherite_chestplate"),
|
||||||
|
Map.entry("netherite leggings", "netherite_leggings"),
|
||||||
|
Map.entry("netherite boots", "netherite_boots"),
|
||||||
|
|
||||||
|
// common blocks
|
||||||
|
Map.entry("oak log", "oak_log"),
|
||||||
|
Map.entry("oak plank", "oak_planks"),
|
||||||
|
Map.entry("oak planks", "oak_planks"),
|
||||||
|
Map.entry("oak wood", "oak_log"),
|
||||||
|
Map.entry("spruce log", "spruce_log"),
|
||||||
|
Map.entry("birch log", "birch_log"),
|
||||||
|
Map.entry("jungle log", "jungle_log"),
|
||||||
|
Map.entry("acacia log", "acacia_log"),
|
||||||
|
Map.entry("dark oak log", "dark_oak_log"),
|
||||||
|
Map.entry("glass", "glass"),
|
||||||
|
Map.entry("sand", "sand"),
|
||||||
|
Map.entry("gravel", "gravel"),
|
||||||
|
Map.entry("obsidian", "obsidian"),
|
||||||
|
Map.entry("glowstone", "glowstone"),
|
||||||
|
Map.entry("netherrack", "netherrack"),
|
||||||
|
Map.entry("endstone", "end_stone"),
|
||||||
|
Map.entry("end stone", "end_stone"),
|
||||||
|
|
||||||
|
// food
|
||||||
|
Map.entry("steak", "cooked_beef"),
|
||||||
|
Map.entry("cooked steak", "cooked_beef"),
|
||||||
|
Map.entry("porkchop", "cooked_porkchop"),
|
||||||
|
Map.entry("cooked porkchop", "cooked_porkchop"),
|
||||||
|
Map.entry("chicken", "cooked_chicken"),
|
||||||
|
Map.entry("cooked chicken", "cooked_chicken"),
|
||||||
|
Map.entry("bread", "bread"),
|
||||||
|
Map.entry("apple", "apple"),
|
||||||
|
Map.entry("golden apple", "golden_apple"),
|
||||||
|
Map.entry("gapple", "golden_apple"),
|
||||||
|
Map.entry("notch apple", "enchanted_golden_apple"),
|
||||||
|
Map.entry("enchanted golden apple", "enchanted_golden_apple"),
|
||||||
|
Map.entry("god apple", "enchanted_golden_apple"),
|
||||||
|
|
||||||
|
// misc
|
||||||
|
Map.entry("ender pearl", "ender_pearl"),
|
||||||
|
Map.entry("pearl", "ender_pearl"),
|
||||||
|
Map.entry("enderpearl", "ender_pearl"),
|
||||||
|
Map.entry("blaze rod", "blaze_rod"),
|
||||||
|
Map.entry("slime ball", "slime_ball"),
|
||||||
|
Map.entry("slimeball", "slime_ball"),
|
||||||
|
Map.entry("ghast tear", "ghast_tear"),
|
||||||
|
Map.entry("nether star", "nether_star"),
|
||||||
|
Map.entry("totem", "totem_of_undying"),
|
||||||
|
Map.entry("elytra", "elytra"),
|
||||||
|
Map.entry("shulker", "shulker_box"),
|
||||||
|
Map.entry("shulker box", "shulker_box"),
|
||||||
|
Map.entry("book", "book"),
|
||||||
|
Map.entry("exp bottle", "experience_bottle"),
|
||||||
|
Map.entry("xp bottle", "experience_bottle"),
|
||||||
|
Map.entry("bottle o enchanting", "experience_bottle"));
|
||||||
|
|
||||||
|
public MaterialAliasRegistry(ConfigurationSection aliasSection) {
|
||||||
|
// load built-in aliases first
|
||||||
|
for (Map.Entry<String, String> entry : COMMON_ALIASES.entrySet()) {
|
||||||
|
try {
|
||||||
|
Material material = Material.valueOf(entry.getValue().toUpperCase());
|
||||||
|
aliases.put(entry.getKey().toLowerCase(), material);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
// ignore invalid built-in aliases
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// then load config aliases (override built-ins)
|
||||||
|
loadAliases(aliasSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadAliases(ConfigurationSection section) {
|
||||||
|
if (section == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (String alias : section.getKeys(false)) {
|
||||||
|
String materialName = section.getString(alias);
|
||||||
|
if (materialName != null) {
|
||||||
|
try {
|
||||||
|
Material material = Material.valueOf(materialName.toUpperCase());
|
||||||
|
aliases.put(alias.toLowerCase(), material);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
// invalid material name in config, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolve material from normalized text
|
||||||
|
* tries multiple strategies:
|
||||||
|
* 1. exact alias match (longest first)
|
||||||
|
* 2. word-by-word alias match
|
||||||
|
* 3. space-to-underscore conversion for direct enum match
|
||||||
|
* 4. direct material enum match
|
||||||
|
* 5. with _INGOT/_BLOCK suffixes
|
||||||
|
* 6. strip trailing 's' for plurals
|
||||||
|
*/
|
||||||
|
public Material resolve(String text) {
|
||||||
|
text = text.toLowerCase().trim();
|
||||||
|
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. try longest alias match first (for multi-word aliases)
|
||||||
|
String longestMatch = null;
|
||||||
|
Material longestMaterial = null;
|
||||||
|
|
||||||
|
for (Map.Entry<String, Material> entry : aliases.entrySet()) {
|
||||||
|
String alias = entry.getKey();
|
||||||
|
if (text.contains(alias)) {
|
||||||
|
if (longestMatch == null || alias.length() > longestMatch.length()) {
|
||||||
|
longestMatch = alias;
|
||||||
|
longestMaterial = entry.getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (longestMaterial != null) {
|
||||||
|
return longestMaterial;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. try word-by-word alias match
|
||||||
|
String[] words = text.split("\\s+");
|
||||||
|
for (String word : words) {
|
||||||
|
if (aliases.containsKey(word)) {
|
||||||
|
return aliases.get(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. try space-to-underscore conversion for multi-word materials
|
||||||
|
// e.g., "netherite pickaxe" -> "netherite_pickaxe"
|
||||||
|
String underscored = text.replace(" ", "_");
|
||||||
|
try {
|
||||||
|
return Material.valueOf(underscored.toUpperCase());
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. try each word directly as material
|
||||||
|
for (String word : words) {
|
||||||
|
try {
|
||||||
|
return Material.valueOf(word.toUpperCase());
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. try with common suffixes
|
||||||
|
try {
|
||||||
|
return Material.valueOf(word.toUpperCase() + "_INGOT");
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Material.valueOf(word.toUpperCase() + "_BLOCK");
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. try stripping trailing 's' for plurals
|
||||||
|
if (word.endsWith("s") && word.length() > 1) {
|
||||||
|
String singular = word.substring(0, word.length() - 1);
|
||||||
|
try {
|
||||||
|
return Material.valueOf(singular.toUpperCase());
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. try the whole text with underscores for complex names
|
||||||
|
for (Material material : Material.values()) {
|
||||||
|
String materialName = material.name().toLowerCase();
|
||||||
|
String materialSpaced = materialName.replace("_", " ");
|
||||||
|
|
||||||
|
if (text.contains(materialSpaced) || text.contains(materialName)) {
|
||||||
|
if (longestMatch == null || materialName.length() > longestMatch.length()) {
|
||||||
|
longestMatch = materialName;
|
||||||
|
longestMaterial = material;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return longestMaterial;
|
||||||
|
}
|
||||||
|
}
|
||||||
262
src/main/java/party/cybsec/oyeshops/parser/SignParser.java
Normal file
262
src/main/java/party/cybsec/oyeshops/parser/SignParser.java
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package party.cybsec.oyeshops.parser;
|
||||||
|
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import party.cybsec.oyeshops.model.Trade;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* deterministic sign text parser
|
||||||
|
* requires "." in sign text
|
||||||
|
* ambiguity equals no shop
|
||||||
|
*/
|
||||||
|
public class SignParser {
|
||||||
|
private final MaterialAliasRegistry aliasRegistry;
|
||||||
|
|
||||||
|
// directional keywords - price comes before, product comes after
|
||||||
|
private static final Set<String> COST_INDICATORS = Set.of(
|
||||||
|
"for", "per", "costs", "cost", "price", "=", "->", "=>", ">", "→");
|
||||||
|
private static final Set<String> SELL_INDICATORS = Set.of(
|
||||||
|
"get", "gets", "gives", "buy", "buys", "selling", "trades", "exchanges");
|
||||||
|
|
||||||
|
// quantity patterns
|
||||||
|
private static final Pattern NUMBER_PATTERN = Pattern.compile("\\d+");
|
||||||
|
private static final Map<String, Integer> WORD_QUANTITIES = Map.ofEntries(
|
||||||
|
Map.entry("a", 1),
|
||||||
|
Map.entry("an", 1),
|
||||||
|
Map.entry("one", 1),
|
||||||
|
Map.entry("each", 1),
|
||||||
|
Map.entry("two", 2),
|
||||||
|
Map.entry("three", 3),
|
||||||
|
Map.entry("four", 4),
|
||||||
|
Map.entry("five", 5),
|
||||||
|
Map.entry("six", 6),
|
||||||
|
Map.entry("seven", 7),
|
||||||
|
Map.entry("eight", 8),
|
||||||
|
Map.entry("nine", 9),
|
||||||
|
Map.entry("ten", 10),
|
||||||
|
Map.entry("dozen", 12),
|
||||||
|
Map.entry("half", 32),
|
||||||
|
Map.entry("stack", 64));
|
||||||
|
|
||||||
|
// special keyword for auto-detection
|
||||||
|
public static final String AUTO_KEYWORD = "auto";
|
||||||
|
|
||||||
|
public SignParser(MaterialAliasRegistry aliasRegistry) {
|
||||||
|
this.aliasRegistry = aliasRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parse sign lines into a trade
|
||||||
|
*
|
||||||
|
* @return trade if valid, null if invalid or ambiguous
|
||||||
|
* trade with quantity -1 means AUTO detection needed
|
||||||
|
*/
|
||||||
|
public Trade parse(String[] lines) {
|
||||||
|
// concatenate all lines with spaces
|
||||||
|
String fullText = String.join(" ", lines);
|
||||||
|
|
||||||
|
// REQUIREMENT: must contain "." to be parsed as a shop
|
||||||
|
if (!fullText.contains(".")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalize text
|
||||||
|
String normalized = normalize(fullText);
|
||||||
|
|
||||||
|
// find directional keywords
|
||||||
|
DirectionalInfo directional = findDirectionalKeywords(normalized);
|
||||||
|
|
||||||
|
if (directional != null) {
|
||||||
|
// section-based parsing (original logic)
|
||||||
|
String priceSection = normalized.substring(0, directional.keywordStart).trim();
|
||||||
|
String productSection = normalized.substring(directional.keywordEnd).trim();
|
||||||
|
|
||||||
|
ItemQuantity price = parseItemQuantity(priceSection);
|
||||||
|
if (price == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isAuto = productSection.toLowerCase().contains(AUTO_KEYWORD);
|
||||||
|
ItemQuantity product;
|
||||||
|
if (isAuto) {
|
||||||
|
Material productMaterial = aliasRegistry.resolve(productSection.replace(AUTO_KEYWORD, "").trim());
|
||||||
|
product = new ItemQuantity(productMaterial, -1);
|
||||||
|
} else {
|
||||||
|
product = parseItemQuantity(productSection);
|
||||||
|
if (product == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.material != null && price.material == product.material) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Trade(price.material, price.quantity, product.material != null ? product.material : Material.AIR,
|
||||||
|
product.quantity);
|
||||||
|
} else {
|
||||||
|
// fallback: line-by-line parsing for keyword-less format (e.g. "1 dirt" \n "1
|
||||||
|
// diamond")
|
||||||
|
// we look for two distinct item-quantity pairs in the normalized text
|
||||||
|
List<ItemQuantity> pairs = findMultipleItemQuantities(normalized);
|
||||||
|
|
||||||
|
if (pairs.size() == 2) {
|
||||||
|
// assume first is price, second is product
|
||||||
|
ItemQuantity price = pairs.get(0);
|
||||||
|
ItemQuantity product = pairs.get(1);
|
||||||
|
|
||||||
|
if (price.material != null && product.material != null && price.material != product.material) {
|
||||||
|
return new Trade(price.material, price.quantity, product.material, product.quantity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* find multiple item-quantity pairs in a string
|
||||||
|
*/
|
||||||
|
private List<ItemQuantity> findMultipleItemQuantities(String text) {
|
||||||
|
List<ItemQuantity> results = new ArrayList<>();
|
||||||
|
String[] words = text.split("\\s+");
|
||||||
|
|
||||||
|
// simple heuristic: find clusters of [quantity] [material]
|
||||||
|
for (int i = 0; i < words.length; i++) {
|
||||||
|
String word = words[i];
|
||||||
|
|
||||||
|
// is this a quantity?
|
||||||
|
Integer qty = null;
|
||||||
|
if (NUMBER_PATTERN.matcher(word).matches()) {
|
||||||
|
qty = Integer.parseInt(word);
|
||||||
|
} else if (WORD_QUANTITIES.containsKey(word)) {
|
||||||
|
qty = WORD_QUANTITIES.get(word);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qty != null && i + 1 < words.length) {
|
||||||
|
// check if next word is a material
|
||||||
|
Material mat = aliasRegistry.resolve(words[i + 1]);
|
||||||
|
if (mat != null) {
|
||||||
|
results.add(new ItemQuantity(mat, qty));
|
||||||
|
i++; // skip material word
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// maybe it's just a material (quantity 1)
|
||||||
|
Material mat = aliasRegistry.resolve(word);
|
||||||
|
if (mat != null) {
|
||||||
|
// avoid duplicates/overlaps
|
||||||
|
results.add(new ItemQuantity(mat, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* normalize sign text
|
||||||
|
* removes punctuation except underscores (for material names)
|
||||||
|
* keeps "." check before calling this
|
||||||
|
*/
|
||||||
|
private String normalize(String text) {
|
||||||
|
text = text.toLowerCase();
|
||||||
|
// replace common arrow symbols with spaces
|
||||||
|
text = text.replace("->", " for ");
|
||||||
|
text = text.replace("=>", " for ");
|
||||||
|
text = text.replace("→", " for ");
|
||||||
|
text = text.replace(">", " for ");
|
||||||
|
// replace punctuation with spaces (keep underscores)
|
||||||
|
text = text.replaceAll("[^a-z0-9_\\s]", " ");
|
||||||
|
// normalize whitespace
|
||||||
|
text = text.replaceAll("\\s+", " ").trim();
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* find directional keywords and split point
|
||||||
|
*/
|
||||||
|
private DirectionalInfo findDirectionalKeywords(String text) {
|
||||||
|
String[] tokens = text.split("\\s+");
|
||||||
|
int currentPos = 0;
|
||||||
|
|
||||||
|
// first pass: look for cost indicators
|
||||||
|
for (String token : tokens) {
|
||||||
|
if (COST_INDICATORS.contains(token)) {
|
||||||
|
int keywordEnd = currentPos + token.length();
|
||||||
|
if (keywordEnd < text.length() && text.charAt(keywordEnd) == ' ') {
|
||||||
|
keywordEnd++;
|
||||||
|
}
|
||||||
|
return new DirectionalInfo(currentPos, keywordEnd, token.equals("per") || token.equals("each"), token);
|
||||||
|
}
|
||||||
|
currentPos += token.length() + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// second pass: look for sell indicators
|
||||||
|
currentPos = 0;
|
||||||
|
for (String token : tokens) {
|
||||||
|
if (SELL_INDICATORS.contains(token)) {
|
||||||
|
int keywordEnd = currentPos + token.length();
|
||||||
|
if (keywordEnd < text.length() && text.charAt(keywordEnd) == ' ') {
|
||||||
|
keywordEnd++;
|
||||||
|
}
|
||||||
|
return new DirectionalInfo(currentPos, keywordEnd, false, token);
|
||||||
|
}
|
||||||
|
currentPos += token.length() + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parse item and quantity from text section
|
||||||
|
*/
|
||||||
|
private ItemQuantity parseItemQuantity(String section) {
|
||||||
|
section = section.trim();
|
||||||
|
|
||||||
|
if (section.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// find quantity
|
||||||
|
Integer quantity = null;
|
||||||
|
|
||||||
|
// look for explicit number first
|
||||||
|
Matcher matcher = NUMBER_PATTERN.matcher(section);
|
||||||
|
if (matcher.find()) {
|
||||||
|
quantity = Integer.parseInt(matcher.group());
|
||||||
|
}
|
||||||
|
|
||||||
|
// look for word quantities if no explicit number
|
||||||
|
if (quantity == null) {
|
||||||
|
String[] words = section.split("\\s+");
|
||||||
|
for (String word : words) {
|
||||||
|
if (WORD_QUANTITIES.containsKey(word)) {
|
||||||
|
quantity = WORD_QUANTITIES.get(word);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// default to 1 if no quantity specified
|
||||||
|
if (quantity == null) {
|
||||||
|
quantity = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// find material
|
||||||
|
Material material = aliasRegistry.resolve(section);
|
||||||
|
if (material == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ItemQuantity(material, quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record DirectionalInfo(int keywordStart, int keywordEnd, boolean isPerUnit, String keyword) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ItemQuantity(Material material, int quantity) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/main/java/party/cybsec/oyeshops/registry/ShopRegistry.java
Normal file
106
src/main/java/party/cybsec/oyeshops/registry/ShopRegistry.java
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package party.cybsec.oyeshops.registry;
|
||||||
|
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import party.cybsec.oyeshops.model.Shop;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* in-memory shop cache
|
||||||
|
* synchronized access, backed by sqlite
|
||||||
|
*/
|
||||||
|
public class ShopRegistry {
|
||||||
|
private final Map<String, Shop> shopsByLocation = new ConcurrentHashMap<>();
|
||||||
|
private final Map<Integer, Shop> shopsById = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* register a shop
|
||||||
|
*/
|
||||||
|
public void register(Shop shop) {
|
||||||
|
String key = locationKey(shop.getSignLocation());
|
||||||
|
shopsByLocation.put(key, shop);
|
||||||
|
shopsById.put(shop.getId(), shop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* unregister a shop by location
|
||||||
|
*/
|
||||||
|
public void unregister(Location location) {
|
||||||
|
String key = locationKey(location);
|
||||||
|
Shop shop = shopsByLocation.remove(key);
|
||||||
|
if (shop != null) {
|
||||||
|
shopsById.remove(shop.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* unregister a shop by id
|
||||||
|
*/
|
||||||
|
public void unregister(int shopId) {
|
||||||
|
Shop shop = shopsById.remove(shopId);
|
||||||
|
if (shop != null) {
|
||||||
|
String key = locationKey(shop.getSignLocation());
|
||||||
|
shopsByLocation.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* unregister a shop
|
||||||
|
*/
|
||||||
|
public void unregister(Shop shop) {
|
||||||
|
unregister(shop.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get shop by location
|
||||||
|
*/
|
||||||
|
public Shop getShop(Location location) {
|
||||||
|
String key = locationKey(location);
|
||||||
|
return shopsByLocation.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get shop by id
|
||||||
|
*/
|
||||||
|
public Shop getShop(int shopId) {
|
||||||
|
return shopsById.get(shopId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get shop by id (alias)
|
||||||
|
*/
|
||||||
|
public Shop getShopById(int shopId) {
|
||||||
|
return shopsById.get(shopId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get all shops
|
||||||
|
*/
|
||||||
|
public Collection<Shop> getAllShops() {
|
||||||
|
return shopsById.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if location has a shop
|
||||||
|
*/
|
||||||
|
public boolean hasShop(Location location) {
|
||||||
|
return shopsByLocation.containsKey(locationKey(location));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* clear all shops
|
||||||
|
*/
|
||||||
|
public void clear() {
|
||||||
|
shopsByLocation.clear();
|
||||||
|
shopsById.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* create location key for map lookup
|
||||||
|
*/
|
||||||
|
private String locationKey(Location loc) {
|
||||||
|
return loc.getWorld().getUID() + ":" + loc.getBlockX() + ":" + loc.getBlockY() + ":" + loc.getBlockZ();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package party.cybsec.oyeshops.transaction;
|
||||||
|
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.block.Barrel;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.block.BlockFace;
|
||||||
|
import org.bukkit.block.Chest;
|
||||||
|
import org.bukkit.block.data.type.WallSign;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import party.cybsec.oyeshops.OyeShopsPlugin;
|
||||||
|
import party.cybsec.oyeshops.model.Shop;
|
||||||
|
import party.cybsec.oyeshops.model.Trade;
|
||||||
|
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* handles atomic shop transactions with rollback
|
||||||
|
*/
|
||||||
|
public class TransactionManager {
|
||||||
|
private final OyeShopsPlugin plugin;
|
||||||
|
|
||||||
|
public TransactionManager(OyeShopsPlugin plugin) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Result {
|
||||||
|
SUCCESS,
|
||||||
|
INSUFFICIENT_FUNDS,
|
||||||
|
INSUFFICIENT_STOCK,
|
||||||
|
INVENTORY_FULL,
|
||||||
|
DATABASE_ERROR,
|
||||||
|
SHOP_DISABLED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* execute a transaction for the given number of units
|
||||||
|
*/
|
||||||
|
public Result execute(Player buyer, Shop shop, int units) {
|
||||||
|
if (!shop.isEnabled()) {
|
||||||
|
return Result.SHOP_DISABLED;
|
||||||
|
}
|
||||||
|
|
||||||
|
Trade trade = shop.getTrade();
|
||||||
|
int totalPrice = trade.priceQuantity() * units;
|
||||||
|
int totalProduct = trade.productQuantity() * units;
|
||||||
|
|
||||||
|
// get chest inventory
|
||||||
|
Inventory chestInventory = getShopInventory(shop);
|
||||||
|
if (chestInventory == null) {
|
||||||
|
return Result.DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify buyer has required items
|
||||||
|
if (!hasItems(buyer.getInventory(), trade.priceItem(), totalPrice)) {
|
||||||
|
return Result.INSUFFICIENT_FUNDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify chest has required stock
|
||||||
|
if (!hasItems(chestInventory, trade.productItem(), totalProduct)) {
|
||||||
|
return Result.INSUFFICIENT_STOCK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// take snapshots for rollback
|
||||||
|
ItemStack[] buyerSnapshot = buyer.getInventory().getContents().clone();
|
||||||
|
for (int i = 0; i < buyerSnapshot.length; i++) {
|
||||||
|
if (buyerSnapshot[i] != null) {
|
||||||
|
buyerSnapshot[i] = buyerSnapshot[i].clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemStack[] chestSnapshot = chestInventory.getContents().clone();
|
||||||
|
for (int i = 0; i < chestSnapshot.length; i++) {
|
||||||
|
if (chestSnapshot[i] != null) {
|
||||||
|
chestSnapshot[i] = chestSnapshot[i].clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// remove price items from buyer
|
||||||
|
removeItems(buyer.getInventory(), trade.priceItem(), totalPrice);
|
||||||
|
|
||||||
|
// remove product items from chest
|
||||||
|
removeItems(chestInventory, trade.productItem(), totalProduct);
|
||||||
|
|
||||||
|
// add product items to buyer
|
||||||
|
HashMap<Integer, ItemStack> overflow = buyer.getInventory().addItem(
|
||||||
|
createItemStacks(trade.productItem(), totalProduct));
|
||||||
|
|
||||||
|
if (!overflow.isEmpty()) {
|
||||||
|
// rollback
|
||||||
|
buyer.getInventory().setContents(buyerSnapshot);
|
||||||
|
chestInventory.setContents(chestSnapshot);
|
||||||
|
return Result.INVENTORY_FULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update owed amount in database
|
||||||
|
plugin.getShopRepository().updateOwedAmount(shop.getId(), totalPrice);
|
||||||
|
shop.setOwedAmount(shop.getOwedAmount() + totalPrice);
|
||||||
|
|
||||||
|
// record transaction
|
||||||
|
plugin.getTransactionRepository().recordTransaction(
|
||||||
|
shop.getId(),
|
||||||
|
buyer.getUniqueId(),
|
||||||
|
units);
|
||||||
|
|
||||||
|
// prune old transactions if configured
|
||||||
|
if (plugin.getConfigManager().isAutoPrune()) {
|
||||||
|
int maxHistory = plugin.getConfigManager().getMaxTransactionsPerShop();
|
||||||
|
plugin.getTransactionRepository().pruneTransactions(shop.getId(), maxHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.SUCCESS;
|
||||||
|
|
||||||
|
} catch (SQLException e) {
|
||||||
|
// rollback on database error
|
||||||
|
buyer.getInventory().setContents(buyerSnapshot);
|
||||||
|
chestInventory.setContents(chestSnapshot);
|
||||||
|
e.printStackTrace();
|
||||||
|
return Result.DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if inventory has required items
|
||||||
|
*/
|
||||||
|
private boolean hasItems(Inventory inventory, Material material, int amount) {
|
||||||
|
int count = 0;
|
||||||
|
for (ItemStack item : inventory.getContents()) {
|
||||||
|
if (item != null && item.getType() == material) {
|
||||||
|
count += item.getAmount();
|
||||||
|
if (count >= amount) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count >= amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* remove items from inventory
|
||||||
|
*/
|
||||||
|
private void removeItems(Inventory inventory, Material material, int amount) {
|
||||||
|
int remaining = amount;
|
||||||
|
|
||||||
|
for (int i = 0; i < inventory.getSize() && remaining > 0; i++) {
|
||||||
|
ItemStack item = inventory.getItem(i);
|
||||||
|
if (item != null && item.getType() == material) {
|
||||||
|
int toRemove = Math.min(item.getAmount(), remaining);
|
||||||
|
if (toRemove == item.getAmount()) {
|
||||||
|
inventory.setItem(i, null);
|
||||||
|
} else {
|
||||||
|
item.setAmount(item.getAmount() - toRemove);
|
||||||
|
}
|
||||||
|
remaining -= toRemove;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* create item stacks for given total amount
|
||||||
|
*/
|
||||||
|
private ItemStack[] createItemStacks(Material material, int totalAmount) {
|
||||||
|
int maxStack = material.getMaxStackSize();
|
||||||
|
int fullStacks = totalAmount / maxStack;
|
||||||
|
int remainder = totalAmount % maxStack;
|
||||||
|
|
||||||
|
int arraySize = fullStacks + (remainder > 0 ? 1 : 0);
|
||||||
|
ItemStack[] stacks = new ItemStack[arraySize];
|
||||||
|
|
||||||
|
for (int i = 0; i < fullStacks; i++) {
|
||||||
|
stacks[i] = new ItemStack(material, maxStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainder > 0) {
|
||||||
|
stacks[fullStacks] = new ItemStack(material, remainder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get the shop's container inventory
|
||||||
|
*/
|
||||||
|
private Inventory getShopInventory(Shop shop) {
|
||||||
|
Location signLoc = shop.getSignLocation();
|
||||||
|
Block signBlock = signLoc.getBlock();
|
||||||
|
|
||||||
|
if (!(signBlock.getBlockData() instanceof WallSign wallSign)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockFace attachedFace = wallSign.getFacing().getOppositeFace();
|
||||||
|
Block containerBlock = signBlock.getRelative(attachedFace);
|
||||||
|
|
||||||
|
if (containerBlock.getState() instanceof Chest chest) {
|
||||||
|
return chest.getInventory();
|
||||||
|
} else if (containerBlock.getState() instanceof Barrel barrel) {
|
||||||
|
return barrel.getInventory();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/main/resources/config.yml
Normal file
29
src/main/resources/config.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# hopper protection
|
||||||
|
hoppers:
|
||||||
|
allow-product-output: true
|
||||||
|
block-price-input: true
|
||||||
|
|
||||||
|
# transaction history
|
||||||
|
history:
|
||||||
|
max-transactions-per-shop: 100
|
||||||
|
auto-prune: true
|
||||||
|
|
||||||
|
# material aliases
|
||||||
|
aliases:
|
||||||
|
# common abbreviations
|
||||||
|
dia: diamond
|
||||||
|
dias: diamond
|
||||||
|
iron: iron_ingot
|
||||||
|
gold: gold_ingot
|
||||||
|
emerald: emerald
|
||||||
|
ems: emerald
|
||||||
|
|
||||||
|
# blocks
|
||||||
|
stone: stone
|
||||||
|
dirt: dirt
|
||||||
|
cobble: cobblestone
|
||||||
|
|
||||||
|
# tools
|
||||||
|
pick: diamond_pickaxe
|
||||||
|
sword: diamond_sword
|
||||||
|
axe: diamond_axe
|
||||||
34
src/main/resources/plugin.yml
Normal file
34
src/main/resources/plugin.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: oyeShops
|
||||||
|
version: 1.0.0
|
||||||
|
main: party.cybsec.oyeshops.OyeShopsPlugin
|
||||||
|
api-version: '1.21'
|
||||||
|
description: deterministic item-for-item chest barter
|
||||||
|
author: cybsec
|
||||||
|
website: https://party.cybsec
|
||||||
|
|
||||||
|
commands:
|
||||||
|
oyeshops:
|
||||||
|
description: oyeShops commands
|
||||||
|
usage: /oyeshops <on|off|toggle|reload|inspect|spoof|enable|disable|unregister>
|
||||||
|
aliases: [oyes, oshop]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
oyeshops.create:
|
||||||
|
description: allows placing valid shop signs (requires opt-in)
|
||||||
|
default: true
|
||||||
|
|
||||||
|
oyeshops.use:
|
||||||
|
description: allows buying from shops
|
||||||
|
default: true
|
||||||
|
|
||||||
|
oyeshops.inspect:
|
||||||
|
description: allows admin inspection via inspect mode
|
||||||
|
default: op
|
||||||
|
|
||||||
|
oyeshops.admin:
|
||||||
|
description: full control including spoof, reload, shop management
|
||||||
|
default: op
|
||||||
|
|
||||||
|
oyeshops.break.override:
|
||||||
|
description: allows breaking any shop sign or chest/barrel
|
||||||
|
default: op
|
||||||
Reference in New Issue
Block a user