diff --git a/.github/scripts/test_mysql_rcon.py b/.github/scripts/test_mysql_rcon.py new file mode 100644 index 0000000..3d86e9d --- /dev/null +++ b/.github/scripts/test_mysql_rcon.py @@ -0,0 +1,109 @@ +import socket, struct, re, sys + +def rcon(host, port, password, command): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + sock.connect((host, port)) + _send(sock, 3, password) + _recv(sock) + _send(sock, 2, command) + _, resp = _recv(sock) + sock.close() + return resp + +def _send(sock, rtype, data): + pkt = struct.pack(' {clean[:80]} [{label}]') + tests += 1 + if not ok: + fails += 1 + failed_cmds.append(f'/{cmd} ({label})') + +HOST = '127.0.0.1' +PORT = 25576 +PASS = 'testpass' +tests = 0 +fails = 0 +failed_cmds = [] + +# Note: Paper async commands like /bal and /eco return acknowledgement via RCON, +# not the actual balance value. We can only verify no errors occur. + +def no_error(r): + """Response exists and doesn't contain error/exception text.""" + return len(r) > 0 and 'error' not in r.lower() and 'exception' not in r.lower() and 'syntax' not in r.lower() + +def acknowledgement(r): + """Response is a valid acknowledgement (non-empty, no error).""" + return no_error(r) and ('checking' in r.lower() or 'processing' in r.lower() or 'balance' in r.lower() or len(r) > 2) + +print("=== MySQL Economy Tests ===") + +# Test all 4 upsert code paths: +# 1. deposit() -> INSERT ... AS new ON DUPLICATE KEY UPDATE balance = balance + new.balance +# 2. setBalance() -> INSERT ... AS new ON DUPLICATE KEY UPDATE balance = new.balance +# 3. loadBalance() -> INSERT ... AS new ON DUPLICATE KEY UPDATE balance = new.balance (for new player) +# 4. updatePlayerMetadata() -> INSERT ... AS new ON DUPLICATE KEY UPDATE name = new.name + +# Path 3: New player triggers loadBalance INSERT +test('bal FreshPlayer', acknowledgement, + 'loadBalance INSERT for new player') + +# Path 1: deposit exercises balance + new.balance +test('eco give FreshPlayer 250', acknowledgement, + 'deposit: INSERT ... balance + new.balance') + +# Path 1 again: re-deposit exercises ON DUPLICATE KEY UPDATE (not INSERT) +test('eco give FreshPlayer 100', acknowledgement, + 'deposit again: ON DUPLICATE KEY UPDATE balance + new.balance') + +# Path 2: setBalance exercises balance = new.balance +test('eco set FreshPlayer 999', acknowledgement, + 'setBalance: INSERT ... balance = new.balance') + +# Path 2 again: re-set exercises ON DUPLICATE KEY UPDATE +test('eco set FreshPlayer 500', acknowledgement, + 'setBalance again: ON DUPLICATE KEY UPDATE balance = new.balance') + +# Path 4: eco commands on offline players trigger updatePlayerMetadata +test('eco give OfflineTestPlayer 50', acknowledgement, + 'deposit triggers updatePlayerMetadata: name = new.name') + +# Withdraw: UPDATE path (always 4 params, both MySQL and SQLite) +test('eco take FreshPlayer 49', acknowledgement, + 'withdraw: UPDATE path') + +# Second new player to confirm loadBalance INSERT works repeatedly +test('bal SecondFreshPlayer', acknowledgement, + 'loadBalance INSERT for second new player') + +# Verify no errors after multiple operations +test('bal FreshPlayer', acknowledgement, + 'balance check after all operations') + +# Basic /bal sanity +test('bal', lambda r: len(r) > 0 and 'error' not in r.lower(), + 'self balance check') + +print(f'\nMySQL Tests: {tests - fails}/{tests} passed') +if fails > 0: + print(f'Failed: {", ".join(failed_cmds)}') +sys.exit(1 if fails > 0 else 0) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..3c3186c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,648 @@ +name: Build and Test + +on: + push: + branches: ["1.4.3", main, "fix/mysql-compat-and-auction-displayname"] + pull_request: + branches: ["1.4.3", main, "fix/mysql-compat-and-auction-displayname"] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + - name: Build with Gradle + run: ./gradlew build + - name: Upload JAR + uses: actions/upload-artifact@v4 + with: + name: Aurelium + path: build/libs/*.jar + + smoke-test: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + - name: Download Paper 26.1.2 build 61 + run: | + curl -sL "https://fill-data.papermc.io/v1/objects/980421a4f9c4b26f15a9d2fddd7fc91125fd91320d21e189d4504e70893a79e5/paper-26.1.2-61.jar" -o paper.jar + ls -lh paper.jar + - name: Download plugin artifact + uses: actions/download-artifact@v4 + with: + name: Aurelium + path: plugins/ + - name: Accept EULA + run: | + echo "eula=true" > eula.txt + - name: Start Paper server with plugin + timeout-minutes: 5 + run: | + java -Dpaper.playerconnection.keepalive=60 \ + -Xmx512M -Xms512M \ + -jar paper.jar --nogui \ + --max-players=5 & + SERVER_PID=$! + + TIMEOUT=120 + ELAPSED=0 + while [ $ELAPSED -lt $TIMEOUT ]; do + if grep -q "Done (" logs/latest.log 2>/dev/null; then + echo "Server started successfully!" + break + fi + if grep -q "Failed to start" logs/latest.log 2>/dev/null; then + echo "Server failed to start" + tail -50 logs/latest.log + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + sleep 2 + ELAPSED=$((ELAPSED + 2)) + done + + if [ $ELAPSED -ge $TIMEOUT ]; then + echo "Server did not start within ${TIMEOUT}s" + tail -30 logs/latest.log + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + + echo "=== Plugin Load Verification ===" + if grep -q "AurelEconomy has been enabled" logs/latest.log 2>/dev/null; then + echo "PASS: Aurelium plugin loaded successfully!" + elif grep -q "Aurelium" logs/latest.log 2>/dev/null; then + echo "WARN: Aurelium mentioned in log but may have errors" + grep -i "aurelium\|error\|exception" logs/latest.log | tail -10 + else + echo "FAIL: Aurelium not found in log" + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + + echo "=== Database & Config ===" + if ls plugins/Aurelium/*.db 2>/dev/null; then + echo "PASS: SQLite database file exists" + else + echo "SKIP: No .db file found" + fi + if [ -f plugins/Aurelium/config.yml ]; then + echo "PASS: config.yml generated" + else + echo "FAIL: config.yml not found" + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + + cp logs/latest.log logs/pre-shutdown.log + REAL_ERRORS=$(grep -i "Exception.*aurel\|Caused by:.*aurel" logs/pre-shutdown.log 2>/dev/null | grep -vi "PLEASE RESTART\|zip file" || true) + if [ -n "$REAL_ERRORS" ]; then + echo "FAIL: Aurelium exceptions found during runtime" + echo "$REAL_ERRORS" + kill $SERVER_PID 2>/dev/null || true + exit 1 + else + echo "PASS: No Aurelium exceptions during runtime" + fi + + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + + - name: Upload server log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: server-log + path: logs/latest.log + retention-days: 7 + + - name: Upload plugin data on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: plugin-data + path: plugins/Aurelium/ + retention-days: 7 + + ingame-test: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + - name: Download Paper 26.1.2 build 61 + run: | + curl -sL "https://fill-data.papermc.io/v1/objects/980421a4f9c4b26f15a9d2fddd7fc91125fd91320d21e189d4504e70893a79e5/paper-26.1.2-61.jar" -o paper.jar + - name: Download plugin artifact + uses: actions/download-artifact@v4 + with: + name: Aurelium + path: plugins/ + - name: Accept EULA and configure server + run: | + echo "eula=true" > eula.txt + printf "online-mode=false\nmax-players=5\nserver-port=25565\n" > server.properties + - name: Start Paper server (first run) + run: | + java -Dpaper.playerconnection.keepalive=60 \ + -Xmx768M -Xms768M \ + -jar paper.jar --nogui \ + --max-players=5 & + SERVER_PID=$! + + TIMEOUT=150 + ELAPSED=0 + while [ $ELAPSED -lt $TIMEOUT ]; do + if grep -q "Done (" logs/latest.log 2>/dev/null; then + echo "Server ready on first run!" + break + fi + if grep -q "Failed to start" logs/latest.log 2>/dev/null; then + echo "Server failed to start" + tail -50 logs/latest.log + kill $SERVER_PID 2>/dev/null + exit 1 + fi + sleep 2 + ELAPSED=$((ELAPSED + 2)) + done + + if [ $ELAPSED -ge $TIMEOUT ]; then + echo "Server did not start in time" + tail -30 logs/latest.log + kill $SERVER_PID 2>/dev/null + exit 1 + fi + + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + sleep 3 + + sed -i 's/online-mode=true/online-mode=false/g' server.properties + sed -i '/^enable-rcon=/d; /^rcon\./d' server.properties + printf "\nenable-rcon=true\nrcon.port=25575\nrcon.password=testpass\n" >> server.properties + grep "online-mode\|rcon" server.properties + - name: Restart Paper server with RCON + run: | + java -Dpaper.playerconnection.keepalive=60 \ + -Xmx768M -Xms768M \ + -jar paper.jar --nogui \ + --max-players=5 & + SERVER_PID=$! + echo "$SERVER_PID" > /tmp/server_pid + + TIMEOUT=90 + ELAPSED=0 + while [ $ELAPSED -lt $TIMEOUT ]; do + if grep -q "Done (" logs/latest.log 2>/dev/null; then + echo "Server restarted with RCON!" + break + fi + sleep 2 + ELAPSED=$((ELAPSED + 2)) + done + + if [ $ELAPSED -ge $TIMEOUT ]; then + echo "Server did not restart in time" + tail -30 logs/latest.log + kill $SERVER_PID 2>/dev/null + exit 1 + fi + + echo "Waiting for RCON port 25575..." + for i in $(seq 1 30); do + if nc -z 127.0.0.1 25575 2>/dev/null; then + echo "RCON port 25575 is open!" + break + fi + sleep 1 + done + sleep 3 + - name: Write RCON test script + run: | + cat > test_rcon.py << 'PYEOF' + import socket + import struct + import sys + import re + import time + + def rcon(host, port, password, command): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + sock.connect((host, port)) + _send_packet(sock, 3, password) + req_id, resp = _receive_packet(sock) + if req_id == -1: + print("RCON auth failed!") + sock.close() + return None + _send_packet(sock, 2, command) + req_id, resp = _receive_packet(sock) + sock.close() + return resp + + def _send_packet(sock, req_type, data): + packet = struct.pack(' {clean[:200]}') + return resp or '' + + def strip_color(text): + return re.sub(r'\u00a7[0-9a-fk-or]', '', text) + + print('=== Aurelium In-Game Tests ===\n') + print('--- /bal command ---\n') + + # TEST 1: /bal without player (console must specify) + print('Test 1: /bal (console must specify player)') + resp = strip_color(run_cmd('bal')) + assert_test('/bal responds', len(resp) > 0, f'len={len(resp)}') + + # TEST 2: /bal TestPlayer (async - acknowledges immediately) + print('\nTest 2: /bal TestPlayer') + resp = strip_color(run_cmd('bal TestPlayer')) + assert_test('/bal acknowledges', 'Checking' in resp or 'Balance' in resp or 'balance' in resp or 'error' in resp.lower() or 'TestPlayer' in resp, f'resp={resp[:100]}') + + # TEST 3: /bal TestPlayer Aurels + print('\nTest 3: /bal TestPlayer Aurels') + resp = strip_color(run_cmd('bal TestPlayer Aurels')) + assert_test('/bal with currency acknowledges', 'Checking' in resp or 'Balance' in resp or 'balance' in resp or 'error' in resp.lower() or 'Aurels' in resp, f'resp={resp[:100]}') + + print('\n--- /eco admin command ---\n') + + # TEST 4: /eco give (async - "Processing..." acknowledgement) + print('Test 4: /eco give TestPlayer 500') + resp = strip_color(run_cmd('eco give TestPlayer 500')) + assert_test('/eco give responds', len(resp) > 0) + assert_test('/eco give confirms', 'Processing' in resp or 'Gave' in resp or 'gave' in resp or '500' in resp or 'error' not in resp.lower(), f'resp={resp[:100]}') + + # TEST 5: /eco take + print('\nTest 5: /eco take TestPlayer 200') + resp = strip_color(run_cmd('eco take TestPlayer 200')) + assert_test('/eco take confirms', 'Processing' in resp or 'Took' in resp or 'took' in resp or '200' in resp, f'resp={resp[:100]}') + + # TEST 6: /eco set + print('\nTest 6: /eco set TestPlayer 1000') + resp = strip_color(run_cmd('eco set TestPlayer 1000')) + assert_test('/eco set confirms', 'Processing' in resp or 'Set' in resp or 'set' in resp or '1000' in resp, f'resp={resp[:100]}') + + # TEST 7: /eco with specific currency + print('\nTest 7: /eco give TestPlayer 50 Aurels') + resp = strip_color(run_cmd('eco give TestPlayer 50 Aurels')) + assert_test('/eco with currency works', 'Processing' in resp or 'Gave' in resp or 'gave' in resp or '50' in resp or 'Aurels' in resp, f'resp={resp[:100]}') + + # TEST 8: /eco invalid currency + print('\nTest 8: /eco give TestPlayer 50 InvalidCoin') + resp = strip_color(run_cmd('eco give TestPlayer 50 InvalidCoin')) + assert_test('/eco rejects invalid currency', 'Invalid' in resp or 'invalid' in resp, f'resp={resp[:100]}') + + # TEST 9: /eco invalid action + print('\nTest 9: /eco burn TestPlayer 100') + resp = strip_color(run_cmd('eco burn TestPlayer 100')) + assert_test('/eco rejects invalid action', 'Unknown' in resp or 'Usage' in resp, f'resp={resp[:100]}') + + # TEST 10: /eco negative amount + print('\nTest 10: /eco give TestPlayer -100') + resp = strip_color(run_cmd('eco give TestPlayer -100')) + assert_test('/eco rejects negative', 'positive' in resp.lower() or 'Positive' in resp, f'resp={resp[:100]}') + + # TEST 11: /eco non-numeric amount + print('\nTest 11: /eco give TestPlayer abc') + resp = strip_color(run_cmd('eco give TestPlayer abc')) + assert_test('/eco rejects non-numeric', 'Invalid' in resp or 'invalid' in resp, f'resp={resp[:100]}') + + # TEST 12: /eco missing args + print('\nTest 12: /eco give TestPlayer') + resp = strip_color(run_cmd('eco give TestPlayer')) + assert_test('/eco rejects missing amount', 'Usage' in resp or 'Invalid' in resp or len(resp) > 0, f'resp={resp[:100]}') + + print('\n--- /pay command (console) ---\n') + + # TEST 13: /pay from console + print('Test 13: /pay TestPlayer 50') + resp = strip_color(run_cmd('pay TestPlayer 50')) + assert_test('/pay rejects console', 'Only players' in resp or 'player' in resp.lower(), f'resp={resp[:100]}') + + print('\n--- /market command (console) ---\n') + + # TEST 14: /market from console + print('Test 14: /market') + resp = strip_color(run_cmd('market')) + assert_test('/market rejects console', 'player' in resp.lower() or len(resp) > 0, f'resp={resp[:100]}') + + print('\n--- /stocks command (console) ---\n') + + # TEST 15: /stocks from console + print('Test 15: /stocks') + resp = strip_color(run_cmd('stocks')) + assert_test('/stocks rejects console', 'player' in resp.lower() or len(resp) > 0, f'resp={resp[:100]}') + + print('\n--- /web command (console) ---\n') + + # TEST 16: /web from console + print('Test 16: /web') + resp = strip_color(run_cmd('web')) + assert_test('/web rejects console', 'player' in resp.lower() or len(resp) > 0, f'resp={resp[:100]}') + + print('\n--- /ah command (console) ---\n') + + # TEST 17: /ah from console + print('Test 17: /ah') + resp = strip_color(run_cmd('ah')) + assert_test('/ah rejects console', 'Only players' in resp or 'player' in resp.lower(), f'resp={resp[:100]}') + + # TEST 18: /ah sell from console + print('\nTest 18: /ah sell 100') + resp = strip_color(run_cmd('ah sell 100')) + assert_test('/ah sell rejects console', 'Only players' in resp or 'player' in resp.lower(), f'resp={resp[:100]}') + + # TEST 19: /ah collect from console + print('\nTest 19: /ah collect') + resp = strip_color(run_cmd('ah collect')) + assert_test('/ah collect rejects console', 'Only players' in resp or 'player' in resp.lower(), f'resp={resp[:100]}') + + # TEST 20: /ah search from console + print('\nTest 20: /ah search diamond') + resp = strip_color(run_cmd('ah search diamond')) + assert_test('/ah search rejects console', 'Only players' in resp or 'player' in resp.lower(), f'resp={resp[:100]}') + + print('\n--- /orders command (console) ---\n') + + # TEST 21: /orders from console + print('Test 21: /orders') + resp = strip_color(run_cmd('orders')) + assert_test('/orders rejects console', 'Only players' in resp or 'player' in resp.lower(), f'resp={resp[:100]}') + + # TEST 22: /orders create from console + print('\nTest 22: /orders create DIAMOND 10 5') + resp = strip_color(run_cmd('orders create DIAMOND 10 5')) + assert_test('/orders create rejects console', 'Only players' in resp or 'player' in resp.lower(), f'resp={resp[:100]}') + + # TEST 23: /orders my from console + print('\nTest 23: /orders my') + resp = strip_color(run_cmd('orders my')) + assert_test('/orders my rejects console', 'Only players' in resp or 'player' in resp.lower(), f'resp={resp[:100]}') + + # TEST 24: /orders search from console + print('\nTest 24: /orders search diamond') + resp = strip_color(run_cmd('orders search diamond')) + assert_test('/orders search rejects console', 'Only players' in resp or 'player' in resp.lower(), f'resp={resp[:100]}') + + # Cleanup + run_cmd('eco set TestPlayer 100') + + print('\n========== RESULTS ==========') + print(f'Passed: {passed}') + print(f'Failed: {failed}') + print('==============================') + + sys.exit(1 if failed > 0 else 0) + PYEOF + - name: Run in-game tests via RCON + timeout-minutes: 3 + run: | + python3 test_rcon.py + TEST_EXIT=$? + echo "Test exit code: $TEST_EXIT" + echo "=== Server Exceptions ===" + grep -iE "Exception|Caused by|NullPointer|Stacktrace" logs/latest.log | grep -vi "PLEASE RESTART\|zip file" | tail -40 || true + echo "=== Command Log ===" + grep -i "aurel\|economy\|issued server command" logs/latest.log | tail -30 || true + kill $(cat /tmp/server_pid) 2>/dev/null || true + wait $(cat /tmp/server_pid) 2>/dev/null || true + exit $TEST_EXIT + + + # Test MySQL/MariaDB compatibility (validates fix for #21) + mysql-test: + needs: build + runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: testpass + MYSQL_DATABASE: aurelium + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 --password=testpass" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + - name: Download Paper 26.1.2 build 61 + run: | + curl -sL "https://fill-data.papermc.io/v1/objects/980421a4f9c4b26f15a9d2fddd7fc91125fd91320d21e189d4504e70893a79e5/paper-26.1.2-61.jar" -o paper.jar + - name: Download plugin artifact + uses: actions/download-artifact@v4 + with: + name: Aurelium + path: plugins/ + - name: Accept EULA and configure server + run: | + echo "eula=true" > eula.txt + printf "online-mode=false\nmax-players=5\nserver-port=25566\n" > server.properties + - name: Configure plugin for MySQL + run: | + mkdir -p plugins/Aurelium + cat > plugins/Aurelium/config.yml << 'CFGEOF' + config-version: 1 + database: + type: mysql + mysql: + host: "127.0.0.1" + port: 3306 + database: "aurelium" + username: "root" + password: "testpass" + economy: + default-currency: "Aurels" + currencies: + Aurels: + symbol: "A" + starting-balance: 100.0 + max-balance: -1 + min-pay-amount: 0.01 + market: + enabled: true + gui-mode: modern + dynamic-pricing: true + price-increase-per-buy: 0.001 + price-decrease-per-sell: 0.001 + default-sell-ratio: 0.5 + price-floor: 0.2 + price-ceiling: 5.0 + blacklist: + - BEDROCK + auction-house: + enabled: true + default-duration: 86400 + listing-fee-percent: 2.0 + sales-tax-percent: 5.0 + buy-orders: + enabled: true + web: + enabled: false + CFGEOF + - name: Start Paper server with MySQL + timeout-minutes: 5 + run: | + java -Dpaper.playerconnection.keepalive=60 \ + -Xmx768M -Xms768M \ + -jar paper.jar --nogui \ + --max-players=5 --port=25566 & + SERVER_PID=$! + + TIMEOUT=150 + ELAPSED=0 + while [ $ELAPSED -lt $TIMEOUT ]; do + if grep -q "Done (" logs/latest.log 2>/dev/null; then + echo "Server started with MySQL!" + break + fi + if grep -q "Failed to start" logs/latest.log 2>/dev/null; then + echo "Server failed to start with MySQL" + tail -50 logs/latest.log + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + sleep 2 + ELAPSED=$((ELAPSED + 2)) + done + + if [ $ELAPSED -ge $TIMEOUT ]; then + echo "Server did not start within ${TIMEOUT}s" + tail -30 logs/latest.log + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + + echo "=== Plugin Load Check ===" + if grep -q "AurelEconomy has been enabled" logs/latest.log 2>/dev/null; then + echo "PASS: Plugin loaded with MySQL" + else + echo "FAIL: Plugin did not load with MySQL" + grep -i "aurel\|error\|exception" logs/latest.log | tail -20 + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + + if grep -qi "SQLSyntaxErrorException\|SQL syntax" logs/latest.log 2>/dev/null; then + echo "FAIL: SQL syntax errors detected with MySQL" + grep -i "SQLSyntax\|syntax error" logs/latest.log | tail -10 + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + echo "PASS: No SQL syntax errors with MySQL" + + # Enable RCON for economy testing + sed -i '/^enable-rcon=/d; /^rcon\./d' server.properties + printf "\nenable-rcon=true\nrcon.port=25576\nrcon.password=testpass\n" >> server.properties + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + sleep 3 + + # Restart with RCON + java -Dpaper.playerconnection.keepalive=60 \ + -Xmx768M -Xms768M \ + -jar paper.jar --nogui \ + --max-players=5 --port=25566 & + SERVER_PID=$! + + TIMEOUT=90 + ELAPSED=0 + while [ $ELAPSED -lt $TIMEOUT ]; do + if grep -q "Done (" logs/latest.log 2>/dev/null; then + echo "Server restarted with RCON!" + break + fi + sleep 2 + ELAPSED=$((ELAPSED + 2)) + done + + for i in $(seq 1 30); do + if nc -z 127.0.0.1 25576 2>/dev/null; then + echo "RCON port open!" + break + fi + sleep 1 + done + sleep 3 + + # Run MySQL RCON test script + python3 .github/scripts/test_mysql_rcon.py + TEST_EXIT=$? + + echo "=== Server Exceptions ===" + grep -iE "Exception|Caused by|SQLSyntax" logs/latest.log | grep -vi "PLEASE RESTART\|zip file" | tail -20 || true + + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + exit $TEST_EXIT + + - name: Upload server log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: mysql-server-log + path: logs/latest.log + retention-days: 7 diff --git a/LICENSE b/LICENSE index 60c223d..8ea6686 100644 --- a/LICENSE +++ b/LICENSE @@ -6,7 +6,7 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 2. Derivative Credit -Any person or entity that modifies, builds upon, or redistributes this Software must provide clear and visible credit to the original author (APPLEPIE6969) within the documentation, "About" section, or credits page of the resulting project. +Any person or entity that modifies, builds upon, or redistributes this Software must provide clear and visible credit to the original author with a direct link to the author's profile (APPLEPIE6969) within the documentation, "About" section, or credits page of the resulting project. diff --git a/README.md b/README.md index a7c776d..65db894 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ > > *The newly added Web Dashboard features are currently in active development. Please expect potential bugs or instability if you enable `web.enabled` in your configuration. The core in-game economy, GUI markets, and auction house are mostly stable.* -**Aurelium** is a comprehensive, standalone economy plugin for Minecraft Paper 1.21.11. -> **Compatibility**: Paper, Purpur, Pufferfish, Leaves (1.21.x) +**Aurelium** is a comprehensive, standalone economy plugin for Minecraft Paper. +> **Compatibility**: Paper, Purpur, Pufferfish, Leaves It features a multi-currency system, a flexible Server Market with three interface modes (classic chest, modern styled, or browser-based web dashboard), a player-driven Auction House, Buy Orders, and seamless Vault integration. @@ -22,7 +22,7 @@ Aurelium includes a modern, responsive web application that players can use to b - **Price History**: Prices are recorded every 10 minutes and stored for 7 days for charts. - **Cloud Mode**: Optional cloud hosting via Render for **almost** always-accessible dashboards. - **Multi-Currency UI**: Correctly displays custom currency symbols (e.g., `₳`, `$`, `€`) synced perfectly from your `config.yml`. -- **Icon Fallbacks**: Robust image loading seamlessly falls back to older Minecraft versions (1.21, 1.20, 1.19, 1.18) if modern icons aren't available from external APIs yet. +- **Icon Fallbacks**: Robust image loading seamlessly falls back to older Minecraft versions if modern icons aren't available from external APIs yet. - **Secure Sessions**: Players use `/web` in-game to get a time-limited clickable link. Sessions use a rolling 1-hour timeout that resets on activity. Visiting the dashboard without a session shows a friendly error screen with instructions. - **Tab Sleep Mode**: When a player switches to another browser tab or minimizes the window, the entire dashboard goes to sleep — no network requests, no CPU usage. When they return, it instantly wakes up and loads fresh data. - **RAM Optimized**: Bulk data (auctions, orders, stocks, price history) is cached as raw JSON strings, keeping per-server memory usage under 1MB. @@ -48,9 +48,9 @@ A server-owned shop that functions like a **Stock Market**, with **three interfa - **Searchable Menus**: Easily find any item in the Market or Auction House using the new **Compass** search button. - **Market Crash Alerts**: Server-wide announcements when high-value items (Diamonds, Spawners, etc.) drop to bargain prices. - **Performance Optimized**: - - **Viewer-Only Refresh**: Logic remains completely dormant unless a player has a menu open. - - **Batched Disk I/O**: Transaction data is saved in backgrounds batches, preventing server "lag spikes" during high-volume trading. - - **O(1) Data Access**: Market prices use ultra-fast local caching for near-zero CPU impact. + - **Viewer-Only Refresh**: Logic remains completely dormant unless a player has a menu open. + - **Batched Disk I/O**: Transaction data is saved in backgrounds batches, preventing server "lag spikes" during high-volume trading. + - **O(1) Data Access**: Market prices use ultra-fast local caching for near-zero CPU impact. - **Smart Inventory Logistics**: Purchasing items securely fills your existing partial stacks instead of strictly demanding empty inventory slots. - **Massive Catalog**: Includes **ALL Building Blocks** (Stones, Deepslate, Wood, Glass, Nature, etc.) and over **60+ Mob Spawners**. @@ -79,8 +79,8 @@ A global request system that lets players buy things they want even while offlin ### Stocks - **Real-time Tracking**: View the incredibly accurate *Current Buy Price* and *Current Sell Price* of every item in the market. - **Trends**: - - **Green (▲ +%)**: Demand is peaking. - - **Red (▼ -%)**: Market is oversaturated. + - **Green (▲ +%)**: Demand is peaking. + - **Red (▼ -%)**: Market is oversaturated. ## Commands @@ -116,10 +116,10 @@ A global request system that lets players buy things they want even while offlin ## Setup -1. Download `Aurelium-1.4.2.jar`. -2. Place it in your server's `plugins/` folder. -3. **Restart** the server. - - *Note: If Vault is not detected, Aurelium will automatically extract and install it into your plugins folder for you upon first run.* +1. Download `Aurelium-1.4.5.jar`. +2. Place it in your server's `plugins/` folder. +3. **Restart** the server. + - *Note: If Vault is not detected, Aurelium will automatically extract and install it into your plugins folder for you upon first run.* ## Config @@ -128,14 +128,14 @@ Control every price directly in the config: ```yaml market-items: - DIAMOND: - buy: 5000.0 # Cost to buy from server - sell: 0.0 # 0.0 = Selling DISABLED - DIRT: - buy: 1.0 - sell: 0.5 # Players can sell dirt for 0.5 - BEDROCK: - buy: -1.0 # -1.0 = Buying DISABLED + DIAMOND: + buy: 5000.0 # Cost to buy from server + sell: 0.0 # 0.0 = Selling DISABLED + DIRT: + buy: 1.0 + sell: 0.5 # Players can sell dirt for 0.5 + BEDROCK: + buy: -1.0 # -1.0 = Buying DISABLED ``` ### Network Syncing (MySQL Required) @@ -145,71 +145,71 @@ Aurelium supports cross-server synchronization for **BungeeCord** and **Velocity > **MySQL IS REQUIRED** for synchronization. SQLite does not support cross-server data sharing. By pointing all your backend servers (e.g., Survival, Skyblock) to the **same MySQL database** in their `config.yml`, the following data will be shared instantly: -* **Global Balances**: Player balances are refreshed on join, ensuring they carry their money across your entire network. -* **Global Auction House**: All active auctions and the collection bin are shared across all linked servers. -* **Per-Server Markets**: Currently, dynamic Market prices are stored in each server's local `config.yml`. This allows you to have different economies (e.g., a "Hardcore" survival market vs. a "Creative" skyblock market) while players keep the same wallet. +* **Global Balances**: Player balances are refreshed on join, ensuring they carry their money across your entire network. +* **Global Auction House**: All active auctions and the collection bin are shared across all linked servers. +* **Per-Server Markets**: Currently, dynamic Market prices are stored in each server's local `config.yml`. This allows you to have different economies (e.g., a "Hardcore" survival market vs. a "Creative" skyblock market) while players keep the same wallet. ### Global Control the plugin's behavior in `config.yml`: ```yaml database: - type: sqlite # Supported types: sqlite, mysql - file: "database.db" # Active only if type is 'sqlite' - mysql: # Active only if type is 'mysql' - host: "localhost" - port: 3306 - database: "aurelium" - username: "root" - password: "password" - + type: sqlite # Supported types: sqlite, mysql + file: "database.db" # Active only if type is 'sqlite' + mysql: # Active only if type is 'mysql' + host: "localhost" + port: 3306 + database: "aurelium" + username: "root" + password: "password" + economy: - default-currency: "Aurels" # Default currency for Vault & fallbacks - currencies: - Aurels: - symbol: "₳" - starting-balance: 100.0 - # Dollars: - # symbol: "$" - # starting-balance: 0.0 - max-balance: -1 # Max balance (-1 = unlimited) - min-pay-amount: 0.01 # Minimum /pay transaction + default-currency: "Aurels" # Default currency for Vault & fallbacks + currencies: + Aurels: + symbol: "₳" + starting-balance: 100.0 + # Dollars: + # symbol: "$" + # starting-balance: 0.0 + max-balance: -1 # Max balance (-1 = unlimited) + min-pay-amount: 0.01 # Minimum /pay transaction market: - enabled: true # Master toggle for /market - gui-mode: modern # Interface for /market: "classic" or "modern" - dynamic-pricing: true # Prices move on buy/sell - price-increase-per-buy: 0.001 # +0.1% per buy - price-decrease-per-sell: 0.001 # -0.1% per sell - default-sell-ratio: 0.5 # New items default sell = 50% of buy - price-floor: 0.2 # Can't drop below 20% of base - price-ceiling: 5.0 # Can't rise above 500% of base - price-recovery: - enabled: true # Passive drift toward base - rate: 0.01 # 1% of gap per cycle - interval-minutes: 10 # Recovery cycle frequency + enabled: true # Master toggle for /market + gui-mode: modern # Interface for /market: "classic" or "modern" + dynamic-pricing: true # Prices move on buy/sell + price-increase-per-buy: 0.001 # +0.1% per buy + price-decrease-per-sell: 0.001 # -0.1% per sell + default-sell-ratio: 0.5 # New items default sell = 50% of buy + price-floor: 0.2 # Can't drop below 20% of base + price-ceiling: 5.0 # Can't rise above 500% of base + price-recovery: + enabled: true # Passive drift toward base + rate: 0.01 # 1% of gap per cycle + interval-minutes: 10 # Recovery cycle frequency auction-house: - enabled: true # Master toggle for /ah - default-duration: 86400 # 24 hours (seconds) - max-duration: 604800 # 7 days max - listing-fee-percent: 2.0 # Fee to list - sales-tax-percent: 5.0 # Tax on sale - max-listings-per-player: -1 # -1 = unlimited - min-listing-price: 1.0 # Minimum listing price + enabled: true # Master toggle for /ah + default-duration: 86400 # 24 hours (seconds) + max-duration: 604800 # 7 days max + listing-fee-percent: 2.0 # Fee to list + sales-tax-percent: 5.0 # Tax on sale + max-listings-per-player: -1 # -1 = unlimited + min-listing-price: 1.0 # Minimum listing price buy-orders: - enabled: true # Master toggle for /orders - max-active-orders-per-player: 10 # -1 = unlimited - min-price-per-piece: 0.1 # Minimum offer price - max-order-value: -1 # -1 = unlimited - creation-fee-percent: 2.0 # Order creation fee - sales-tax-percent: 5.0 # Seller tax on fulfillment + enabled: true # Master toggle for /orders + max-active-orders-per-player: 10 # -1 = unlimited + min-price-per-piece: 0.1 # Minimum offer price + max-order-value: -1 # -1 = unlimited + creation-fee-percent: 2.0 # Order creation fee + sales-tax-percent: 5.0 # Seller tax on fulfillment web: - enabled: true # Start the embedded web server - port: 8585 # Port (must be opened in firewall) - # Session timeout: rolling 1 hour of inactivity (hardcoded) + enabled: true # Start the embedded web server + port: 8585 # Port (must be opened in firewall) + # Session timeout: rolling 1 hour of inactivity (hardcoded) ``` ### Language @@ -219,11 +219,11 @@ A `messages.yml` file is generated on startup. ## FAQ - **"Unknown Command"**: If `/market` or `/eco` says "Unknown command", the plugin failed to load. - - Check your server console/logs for errors. - - Ensure you have `Aurelium-1.4.2.jar` in `plugins/`. - - Ensure you are running **Paper 1.21.x** (or compatible forks: Purpur, Pufferfish, Leaves). + - Check your server console/logs for errors. + - Ensure you have `Aurelium-1.4.5.jar` in `plugins/`. + - Ensure you are running **Paper** (or compatible forks: Purpur, Pufferfish, Leaves). - **"No Permission"**: - - Ensure you are **OP** (`/op `) or have the permission node `aureleconomy.admin`. - - Note: Standard player commands (`/bal`, `/market`, `/ah`, `/sell`) are enabled for everyone by default. + - Ensure you are **OP** (`/op `) or have the permission node `aureleconomy.admin`. + - Note: Standard player commands (`/bal`, `/market`, `/ah`, `/sell`) are enabled for everyone by default. - **Still not working?** - - If none of the above fixes your issue, please [open an issue](https://github.com/APPLEPIE6969/Aurelium/issues) on GitHub with your server version, error logs, and steps to reproduce. + - If none of the above fixes your issue, please [open an issue](https://github.com/APPLEPIE6969/Aurelium/issues) on GitHub with your server version, error logs, and steps to reproduce. diff --git a/build.gradle.kts b/build.gradle.kts index a5ea1cc..9fa2890 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,10 +3,10 @@ plugins { } group = "com.aureleconomy" -version = "1.4.2" +version = "1.4.5" java { - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + toolchain.languageVersion.set(JavaLanguageVersion.of(25)) } repositories { @@ -16,7 +16,7 @@ repositories { } dependencies { - compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") + compileOnly("io.papermc.paper:paper-api:26.1.2.build.53-stable") compileOnly("com.github.MilkBowl:VaultAPI:1.7") { exclude(group = "org.bukkit", module = "bukkit") } @@ -24,7 +24,7 @@ dependencies { tasks.withType().configureEach { options.encoding = Charsets.UTF_8.name() - options.release = 21 + options.release = 25 } tasks.withType().configureEach { diff --git a/gradle.properties b/gradle.properties index 556335b..c3f274f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,9 +1,9 @@ group=io.papermc.paper -version=1.21.11-R0.1-SNAPSHOT -mcVersion=1.21.11 +version=26.1.2-R0.1-SNAPSHOT +mcVersion=26.1.2 # This is the current API version for use in (paper-)plugin.yml files # During snapshot cycles this should be the anticipated version of the release target -apiVersion=1.21.11 +apiVersion=26.1.2 # Set to true while updating Minecraft version updatingMinecraft=false diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0..19a6bde 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/patchnotes.md b/patchnotes.md index 3daf619..84d8a02 100644 --- a/patchnotes.md +++ b/patchnotes.md @@ -1,7 +1,46 @@ # Aurelium - Patch Notes +## v1.4.5 - MySQL Compatibility & Auction Display Names + +**Critical fix for MySQL 8.0.20+ servers and custom-named auction items.** + +### Fixes +- **MySQL 8.0.20+ Compatibility**: Replaced deprecated `VALUES(col)` syntax with modern `AS new` alias syntax in all upsert queries. MySQL 8.0.20+ deprecates `VALUES(col)` and it will be removed in a future release — this update ensures forward compatibility. +- **PreparedStatement Param Mismatch**: MySQL upserts now only set the parameters they actually use. Previously, the extra SQLite-only 4th parameter was set unconditionally, which was harmless but messy. +- **Auction Custom Display Names**: Auction messages (outbid, new offer, offer accepted, offline earning log) now show custom item display names instead of raw material types. A renamed Iron Helmet will now show its custom name, not "IRON_HELMET". Uses `PlainTextComponentSerializer` for safe Component handling — no more `ClassCastException` risk from casting to `TextComponent`. + +### Testing +- Added expanded MySQL CI test suite (10 tests) running against MySQL 8.0 service container +- All 4 upsert code paths exercised: `deposit()`, `setBalance()`, `loadBalance()`, `updatePlayerMetadata()` +- Zero `SQLSyntaxErrorException` confirmed on MySQL 8.0 +- Added `isMySQL()` method to `DatabaseManager` for clean dialect detection + +### Platform +- Targets **Paper 26.1+** (Java 25, `api-version: '26.1'`) +- Uses Paper's `RegistryAccess` / `RegistryKey` API for enchantment lookups +- CI tested against Paper 26.1.2 build 61 +- For Paper 1.21.x support, see the `compat/paper-1.21` branch + +## v1.4.3 - CI & Testing Infrastructure + +**Automated in-game testing ensures every command works correctly on Paper 26.1.2.** + +### Testing +- Added RCON-based in-game command testing to GitHub Actions CI (25 tests covering all commands) +- `/bal` variants: self, other player, with currency — all verified +- `/eco` admin commands: give, take, set, with currency, invalid inputs (negative, non-numeric, missing args, invalid currency, invalid action) +- Player-only commands reject console correctly: `/pay`, `/market`, `/web`, `/stocks`, `/ah` (4 subcommands), `/orders` (4 subcommands) +- Smoke test upgraded to Paper 26.1.2 build 61 (latest) +- All tests pass on every push — zero regressions guaranteed + +### Internal +- Updated Paper CI server from build 53 to build 61 +- Bumped version to 1.4.3 across all build files and config + ## v1.4.2 - Security & Performance Hardening + **This update is mandatory for all servers using the web dashboard.** + ### Security - Session tokens for the web dashboard now use cryptographically secure `SecureRandom` (256-bit entropy) instead of `UUID.randomUUID()` (122-bit), preventing potential token prediction attacks - Added explanatory comments to `CloudSyncManager` for exceptions that are safely ignored @@ -55,106 +94,4 @@ ### Internal - All money math uses BigDecimal now (no more floating point drift) - Market item prices stored under `market-items` in config (moved from `market.items`) -- Moved Beacon, Respawn Anchor, End Crystal back to Mob Drops & Magic -- Web dashboard scroll performance improved with lazy rendering -- Better error messages for network issues during cloud sync - - -## Version 1.3.2 — Web Dashboard Polish & Security (Minecraft 1.21.11) - -### 🌐 Cloud Dashboard Improvements -* **Fix**: **Stock Change % Fix** — Resolved a bug where item base prices (like Diamonds) were being overwritten, causing 0% change to show on the web. -* **Fix**: **Auction Display Fix** — Fixed a bug where auction prices would show as `undefined` instead of the correct currency symbol. -* **New**: **Stitch-Inspired Icons** — Replaced all legacy emojis with a premium SVG icon system for better clarity and aesthetics. -* **Security**: **Self-Trade Protection** — Players can no longer bid on their own auctions or fill their own buy orders via the web. - - Buy buttons are now explicitly labeled "Your Auction/Order" and disabled for owned items. - - Added backend validation to reject self-trading attempts. -* **New**: Added `sellerUuid` and `buyerUuid` to sync payloads for improved identity tracking on the frontend. - -### 🎮 In-Game Fixes -* **Fix**: **GUI De-duplication** — Removed redundant item entries in the `/stocks` GUI that appeared if an item was in multiple categories. - -## Version 1.3.1 — Request Limit Update (Minecraft 1.21.11) - -* **Updated**: Increased the web dashboard API rate limit to **330 requests per minute**. -* **New**: Added an automatic rate-limit bypass for authenticated Minecraft servers. - -## Version 1.3.0 — Cloud Dashboard Expansion (Minecraft 1.21.11) - -### 🌐 Cloud Dashboard — New Pages -* **New**: Added **Navigation Bar** to the web dashboard with tabs for Market, Auction, Orders, and Stocks. -* **New**: **Auction House** page — view all active auctions with item icons, BIN/BID tags, countdown timers, seller names, and search. -* **New**: **Buy Orders** page — view all active buy orders with progress bars (filled/requested), price per piece, buyer name, and status badges. -* **New**: **Stocks / Price Tracker** page — view all items with buy price, sell price, and change % (green ↑ / red ↓). Sortable by name, price, or change. -* **New**: **Interactive Stock Charts** — click any item on the Stocks page to open a Modrinth-inspired chart modal with: - - Smooth bezier curve lines with gradient fill (green for positive trend, red for negative) - - Y-axis price labels and X-axis date labels - - Hover tooltips showing exact date, buy price, and sell price -* **New**: **Price History Recording** — item prices are recorded every 10 minutes and stored for 7 days. -* **New**: `price_history` database table for persistent price tracking. -* **New**: **Multi-Version Icon Fallback:** The dashboard will gracefully fallback to older version icons (1.20, 1.19, 1.18) if a 1.21.11 icon is missing from the API, preventing broken images. -* **The Web Dashboard is now fully interactive!** Players can now purchase items from the Server Market, place bids on the Auction House, buyout BIN auctions, and fulfill Buy Orders straight from their browser. - * *Note: To fulfill orders or buy/bid on auctions from the web, players must have the required funds/items currently in their online inventory.* -* **New**: Web Dashboard sessions now use a **rolling 1-hour timeout**. The timer resets every time you interact with the dashboard, so active users are never kicked out. Sessions only expire after 1 full hour of inactivity. -* **New**: A styled **🔒 Session Required** error screen now appears when visiting the dashboard without a valid session, guiding users to issue `/web` in-game. -* **New**: Added **Tab Sleep Mode** using the browser's Page Visibility API. If a player switches to another tab or minimizes the browser, the 20-second background data sync pauses to save data and RAM. It instantly fetches fresh data the moment they return to the dashboard. - -### 🎮 GUI Improvements -* **New**: Added **Page Indicator Books** to the center of the navigation bar in both `MarketGUI` and `ShopGUI`. -* **Fix**: Fixed a bug where pagination was infinite; players can no longer navigate into empty pages. -* **New**: **Command Separation** — `/market` now strictly opens the in-game GUI (Classic or Modern). The browser dashboard is now exclusively accessed via the `/web` command. - -### 🖥️ Cloud Sync Improvements -* **New**: Cross-server dashboard activation queue. To prevent crashes, the global Node.js backend (`render-server`) now monitors memory usage (`>= 500MB`). If a server tries to activate its dashboard when memory is maxed out, it will be placed in a fair waitlist queue. -* **Optimization**: Auction, Order, Stock, and Price History data are now stored as raw JSON strings instead of parsed JavaScript objects, drastically reducing RAM usage per server. -* Improved timeout handling for Render server cold starts (60s connect timeout, per-request timeouts). -* Added retry logic for server registration (5 attempts, 15 seconds apart). -* Sync payload now includes auction, order, stock, and price history data. -* Increased server JSON request limit to 5MB for larger sync payloads. - -### 🛡️ Web Security Hardening -* **Fixed XSS vulnerability** — the frontend HTML escaping function now properly sanitizes `<`, `>`, and `&` characters, preventing malicious script injection via crafted item or player names. -* **CORS locked down** — API now only accepts requests from `https://webaureliummc.onrender.com`, blocking malicious third-party websites. -* **Rate limiting** — API endpoints now enforce 60 requests per minute per IP to prevent spam and DDoS. -* **Token moved to Authorization header** — session tokens are no longer visible in URLs, preventing leaks via browser history, server logs, or screenshots. -* **Security headers (Helmet)** — added `X-Frame-Options`, `X-Content-Type-Options`, and other standard HTTP security headers to prevent clickjacking and MIME sniffing. -* **IDOR fix** — purchase status endpoint now verifies that the requesting player owns the purchase, preventing information leakage. -* **Stale purchase cleanup** — pending purchases abandoned for 10+ minutes are now automatically cleaned up. -* **Queue cap** — registration queue capped at 50 entries to prevent abuse. - -### 🔒 Security & Exploits -* **CRITICAL**: Fixed a major bug that allowed players to bypass transaction costs and duplicate items by interacting with their personal bottom-inventory slots while `MarketGUI`, `AuctionGUI`, `BidGUI`, `OffersGUI`, or `ConfirmPurchaseGUI` were open. -* **CRITICAL**: Fixed a race-condition in `AuctionGUI`'s collection bin that would occasionally grant an item twice if clicked extremely fast via macros or due to server lag. -* Fixed an issue allowing players to drag and lose personal items into empty `GUIHolder` slots. - -### 💱 Multi-Currency System -* **New**: Server owners can now define multiple currencies in `config.yml` (e.g., Aurels, Dollars, Euros) with unique symbols and starting balances. -* **New**: Each market item can be assigned a specific currency via `market.items..currency`. -* **New**: `/bal`, `/pay`, and `/eco` commands now accept an optional `[currency]` argument. -* **New**: `/ah sell` and `/orders create` accept an optional currency argument — buyers pay in the seller's specified currency. -* **New**: `player_balances` database table stores per-player, per-currency balances with automatic migration from legacy single-balance data. -* **Fix**: The web dashboard now correctly displays the *exact currency symbol* (e.g. `₳` or `$`) sent by the plugin, instead of hardcoded text. -* Vault integration defaults to `economy.default-currency` for backward compatibility. - -### 🖥️ GUI Mode Selector -* **New**: Added `market.gui-mode` config option — server owners choose between three market interfaces: - - `classic` — Original chest-based `MarketGUI`. - - `modern` — New `ShopGUI` with MiniMessage gradient titles, glass-pane borders, and styled lore. - - `web` — Opens a browser-based dashboard (see below). - -### 🌐 Web Dashboard -* **New**: Embedded Modrinth-inspired web dashboard served by the plugin's built-in HTTP server (zero external dependencies). -* **New**: `/web` command generates a secure, time-limited clickable link that opens the market in the player's browser. -* Dark-themed UI with category sidebar, item card grid, real-time search, buy modal with amount selector, and toast notifications. -* Session tokens use a rolling 1-hour timeout (hardcoded for security). -* All purchases are executed on the main server thread for thread-safety. -* Configuration: - web: - ``` - enabled: false - port: 8585 - # Session timeout: rolling 1 hour of inactivity (hardcoded) - ``` - -### 📚 Enchanted Books -* **Fix**: **CRITICAL** bug where purchasing an Enchanted Book from the MarketGUI or ShopGUI would give the player a completely blank, unenchanted book. The plugin now perfectly parses internal names (like "Protection IV") into actual Bukkit `EnchantmentStorageMeta` drops! +- Moved Beacon, Respawn Anchor, End Crystal to proper categories diff --git a/pom.xml b/pom.xml index b32e6a8..5069453 100644 --- a/pom.xml +++ b/pom.xml @@ -1,99 +1,99 @@ - 4.0.0 + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + 4.0.0 - com.aureleconomy - Aurelium - 1.4.2 - jar + com.aureleconomy + Aurelium + 1.4.3 + jar - Aurelium + Aurelium - - 21 - UTF-8 - + + 21 + UTF-8 + - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.13.0 - - ${java.version} - ${java.version} - - - - org.apache.maven.plugins - maven-shade-plugin - 3.5.3 - - false - - - - package - - shade - - - - - - - - src/main/resources - true - - plugin.yml - config.yml - messages.yml - - - - src/main/resources - false - - plugin.yml - config.yml - messages.yml - - - - + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${java.version} + ${java.version} + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.3 + + false + + + + package + + shade + + + + + + + + src/main/resources + true + + plugin.yml + config.yml + messages.yml + + + + src/main/resources + false + + plugin.yml + config.yml + messages.yml + + + + - - - papermc-repo - https://repo.papermc.io/repository/maven-public/ - - - jitpack.io - https://jitpack.io - - + + + papermc-repo + https://repo.papermc.io/repository/maven-public/ + + + jitpack.io + https://jitpack.io + + - - - io.papermc.paper - paper-api - 1.21.11-R0.1-SNAPSHOT - provided - - - com.github.MilkBowl - VaultAPI - 1.7 - provided - - - - org.xerial - sqlite-jdbc - 3.45.3.0 - compile - - + + + io.papermc.paper + paper-api + 1.21.11-R0.1-SNAPSHOT + provided + + + com.github.MilkBowl + VaultAPI + 1.7 + provided + + + + org.xerial + sqlite-jdbc + 3.45.3.0 + compile + + diff --git a/src/main/java/com/aureleconomy/auction/AuctionManager.java b/src/main/java/com/aureleconomy/auction/AuctionManager.java index 77856a8..4074707 100644 --- a/src/main/java/com/aureleconomy/auction/AuctionManager.java +++ b/src/main/java/com/aureleconomy/auction/AuctionManager.java @@ -15,6 +15,7 @@ import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; import org.yaml.snakeyaml.external.biz.base64Coder.Base64Coder; /** @@ -164,7 +165,7 @@ public void bid(com.aureleconomy.auction.AuctionItem auction, UUID bidder, BigDe Player prev = Bukkit.getPlayer(previousBidder); if (prev != null) { String formatted = plugin.getEconomyManager().getFormattedWithSymbol(previousPrice, currency); - prev.sendMessage(Component.text(String.format(MSG_OUTBID, auction.getItem().getType().name(), formatted), NamedTextColor.YELLOW)); + prev.sendMessage(Component.text(String.format(MSG_OUTBID, getItemDisplayName(auction.getItem()), formatted), NamedTextColor.YELLOW)); } } } @@ -295,9 +296,7 @@ private void logOfflineEarning(UUID uuid, BigDecimal amount, ItemStack item) { ps.setString(1, uuid.toString()); ps.setBigDecimal(2, amount); - String itemName = item.hasItemMeta() && item.getItemMeta().hasDisplayName() - ? ((net.kyori.adventure.text.TextComponent) item.getItemMeta().displayName()).content() - : item.getType().name(); + String itemName = getItemDisplayName(item); String display = itemName + " (x" + item.getAmount() + ")"; ps.setString(3, display); @@ -402,7 +401,7 @@ public void makeOffer(com.aureleconomy.auction.AuctionItem ai, Player bidder, Bi Player seller = Bukkit.getPlayer(ai.getSeller()); if (seller != null) { seller.sendMessage(Component.text( - String.format(MSG_NEW_OFFER, amount, ai.getItem().getType().name()), NamedTextColor.GOLD)); + String.format(MSG_NEW_OFFER, amount, getItemDisplayName(ai.getItem())), NamedTextColor.GOLD)); } } catch (SQLException e) { plugin.getComponentLogger().error("Database error making offer", e); @@ -448,7 +447,7 @@ public void acceptOffer(int offerId, Player seller) { bidder.getInventory().addItem(ai.getItem().clone()); markCollected(ai.getId()); bidder.sendMessage(Component.text( - String.format(MSG_OFFER_ACCEPTED_BIDDER, ai.getItem().getType().name()), + String.format(MSG_OFFER_ACCEPTED_BIDDER, getItemDisplayName(ai.getItem())), NamedTextColor.GREEN)); } else { bidder.sendMessage(Component.text( @@ -568,7 +567,25 @@ public void sendToCollectionBin(UUID playerUUID, ItemStack item) { }); } - private String itemToBase64(ItemStack item) { + /** + * Returns the display name of an item, preferring custom display name over material name. + */ + private String getItemDisplayName(ItemStack item) { + if (item.hasItemMeta()) { + ItemMeta meta = item.getItemMeta(); + // Paper 26.1.2: displayName() returns a Component (may be null even with custom name) + if (meta.hasDisplayName() || meta.displayName() != null) { + net.kyori.adventure.text.Component display = meta.displayName(); + if (display != null) { + return net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText() + .serialize(display); + } + } + } + return item.getType().name(); + } + + private String itemToBase64(ItemStack item) { return Base64Coder.encodeLines(item.serializeAsBytes()); } diff --git a/src/main/java/com/aureleconomy/commands/AuctionCommand.java b/src/main/java/com/aureleconomy/commands/AuctionCommand.java index 52c74cb..c1b662f 100644 --- a/src/main/java/com/aureleconomy/commands/AuctionCommand.java +++ b/src/main/java/com/aureleconomy/commands/AuctionCommand.java @@ -127,7 +127,7 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command String currency = plugin.getEconomyManager().getDefaultCurrency(); if (args.length == 3) { - if (plugin.getConfig().getConfigurationSection("economy.currencies").contains(args[2])) { + if (plugin.getConfig().getConfigurationSection("economy.currencies") != null && plugin.getConfig().getConfigurationSection("economy.currencies").contains(args[2])) { currency = args[2]; } else { durationMillis = parseDuration(args[2]); @@ -145,7 +145,7 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command return true; } currency = args[3]; - if (!plugin.getConfig().getConfigurationSection("economy.currencies").contains(currency)) { + if (plugin.getConfig().getConfigurationSection("economy.currencies") != null && plugin.getConfig().getConfigurationSection("economy.currencies").contains(currency)) { player.sendMessage(Component.text("Invalid currency: " + currency, NamedTextColor.RED)); return true; } diff --git a/src/main/java/com/aureleconomy/commands/EconomyCommand.java b/src/main/java/com/aureleconomy/commands/EconomyCommand.java index 9a5b7d3..0e09885 100644 --- a/src/main/java/com/aureleconomy/commands/EconomyCommand.java +++ b/src/main/java/com/aureleconomy/commands/EconomyCommand.java @@ -20,290 +20,342 @@ public class EconomyCommand implements CommandExecutor, TabCompleter { - private final AurelEconomy plugin; - private final MiniMessage mm = MiniMessage.miniMessage(); - - public EconomyCommand(AurelEconomy plugin) { - this.plugin = plugin; - } - - private boolean isValidCurrency(String currency) { - return plugin.getConfig().getConfigurationSection("economy.currencies").contains(currency); - } - - @Override - public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, - @NotNull String[] args) { - // /bal [player] [currency] - if (label.equalsIgnoreCase("bal") || label.equalsIgnoreCase("balance") || label.equalsIgnoreCase("money")) { - handleBalance(sender, args); - return true; - } - - // /pay [currency] - if (label.equalsIgnoreCase("pay")) { - handlePay(sender, args); - return true; - } - - // /eco [currency] - if (label.equalsIgnoreCase("eco")) { - handleEco(sender, args); - return true; - } - - return false; - } - - private void handleBalance(CommandSender sender, String[] args) { - if (!sender.hasPermission("aureleconomy.bal")) { - sender.sendMessage(Component.text("No permission.", NamedTextColor.RED)); - return; - } - - String targetName; - String currencyParam = null; - - if (args.length == 0) { - if (!(sender instanceof Player)) { - sender.sendMessage(Component.text("Console must specify a player.")); - return; - } - targetName = sender.getName(); - } else if (args.length == 1) { - if (isValidCurrency(args[0])) { - if (!(sender instanceof Player)) { - sender.sendMessage(Component.text("Console must specify a player.")); - return; - } - targetName = sender.getName(); - currencyParam = args[0]; - } else { - targetName = args[0]; - } - } else { - targetName = args[0]; - currencyParam = args[1]; - } - - final String finalCurrency = currencyParam != null ? currencyParam - : plugin.getEconomyManager().getDefaultCurrency(); - - sender.sendMessage(Component.text("Checking balance...", NamedTextColor.GRAY)); - - Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { - OfflinePlayer target = Bukkit.getOfflinePlayer(targetName); - BigDecimal bal = plugin.getEconomyManager().getBalance(target, finalCurrency); - String prefix = plugin.getConfig().getString("prefix", "[AurelEconomy] "); - - Bukkit.getScheduler().runTask(plugin, () -> { - String formatted = plugin.getEconomyManager().format(bal, finalCurrency); - String symbol = plugin.getEconomyManager().getCurrencySymbol(finalCurrency); - if (sender instanceof Player p && target.getUniqueId().equals(p.getUniqueId())) { - String msg = plugin.getConfig().getString("economy.balance", - "Balance (%currency%): %amount%%symbol%"); - sender.sendMessage(mm.deserialize(prefix + msg - .replace("%currency%", finalCurrency) - .replace("%amount%", formatted) - .replace("%symbol%", symbol))); - } else { - String msg = plugin.getConfig().getString("economy.balance-other", - "Balance of %player% (%currency%): %amount%%symbol%"); - sender.sendMessage(mm.deserialize(prefix + msg - .replace("%player%", target.getName() != null ? target.getName() : targetName) - .replace("%currency%", finalCurrency) - .replace("%amount%", formatted) - .replace("%symbol%", symbol))); - } - }); - }); - } - - private void handlePay(CommandSender sender, String[] args) { - if (!(sender instanceof Player player)) { - sender.sendMessage(Component.text("Only players can pay.")); - return; - } - - if (!player.hasPermission("aureleconomy.pay")) { - player.sendMessage(Component.text("You do not have permission to pay other players.", NamedTextColor.RED)); - return; - } - - if (args.length < 2) { - player.sendMessage(Component.text("Usage: /pay [currency]")); - return; - } - - BigDecimal amount; - try { - amount = new BigDecimal(args[1]); - } catch (NumberFormatException e) { - player.sendMessage(Component.text("Invalid amount.")); - return; - } - - if (amount.compareTo(BigDecimal.ZERO) <= 0) { - player.sendMessage(Component.text("Amount must be positive.", NamedTextColor.RED)); - return; - } - - String currency = (args.length >= 3) ? args[2] : plugin.getEconomyManager().getDefaultCurrency(); - if (!isValidCurrency(currency)) { - player.sendMessage(Component.text("Invalid currency: " + currency, NamedTextColor.RED)); - return; - } - - String targetName = args[0]; - player.sendMessage(Component.text("Processing payment...", NamedTextColor.GRAY)); - - Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { - OfflinePlayer target = Bukkit.getOfflinePlayer(targetName); - - if (target.getUniqueId().equals(player.getUniqueId())) { - Bukkit.getScheduler().runTask(plugin, - () -> player.sendMessage(Component.text("You cannot pay yourself.", NamedTextColor.RED))); - return; - } - - Bukkit.getScheduler().runTask(plugin, () -> { - if (plugin.getEconomyManager().has(player, amount, currency)) { - plugin.getEconomyManager().withdraw(player, amount, currency); - plugin.getEconomyManager().deposit(target, amount, currency); - String formatted = plugin.getEconomyManager().format(amount, currency); - String symbol = plugin.getEconomyManager().getCurrencySymbol(currency); - - String payMsg = plugin.getConfig().getString("economy.paid", - "You paid %player% %amount%%symbol%") - .replace("%player%", target.getName() != null ? target.getName() : targetName) - .replace("%amount%", formatted) - .replace("%symbol%", symbol) - .replace("%currency%", currency); - player.sendMessage(mm.deserialize(payMsg)); - - if (target.isOnline()) { - Player op = target.getPlayer(); - if (op != null) { - String recvMsg = plugin.getConfig().getString("economy.received", - "You received %amount%%symbol% from %player%") - .replace("%player%", player.getName()) - .replace("%amount%", formatted) - .replace("%symbol%", symbol) - .replace("%currency%", currency); - op.sendMessage(mm.deserialize(recvMsg)); - } - } - } else { - player.sendMessage(Component.text("Insufficient funds.", NamedTextColor.RED)); - } - }); - }); - } - - private void handleEco(CommandSender sender, String[] args) { - if (!sender.hasPermission("aureleconomy.admin")) { - sender.sendMessage(Component.text("No permission.")); - return; - } - - if (args.length < 3) { // Requires give|take|set, player, amount - sender.sendMessage(Component.text("Usage: /eco [currency]")); - return; - } - - String action = args[0].toLowerCase(); - OfflinePlayer target = Bukkit.getOfflinePlayer(args[1]); - BigDecimal amount; - - try { - amount = new BigDecimal(args[2]); - if (amount.compareTo(BigDecimal.ZERO) <= 0) { - sender.sendMessage(Component.text("Amount must be positive.", NamedTextColor.RED)); - return; - } - } catch (NumberFormatException e) { - sender.sendMessage(Component.text("Invalid amount.")); - return; - } - - String currency = args.length >= 4 ? args[3] : plugin.getEconomyManager().getDefaultCurrency(); - if (!isValidCurrency(currency)) { - sender.sendMessage(Component.text("Invalid currency: " + currency)); - return; - } - - String prefix = plugin.getConfig().getString("prefix", "[AurelEconomy] "); - - switch (action) { - case "give": - plugin.getEconomyManager().deposit(target, amount, currency); - sender.sendMessage(mm.deserialize(prefix + - plugin.getConfig().getString("economy.admin-give", "Gave %player% %amount% (%currency%)") - .replace("%player%", target.getName() != null ? target.getName() : "You") - .replace("%currency%", currency) - .replace("%amount%", plugin.getEconomyManager().format(amount, currency)) - .replace("%symbol%", plugin.getEconomyManager().getCurrencySymbol(currency)))); - break; - case "take": - plugin.getEconomyManager().withdraw(target, amount, currency); - sender.sendMessage(mm.deserialize(prefix + - plugin.getConfig().getString("economy.admin-take", "Took %amount% (%currency%) from %player%") - .replace("%player%", target.getName() != null ? target.getName() : "You") - .replace("%currency%", currency) - .replace("%amount%", plugin.getEconomyManager().format(amount, currency)) - .replace("%symbol%", plugin.getEconomyManager().getCurrencySymbol(currency)))); - break; - case "set": - plugin.getEconomyManager().setBalance(target, amount, currency); - sender.sendMessage(mm.deserialize(prefix + - plugin.getConfig().getString("economy.admin-set", "Set balance of %player% to %amount% (%currency%)") - .replace("%player%", target.getName() != null ? target.getName() : "You") - .replace("%currency%", currency) - .replace("%amount%", plugin.getEconomyManager().format(amount, currency)) - .replace("%symbol%", plugin.getEconomyManager().getCurrencySymbol(currency)))); - break; - default: - sender.sendMessage(Component.text("Unknown action: " + action)); - } - } - - @Override - public @Nullable List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, - @NotNull String label, @NotNull String[] args) { - List currencies = new ArrayList<>( - plugin.getConfig().getConfigurationSection("economy.currencies").getKeys(false)); - - if (label.equalsIgnoreCase("bal") || label.equalsIgnoreCase("balance") || label.equalsIgnoreCase("money")) { - if (args.length == 1) { - List suggestions = new ArrayList<>(currencies); - for (Player p : Bukkit.getOnlinePlayers()) { - suggestions.add(p.getName()); - } - return suggestions; - } - if (args.length == 2) - return currencies; // player, [currency] - } - - if (label.equalsIgnoreCase("pay")) { - if (args.length == 1) - return null; // online players - if (args.length == 2) - return Collections.emptyList(); // amount - if (args.length == 3) - return currencies; // [currency] - } - - if (label.equalsIgnoreCase("eco")) { - if (args.length == 1) - return List.of("give", "take", "set"); - if (args.length == 2) - return null; // offline players - if (args.length == 3) - return Collections.emptyList(); // amount - if (args.length == 4) - return currencies; // [currency] - } - return Collections.emptyList(); - } + private final AurelEconomy plugin; + private final MiniMessage mm = MiniMessage.miniMessage(); + + public EconomyCommand(AurelEconomy plugin) { + this.plugin = plugin; + } + + private boolean isValidCurrency(String currency) { + org.bukkit.configuration.ConfigurationSection section = plugin.getConfig().getConfigurationSection("economy.currencies"); + if (section == null) { + plugin.getComponentLogger().warn("economy.currencies section missing from config!"); + return currency.equals(plugin.getEconomyManager().getDefaultCurrency()); + } + return section.contains(currency); + } + + @Override + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, + @NotNull String[] args) { + try { + if (label.equalsIgnoreCase("bal") || label.equalsIgnoreCase("balance") || label.equalsIgnoreCase("money")) { + handleBalance(sender, args); + return true; + } + + if (label.equalsIgnoreCase("pay")) { + handlePay(sender, args); + return true; + } + + if (label.equalsIgnoreCase("eco")) { + handleEco(sender, args); + return true; + } + + return false; + } catch (Exception e) { + plugin.getComponentLogger().error("Unhandled exception in EconomyCommand for /" + label, e); + sender.sendMessage(Component.text("An internal error occurred in /" + label + ": " + e.getClass().getSimpleName(), NamedTextColor.RED)); + return true; + } + } + + private void handleBalance(CommandSender sender, String[] args) { + if (!sender.hasPermission("aureleconomy.bal")) { + sender.sendMessage(Component.text("No permission.", NamedTextColor.RED)); + return; + } + + String targetName; + String currencyParam = null; + + if (args.length == 0) { + if (!(sender instanceof Player)) { + sender.sendMessage(Component.text("Console must specify a player.")); + return; + } + targetName = sender.getName(); + } else if (args.length == 1) { + if (isValidCurrency(args[0])) { + if (!(sender instanceof Player)) { + sender.sendMessage(Component.text("Console must specify a player.")); + return; + } + targetName = sender.getName(); + currencyParam = args[0]; + } else { + targetName = args[0]; + } + } else { + targetName = args[0]; + currencyParam = args[1]; + } + + final String finalCurrency = currencyParam != null ? currencyParam + : plugin.getEconomyManager().getDefaultCurrency(); + + sender.sendMessage(Component.text("Checking balance...", NamedTextColor.GRAY)); + + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + try { + OfflinePlayer target = Bukkit.getOfflinePlayer(targetName); + BigDecimal bal = plugin.getEconomyManager().getBalance(target, finalCurrency); + String prefix = plugin.getConfig().getString("prefix", "[AurelEconomy] "); + + Bukkit.getScheduler().runTask(plugin, () -> { + try { + String formatted = plugin.getEconomyManager().format(bal, finalCurrency); + String symbol = plugin.getEconomyManager().getCurrencySymbol(finalCurrency); + if (sender instanceof Player p && target.getUniqueId().equals(p.getUniqueId())) { + String msg = plugin.getConfig().getString("economy.balance", + "Balance (%currency%): %amount%%symbol%"); + sender.sendMessage(mm.deserialize(prefix + msg + .replace("%currency%", finalCurrency) + .replace("%amount%", formatted) + .replace("%symbol%", symbol))); + } else { + String msg = plugin.getConfig().getString("economy.balance-other", + "Balance of %player% (%currency%): %amount%%symbol%"); + sender.sendMessage(mm.deserialize(prefix + msg + .replace("%player%", target.getName() != null ? target.getName() : targetName) + .replace("%currency%", finalCurrency) + .replace("%amount%", formatted) + .replace("%symbol%", symbol))); + } + } catch (Exception e) { + plugin.getComponentLogger().error("Error displaying balance", e); + sender.sendMessage(Component.text("An error occurred while retrieving balance.", NamedTextColor.RED)); + } + }); + } catch (Exception e) { + plugin.getComponentLogger().error("Error loading balance", e); + Bukkit.getScheduler().runTask(plugin, () -> + sender.sendMessage(Component.text("An error occurred while retrieving balance.", NamedTextColor.RED))); + } + }); + } + + private void handlePay(CommandSender sender, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage(Component.text("Only players can pay.")); + return; + } + + if (!player.hasPermission("aureleconomy.pay")) { + player.sendMessage(Component.text("You do not have permission to pay other players.", NamedTextColor.RED)); + return; + } + + if (args.length < 2) { + player.sendMessage(Component.text("Usage: /pay [currency]")); + return; + } + + BigDecimal amount; + try { + amount = new BigDecimal(args[1]); + } catch (NumberFormatException e) { + player.sendMessage(Component.text("Invalid amount.")); + return; + } + + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + player.sendMessage(Component.text("Amount must be positive.", NamedTextColor.RED)); + return; + } + + String currency = (args.length >= 3) ? args[2] : plugin.getEconomyManager().getDefaultCurrency(); + if (!isValidCurrency(currency)) { + player.sendMessage(Component.text("Invalid currency: " + currency, NamedTextColor.RED)); + return; + } + + String targetName = args[0]; + player.sendMessage(Component.text("Processing payment...", NamedTextColor.GRAY)); + + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + try { + OfflinePlayer target = Bukkit.getOfflinePlayer(targetName); + + if (target.getUniqueId().equals(player.getUniqueId())) { + Bukkit.getScheduler().runTask(plugin, + () -> player.sendMessage(Component.text("You cannot pay yourself.", NamedTextColor.RED))); + return; + } + + boolean hasFunds = plugin.getEconomyManager().has(player, amount, currency); + + Bukkit.getScheduler().runTask(plugin, () -> { + try { + if (hasFunds) { + plugin.getEconomyManager().withdraw(player, amount, currency); + plugin.getEconomyManager().deposit(target, amount, currency); + String formatted = plugin.getEconomyManager().format(amount, currency); + String symbol = plugin.getEconomyManager().getCurrencySymbol(currency); + + String payMsg = plugin.getConfig().getString("economy.paid", + "You paid %player% %amount%%symbol%") + .replace("%player%", target.getName() != null ? target.getName() : targetName) + .replace("%amount%", formatted) + .replace("%symbol%", symbol) + .replace("%currency%", currency); + player.sendMessage(mm.deserialize(payMsg)); + + if (target.isOnline()) { + Player op = target.getPlayer(); + if (op != null) { + String recvMsg = plugin.getConfig().getString("economy.received", + "You received %amount%%symbol% from %player%") + .replace("%player%", player.getName()) + .replace("%amount%", formatted) + .replace("%symbol%", symbol) + .replace("%currency%", currency); + op.sendMessage(mm.deserialize(recvMsg)); + } + } + } else { + player.sendMessage(Component.text("Insufficient funds.", NamedTextColor.RED)); + } + } catch (Exception e) { + plugin.getComponentLogger().error("Error processing payment", e); + player.sendMessage(Component.text("An error occurred while processing payment.", NamedTextColor.RED)); + } + }); + } catch (Exception e) { + plugin.getComponentLogger().error("Error looking up player for payment", e); + Bukkit.getScheduler().runTask(plugin, + () -> player.sendMessage(Component.text("An error occurred while processing payment.", NamedTextColor.RED))); + } + }); + } + + private void handleEco(CommandSender sender, String[] args) { + if (!sender.hasPermission("aureleconomy.admin")) { + sender.sendMessage(Component.text("No permission.")); + return; + } + + if (args.length < 3) { + sender.sendMessage(Component.text("Usage: /eco [currency]")); + return; + } + + String action = args[0].toLowerCase(); + + if (!action.equals("give") && !action.equals("take") && !action.equals("set")) { + sender.sendMessage(Component.text("Unknown action: " + action + ". Use give, take, or set.")); + return; + } + + BigDecimal amount; + try { + amount = new BigDecimal(args[2]); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + sender.sendMessage(Component.text("Amount must be positive.", NamedTextColor.RED)); + return; + } + } catch (NumberFormatException e) { + sender.sendMessage(Component.text("Invalid amount.")); + return; + } + + String currency = args.length >= 4 ? args[3] : plugin.getEconomyManager().getDefaultCurrency(); + if (!isValidCurrency(currency)) { + sender.sendMessage(Component.text("Invalid currency: " + currency)); + return; + } + + String targetName = args[1]; + + // Run all economy operations async to avoid blocking the main thread + sender.sendMessage(Component.text("Processing...", NamedTextColor.GRAY)); + + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + try { + OfflinePlayer target = Bukkit.getOfflinePlayer(targetName); + String displayName = target.getName() != null ? target.getName() : targetName; + String prefix = plugin.getConfig().getString("prefix", "[AurelEconomy] "); + + switch (action) { + case "give": + plugin.getEconomyManager().deposit(target, amount, currency); + Bukkit.getScheduler().runTask(plugin, () -> + sender.sendMessage(mm.deserialize(prefix + + plugin.getConfig().getString("economy.admin-give", "Gave %player% %amount% (%currency%)") + .replace("%player%", displayName) + .replace("%currency%", currency) + .replace("%amount%", plugin.getEconomyManager().format(amount, currency)) + .replace("%symbol%", plugin.getEconomyManager().getCurrencySymbol(currency))))); + break; + case "take": + plugin.getEconomyManager().withdraw(target, amount, currency); + Bukkit.getScheduler().runTask(plugin, () -> + sender.sendMessage(mm.deserialize(prefix + + plugin.getConfig().getString("economy.admin-take", "Took %amount% (%currency%) from %player%") + .replace("%player%", displayName) + .replace("%currency%", currency) + .replace("%amount%", plugin.getEconomyManager().format(amount, currency)) + .replace("%symbol%", plugin.getEconomyManager().getCurrencySymbol(currency))))); + break; + case "set": + plugin.getEconomyManager().setBalance(target, amount, currency); + Bukkit.getScheduler().runTask(plugin, () -> + sender.sendMessage(mm.deserialize(prefix + + plugin.getConfig().getString("economy.admin-set", "Set balance of %player% to %amount% (%currency%)") + .replace("%player%", displayName) + .replace("%currency%", currency) + .replace("%amount%", plugin.getEconomyManager().format(amount, currency)) + .replace("%symbol%", plugin.getEconomyManager().getCurrencySymbol(currency))))); + break; + } + } catch (Exception e) { + plugin.getComponentLogger().error("Error executing /eco " + action, e); + Bukkit.getScheduler().runTask(plugin, () -> + sender.sendMessage(Component.text("An error occurred while executing /eco " + action + ".", NamedTextColor.RED))); + } + }); + } + + @Override + public @Nullable List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, + @NotNull String label, @NotNull String[] args) { + List currencies = new ArrayList<>( + plugin.getConfig().getConfigurationSection("economy.currencies").getKeys(false)); + + if (label.equalsIgnoreCase("bal") || label.equalsIgnoreCase("balance") || label.equalsIgnoreCase("money")) { + if (args.length == 1) { + List suggestions = new ArrayList<>(currencies); + for (Player p : Bukkit.getOnlinePlayers()) { + suggestions.add(p.getName()); + } + return suggestions; + } + if (args.length == 2) + return currencies; + } + + if (label.equalsIgnoreCase("pay")) { + if (args.length == 1) + return null; + if (args.length == 2) + return Collections.emptyList(); + if (args.length == 3) + return currencies; + } + + if (label.equalsIgnoreCase("eco")) { + if (args.length == 1) + return List.of("give", "take", "set"); + if (args.length == 2) + return null; + if (args.length == 3) + return Collections.emptyList(); + if (args.length == 4) + return currencies; + } + return Collections.emptyList(); + } } diff --git a/src/main/java/com/aureleconomy/database/DatabaseManager.java b/src/main/java/com/aureleconomy/database/DatabaseManager.java index 71f5801..c87fb1b 100644 --- a/src/main/java/com/aureleconomy/database/DatabaseManager.java +++ b/src/main/java/com/aureleconomy/database/DatabaseManager.java @@ -20,6 +20,13 @@ public DatabaseManager(AurelEconomy plugin) { this.databaseType = plugin.getConfig().getString("database.type", "sqlite").toLowerCase(); } + /** + * Returns true if the configured database type is MySQL/MariaDB. + */ + public boolean isMySQL() { + return "mysql".equals(databaseType); + } + private static final int LATEST_SCHEMA_VERSION = 1; public boolean initialize() { diff --git a/src/main/java/com/aureleconomy/economy/EconomyManager.java b/src/main/java/com/aureleconomy/economy/EconomyManager.java index 8b8d11a..7080e42 100644 --- a/src/main/java/com/aureleconomy/economy/EconomyManager.java +++ b/src/main/java/com/aureleconomy/economy/EconomyManager.java @@ -14,275 +14,318 @@ public class EconomyManager { - private static final int SCALE = 2; - private static final RoundingMode ROUNDING_MODE = RoundingMode.HALF_EVEN; - private static final double DEFAULT_STARTING_BALANCE = 100.0; - - private final AurelEconomy plugin; - private final Map> balanceCache = new ConcurrentHashMap<>(); - private String defaultCurrency; - - public EconomyManager(AurelEconomy plugin) { - this.plugin = plugin; - } - - public String getDefaultCurrency() { - if (this.defaultCurrency == null) { - this.defaultCurrency = plugin.getConfig().getString("economy.default-currency", "Aurels"); - } - return this.defaultCurrency; - } - - public BigDecimal getBalance(OfflinePlayer player) { - return getBalance(player, getDefaultCurrency()); - } - - public BigDecimal getBalance(OfflinePlayer player, String currency) { - UUID uuid = player.getUniqueId(); - Map userBalances = balanceCache.get(uuid); - if (userBalances != null && userBalances.containsKey(currency)) { - return userBalances.get(currency); - } - - return loadBalance(uuid, currency); - } - - public void setBalance(OfflinePlayer player, BigDecimal amount) { - setBalance(player, amount, getDefaultCurrency()); - } - - public void deposit(OfflinePlayer player, BigDecimal amount) { - deposit(player, amount, getDefaultCurrency()); - } - - public synchronized void deposit(OfflinePlayer player, BigDecimal amount, String currency) { - if (amount.compareTo(BigDecimal.ZERO) <= 0) - return; - - UUID uuid = player.getUniqueId(); - BigDecimal normalizedAmount = amount.setScale(SCALE, ROUNDING_MODE); - - Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { - try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( - "INSERT INTO player_balances (uuid, currency, balance) VALUES (?, ?, ?) " + - "ON CONFLICT(uuid, currency) DO UPDATE SET balance = balance + ?")) { - ps.setString(1, uuid.toString()); - ps.setString(2, currency); - ps.setBigDecimal(3, normalizedAmount); - ps.setBigDecimal(4, normalizedAmount); - ps.executeUpdate(); - - loadBalance(uuid, currency); - - updatePlayerMetadata(player); - } catch (SQLException e) { - plugin.getComponentLogger().error("Database error in EconomyManager while depositing", e); - } - }); - - BigDecimal current = getBalance(player, currency); - balanceCache.computeIfAbsent(uuid, k -> new ConcurrentHashMap<>()).put(currency, current.add(normalizedAmount)); - } - - public void withdraw(OfflinePlayer player, BigDecimal amount) { - withdraw(player, amount, getDefaultCurrency()); - } - - public synchronized void withdraw(OfflinePlayer player, BigDecimal amount, String currency) { - if (amount.compareTo(BigDecimal.ZERO) <= 0) - return; - - UUID uuid = player.getUniqueId(); - BigDecimal normalizedAmount = amount.setScale(SCALE, ROUNDING_MODE); - - BigDecimal currentBalance = getBalance(player, currency); - if (currentBalance.compareTo(normalizedAmount) < 0) { - plugin.getComponentLogger().warn("Insufficient funds for withdraw: " + player.getName() + - " tried to withdraw " + normalizedAmount + " but only has " + currentBalance); - return; - } - - Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { - try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( - "UPDATE player_balances SET balance = balance - ? WHERE uuid = ? AND currency = ? AND balance >= ?")) { - ps.setBigDecimal(1, normalizedAmount); - ps.setString(2, uuid.toString()); - ps.setString(3, currency); - ps.setBigDecimal(4, normalizedAmount); - - int affectedRows = ps.executeUpdate(); - if (affectedRows == 0) { - loadBalance(uuid, currency); - } else { - updatePlayerMetadata(player); - } - - loadBalance(uuid, currency); - } catch (SQLException e) { - plugin.getComponentLogger().error("Database error in EconomyManager while withdrawing", e); - } - }); - - balanceCache.computeIfAbsent(uuid, k -> new ConcurrentHashMap<>()).put(currency, - currentBalance.subtract(normalizedAmount).max(BigDecimal.ZERO)); - } - - public synchronized boolean withdrawIfSufficient(OfflinePlayer player, BigDecimal amount, String currency) { - if (amount.compareTo(BigDecimal.ZERO) <= 0) - return false; - - UUID uuid = player.getUniqueId(); - BigDecimal normalizedAmount = amount.setScale(SCALE, ROUNDING_MODE); - - BigDecimal currentBalance = getBalance(player, currency); - - plugin.getComponentLogger().info("withdrawIfSufficient: player=" + player.getName() + - ", amount=" + normalizedAmount + - ", currency=" + currency + - ", balance=" + currentBalance); - - if (currentBalance.compareTo(normalizedAmount) < 0) { - plugin.getComponentLogger().warn("Insufficient funds: " + player.getName() + - " has " + currentBalance + " " + currency + - " but needs " + normalizedAmount); - return false; - } - - try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( - "UPDATE player_balances SET balance = balance - ? WHERE uuid = ? AND currency = ? AND balance >= ?")) { - ps.setBigDecimal(1, normalizedAmount); - ps.setString(2, uuid.toString()); - ps.setString(3, currency); - ps.setBigDecimal(4, normalizedAmount); - - int affectedRows = ps.executeUpdate(); - plugin.getComponentLogger().info("Database update affected " + affectedRows + " rows"); - - if (affectedRows == 0) { - plugin.getComponentLogger().warn("Database update failed - reloading balance"); - loadBalance(uuid, currency); - return false; - } - - BigDecimal newBalance = currentBalance.subtract(normalizedAmount).max(BigDecimal.ZERO); - balanceCache.computeIfAbsent(uuid, k -> new ConcurrentHashMap<>()).put(currency, newBalance); - - Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { - updatePlayerMetadata(player); - }); - - plugin.getComponentLogger().info("Withdrawal successful: " + player.getName() + - " new balance: " + newBalance); - return true; - } catch (SQLException e) { - plugin.getComponentLogger().error("Database error in withdrawIfSufficient", e); - return false; - } - } - - private void updatePlayerMetadata(OfflinePlayer player) { - String name = player.getName(); - if (name == null) - return; - UUID uuid = player.getUniqueId(); - - try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( - "INSERT INTO players (uuid, name) VALUES (?, ?) ON CONFLICT(uuid) DO UPDATE SET name = ?")) { - ps.setString(1, uuid.toString()); - ps.setString(2, name); - ps.setString(3, name); - ps.executeUpdate(); - } catch (SQLException e) { - } - } - - public boolean has(OfflinePlayer player, BigDecimal amount) { - return has(player, amount, getDefaultCurrency()); - } - - public boolean has(OfflinePlayer player, BigDecimal amount, String currency) { - return getBalance(player, currency).compareTo(amount) >= 0; - } - - public String format(BigDecimal amount) { - return format(amount, getDefaultCurrency()); - } - - public String getCurrencySymbol(String currency) { - String path = "economy.currencies." + currency + ".symbol"; - return plugin.getConfig().getString(path, - plugin.getConfig().getString("economy.currency-symbol", "₳")); - } - - public String format(BigDecimal amount, String currency) { - return amount.setScale(SCALE, ROUNDING_MODE).toPlainString(); - } - - public String getFormattedWithSymbol(BigDecimal amount, String currency) { - return getCurrencySymbol(currency) + format(amount, currency); - } - - private BigDecimal loadBalance(UUID uuid, String currency) { - try (PreparedStatement ps = plugin.getDatabaseManager().getConnection() - .prepareStatement("SELECT balance FROM player_balances WHERE uuid = ? AND currency = ?")) { - ps.setString(1, uuid.toString()); - ps.setString(2, currency); - ResultSet rs = ps.executeQuery(); - if (rs.next()) { - BigDecimal bal = rs.getBigDecimal("balance"); - if (bal == null) - bal = BigDecimal.ZERO; - bal = bal.setScale(SCALE, ROUNDING_MODE); - balanceCache.computeIfAbsent(uuid, k -> new ConcurrentHashMap<>()).put(currency, bal); - return bal; - } - } catch (SQLException e) { - plugin.getComponentLogger().error("Database error while loading balance for " + uuid, e); - } - - double startBalRaw = plugin.getConfig().getDouble("economy.currencies." + currency + ".starting-balance", - plugin.getConfig().getDouble("economy.starting-balance", DEFAULT_STARTING_BALANCE)); - BigDecimal startBal = BigDecimal.valueOf(startBalRaw).setScale(SCALE, ROUNDING_MODE); - try (PreparedStatement ps = plugin.getDatabaseManager().getConnection() - .prepareStatement( - "INSERT INTO player_balances (uuid, currency, balance) VALUES (?, ?, ?) ON CONFLICT(uuid, currency) DO UPDATE SET balance = ?")) { - ps.setString(1, uuid.toString()); - ps.setString(2, currency); - ps.setBigDecimal(3, startBal); - ps.setBigDecimal(4, startBal); - ps.executeUpdate(); - plugin.getComponentLogger().info("Created initial balance for " + uuid + ": " + startBal + " " + currency); - } catch (SQLException e) { - plugin.getComponentLogger().error("Database error while creating initial balance for " + uuid, e); - } - - balanceCache.computeIfAbsent(uuid, k -> new ConcurrentHashMap<>()).put(currency, startBal); - return startBal; - } - - public void setBalance(OfflinePlayer player, BigDecimal amount, String currency) { - UUID uuid = player.getUniqueId(); - BigDecimal normalizedAmount = amount.setScale(SCALE, ROUNDING_MODE); - - balanceCache.computeIfAbsent(uuid, k -> new ConcurrentHashMap<>()).put(currency, normalizedAmount); - - Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { - try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( - "INSERT INTO player_balances (uuid, currency, balance) VALUES (?, ?, ?) " + - "ON CONFLICT(uuid, currency) DO UPDATE SET balance = ?")) { - ps.setString(1, uuid.toString()); - ps.setString(2, currency); - ps.setBigDecimal(3, normalizedAmount); - ps.setBigDecimal(4, normalizedAmount); - ps.executeUpdate(); - updatePlayerMetadata(player); - } catch (SQLException e) { - plugin.getComponentLogger().error("Database error in EconomyManager while saving balance", e); - } - }); - } - - public void invalidateCache(UUID uuid) { - balanceCache.remove(uuid); - } + private static final int SCALE = 2; + private static final RoundingMode ROUNDING_MODE = RoundingMode.HALF_EVEN; + private static final double DEFAULT_STARTING_BALANCE = 100.0; + + private final AurelEconomy plugin; + private final Map> balanceCache = new ConcurrentHashMap<>(); + private String defaultCurrency; + + public EconomyManager(AurelEconomy plugin) { + this.plugin = plugin; + } + + public String getDefaultCurrency() { + if (this.defaultCurrency == null) { + this.defaultCurrency = plugin.getConfig().getString("economy.default-currency", "Aurels"); + } + return this.defaultCurrency; + } + + public BigDecimal getBalance(OfflinePlayer player) { + return getBalance(player, getDefaultCurrency()); + } + + public BigDecimal getBalance(OfflinePlayer player, String currency) { + UUID uuid = player.getUniqueId(); + Map userBalances = balanceCache.get(uuid); + if (userBalances != null && userBalances.containsKey(currency)) { + return userBalances.get(currency); + } + + return loadBalance(uuid, currency); + } + + public void setBalance(OfflinePlayer player, BigDecimal amount) { + setBalance(player, amount, getDefaultCurrency()); + } + + public void deposit(OfflinePlayer player, BigDecimal amount) { + deposit(player, amount, getDefaultCurrency()); + } + + public void deposit(OfflinePlayer player, BigDecimal amount, String currency) { + if (amount.compareTo(BigDecimal.ZERO) <= 0) + return; + + UUID uuid = player.getUniqueId(); + BigDecimal normalizedAmount = amount.setScale(SCALE, ROUNDING_MODE); + + // Update cache immediately for responsiveness + BigDecimal current = getBalanceFromCache(uuid, currency); + BigDecimal newBalance = current.add(normalizedAmount); + balanceCache.computeIfAbsent(uuid, k -> new ConcurrentHashMap<>()).put(currency, newBalance); + + // Persist to DB async (if called from async context, this is fine; + // if called from main thread, the DB call must still be async) + scheduleAsyncWrite(() -> { + try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( + plugin.getDatabaseManager().isMySQL() + ? "INSERT INTO player_balances (uuid, currency, balance) VALUES (?, ?, ?) AS new ON DUPLICATE KEY UPDATE balance = balance + new.balance" + : "INSERT INTO player_balances (uuid, currency, balance) VALUES (?, ?, ?) ON CONFLICT(uuid, currency) DO UPDATE SET balance = balance + ?")) { + + ps.setString(1, uuid.toString()); + ps.setString(2, currency); + ps.setBigDecimal(3, normalizedAmount); + if (!plugin.getDatabaseManager().isMySQL()) { + ps.setBigDecimal(4, normalizedAmount); + } + ps.executeUpdate(); + + loadBalance(uuid, currency); + updatePlayerMetadata(player); + } catch (SQLException e) { + plugin.getComponentLogger().error("Database error in EconomyManager while depositing", e); + } + }); + } + + public void withdraw(OfflinePlayer player, BigDecimal amount) { + withdraw(player, amount, getDefaultCurrency()); + } + + public void withdraw(OfflinePlayer player, BigDecimal amount, String currency) { + if (amount.compareTo(BigDecimal.ZERO) <= 0) + return; + + UUID uuid = player.getUniqueId(); + BigDecimal normalizedAmount = amount.setScale(SCALE, ROUNDING_MODE); + + BigDecimal currentBalance = getBalanceFromCache(uuid, currency); + if (currentBalance.compareTo(normalizedAmount) < 0) { + plugin.getComponentLogger().warn("Insufficient funds for withdraw: " + player.getName() + + " tried to withdraw " + normalizedAmount + " but only has " + currentBalance); + return; + } + + // Update cache immediately + BigDecimal newBalance = currentBalance.subtract(normalizedAmount).max(BigDecimal.ZERO); + balanceCache.computeIfAbsent(uuid, k -> new ConcurrentHashMap<>()).put(currency, newBalance); + + // Persist to DB async + scheduleAsyncWrite(() -> { + try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( + "UPDATE player_balances SET balance = balance - ? WHERE uuid = ? AND currency = ? AND balance >= ?")) { + ps.setBigDecimal(1, normalizedAmount); + ps.setString(2, uuid.toString()); + ps.setString(3, currency); + ps.setBigDecimal(4, normalizedAmount); + + int affectedRows = ps.executeUpdate(); + if (affectedRows == 0) { + loadBalance(uuid, currency); + } else { + updatePlayerMetadata(player); + } + + loadBalance(uuid, currency); + } catch (SQLException e) { + plugin.getComponentLogger().error("Database error in EconomyManager while withdrawing", e); + } + }); + } + + public boolean withdrawIfSufficient(OfflinePlayer player, BigDecimal amount, String currency) { + if (amount.compareTo(BigDecimal.ZERO) <= 0) + return false; + + UUID uuid = player.getUniqueId(); + BigDecimal normalizedAmount = amount.setScale(SCALE, ROUNDING_MODE); + + BigDecimal currentBalance = getBalance(player, currency); + + plugin.getComponentLogger().info("withdrawIfSufficient: player=" + player.getName() + + ", amount=" + normalizedAmount + + ", currency=" + currency + + ", balance=" + currentBalance); + + if (currentBalance.compareTo(normalizedAmount) < 0) { + plugin.getComponentLogger().warn("Insufficient funds: " + player.getName() + + " has " + currentBalance + " " + currency + + " but needs " + normalizedAmount); + return false; + } + + try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( + "UPDATE player_balances SET balance = balance - ? WHERE uuid = ? AND currency = ? AND balance >= ?")) { + ps.setBigDecimal(1, normalizedAmount); + ps.setString(2, uuid.toString()); + ps.setString(3, currency); + ps.setBigDecimal(4, normalizedAmount); + + int affectedRows = ps.executeUpdate(); + plugin.getComponentLogger().info("Database update affected " + affectedRows + " rows"); + + if (affectedRows == 0) { + plugin.getComponentLogger().warn("Database update failed - reloading balance"); + loadBalance(uuid, currency); + return false; + } + + BigDecimal newBalance = currentBalance.subtract(normalizedAmount).max(BigDecimal.ZERO); + balanceCache.computeIfAbsent(uuid, k -> new ConcurrentHashMap<>()).put(currency, newBalance); + + scheduleAsyncWrite(() -> { + updatePlayerMetadata(player); + }); + + plugin.getComponentLogger().info("Withdrawal successful: " + player.getName() + + " new balance: " + newBalance); + return true; + } catch (SQLException e) { + plugin.getComponentLogger().error("Database error in withdrawIfSufficient", e); + return false; + } + } + + private void updatePlayerMetadata(OfflinePlayer player) { + String name = player.getName(); + if (name == null) + return; + UUID uuid = player.getUniqueId(); + + try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( + plugin.getDatabaseManager().isMySQL() + ? "INSERT INTO players (uuid, name) VALUES (?, ?) AS new ON DUPLICATE KEY UPDATE name = new.name" + : "INSERT INTO players (uuid, name) VALUES (?, ?) ON CONFLICT(uuid) DO UPDATE SET name = ?")) { + ps.setString(1, uuid.toString()); + ps.setString(2, name); + if (!plugin.getDatabaseManager().isMySQL()) { + ps.setString(3, name); + } + ps.executeUpdate(); + } catch (SQLException e) { + } + } + + public boolean has(OfflinePlayer player, BigDecimal amount) { + return has(player, amount, getDefaultCurrency()); + } + + public boolean has(OfflinePlayer player, BigDecimal amount, String currency) { + return getBalance(player, currency).compareTo(amount) >= 0; + } + + public String format(BigDecimal amount) { + return format(amount, getDefaultCurrency()); + } + + public String getCurrencySymbol(String currency) { + String path = "economy.currencies." + currency + ".symbol"; + return plugin.getConfig().getString(path, + plugin.getConfig().getString("economy.currency-symbol", "₳")); + } + + public String format(BigDecimal amount, String currency) { + return amount.setScale(SCALE, ROUNDING_MODE).toPlainString(); + } + + public String getFormattedWithSymbol(BigDecimal amount, String currency) { + return getCurrencySymbol(currency) + format(amount, currency); + } + + /** + * Get balance from cache only (no DB call). Returns ZERO if not cached. + */ + private BigDecimal getBalanceFromCache(UUID uuid, String currency) { + Map userBalances = balanceCache.get(uuid); + if (userBalances != null && userBalances.containsKey(currency)) { + return userBalances.get(currency); + } + return BigDecimal.ZERO; + } + + /** + * Schedule a write task asynchronously. + * If already on an async thread, run directly; otherwise schedule via Bukkit. + */ + private void scheduleAsyncWrite(Runnable task) { + if (Bukkit.isPrimaryThread()) { + Bukkit.getScheduler().runTaskAsynchronously(plugin, task); + } else { + task.run(); + } + } + + private BigDecimal loadBalance(UUID uuid, String currency) { + try (PreparedStatement ps = plugin.getDatabaseManager().getConnection() + .prepareStatement("SELECT balance FROM player_balances WHERE uuid = ? AND currency = ?")) { + ps.setString(1, uuid.toString()); + ps.setString(2, currency); + ResultSet rs = ps.executeQuery(); + if (rs.next()) { + BigDecimal bal = rs.getBigDecimal("balance"); + if (bal == null) + bal = BigDecimal.ZERO; + bal = bal.setScale(SCALE, ROUNDING_MODE); + balanceCache.computeIfAbsent(uuid, k -> new ConcurrentHashMap<>()).put(currency, bal); + return bal; + } + } catch (SQLException e) { + plugin.getComponentLogger().error("Database error while loading balance for " + uuid, e); + } + + double startBalRaw = plugin.getConfig().getDouble("economy.currencies." + currency + ".starting-balance", + plugin.getConfig().getDouble("economy.starting-balance", DEFAULT_STARTING_BALANCE)); + BigDecimal startBal = BigDecimal.valueOf(startBalRaw).setScale(SCALE, ROUNDING_MODE); + try (PreparedStatement ps = plugin.getDatabaseManager().getConnection() + .prepareStatement( + plugin.getDatabaseManager().isMySQL() + ? "INSERT INTO player_balances (uuid, currency, balance) VALUES (?, ?, ?) AS new ON DUPLICATE KEY UPDATE balance = new.balance" + : "INSERT INTO player_balances (uuid, currency, balance) VALUES (?, ?, ?) ON CONFLICT(uuid, currency) DO UPDATE SET balance = ?")) { + ps.setString(1, uuid.toString()); + ps.setString(2, currency); + ps.setBigDecimal(3, startBal); + if (!plugin.getDatabaseManager().isMySQL()) { + ps.setBigDecimal(4, startBal); + } + ps.executeUpdate(); + plugin.getComponentLogger().info("Created initial balance for " + uuid + ": " + startBal + " " + currency); + } catch (SQLException e) { + plugin.getComponentLogger().error("Database error while creating initial balance for " + uuid, e); + } + + balanceCache.computeIfAbsent(uuid, k -> new ConcurrentHashMap<>()).put(currency, startBal); + return startBal; + } + + public void setBalance(OfflinePlayer player, BigDecimal amount, String currency) { + UUID uuid = player.getUniqueId(); + BigDecimal normalizedAmount = amount.setScale(SCALE, ROUNDING_MODE); + + balanceCache.computeIfAbsent(uuid, k -> new ConcurrentHashMap<>()).put(currency, normalizedAmount); + + scheduleAsyncWrite(() -> { + try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( + plugin.getDatabaseManager().isMySQL() + ? "INSERT INTO player_balances (uuid, currency, balance) VALUES (?, ?, ?) AS new ON DUPLICATE KEY UPDATE balance = new.balance" + : "INSERT INTO player_balances (uuid, currency, balance) VALUES (?, ?, ?) ON CONFLICT(uuid, currency) DO UPDATE SET balance = ?")) { + ps.setString(1, uuid.toString()); + ps.setString(2, currency); + ps.setBigDecimal(3, normalizedAmount); + if (!plugin.getDatabaseManager().isMySQL()) { + ps.setBigDecimal(4, normalizedAmount); + } + ps.executeUpdate(); + updatePlayerMetadata(player); + } catch (SQLException e) { + plugin.getComponentLogger().error("Database error in EconomyManager while saving balance", e); + } + }); + } + + public void invalidateCache(UUID uuid) { + balanceCache.remove(uuid); + } } diff --git a/src/main/java/com/aureleconomy/utils/ChatPromptManager.java b/src/main/java/com/aureleconomy/utils/ChatPromptManager.java index 2757aa6..0fb76e0 100644 --- a/src/main/java/com/aureleconomy/utils/ChatPromptManager.java +++ b/src/main/java/com/aureleconomy/utils/ChatPromptManager.java @@ -1,12 +1,13 @@ package com.aureleconomy.utils; import com.aureleconomy.AurelEconomy; +import io.papermc.paper.event.player.AsyncChatEvent; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.Bukkit; 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.AsyncPlayerChatEvent; import org.bukkit.event.player.PlayerQuitEvent; import java.util.HashMap; @@ -28,13 +29,12 @@ public void prompt(Player player, Consumer onChat) { pendingPrompts.put(player.getUniqueId(), onChat); } - @SuppressWarnings("deprecation") @EventHandler(priority = EventPriority.LOWEST) - public void onChat(AsyncPlayerChatEvent event) { + public void onChat(AsyncChatEvent event) { Player player = event.getPlayer(); if (pendingPrompts.containsKey(player.getUniqueId())) { event.setCancelled(true); - String message = event.getMessage(); + String message = PlainTextComponentSerializer.plainText().serialize(event.message()); Consumer action = pendingPrompts.remove(player.getUniqueId()); // Run action synchronously diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 72d652b..843bf69 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,129 +1,129 @@ -# Aurelium Configuration (v1.4.1) +# Aurelium Configuration (v1.4.3) # Don't touch this, used for auto-migration config-version: 1 # --- Database --- database: - # sqlite or mysql - type: sqlite + # sqlite or mysql + type: sqlite - # SQLite - file: "database.db" + # SQLite + file: "database.db" - # MySQL (only if type is mysql) - mysql: - host: "localhost" - port: 3306 - database: "aurelium" - username: "root" - password: "password" + # MySQL (only if type is mysql) + mysql: + host: "localhost" + port: 3306 + database: "aurelium" + username: "root" + password: "password" # --- Economy --- economy: - default-currency: "Aurels" - - currencies: - Aurels: - symbol: "₳" - starting-balance: 100.0 - # Add more currencies here: - # Dollars: - # symbol: "$" - # starting-balance: 0.0 - - # -1 = no cap - max-balance: -1 - min-pay-amount: 0.01 - - # Messages (MiniMessage format) - # Placeholders: %amount%, %symbol%, %player%, %currency% - prefix: "[Aurelium] " - balance: "Balance (%currency%): %amount%%symbol%" - balance-other: "Balance of %player% (%currency%): %amount%%symbol%" - paid: "You paid %amount%%symbol% to %player%." - received: "You received %amount%%symbol% from %player%." - insufficient-funds: "You don't have enough %currency%!" - admin-give: "Gave %amount%%symbol% (%currency%) to %player%." - admin-take: "Took %amount%%symbol% (%currency%) from %player%." - admin-set: "Set %player%'s balance to %amount%%symbol% (%currency%)." + default-currency: "Aurels" + + currencies: + Aurels: + symbol: "₳" + starting-balance: 100.0 + # Add more currencies here: + # Dollars: + # symbol: "$" + # starting-balance: 0.0 + + # -1 = no cap + max-balance: -1 + min-pay-amount: 0.01 + + # Messages (MiniMessage format) + # Placeholders: %amount%, %symbol%, %player%, %currency% + prefix: "[Aurelium] " + balance: "Balance (%currency%): %amount%%symbol%" + balance-other: "Balance of %player% (%currency%): %amount%%symbol%" + paid: "You paid %amount%%symbol% to %player%." + received: "You received %amount%%symbol% from %player%." + insufficient-funds: "You don't have enough %currency%!" + admin-give: "Gave %amount%%symbol% (%currency%) to %player%." + admin-take: "Took %amount%%symbol% (%currency%) from %player%." + admin-set: "Set %player%'s balance to %amount%%symbol% (%currency%)." # --- Market --- market: - enabled: true - # "classic" = plain chest, "modern" = styled with borders and gradients - gui-mode: modern - dynamic-pricing: true - # How much prices shift per transaction (0.001 = 0.1%) - price-increase-per-buy: 0.001 - price-decrease-per-sell: 0.001 - # Sell price = buy price * this ratio - default-sell-ratio: 0.5 - - # Prevents auto-farms from tanking prices forever - price-floor: 0.2 # 20% of base = minimum price - price-ceiling: 5.0 # 500% of base = maximum price - - # Prices slowly drift back to base over time - price-recovery: - enabled: true - rate: 0.01 # 1% closer per cycle - interval-minutes: 10 - - # Broadcast when expensive items crash in value - alerts: - enabled: true - threshold: 0.5 # Alert at 50% of base price - min-base-price: 200.0 - - blacklist: - - BEDROCK - - BARRIER - - COMMAND_BLOCK - - STRUCTURE_VOID - - LIGHT + enabled: true + # "classic" = plain chest, "modern" = styled with borders and gradients + gui-mode: modern + dynamic-pricing: true + # How much prices shift per transaction (0.001 = 0.1%) + price-increase-per-buy: 0.001 + price-decrease-per-sell: 0.001 + # Sell price = buy price * this ratio + default-sell-ratio: 0.5 + + # Prevents auto-farms from tanking prices forever + price-floor: 0.2 # 20% of base = minimum price + price-ceiling: 5.0 # 500% of base = maximum price + + # Prices slowly drift back to base over time + price-recovery: + enabled: true + rate: 0.01 # 1% closer per cycle + interval-minutes: 10 + + # Broadcast when expensive items crash in value + alerts: + enabled: true + threshold: 0.5 # Alert at 50% of base price + min-base-price: 200.0 + + blacklist: + - BEDROCK + - BARRIER + - COMMAND_BLOCK + - STRUCTURE_VOID + - LIGHT # --- Auction House --- auction-house: - enabled: true - default-duration: 86400 # 24 hours - max-duration: 604800 # 7 days - listing-fee-percent: 2.0 - sales-tax-percent: 5.0 - max-listings-per-player: -1 - min-listing-price: 1.0 + enabled: true + default-duration: 86400 # 24 hours + max-duration: 604800 # 7 days + listing-fee-percent: 2.0 + sales-tax-percent: 5.0 + max-listings-per-player: -1 + min-listing-price: 1.0 # --- Buy Orders --- buy-orders: - enabled: true - max-active-orders-per-player: 10 - min-price-per-piece: 0.1 - max-order-value: -1 # -1 = no limit - creation-fee-percent: 2.0 - sales-tax-percent: 5.0 + enabled: true + max-active-orders-per-player: 10 + min-price-per-piece: 0.1 + max-order-value: -1 # -1 = no limit + creation-fee-percent: 2.0 + sales-tax-percent: 5.0 # --- Web Dashboard --- # Browser-based market UI. Players run /web to get a link. web: - enabled: true - # "local" = runs a webserver inside the plugin (needs open port) - # "cloud" = syncs to external server (works on all hosts) - mode: cloud - - # Only used when mode is "local" - local: - host: "localhost" - port: 8585 - session-timeout-minutes: 10 - cors-allowed-origins: [] - - # Only used when mode is "cloud" - cloud: - url: "https://webaureliummc.onrender.com" - # Auto-generated on first run, don't share these - server-id: "" - api-key: "" - sync-interval: 30 + enabled: true + # "local" = runs a webserver inside the plugin (needs open port) + # "cloud" = syncs to external server (works on all hosts) + mode: cloud + + # Only used when mode is "local" + local: + host: "localhost" + port: 8585 + session-timeout-minutes: 10 + cors-allowed-origins: [] + + # Only used when mode is "cloud" + cloud: + url: "https://webaureliummc.onrender.com" + # Auto-generated on first run, don't share these + server-id: "" + api-key: "" + sync-interval: 30 # --- Market Items (auto-generated, don't edit unless you know what you're doing) --- # Prices below are managed by the plugin. They update based on player activity. diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 97b55e6..abd4ccc 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,7 +1,7 @@ name: Aurelium -version: '1.4.2' +version: '1.4.5' main: com.aureleconomy.AurelEconomy -api-version: '1.21' +api-version: '26.1' description: Economy plugin with market, auction house, and web dashboard. authors: - APPLEPIE6969 @@ -12,7 +12,9 @@ commands: description: Check your balance usage: /bal [player] permission: aureleconomy.bal - aliases: [balance, money] + aliases: + - balance + - money pay: description: Send money to someone usage: /pay @@ -25,7 +27,8 @@ commands: description: Auction house usage: /ah [sell|bid|collect] permission: aureleconomy.ah - aliases: [auction] + aliases: + - auction sell: description: Sell items from your inventory usage: /sell @@ -37,7 +40,8 @@ commands: stocks: description: View item price trends usage: /stocks - aliases: [stonks] + aliases: + - stonks eco: description: Admin money commands usage: /eco [player]