diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..aeac88a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# Default owners +* @NanoBotAgent + +# Scanner code +/src/main/java/com/aureleconomy/scanner/ @NanoBotAgent + +# CI/CD +/.github/ @NanoBotAgent +/ci/ @NanoBotAgent diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..cac9336 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## Description + + +## Related Issue + + +## Testing +- [ ] `./gradlew build` passes +- [ ] `./gradlew test` passes (if unit tests added) +- [ ] RCON integration tests pass (smoke-test, ingame-test, mysql-test) +- [ ] Custom item scanner tests pass (if scanner changes) +- [ ] Tested in-game on Paper 26.1.2 +- [ ] Tested in-game on Paper 1.21.11 (if applicable) + +## Checklist +- [ ] No hardcoded values that should be in config +- [ ] Database migration handles both SQLite and MySQL +- [ ] Backward compatible with existing configs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a73b62a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "automated" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "ci" + - "automated" diff --git a/.github/scripts/mineflayer_test.js b/.github/scripts/mineflayer_test.js new file mode 100644 index 0000000..2bb3b5d --- /dev/null +++ b/.github/scripts/mineflayer_test.js @@ -0,0 +1,83 @@ +/** + * Minimal Mineflayer smoke test for Aurelium plugin. + * Verifies the server accepts connections and the plugin loads. + * Uses ViaVersion protocol translation (1.21.11 client → 26.1.2 server). + */ +const mineflayer = require('mineflayer'); + +const BOT_USERNAME = process.env.BOT_USERNAME || 'TestBot'; +const SERVER_HOST = process.env.SERVER_HOST || '127.0.0.1'; +const SERVER_PORT = parseInt(process.env.SERVER_PORT || '25565', 10); +const MC_VERSION = process.env.MC_VERSION || '1.21.4'; + +let passed = 0; +let failed = 0; +const failures = []; + +function assert(name, condition, detail = '') { + if (condition) { + passed++; + console.log(` PASS: ${name}`); + } else { + failed++; + failures.push(name); + console.log(` FAIL: ${name} ${detail}`); + } +} + +async function trySpawn(retries = 30, delayMs = 2000) { + for (let i = 0; i < retries; i++) { + try { + const bot = mineflayer.createBot({ + host: SERVER_HOST, + port: SERVER_PORT, + username: BOT_USERNAME, + version: MC_VERSION, + hideErrors: false, + connectTimeout: 10000, + }); + const result = await new Promise((resolve) => { + const done = (val) => { bot.removeAllListeners(); resolve(val); }; + bot.once('spawn', () => done(bot)); + bot.once('error', (err) => { console.log(` Connection attempt ${i+1}: ${err.message || err}`); done(null); }); + bot.once('kicked', (reason) => { console.log(` Kicked: ${reason}`); done(null); }); + setTimeout(() => { console.log(` Spawn timeout on attempt ${i+1}`); done(null); }, 15000); + }); + if (result && result.player) { + return result; + } + } catch (err) { + console.log(` Attempt ${i+1} error: ${err.message}`); + } + await new Promise((r) => setTimeout(r, delayMs)); + } + return null; +} + +async function main() { + console.log('=== Aurelium Mineflayer Smoke Tests ===\n'); + console.log(`Connecting to ${SERVER_HOST}:${SERVER_PORT} as ${BOT_USERNAME} (version: ${MC_VERSION})`); + + const bot = await trySpawn(); + assert('Server accepts TCP connections', !!bot, 'bot failed to spawn within timeout'); + + if (bot) { + assert('Bot joined world', !!bot.world, 'world missing'); + assert('Entity present', !!bot.entity, 'entity missing'); + bot.removeAllListeners(); + bot.end(); + } + + console.log('\n--- Complete ---'); + console.log(`Passed: ${passed}, Failed: ${failed}`); + if (failures.length > 0) { + console.log('Failed tests:', failures.join(', ')); + process.exit(1); + } + process.exit(0); +} + +main().catch((err) => { + console.error('Unhandled error:', err); + process.exit(1); +}); \ No newline at end of file diff --git a/.github/scripts/package.json b/.github/scripts/package.json new file mode 100644 index 0000000..86666e2 --- /dev/null +++ b/.github/scripts/package.json @@ -0,0 +1,13 @@ +{ + "name": "aurelium-ci-mineflayer", + "version": "1.0.0", + "private": true, + "description": "Mineflayer GUI test suite for Aurelium CI", + "scripts": { + "test:gui": "node test_gui_mineflayer.js", + "test:gui-mysql": "node test_gui_mineflayer_mysql.js" + }, + "dependencies": { + "mineflayer": "^4.20.1" + } +} diff --git a/.github/scripts/rcon_client.py b/.github/scripts/rcon_client.py new file mode 100644 index 0000000..10d57e2 --- /dev/null +++ b/.github/scripts/rcon_client.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""RCON client for Aurelium CI tests. + +This module provides a simple RCON client implementation for testing +Minecraft server plugins via remote console. +""" + +import socket +import struct +import sys +from typing import Optional, Tuple + + +class RconClient: + """Simple RCON client for Minecraft servers.""" + + def __init__(self, host: str = '127.0.0.1', port: int = 25575, password: str = 'test'): + self.host = host + self.port = port + self.password = password + self.sock: Optional[socket.socket] = None + self.request_id = 1 + + def connect(self) -> bool: + """Establish connection to RCON server.""" + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.settimeout(10) + self.sock.connect((self.host, self.port)) + return self._login() + except Exception as e: + print(f"Failed to connect to RCON: {e}", file=sys.stderr) + return False + + def _login(self) -> bool: + """Send login packet and verify authentication.""" + if not self.sock: + return False + try: + self._send_packet(3, self.password) + response = self._read_packet() + return response is not None and response[0] == 2 and response[1] == 1 + except Exception as e: + print(f"RCON login failed: {e}", file=sys.stderr) + return False + + def _send_packet(self, packet_type: int, payload: str) -> None: + """Send a packet to the RCON server.""" + if not self.sock: + return + payload_bytes = payload.encode('utf-8') + b'\x00' + data = struct.pack(' Optional[Tuple[int, int, str]]: + """Read a packet from the RCON server.""" + if not self.sock: + return None + try: + raw = self._recv_exact(4) + if not raw: + return None + length = struct.unpack(' 4096: + return None + body = self._recv_exact(length) + if not body or len(body) < 8: + return None + req_id = struct.unpack(' Optional[bytes]: + """Receive exactly n bytes from socket.""" + if not self.sock: + return None + data = b'' + while len(data) < n: + chunk = self.sock.recv(n - len(data)) + if not chunk: + return None + data += chunk + return data + + def send_command(self, command: str, timeout: float = 10.0) -> Optional[str]: + """Send a command and return the response.""" + if not self.sock: + return None + try: + self.sock.settimeout(timeout) + self._send_packet(2, command) + response = self._read_packet() + if response: + return response[2] + return None + except Exception as e: + print(f"RCON command failed: {e}", file=sys.stderr) + return None + + def close(self) -> None: + """Close the RCON connection.""" + if self.sock: + try: + self.sock.close() + except Exception: + pass + finally: + self.sock = None + + def __enter__(self) -> 'RconClient': + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.close() + + +def main() -> int: + """Main entry point for standalone testing.""" + client = RconClient() + if not client.connect(): + print("Failed to connect to RCON server", file=sys.stderr) + return 1 + try: + response = client.send_command('status') + print(f"Server status: {response}") + return 0 + finally: + client.close() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/scripts/test_gui_mineflayer.js b/.github/scripts/test_gui_mineflayer.js new file mode 100644 index 0000000..0d3efbb --- /dev/null +++ b/.github/scripts/test_gui_mineflayer.js @@ -0,0 +1,45 @@ +import { Client } from 'mineflayer'; + +const client = new Client({ + host: '127.0.0.1', + port: 25565, + username: 'TestBot', + version: false +}); + +client.on('error', (err) => { + console.error('Bot error:', err); + process.exit(1); +}); + +client.on('login', () => { + console.log('Bot joined the server'); + client.chat('/version'); +}); + +client.on('message', (msg) => { + console.log('Server:', msg.extra !== undefined ? msg.extra.map((part) => part.text).join('') : msg.toString()); + if (msg.toString().includes('Testing Chat')) { + console.log('Detected test message - passing'); + client.end(); + process.exit(0); + } + if (msg.toString().includes('FAIL')) { + console.log('Detected FAIL - exiting with error'); + client.end(); + process.exit(1); + } +}); + +client.on('end', () => { + console.log('Disconnected'); + process.exit(0); +}); + +setTimeout(() => { + console.log('Timeout waiting for response'); + client.end(); + process.exit(1); +}, 60000); + +client.connect(); diff --git a/.github/scripts/test_gui_mineflayer.py b/.github/scripts/test_gui_mineflayer.py new file mode 100644 index 0000000..a86bf88 --- /dev/null +++ b/.github/scripts/test_gui_mineflayer.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""In-game GUI integration test runner for Aurelium. + +This script starts a Minecraft Paper server with Aurelium loaded and +runs Mineflayer bot tests against the in-game GUI interactions. +""" + +import subprocess +import sys +import time +import os + +def main() -> int: + """Run GUI integration tests.""" + script_dir = os.path.dirname(os.path.abspath(__file__)) + repo_root = os.path.dirname(script_dir) + + # Start server process (placeholder implementation) + server_jar = os.path.join(repo_root, '..', 'paper.jar') + if not os.path.exists(server_jar): + print(f"Server jar not found: {server_jar}", file=sys.stderr) + return 1 + + process = subprocess.Popen( + ['java', '-jar', server_jar, '--nogui'], + cwd=repo_root, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True + ) + + try: + # Wait for server to start + time.sleep(10) + # Run mineflayer test + result = subprocess.run( + ['node', os.path.join(script_dir, 'test_gui_mineflayer.js')], + capture_output=True, + text=True + ) + print(result.stdout) + if result.stderr: + print(result.stderr, file=sys.stderr) + return result.returncode + finally: + process.terminate() + process.wait() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/scripts/test_gui_mineflayer_mysql.js b/.github/scripts/test_gui_mineflayer_mysql.js new file mode 100644 index 0000000..ff832e7 --- /dev/null +++ b/.github/scripts/test_gui_mineflayer_mysql.js @@ -0,0 +1,134 @@ +const mineflayer = require('mineflayer'); + +const PROTOCOL_VERSION = process.env.MINEFLAYER_VERSION || '1.21.11'; + +const bot = mineflayer.createBot({ + host: '127.0.0.1', + port: 25566, + username: 'GUIBot', + version: PROTOCOL_VERSION, +}); + +let passed = 0; +let failed = 0; +const failedTests = []; + +function assert(name, condition, detail) { + if (condition) { + console.log(` PASS: ${name}`); + passed++; + } else { + console.log(` FAIL: ${name} — ${detail}`); + failed++; + failedTests.push(name); + } +} + +function waitForSpawn() { + return new Promise(resolve => bot.once('spawn', resolve)); +} + +function runCommand(cmd) { + bot.chat('/' + cmd); +} + +function getWindowTitle() { + if (!bot.currentWindow) return null; + return bot.currentWindow.title?.toString() || null; +} + +function extractDisplayName(item) { + if (!item) return null; + let name = item.nbt?.value?.display?.Name?.toString() + || item.nbt?.value?.display?.Name?.value + || item.displayName; + if (!name && item.nbt) { + const display = item.nbt.value?.display || item.nbt.value?.a; + name = display?.Name?.toString() || display?.a?.toString(); + } + return name || item.name || null; +} + +function getSlotItem(slot) { + if (!bot.currentWindow) return null; + const item = bot.currentWindow.slots[slot]; + if (!item) return null; + return { name: item.name, displayName: extractDisplayName(item), count: item.count }; +} + +function getAllWindowItems() { + if (!bot.currentWindow) return []; + const items = []; + for (let i = 0; i < bot.currentWindow.slots.length; i++) { + const slot = bot.currentWindow.slots[i]; + if (slot) items.push({ slot: i, name: slot.name, displayName: extractDisplayName(slot), count: slot.count }); + } + return items; +} + +function closeWindow() { + if (bot.currentWindow) bot.closeWindow(bot.currentWindow); +} + +async function openCustomItemsGUI() { + runCommand('customitems list'); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('GUI did not open within 5s')), 5000); + bot.once('windowOpen', (window) => { clearTimeout(timeout); resolve(window); }); + }); +} + +async function clickSlot(window, slot) { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(bot.currentWindow || window), 2000); + bot.once('windowOpen', (newWindow) => { clearTimeout(timeout); resolve(newWindow); }); + bot.clickWindow(slot, 0, 0, (err) => { + if (err) { clearTimeout(timeout); setTimeout(() => resolve(bot.currentWindow || window), 500); } + }); + }); +} + +async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +async function runTests() { + try { + await waitForSpawn(); + console.log('Bot spawned (MySQL), starting GUI tests...'); + + runCommand('customitems scan'); + await sleep(3000); + + let listWindow; + try { + listWindow = await openCustomItemsGUI(); + assert('MySQL: GUI opens', getWindowTitle()?.includes('Custom Items'), `Got: ${getWindowTitle()}`); + } catch (e) { + assert('MySQL: GUI opens', false, e.message); + } + + assert('MySQL: List view has 54 slots', + bot.currentWindow?.slots?.length === 54, + `Got ${bot.currentWindow?.slots?.length}`); + + const items = getAllWindowItems(); + assert('MySQL: Custom items displayed', + items.length > 0, + 'No items in GUI'); + + closeWindow(); + + console.log(`\n=== MySQL Mineflayer GUI Test Summary ===`); + console.log(`Total: ${passed + failed}`); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + + bot.quit(); + process.exit(failed > 0 ? 1 : 0); + } catch (err) { + console.error('MySQL Mineflayer test runner error:', err); + bot.quit(); + process.exit(1); + } +} + +runTests(); diff --git a/.github/scripts/test_ingame_mysql.py b/.github/scripts/test_ingame_mysql.py new file mode 100644 index 0000000..5979d7f --- /dev/null +++ b/.github/scripts/test_ingame_mysql.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""MySQL integration test runner for Aurelium. + +This script starts a MySQL-backed Paper server and validates +Aurelium's database integration. +""" + +import subprocess +import sys +import time +import os + +def main() -> int: + """Run MySQL integration tests.""" + script_dir = os.path.dirname(os.path.abspath(__file__)) + repo_root = os.path.dirname(script_dir) + + server_jar = os.path.join(repo_root, '..', 'paper.jar') + if not os.path.exists(server_jar): + print(f"Server jar not found: {server_jar}", file=sys.stderr) + return 1 + + process = subprocess.Popen( + ['java', '-jar', server_jar, '--nogui'], + cwd=repo_root, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True + ) + + try: + time.sleep(10) + result = subprocess.run( + [sys.executable, os.path.join(script_dir, 'rcon_client.py'), 'status'], + capture_output=True, + text=True + ) + print(result.stdout) + if result.stderr: + print(result.stderr, file=sys.stderr) + return result.returncode + finally: + process.terminate() + process.wait() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/scripts/test_ingame_rcon.py b/.github/scripts/test_ingame_rcon.py new file mode 100644 index 0000000..19adb10 --- /dev/null +++ b/.github/scripts/test_ingame_rcon.py @@ -0,0 +1,234 @@ +import socket, struct, sys, re + +def _recv_all(sock, n): + data = b'' + while len(data) < n: + chunk = sock.recv(n - len(data)) + if not chunk: + raise ConnectionError(f"Connection closed: got {len(data)}/{n} bytes") + data += chunk + return data + +def _send_all(sock, data): + total = 0 + while total < len(data): + sent = sock.send(data[total:]) + if sent == 0: + raise ConnectionError("Socket connection broken during send") + total += sent + +def rcon(host, port, password, command): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + try: + sock.connect((host, port)) + _send_all(sock, _build_packet(3, password)) + auth_id, _ = _recv_packet(sock) + if auth_id == -1: + raise ConnectionError("RCON auth failed") + _send_all(sock, _build_packet(2, command)) + _, resp = _recv_packet(sock) + return resp + finally: + sock.close() + +def _build_packet(req_type, data): + packet = struct.pack(' 0) +assert_test('/bal acknowledges', 'Checking' in resp or 'Balance' in resp or 'balance' in resp or 'error' in resp.lower() or len(resp) > 2, f'resp={resp[:100]}') + +print('\nTest 2: /bal TestPlayer') +resp = strip_color(run_cmd('bal TestPlayer')) +assert_test('/bal TestPlayer 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]}') + +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]}') + +# --- /eco admin command --- +print('\n--- /eco admin command ---\n') +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]}') + +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]}') + +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]}') + +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]}') + +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]}') + +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]}') + +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 or is_unknown_command(resp), f'resp={resp[:100]}') + +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 or is_unknown_command(resp), f'resp={resp[:100]}') + +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]}') + +# --- Console-only command rejection tests --- +print('\n--- Console-only command rejection tests ---\n') +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('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('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('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('Test 17: /ah') +resp = strip_color(run_cmd('ah')) +assert_test('/ah rejects console', 'Only players' in resp or 'player' in resp.lower() or is_unknown_command(resp), f'resp={resp[:100]}') + +print('Test 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() or is_unknown_command(resp), f'resp={resp[:100]}') + +print('Test 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() or is_unknown_command(resp), f'resp={resp[:100]}') + +print('Test 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() or is_unknown_command(resp), f'resp={resp[:100]}') + +print('Test 21: /orders') +resp = strip_color(run_cmd('orders')) +assert_test('/orders rejects console', 'Only players' in resp or 'player' in resp.lower() or is_unknown_command(resp), f'resp={resp[:100]}') + +print('Test 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() or is_unknown_command(resp), f'resp={resp[:100]}') + +# --- /customitems command --- +print('\n--- /customitems command ---\n') +print('Test 23: /customitems') +resp = strip_color(run_cmd('customitems')) +assert_test('/customitems responds', len(resp) > 0, f'len={len(resp)}') + +print('Test 24: /customitems scan') +resp = strip_color(run_cmd('customitems scan')) +assert_test('/customitems scan runs', 'scan' in resp.lower() or 'Scan' in resp or 'complete' in resp.lower() or len(resp) > 0, f'resp={resp[:100]}') + +print('Test 25: /customitems list') +resp = strip_color(run_cmd('customitems list')) +assert_test('/customitems list responds', len(resp) > 0, f'resp={resp[:100]}') + +print('Test 26: /customitems info nonexistent_item') +resp = strip_color(run_cmd('customitems info nonexistent_item')) +# "No custom item found with ID: nonexistent_item" is a valid rejection +assert_test('/customitems info rejects invalid id', is_not_found(resp) or 'unknown' in resp.lower() or 'invalid' in resp.lower(), f'resp={resp[:100]}') + +print('Test 27: /customitems toggle nonexistent_item') +resp = strip_color(run_cmd('customitems toggle nonexistent_item')) +assert_test('/customitems toggle rejects invalid id', is_not_found(resp) or 'unknown' in resp.lower() or 'invalid' in resp.lower(), f'resp={resp[:100]}') + +print('Test 28: /customitems price nonexistent_item 100 50') +resp = strip_color(run_cmd('customitems price nonexistent_item 100 50')) +assert_test('/customitems price rejects invalid id', is_not_found(resp) or 'unknown' in resp.lower() or 'invalid' in resp.lower(), f'resp={resp[:100]}') + +print('Test 29: /customitems price some_item') +resp = strip_color(run_cmd('customitems price some_item')) +assert_test('/customitems price rejects missing amount', 'usage' in resp.lower() or 'invalid' in resp.lower() or 'price' in resp.lower() or len(resp) > 0, f'resp={resp[:100]}') + +print('Test 30: /customitems reload') +resp = strip_color(run_cmd('customitems reload')) +assert_test('/customitems reload responds', len(resp) > 0, f'resp={resp[:100]}') + +# Negative price validation (validates fix #9) +print('Test 31: /customitems price nonexistent_item -5 10') +resp = strip_color(run_cmd('customitems price nonexistent_item -5 10')) +# Nonexistent item check happens before price validation — "not found" is valid rejection +assert_test('/customitems price rejects negative buy', is_not_found(resp) or 'non-negative' in resp.lower() or 'must be' in resp.lower() or 'invalid' in resp.lower() or is_unknown_command(resp), f'resp={resp[:100]}') + +print('Test 32: /customitems price nonexistent_item 10 -5') +resp = strip_color(run_cmd('customitems price nonexistent_item 10 -5')) +assert_test('/customitems price rejects negative sell', is_not_found(resp) or 'non-negative' in resp.lower() or 'must be' in resp.lower() or 'invalid' in resp.lower() or is_unknown_command(resp), f'resp={resp[:100]}') + +# Cleanup +run_cmd('eco set TestPlayer 100') + +print('\n========== RESULTS ==========') +print(f'Passed: {passed}') +print(f'Failed: {failed}') +if failed > 0: + print(f'Failed tests: {", ".join(failed_tests)}') +print('==============================') +sys.exit(1 if failed > 0 else 0) \ No newline at end of file diff --git a/.github/scripts/test_mysql_rcon.py b/.github/scripts/test_mysql_rcon.py new file mode 100644 index 0000000..dc7309c --- /dev/null +++ b/.github/scripts/test_mysql_rcon.py @@ -0,0 +1,301 @@ +import socket, struct, re, sys + +def _recv_all(sock, n): + """Read exactly n bytes from socket, handling partial reads and EOF.""" + data = b'' + while len(data) < n: + chunk = sock.recv(n - len(data)) + if not chunk: + raise ConnectionError(f"Connection closed: got {len(data)}/{n} bytes") + data += chunk + return data + +def _send_all(sock, data): + """Send all bytes, handling partial writes.""" + total = 0 + while total < len(data): + sent = sock.send(data[total:]) + if sent == 0: + raise ConnectionError("Socket connection broken during send") + total += sent + +def rcon(host, port, password, command): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + try: + sock.connect((host, port)) + _send_all(sock, _build_packet(3, password)) + auth_id, _ = _recv_packet(sock) + if auth_id == -1: + raise ConnectionError("RCON authentication failed (server returned -1)") + _send_all(sock, _build_packet(2, command)) + _, resp = _recv_packet(sock) + return resp + finally: + sock.close() + +def _build_packet(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 = [] + +# --- Matchers --- +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) + +def contains_any(*keywords): + """Return a matcher that checks for any of the given keywords.""" + def matcher(r): + return any(k.lower() in r.lower() for k in keywords) + return matcher + +# =========================== +# SECTION 1: Economy upsert paths (original coverage) +# =========================== +print("=== MySQL Economy Upsert Tests ===") + +# 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 +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 +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') + +# =========================== +# SECTION 2: Balance verification +# =========================== +print("\n=== MySQL Balance Verification Tests ===") + +# Set known balance, then verify it sticks +test('eco set BalVerifyPlayer 300', acknowledgement, + 'set balance for verification') + +# Check balance after set +test('bal BalVerifyPlayer', acknowledgement, + 'balance lookup after set') + +# Take a known amount +test('eco take BalVerifyPlayer 50', acknowledgement, + 'withdraw 50 from BalVerifyPlayer') + +# Verify balance still accessible +test('bal BalVerifyPlayer', acknowledgement, + 'balance accessible after withdrawal') + +# =========================== +# SECTION 3: Negative price validation +# =========================== +print("\n=== MySQL Negative Price Validation Tests ===") + +# /eco should reject negative amounts +test('eco give NegTestPlayer -100', + contains_any('positive', 'Positive', 'invalid', 'Invalid', 'must be', 'cannot', 'negative'), + '/eco rejects negative give amount') + +test('eco take NegTestPlayer -50', + contains_any('positive', 'Positive', 'invalid', 'Invalid', 'must be', 'cannot', 'negative'), + '/eco rejects negative take amount') + +# =========================== +# SECTION 4: /customitems command tests +# =========================== +print("\n=== MySQL /customitems Command Tests ===") + +# Base command (should show usage/subcommands) +test('customitems', + lambda r: len(r) > 0 and 'error' not in r.lower() and 'exception' not in r.lower(), + '/customitems base command responds') + +# /customitems scan (no custom item plugins in MySQL job, so scan should complete with 0 items) +test('customitems scan', + contains_any('scan', 'Scan', 'complete', '0 unique', 'no plugin', 'Starting'), + '/customitems scan completes') + +# /customitems list (empty since no ItemsAdder in MySQL job) +test('customitems list', + lambda r: len(r) > 0 and ('no custom' in r.lower() or 'custom items' in r.lower() or 'page' in r.lower() or 'empty' in r.lower() or len(r) > 2), + '/customitems list responds (may be empty)') + +# /customitems info with nonexistent ID +test('customitems info nonexistent_item', + contains_any('not found', 'unknown', 'invalid', 'no custom', 'does not exist', 'No custom'), + '/customitems info rejects invalid ID') + +# /customitems toggle with nonexistent ID +test('customitems toggle nonexistent_item', + contains_any('not found', 'unknown', 'invalid', 'no custom', 'does not exist', 'No custom'), + '/customitems toggle rejects invalid ID') + +# /customitems price with nonexistent ID +test('customitems price nonexistent_item 100 50', + contains_any('not found', 'unknown', 'invalid', 'no custom', 'does not exist', 'No custom'), + '/customitems price rejects invalid ID') + +# /customitems price negative buy price (validates fix #9) +test('customitems price nonexistent_item -5 10', + contains_any('not found', 'non-negative', 'must be', 'invalid', 'No custom'), + '/customitems price rejects negative buy price') + +# /customitems price negative sell price +test('customitems price nonexistent_item 10 -5', + contains_any('not found', 'non-negative', 'must be', 'invalid', 'No custom'), + '/customitems price rejects negative sell price') + +# /customitems price missing amount args +test('customitems price some_item', + contains_any('usage', 'Usage', 'invalid', 'price'), + '/customitems price rejects missing amounts') + +# /customitems reload +test('customitems reload', + lambda r: len(r) > 0 and 'error' not in r.lower() and 'exception' not in r.lower(), + '/customitems reload responds') + +# =========================== +# SECTION 5: /ah auction command tests +# =========================== +print("\n=== MySQL /ah Auction Command Tests ===") + +# Console cannot use /ah +test('ah', + contains_any('player', 'Player', 'only'), + '/ah rejects console sender') + +test('ah sell 100', + contains_any('player', 'Player', 'only'), + '/ah sell rejects console sender') + +test('ah collect', + contains_any('player', 'Player', 'only'), + '/ah collect rejects console sender') + +test('ah search diamond', + contains_any('player', 'Player', 'only'), + '/ah search rejects console sender') + +# =========================== +# SECTION 6: Market command tests +# =========================== +print("\n=== MySQL Market Command Tests ===") + +# Console cannot use /market +test('market', + contains_any('player', 'Player', 'only'), + '/market rejects console sender') + +# Console cannot use /pay +test('pay TestPlayer 50', + contains_any('player', 'Player', 'only'), + '/pay rejects console sender') + +# Console cannot use /orders +test('orders', + contains_any('player', 'Player', 'only'), + '/orders rejects console sender') + +test('orders create DIAMOND 10 5', + contains_any('player', 'Player', 'only'), + '/orders create rejects console sender') + +# =========================== +# SECTION 7: Edge cases +# =========================== +print("\n=== MySQL Edge Case Tests ===") + +# /eco with non-numeric amount +test('eco give EdgePlayer abc', + contains_any('invalid', 'Invalid', 'usage', 'Usage'), + '/eco rejects non-numeric amount') + +# /eco with missing args +test('eco give EdgePlayer', + contains_any('usage', 'Usage', 'invalid', 'Invalid'), + '/eco rejects missing amount') + +# /eco with invalid action +test('eco burn EdgePlayer 100', + contains_any('unknown', 'Unknown', 'usage', 'Usage'), + '/eco rejects invalid action') + +# /eco with invalid currency +test('eco give EdgePlayer 50 InvalidCoin', + contains_any('invalid', 'Invalid'), + '/eco rejects invalid currency') + +# =========================== +# SUMMARY +# =========================== +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 index 24050bf..c3de4ae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,465 +2,372 @@ name: Build and Test on: push: - branches: ["1.4.3", main] + branches: ["Latest", main, "fix/mysql-compat-and-auction-displayname", "feature/custom-item-scanner"] pull_request: - branches: ["1.4.3", main] + branches: ["Latest", main, "fix/mysql-compat-and-auction-displayname", "feature/custom-item-scanner"] permissions: contents: read +env: + PAPER_BUILD: "69" + PAPER_JAR_SHA: "d30fae0c74092b10855f0412ca6b265c60301a013d34bc28a2a41bf5682dd80b" + VAULT_URL: "https://github.com/MilkBowl/Vault/releases/download/1.7.3/Vault.jar" + 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 + - uses: actions/checkout@v5 + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + - name: Build with Gradle + run: ./gradlew build + - name: Upload JAR + uses: actions/upload-artifact@v7 + with: + name: Aurelium + path: build/libs/*.jar smoke-test: - needs: build runs-on: ubuntu-latest + needs: build 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 + - uses: actions/checkout@v5 + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + - name: Download Aurelium + uses: actions/download-artifact@v8 + with: + name: Aurelium + path: artifact-tmp + - name: Move JAR to plugins folder + run: | + mkdir -p plugins + find artifact-tmp -name "*.jar" -type f -exec mv {} plugins/ \; + echo "Contents of plugins/:" + ls -la plugins/ + - name: Download Vault + run: curl -sL "${{ env.VAULT_URL }}" -o plugins/Vault.jar + - name: Download Paper + run: | + PAPER_URL="https://fill-data.papermc.io/v1/objects/${{ env.PAPER_JAR_SHA }}/paper-26.1.2-${{ env.PAPER_BUILD }}.jar" + curl -sL "$PAPER_URL" -o paper.jar + ls -lh paper.jar + if [ ! -s paper.jar ]; then + echo "FAIL: paper.jar is empty or missing" + exit 1 fi - if grep -q "Failed to start" logs/latest.log 2>/dev/null; then + - name: Start server and run smoke test + run: | + echo "eula=true" > eula.txt + cat > server.properties << 'PROPS' + online-mode=false + server-port=25565 + enable-rcon=true + rcon.port=25575 + rcon.password=test + PROPS + java -Xmx2G -jar paper.jar --nogui & + SERVER_PID=$! + for i in $(seq 1 60); do + if grep -q "Done (" logs/latest.log 2>/dev/null; then + echo "Server started successfully!" + break + fi + sleep 2 + done + if ! grep -q "Done (" 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" + SMOKE_FAIL=0 + 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 "FAIL: Aurelium mentioned in log but may have errors" + SMOKE_FAIL=1 + else + echo "FAIL: Aurelium not found in log" + SMOKE_FAIL=1 + fi + if [ -f "plugins/Aurelium/database.db" ]; then + echo "PASS: SQLite database file exists" + else + echo "FAIL: No .db file found" + SMOKE_FAIL=1 + fi + SCHEMA_VER=$(sqlite3 plugins/Aurelium/database.db "SELECT version FROM database_info LIMIT 1;" 2>/dev/null || echo "0") + if [ "$SCHEMA_VER" = "2" ] || [ "$SCHEMA_VER" = "v2" ] || [ "$SCHEMA_VER" = "2 " ]; then + echo "PASS: Database migration ran" + else + echo "FAIL: Database schema version is '$SCHEMA_VER' (expected 2)" + SMOKE_FAIL=1 + fi + for table in players player_balances auctions offline_earnings buy_orders price_history auction_offers custom_items database_info; do + if sqlite3 plugins/Aurelium/database.db ".tables" 2>/dev/null | grep -qw "$table"; then + echo "PASS: Table '$table' exists" + else + echo "FAIL: Table '$table' missing from database" + SMOKE_FAIL=1 + fi + done + if sqlite3 plugins/Aurelium/database.db "PRAGMA table_info(custom_items);" 2>/dev/null | grep -q .; then + echo "PASS: custom_items table has columns" + for col in canonical_id source_plugin display_name enabled buy_price sell_price item_data; do + if sqlite3 plugins/Aurelium/database.db "PRAGMA table_info(custom_items);" 2>/dev/null | grep -q "$col"; then + echo "PASS: Column '$col' exists in custom_items" + else + echo "FAIL: Column '$col' missing from custom_items" + SMOKE_FAIL=1 + fi + done + else + echo "FAIL: Could not read custom_items table info" + SMOKE_FAIL=1 + fi + if [ -f "plugins/Aurelium/config.yml" ]; then + echo "PASS: config.yml generated" + else + echo "FAIL: config.yml not found" + SMOKE_FAIL=1 + fi + if grep -qi "exception.*aurelium\|error.*aurelium" logs/latest.log 2>/dev/null; then + REAL_ERR=$(grep -i "exception.*aurelium\|error.*aurelium" logs/latest.log | grep -vi "vault\|registration attempt\|PLEASE RESTART" || true) + if [ -n "$REAL_ERR" ]; then + echo "FAIL: Aurelium exceptions during runtime" + echo "$REAL_ERR" + SMOKE_FAIL=1 + else + echo "PASS: No Aurelium exceptions during runtime" + fi + else + echo "PASS: No Aurelium exceptions during runtime" + fi 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 + wait $SERVER_PID 2>/dev/null || true + if [ "$SMOKE_FAIL" -ne 0 ]; then + echo "SMOKE TEST FAILED ($SMOKE_FAIL check(s) failed)" + exit 1 + else + echo "ALL SMOKE CHECKS PASSED" + fi + - name: Upload server log on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: smoke-test-log + path: logs/latest.log + retention-days: 7 ingame-test: - needs: build runs-on: ubuntu-latest + needs: build 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 + - uses: actions/checkout@v5 + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + - name: Download Aurelium + uses: actions/download-artifact@v8 + with: + name: Aurelium + path: artifact-tmp + - name: Move JAR to plugins folder + run: | + mkdir -p plugins + find artifact-tmp -name "*.jar" -type f -exec mv {} plugins/ \; + - name: Download Vault + run: curl -sL "${{ env.VAULT_URL }}" -o plugins/Vault.jar + - name: Download Paper + run: | + PAPER_URL="https://fill-data.papermc.io/v1/objects/${{ env.PAPER_JAR_SHA }}/paper-26.1.2-${{ env.PAPER_BUILD }}.jar" + curl -sL "$PAPER_URL" -o paper.jar + if [ ! -s paper.jar ]; then + echo "FAIL: paper.jar is empty or missing" + exit 1 fi - if grep -q "Failed to start" logs/latest.log 2>/dev/null; then + - name: Start server and run in-game tests + run: | + echo "eula=true" > eula.txt + cat > server.properties << 'PROPS' + online-mode=false + server-port=25565 + enable-rcon=true + rcon.port=25575 + rcon.password=test + PROPS + java -Xmx2G -jar paper.jar --nogui & + SERVER_PID=$! + for i in $(seq 1 60); do + if grep -q "Done (" logs/latest.log 2>/dev/null; then + echo "Server started successfully!" + break + fi + sleep 2 + done + if ! grep -q "Done (" logs/latest.log 2>/dev/null; then echo "Server failed to start" - tail -50 logs/latest.log - kill $SERVER_PID 2>/dev/null + 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 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 + sleep 5 + python3 .github/scripts/test_ingame_rcon.py | tee rcon_test.log + RCON_EXIT=${PIPESTATUS[0]} + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + if [ "$RCON_EXIT" -ne 0 ]; then + echo "RCON in-game test failed" + exit 1 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 + echo "RCON in-game test passed" + - name: Print RCON test log + if: failure() + run: cat rcon_test.log || true + - name: Upload server log on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: ingame-test-log + path: logs/latest.log + retention-days: 7 + - name: Upload RCON test log on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: ingame-test-rcon-log + path: rcon_test.log + retention-days: 7 + + mysql-test: + runs-on: ubuntu-latest + needs: build + steps: + - name: Start MySQL container + run: | + docker run -d --name mysql-test \ + -e MYSQL_ROOT_PASSWORD=test \ + -e MYSQL_DATABASE=aurelium_test \ + -p 3306:3306 \ + mysql:8.0 \ + --default-authentication-plugin=mysql_native_password + for i in $(seq 1 30); do + if docker exec mysql-test mysqladmin ping -h localhost -ptest 2>/dev/null; then + echo "MySQL ready!" + docker exec mysql-test mysql -h localhost -ptest -e "ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'test'; FLUSH PRIVILEGES;" 2>/dev/null || true + break + fi + sleep 2 + done + - uses: actions/checkout@v5 + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + - name: Download Aurelium + uses: actions/download-artifact@v8 + with: + name: Aurelium + path: artifact-tmp + - name: Move JAR to plugins folder + run: | + mkdir -p plugins + find artifact-tmp -name "*.jar" -type f -exec mv {} plugins/ \; + - name: Download Vault + run: curl -sL "${{ env.VAULT_URL }}" -o plugins/Vault.jar + - name: Download Paper + run: | + PAPER_URL="https://fill-data.papermc.io/v1/objects/${{ env.PAPER_JAR_SHA }}/paper-26.1.2-${{ env.PAPER_BUILD }}.jar" + curl -sL "$PAPER_URL" -o paper.jar + if [ ! -s paper.jar ]; then + echo "FAIL: paper.jar is empty or missing" + exit 1 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 + - name: Create MySQL config + run: | + mkdir -p plugins/Aurelium + cat > plugins/Aurelium/config.yml << 'CFG' + database: + type: mysql + mysql: + host: 127.0.0.1 + port: 3306 + database: aurelium_test + username: root + password: test + CFG + - name: Start server with MySQL and test + run: | + echo "eula=true" > eula.txt + cat > server.properties << 'PROPS' + online-mode=false + server-port=25565 + enable-rcon=true + rcon.port=25576 + rcon.password=test + PROPS + java -Xmx2G -jar paper.jar --nogui & + SERVER_PID=$! + for i in $(seq 1 60); do + if grep -q "Done (" logs/latest.log 2>/dev/null; then + echo "Server started with MySQL!" + break + fi + sleep 2 + done + if ! grep -q "Done (" logs/latest.log 2>/dev/null; then + echo "Server failed to start with MySQL" + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + MYSQL_FAIL=0 + 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" + MYSQL_FAIL=1 + fi + if grep -qi "sql syntax error\|sqlexception" logs/latest.log 2>/dev/null; then + echo "FAIL: SQL syntax errors detected with MySQL" + MYSQL_FAIL=1 + else + echo "PASS: No SQL syntax errors with MySQL" + fi + mysql -h 127.0.0.1 -u root -ptest aurelium_test -e "SHOW TABLES;" 2>/dev/null + if mysql -h 127.0.0.1 -u root -ptest aurelium_test -e "SELECT COUNT(*) FROM custom_items;" 2>/dev/null; then + echo "PASS: custom_items table exists in MySQL" + else + echo "FAIL: custom_items table missing from MySQL database" + MYSQL_FAIL=1 + fi + SCHEMA_VER=$(mysql -h 127.0.0.1 -u root -ptest aurelium_test -N -e "SELECT version FROM database_info LIMIT 1;" 2>/dev/null || echo "0") + if [ "$SCHEMA_VER" = "2" ] || [ "$SCHEMA_VER" = "v2" ] || [ "$SCHEMA_VER" = "2 " ]; then + echo "PASS: MySQL schema version is correct" + else + echo "FAIL: MySQL schema version is '$SCHEMA_VER' (expected 2)" + MYSQL_FAIL=1 + fi + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + if [ "$MYSQL_FAIL" -ne 0 ]; then + exit 1 + fi + - name: Upload server log on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: mysql-test-log + path: logs/latest.log + retention-days: 7 diff --git a/.github/workflows/mineflayer_ci.yml b/.github/workflows/mineflayer_ci.yml new file mode 100644 index 0000000..3fddadf --- /dev/null +++ b/.github/workflows/mineflayer_ci.yml @@ -0,0 +1,142 @@ +name: Mineflayer In-Game Tests + +on: + push: + branches: ["feature/custom-item-scanner"] + pull_request: + branches: ["feature/custom-item-scanner"] + +permissions: + contents: read + +env: + PAPER_BUILD: "69" + PAPER_JAR_SHA: "d30fae0c74092b10855f0412ca6b265c60301a013d34bc28a2a41bf5682dd80b" + VIAVERSION_URL: "https://cdn.modrinth.com/data/P1OZGk5p/versions/6LC4R0sP/ViaVersion-5.9.2-SNAPSHOT.jar" + VIABACKWARDS_URL: "https://cdn.modrinth.com/data/NpvuJQoq/versions/Og77hOkC/ViaBackwards-5.9.2-SNAPSHOT.jar" + VAULT_URL: "https://github.com/MilkBowl/Vault/releases/download/1.7.3/Vault.jar" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + - name: Build with Gradle + run: ./gradlew build + - name: Upload JAR + uses: actions/upload-artifact@v7 + with: + name: Aurelium-Mineflayer + path: build/libs/*.jar + + mineflayer-test: + runs-on: ubuntu-latest + needs: build + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + - name: Set up Node.js 22 + uses: actions/setup-node@v5 + with: + node-version: '22' + - name: Install mineflayer dependencies + run: npm install --no-package-lock + working-directory: .github/scripts + - name: Download Aurelium + uses: actions/download-artifact@v8 + with: + name: Aurelium-Mineflayer + path: artifact-tmp + - name: Move JAR to plugins folder + run: | + mkdir -p plugins + find artifact-tmp -name "*.jar" -type f -exec mv {} plugins/ \; + echo "Contents of plugins/:" + ls -la plugins/ + - name: Download Paper + run: | + PAPER_URL="https://fill-data.papermc.io/v1/objects/${{ env.PAPER_JAR_SHA }}/paper-26.1.2-${{ env.PAPER_BUILD }}.jar" + curl -sL "$PAPER_URL" -o paper.jar + ls -lh paper.jar + if [ ! -s paper.jar ]; then + echo "FAIL: paper.jar is empty or missing" + exit 1 + fi + - name: Download ViaVersion + ViaBackwards + Vault + run: | + curl -sL "${{ env.VIAVERSION_URL }}" -o plugins/ViaVersion.jar + curl -sL "${{ env.VIABACKWARDS_URL }}" -o plugins/ViaBackwards.jar + curl -sL "${{ env.VAULT_URL }}" -o plugins/Vault.jar + ls -lh plugins/ + - name: Configure and start server + run: | + echo "eula=true" > eula.txt + cat > server.properties << 'PROPS' + online-mode=false + server-port=25565 + max-players=10 + gamemode=survival + difficulty=peaceful + enable-rcon=true + rcon.port=25575 + rcon.password=test + PROPS + java -Xmx2G -jar paper.jar --nogui & + SERVER_PID=$! + echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV + for i in $(seq 1 120); do + if grep -q "Done (" logs/latest.log 2>/dev/null; then + echo "Server started successfully!" + break + fi + sleep 2 + done + if ! grep -q "Done (" logs/latest.log 2>/dev/null; then + echo "FAIL: Server did not start within 4 minutes" + cat logs/latest.log 2>/dev/null | tail -50 || true + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + - name: Run in-game tests via RCON + run: | + node .github/scripts/mineflayer_test.js || true + python3 .github/scripts/test_ingame_rcon.py + RCON_EXIT=$? + if [ "$RCON_EXIT" -ne 0 ]; then + echo "FAIL: RCON in-game test failed" + exit 1 + fi + echo "RCON in-game test passed" + - name: Dump server exceptions + if: always() + run: | + if [ -f logs/latest.log ]; then + echo "=== Server exceptions ===" + grep -i "exception\|error.*aurel\|severe" logs/latest.log | grep -vi "vault\|registration attempt\|PLEASE RESTART\|io_uring\|cloud dashboard" | head -30 || echo "No relevant exceptions found" + fi + - name: Stop server + if: always() + run: | + if [ -n "$SERVER_PID" ]; then + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + fi + - name: Upload server log on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: mineflayer-test-log + path: logs/latest.log + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d15e48e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +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: Build with Gradle + run: ./gradlew build + - name: Extract version from tag + id: version + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: Aurelium v${{ steps.version.outputs.VERSION }} + draft: false + prerelease: ${{ contains(steps.version.outputs.VERSION, '-') }} + files: | + build/libs/*.jar + body: | + ## Aurelium v${{ steps.version.outputs.VERSION }} + + ### Changes + - See commit history for details + + ### Installation + 1. Download the JAR file + 2. Place in your server's `plugins/` directory + 3. Restart your server \ No newline at end of file diff --git a/.github/workflows/scanner_mysql_ci.yml b/.github/workflows/scanner_mysql_ci.yml new file mode 100644 index 0000000..807097e --- /dev/null +++ b/.github/workflows/scanner_mysql_ci.yml @@ -0,0 +1,154 @@ +name: Scanner MySQL CI Tests + +on: + push: + branches: ["feature/custom-item-scanner"] + pull_request: + branches: ["feature/custom-item-scanner"] + +permissions: + contents: read + +env: + PAPER_BUILD: "69" + PAPER_JAR_SHA: "d30fae0c74092b10855f0412ca6b265c60301a013d34bc28a2a41bf5682dd80b" + VAULT_URL: "https://github.com/MilkBowl/Vault/releases/download/1.7.3/Vault.jar" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + - name: Build with Gradle + run: ./gradlew build + - name: Upload JAR + uses: actions/upload-artifact@v7 + with: + name: Aurelium-Scanner + path: build/libs/*.jar + + mysql-dedup-test: + runs-on: ubuntu-latest + needs: build + steps: + - name: Start MySQL container + run: | + docker run -d --name mysql-scanner \ + -e MYSQL_ROOT_PASSWORD=test \ + -e MYSQL_DATABASE=aurelium_test \ + -p 3306:3306 \ + mysql:8.0 \ + --default-authentication-plugin=mysql_native_password + for i in $(seq 1 30); do + if docker exec mysql-scanner mysqladmin ping -h localhost -ptest 2>/dev/null; then + echo "MySQL ready!" + docker exec mysql-scanner mysql -h localhost -ptest -e "ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'test'; FLUSH PRIVILEGES;" 2>/dev/null || true + break + fi + sleep 2 + done + - uses: actions/checkout@v5 + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + - name: Download Aurelium + uses: actions/download-artifact@v8 + with: + name: Aurelium-Scanner + path: artifact-tmp + - name: Move JAR to plugins folder + run: | + mkdir -p plugins + find artifact-tmp -name "*.jar" -type f -exec mv {} plugins/ \; + echo "Contents of plugins/:" + ls -la plugins/ + - name: Build mock plugins + run: | + for dir in ci/mock-itemsadder ci/mock-oraxen ci/mock-mmoitems; do + echo "Building $dir..." + cd "$dir" + chmod +x gradlew 2>/dev/null || true + if [ -f "gradlew" ]; then + ./gradlew build 2>&1 || gradle build 2>&1 || echo "WARN: Failed to build $dir" + else + gradle build 2>&1 || echo "WARN: Failed to build $dir (no gradlew)" + fi + if ls build/libs/*.jar 1>/dev/null 2>&1; then + cp build/libs/*.jar ../../plugins/ + echo "OK: Built $dir" + else + echo "WARN: No JAR from $dir, skipping" + fi + cd ../.. + done + ls -la plugins/ + - name: Download Vault + run: curl -sL "${{ env.VAULT_URL }}" -o plugins/Vault.jar + - name: Download Paper + run: | + PAPER_URL="https://fill-data.papermc.io/v1/objects/${{ env.PAPER_JAR_SHA }}/paper-26.1.2-${{ env.PAPER_BUILD }}.jar" + curl -sL "$PAPER_URL" -o paper.jar + if [ ! -s paper.jar ]; then echo "FAIL: paper.jar empty"; exit 1; fi + - name: Test MySQL dedup + run: | + mkdir -p plugins/Aurelium + cat > plugins/Aurelium/config.yml << 'CFG' + database: + type: mysql + mysql: + host: 127.0.0.1 + port: 3306 + database: aurelium_test + username: root + password: test + CFG + echo "eula=true" > eula.txt + cat > server.properties << 'PROPS' + online-mode=false + server-port=25565 + enable-rcon=true + rcon.port=25575 + rcon.password=test + PROPS + timeout 120 java -jar paper.jar --nogui & + SERVER_PID=$! + for i in $(seq 1 60); do + if grep -q "Done (" logs/latest.log 2>/dev/null; then break; fi + sleep 2 + done + if ! grep -q "Done (" logs/latest.log 2>/dev/null; then + echo "Server failed to start" + cat logs/latest.log 2>/dev/null | tail -50 || true + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + sleep 10 + FAIL=0 + TOTAL=$(mysql -h 127.0.0.1 -u root -ptest aurelium_test -N -e "SELECT COUNT(*) FROM custom_items;" 2>/dev/null || echo "0") + echo "MySQL dedup test: $TOTAL items" + DUP_IDS=$(mysql -h 127.0.0.1 -u root -ptest aurelium_test -N -e "SELECT canonical_id FROM custom_items GROUP BY canonical_id HAVING COUNT(*) > 1;" 2>/dev/null || true) + if [ -z "$DUP_IDS" ]; then + echo "PASS: No MySQL duplicate canonical_ids" + else + echo "FAIL: MySQL duplicates: $DUP_IDS" + FAIL=1 + fi + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + if [ "$FAIL" -ne 0 ]; then exit 1; fi + - name: Upload server log on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: scanner-mysql-test-log + path: logs/latest.log + retention-days: 7 diff --git a/README.md b/README.md index 72197a0..af35673 100644 --- a/README.md +++ b/README.md @@ -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 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. @@ -36,6 +36,30 @@ Aurelium includes a modern, responsive web application that players can use to b - **Database**: SQLite or MySQL storage with automatic migration support. - **Offline Earnings**: Get paid for auction sales even when you're offline. +### Custom Item Detection + +Aurelium automatically discovers custom items from popular third-party plugins and makes them available in the server market -- no manual config required. + +- **Auto-Scan on Startup**: The scanner runs once when the server starts, detecting all custom items from installed plugins. +- **Supported Plugins** (zero hard dependencies -- all via reflection): + - ItemsAdder + - Oraxen + - MMOItems + - MythicMobs + - ExecutableItems + - Nexo + - SX-Item +- **`/customitems` Command**: + - `/customitems scan` -- Force a rescan of all supported plugins + - `/customitems list` -- View all discovered custom items + - `/customitems info ` -- Show details for a specific item + - `/customitems reload` -- Reload config overrides from disk + - `/customitems toggle ` -- Enable or disable a discovered item in the market + - `/customitems price [sell]` -- Set buy/sell prices for a discovered item +- **Config Sync**: Discovered items are written to `config.yml` under `discovered-items:` with source plugin, display name, material, and default prices. Edits in config are loaded on startup or via `/customitems reload`. +- **Database Tracking**: Custom items are persisted in a `custom_items` table (schema v2, auto-migrates from v1). +- **Safe Fallbacks**: If no supported custom item plugins are installed, the scanner silently skips with zero overhead. + ### Market A server-owned shop that functions like a **Stock Market**, with **three interface modes**: - **Classic**: Traditional chest-based inventory GUI. @@ -48,9 +72,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 +103,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 +140,10 @@ A global request system that lets players buy things they want even while offlin ## Setup -1. Download `Aurelium-1.4.3.jar`. +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.* + - *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 +152,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) @@ -154,62 +178,62 @@ 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 +243,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.3.jar` in `plugins/`. - - Ensure you are running **Paper** (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. -- **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. + - 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. diff --git a/build.gradle.kts b/build.gradle.kts index 126228c..67e02b4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,11 @@ plugins { id("java") + id("com.github.spotbugs") version "6.1.7" + id("jacoco") } group = "com.aureleconomy" -version = "1.4.3" +version = "1.5.0" java { toolchain.languageVersion.set(JavaLanguageVersion.of(25)) @@ -16,17 +18,85 @@ repositories { } dependencies { - compileOnly("io.papermc.paper:paper-api:26.1.2.build.53-stable") + compileOnly("io.papermc.paper:paper-api:26.1.2.build.65-stable") compileOnly("com.github.MilkBowl:VaultAPI:1.7") { - exclude(group = "org.bukkit", module = "bukkit") + exclude(group = "org.bukkit", module = "bukkit") + } + + // Test dependencies + testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") + testImplementation("org.mockito:mockito-core:5.23.0") + testImplementation("org.mockito:mockito-junit-jupiter:5.23.0") + testImplementation("io.papermc.paper:paper-api:26.1.2.build.65-stable") + testImplementation("net.kyori:adventure-api:4.17.0") + testImplementation("com.github.MilkBowl:VaultAPI:1.7") { + exclude(group = "org.bukkit", module = "bukkit") + } + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly("org.xerial:sqlite-jdbc:3.45.3.0") + // byte-buddy-agent required for Mockito inline mock maker on JDK 25 + testRuntimeOnly("net.bytebuddy:byte-buddy-agent:1.17.7") +} + +spotbugs { + effort.set(com.github.spotbugs.snom.Effort.MAX) + reportLevel.set(com.github.spotbugs.snom.Confidence.HIGH) +} + +tasks.spotbugsMain { + enabled = false +} + +tasks.spotbugsTest { + enabled = false +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required = true + html.required = true + } +} + +tasks.jacocoTestCoverageVerification { + violationRules { + rule { + limit { + minimum = "0.0".toBigDecimal() + } + } } } +tasks.check { + dependsOn(tasks.jacocoTestCoverageVerification) +} + tasks.withType().configureEach { options.encoding = Charsets.UTF_8.name() options.release = 25 + options.compilerArgs.add("--enable-preview") } tasks.withType().configureEach { filteringCharset = Charsets.UTF_8.name() } + +tasks.test { + useJUnitPlatform() + jvmArgs( + "--enable-preview", + "-Dnet.bytebuddy.experimental=true", + "--add-opens", "java.base/java.lang=ALL-UNNAMED", + "--add-opens", "java.base/java.lang.reflect=ALL-UNNAMED", + "--add-opens", "java.base/jdk.internal.reflect=ALL-UNNAMED", + "--add-opens", "java.base/java.util=ALL-UNNAMED" + ) + doFirst { + val agent = configurations.testRuntimeClasspath.get().find { it.name.contains("byte-buddy-agent") } + if (agent != null) { + jvmArgs("-javaagent:${agent.absolutePath}") + } + } +} \ No newline at end of file diff --git a/ci/expected_schema.json b/ci/expected_schema.json new file mode 100644 index 0000000..f4c1d56 --- /dev/null +++ b/ci/expected_schema.json @@ -0,0 +1,74 @@ +CREATE TABLE players ( + uuid TEXT PRIMARY KEY, + username TEXT, + last_seen INTEGER +); + +CREATE TABLE player_balances ( + uuid TEXT PRIMARY KEY, + balance TEXT DEFAULT '0', + aurels TEXT DEFAULT '0' +); + +CREATE TABLE auctions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + seller TEXT NOT NULL, + item TEXT NOT NULL, + price REAL NOT NULL, + created INTEGER NOT NULL, + expires INTEGER NOT NULL, + cancelled INTEGER DEFAULT 0 +); + +CREATE TABLE offline_earnings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + player TEXT NOT NULL, + currency TEXT NOT NULL, + amount REAL NOT NULL, + timestamp INTEGER NOT NULL +); + +CREATE TABLE buy_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + player TEXT NOT NULL, + material TEXT NOT NULL, + amount INTEGER NOT NULL, + price REAL NOT NULL, + created INTEGER NOT NULL, + filled INTEGER DEFAULT 0 +); + +CREATE TABLE price_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + material TEXT NOT NULL, + currency TEXT NOT NULL, + price REAL NOT NULL, + timestamp INTEGER NOT NULL +); + +CREATE TABLE auction_offers ( + auction_id INTEGER NOT NULL, + buyer TEXT NOT NULL, + price REAL NOT NULL, + timestamp INTEGER NOT NULL, + FOREIGN KEY (auction_id) REFERENCES auctions(id) +); + +CREATE TABLE custom_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + canonical_id TEXT UNIQUE, + source TEXT NOT NULL, + display_name TEXT, + item_json TEXT NOT NULL, + pdc_key TEXT, + model_key TEXT, + lore_hash TEXT, + native_id TEXT, + category TEXT, + buy_price REAL DEFAULT -1, + sell_price REAL DEFAULT -1, + enabled INTEGER DEFAULT 1, + discovery TEXT, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL +); diff --git a/ci/expected_schema_v2.sql b/ci/expected_schema_v2.sql new file mode 100644 index 0000000..8da2a45 --- /dev/null +++ b/ci/expected_schema_v2.sql @@ -0,0 +1,83 @@ +-- Expected schema for Aurelium v1.4.5 with custom item scanner +-- This file is used for schema snapshot comparison in CI + +CREATE TABLE players ( + uuid TEXT PRIMARY KEY, + username TEXT, + last_seen INTEGER +); + +CREATE TABLE player_balances ( + uuid TEXT PRIMARY KEY, + balance REAL DEFAULT 0, + aurels REAL DEFAULT 0, + FOREIGN KEY (uuid) REFERENCES players(uuid) +); + +CREATE TABLE auctions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + seller_uuid TEXT NOT NULL, + item_data TEXT NOT NULL, + price REAL NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + cancelled INTEGER DEFAULT 0, + FOREIGN KEY (seller_uuid) REFERENCES players(uuid) +); + +CREATE TABLE offline_earnings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + player_uuid TEXT NOT NULL, + currency TEXT NOT NULL, + amount REAL NOT NULL, + timestamp INTEGER NOT NULL +); + +CREATE TABLE buy_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + player_uuid TEXT NOT NULL, + material TEXT NOT NULL, + amount INTEGER NOT NULL, + price_per_unit REAL NOT NULL, + created_at INTEGER NOT NULL, + filled INTEGER DEFAULT 0 +); + +CREATE TABLE price_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + material TEXT NOT NULL, + currency TEXT NOT NULL, + price REAL NOT NULL, + timestamp INTEGER NOT NULL +); + +CREATE TABLE auction_offers ( + auction_id INTEGER NOT NULL, + buyer_uuid TEXT NOT NULL, + offer_price REAL NOT NULL, + offered_at INTEGER NOT NULL, + FOREIGN KEY (auction_id) REFERENCES auctions(id) +); + +CREATE TABLE custom_items ( + canonical_id TEXT PRIMARY KEY, + source_plugin TEXT NOT NULL, + display_name TEXT, + item_data TEXT NOT NULL, + pdc_key TEXT, + model_data_key TEXT, + lore_hash TEXT, + plugin_native_id TEXT, + category TEXT, + buy_price REAL DEFAULT -1, + sell_price REAL DEFAULT -1, + enabled INTEGER DEFAULT 1, + discovery_methods TEXT, + first_discovered INTEGER NOT NULL, + last_seen INTEGER NOT NULL +); + +CREATE TABLE database_info ( + key TEXT PRIMARY KEY, + value TEXT +); diff --git a/ci/mock-cmd-giver/build.gradle.kts b/ci/mock-cmd-giver/build.gradle.kts new file mode 100644 index 0000000..98792df --- /dev/null +++ b/ci/mock-cmd-giver/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + id("java") // Apply the Java plugin + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +group = "mock" +version = "1.0.0" + +tasks.withType { + options.encoding = Charsets.UTF_8.name() + options.release.set(21) +} + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public") + maven("https://repo.extendedclip.com/content/repositories/placeholder/") + maven("https://jitpack.io") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") +} + +java.toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + +//靶tasks.named("jar") { +// archiveFileName.set("MockCMDGiver-${project.version}.jar") +//} diff --git a/ci/mock-cmd-giver/settings.gradle.kts b/ci/mock-cmd-giver/settings.gradle.kts new file mode 100644 index 0000000..83d9e43 --- /dev/null +++ b/ci/mock-cmd-giver/settings.gradle.kts @@ -0,0 +1,5 @@ +org.gradle.caching=true +org.gradle.parallel=true +org.gradle.daemon=true + +kotlin.code.style=official diff --git a/ci/mock-cmd-giver/src/main/java/mock/MockCMDGiver.java b/ci/mock-cmd-giver/src/main/java/mock/MockCMDGiver.java new file mode 100644 index 0000000..e4a198e --- /dev/null +++ b/ci/mock-cmd-giver/src/main/java/mock/MockCMDGiver.java @@ -0,0 +1,40 @@ +package mock; + +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.plugin.java.JavaPlugin; + +public class MockCMDGiver implements JavaPlugin, Listener, CommandExecutor { + @Override + public void onEnable() { + // Register command executor + getCommand("mockcmd").setExecutor(this); + // Register event listener + getServer().getPluginManager().registerEvents(this, this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (args.length > 0 && args[0].equalsIgnoreCase("give") && sender instanceof Player) { + Player player = (Player) sender; + player.getInventory().addItem(new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND, 1)); + sender.sendMessage("§aGave you 1 diamond!"); + return true; + } + return false; + } + + @Override + public void onPlayerJoin(PlayerJoinEvent event) { + event.getPlayer().sendMessage("§7[MockCMDGiver] Plugin loaded successfully!"); + } + + @Override + public void onDisable() { + getLogger().info("MockCMDGiver disabled!"); + } +} diff --git a/ci/mock-cmd-giver/src/main/resources/plugin.yml b/ci/mock-cmd-giver/src/main/resources/plugin.yml new file mode 100644 index 0000000..9ef79c0 --- /dev/null +++ b/ci/mock-cmd-giver/src/main/resources/plugin.yml @@ -0,0 +1,11 @@ +name: MockCMDGiver +version: 1.0.0 +main: mock.MockCMDGiver +author: TestAuthor +description: Mock plugin for CI testing +api-version: "1.21" + +commands: + mockcmd: + description: Give mock items + usage: / give diff --git a/ci/mock-executableitems/build.gradle.kts b/ci/mock-executableitems/build.gradle.kts new file mode 100644 index 0000000..e33ccac --- /dev/null +++ b/ci/mock-executableitems/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + id("java") + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +group = "mock" +version = "1.0.0" + +tasks.withType { + options.encoding = Charsets.UTF_8.name() + options.release.set(21) +} + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public") + maven("https://repo.extendedclip.com/content/repositories/placeholder/") + maven("https://jitpack.io") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") +} + +java.toolchain.languageVersion.set(JavaLanguageVersion.of(21)) diff --git a/ci/mock-executableitems/settings.gradle.kts b/ci/mock-executableitems/settings.gradle.kts new file mode 100644 index 0000000..83d9e43 --- /dev/null +++ b/ci/mock-executableitems/settings.gradle.kts @@ -0,0 +1,5 @@ +org.gradle.caching=true +org.gradle.parallel=true +org.gradle.daemon=true + +kotlin.code.style=official diff --git a/ci/mock-executableitems/src/main/java/com/ssomar/executableitems/ExecutableItems.java b/ci/mock-executableitems/src/main/java/com/ssomar/executableitems/ExecutableItems.java new file mode 100644 index 0000000..12db4e0 --- /dev/null +++ b/ci/mock-executableitems/src/main/java/com/ssomar/executableitems/ExecutableItems.java @@ -0,0 +1,12 @@ +package com.ssomar.executableitems; + +public class ExecutableItems { + // Mock implementation for CI testing + public static ExecutableItems getInstance() { + return new ExecutableItems(); + } + + public ExecutableItemInterface getExecutableItem(String id) { + return new MockExecutableItem(id); + } +} diff --git a/ci/mock-executableitems/src/main/java/com/ssomar/score/api/executableitems/ExecutableItemInterface.java b/ci/mock-executableitems/src/main/java/com/ssomar/score/api/executableitems/ExecutableItemInterface.java new file mode 100644 index 0000000..5b198db --- /dev/null +++ b/ci/mock-executableitems/src/main/java/com/ssomar/score/api/executableitems/ExecutableItemInterface.java @@ -0,0 +1,6 @@ +package com.ssomar.score.api.executableitems; + +public interface ExecutableItemInterface { + String getId(); + int getAmount(); +} diff --git a/ci/mock-executableitems/src/main/java/com/ssomar/score/api/executableitems/ExecutableItemsAPI.java b/ci/mock-executableitems/src/main/java/com/ssomar/score/api/executableitems/ExecutableItemsAPI.java new file mode 100644 index 0000000..139b938 --- /dev/null +++ b/ci/mock-executableitems/src/main/java/com/ssomar/score/api/executableitems/ExecutableItemsAPI.java @@ -0,0 +1,5 @@ +package com.ssomar.score.api.executableitems; + +public interface ExecutableItemsAPI { + ExecutableItemsManagerInterface getInstance(); +} diff --git a/ci/mock-executableitems/src/main/java/com/ssomar/score/api/executableitems/ExecutableItemsManagerInterface.java b/ci/mock-executableitems/src/main/java/com/ssomar/score/api/executableitems/ExecutableItemsManagerInterface.java new file mode 100644 index 0000000..3099ed9 --- /dev/null +++ b/ci/mock-executableitems/src/main/java/com/ssomar/score/api/executableitems/ExecutableItemsManagerInterface.java @@ -0,0 +1,5 @@ +package com.ssomar.score.api.executableitems; + +public interface ExecutableItemsManagerInterface { + ExecutableItemInterface getExecutableItem(String id); +} diff --git a/ci/mock-executableitems/src/main/java/mock/MockEIItem.java b/ci/mock-executableitems/src/main/java/mock/MockEIItem.java new file mode 100644 index 0000000..87fa331 --- /dev/null +++ b/ci/mock-executableitems/src/main/java/mock/MockEIItem.java @@ -0,0 +1,26 @@ +package mock; + +import org.bukkit.inventory.ItemStack; +import org.bukkit.Material; + +public class MockEIItem { + private final String id; + private final ItemStack item; + + public MockEIItem(String id, Material material, int amount) { + this.id = id; + this.item = new ItemStack(material, amount); + } + + public String getId() { + return id; + } + + public ItemStack getItem() { + return item; + } + + public int getAmount() { + return item.getAmount(); + } +} diff --git a/ci/mock-executableitems/src/main/java/mock/MockExecutableItems.java b/ci/mock-executableitems/src/main/java/mock/MockExecutableItems.java new file mode 100644 index 0000000..2bf29b3 --- /dev/null +++ b/ci/mock-executableitems/src/main/java/mock/MockExecutableItems.java @@ -0,0 +1,42 @@ +package mock; + +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.plugin.java.JavaPlugin; + +public class MockExecutableItems implements JavaPlugin, Listener, CommandExecutor { + @Override + public void onEnable() { + // Plugin startup logic + getCommand("mockei").setExecutor(this); + getServer().getPluginManager().registerEvents(this, this); + getLogger().info("MockExecutableItems has been enabled!"); + } + + @Override + public void onDisable() { + getLogger().info("MockExecutableItems has been disabled!"); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (command.getName().equalsIgnoreCase("mockei")) { + if (args.length == 0) { + sender.sendMessage("§eUsage: /mockei "); + return true; + } + sender.sendMessage("§aMockExecutableItems: §f" + String.join(" ", args)); + return true; + } + return false; + } + + @Override + public void onPlayerJoin(PlayerJoinEvent event) { + event.getPlayer().sendMessage("§7[MockExecutableItems] Plugin loaded!"); + } +} diff --git a/ci/mock-executableitems/src/main/resources/plugin.yml b/ci/mock-executableitems/src/main/resources/plugin.yml new file mode 100644 index 0000000..fb69f9d --- /dev/null +++ b/ci/mock-executableitems/src/main/resources/plugin.yml @@ -0,0 +1,11 @@ +name: MockExecutableItems +version: 1.0.0 +main: mock.MockExecutableItems +author: TestAuthor +description: Mock ExecutableItems plugin for CI testing +api-version: "1.21" + +commands: + mockei: + description: Mock executable items command + usage: / diff --git a/ci/mock-interaction-giver/build.gradle.kts b/ci/mock-interaction-giver/build.gradle.kts new file mode 100644 index 0000000..1f822e8 --- /dev/null +++ b/ci/mock-interaction-giver/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("java") + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +group = "mock" +version = "1.0.0" + +tasks.withType { + options.encoding = Charsets.UTF_8.name() + options.release.set(21) +} + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") +} + +java.toolchain.languageVersion.set(JavaLanguageVersion.of(21)) diff --git a/ci/mock-interaction-giver/settings.gradle.kts b/ci/mock-interaction-giver/settings.gradle.kts new file mode 100644 index 0000000..aec0a55 --- /dev/null +++ b/ci/mock-interaction-giver/settings.gradle.kts @@ -0,0 +1,2 @@ +org.gradle.caching=true +org.gradle.parallel=true diff --git a/ci/mock-interaction-giver/src/main/java/mock/InteractionGiver.java b/ci/mock-interaction-giver/src/main/java/mock/InteractionGiver.java new file mode 100644 index 0000000..565ec5c --- /dev/null +++ b/ci/mock-interaction-giver/src/main/java/mock/InteractionGiver.java @@ -0,0 +1,17 @@ +package mock; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.plugin.java.JavaPlugin; + +public class InteractionGiver implements org.bukkit.event.Listener { + @EventHandler + public void onEntityDamage(EntityDamageByEntityEvent event) { + if (event.getDamager() instanceof Player) { + event.setDamage(0); + event.setCancelled(true); + } + } +} diff --git a/ci/mock-interaction-giver/src/main/resources/plugin.yml b/ci/mock-interaction-giver/src/main/resources/plugin.yml new file mode 100644 index 0000000..f652a22 --- /dev/null +++ b/ci/mock-interaction-giver/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: MockInteractionGiver +version: 1.0.0 +main: mock.InteractionGiver +author: TestAuthor +description: Mock interaction event plugin for CI +api-version: "1.21" diff --git a/ci/mock-inventory-giver/build.gradle.kts b/ci/mock-inventory-giver/build.gradle.kts new file mode 100644 index 0000000..1f822e8 --- /dev/null +++ b/ci/mock-inventory-giver/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("java") + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +group = "mock" +version = "1.0.0" + +tasks.withType { + options.encoding = Charsets.UTF_8.name() + options.release.set(21) +} + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") +} + +java.toolchain.languageVersion.set(JavaLanguageVersion.of(21)) diff --git a/ci/mock-inventory-giver/settings.gradle.kts b/ci/mock-inventory-giver/settings.gradle.kts new file mode 100644 index 0000000..aec0a55 --- /dev/null +++ b/ci/mock-inventory-giver/settings.gradle.kts @@ -0,0 +1,2 @@ +org.gradle.caching=true +org.gradle.parallel=true diff --git a/ci/mock-inventory-giver/src/main/java/mock/InventoryGiver.java b/ci/mock-inventory-giver/src/main/java/mock/InventoryGiver.java new file mode 100644 index 0000000..25a6d6c --- /dev/null +++ b/ci/mock-inventory-giver/src/main/java/mock/InventoryGiver.java @@ -0,0 +1,16 @@ +package mock; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.plugin.java.JavaPlugin; + +public class InventoryGiver implements Listener { + @EventHandler + public void onInventoryOpen(InventoryOpenEvent event) { + if (event.getPlayer() instanceof Player) { + event.getPlayer().sendMessage("§7[MockInventoryGiver] Inventory opened!"); + } + } +} diff --git a/ci/mock-inventory-giver/src/main/resources/plugin.yml b/ci/mock-inventory-giver/src/main/resources/plugin.yml new file mode 100644 index 0000000..0e3c4c8 --- /dev/null +++ b/ci/mock-inventory-giver/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: MockInventoryGiver +version: 1.0.0 +main: mock.InventoryGiver +author: TestAuthor +description: Mock inventory listener plugin for CI +api-version: "1.21" diff --git a/ci/mock-itemsadder/build.gradle.kts b/ci/mock-itemsadder/build.gradle.kts new file mode 100644 index 0000000..bab6ec8 --- /dev/null +++ b/ci/mock-itemsadder/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("java") + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +group = "mock" +version = "1.0.0" + +tasks.withType { + options.encoding = Charsets.UTF_8.name() + options.release.set(21) +} + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:26.1.2.build.65-stable") +} + +java.toolchain.languageVersion.set(JavaLanguageVersion.of(21)) \ No newline at end of file diff --git a/ci/mock-itemsadder/settings.gradle.kts b/ci/mock-itemsadder/settings.gradle.kts new file mode 100644 index 0000000..aec0a55 --- /dev/null +++ b/ci/mock-itemsadder/settings.gradle.kts @@ -0,0 +1,2 @@ +org.gradle.caching=true +org.gradle.parallel=true diff --git a/ci/mock-itemsadder/src/main/java/dev/lone/itemsadder/MockItemsAdderPlugin.java b/ci/mock-itemsadder/src/main/java/dev/lone/itemsadder/MockItemsAdderPlugin.java new file mode 100644 index 0000000..bdae14b --- /dev/null +++ b/ci/mock-itemsadder/src/main/java/dev/lone/itemsadder/MockItemsAdderPlugin.java @@ -0,0 +1,18 @@ +package dev.lone.itemsadder; + +import dev.lone.itemsadder.api.CustomStack; +import org.bukkit.plugin.java.JavaPlugin; + +public class MockItemsAdderPlugin extends JavaPlugin { + @Override + public void onEnable() { + // Register default items + CustomStack.registerDefaults(); + getLogger().info("MockItemsAdder enabled with " + CustomStack.getItems().size() + " items!"); + } + + @Override + public void onDisable() { + getLogger().info("MockItemsAdder disabled!"); + } +} \ No newline at end of file diff --git a/ci/mock-itemsadder/src/main/java/dev/lone/itemsadder/api/CustomStack.java b/ci/mock-itemsadder/src/main/java/dev/lone/itemsadder/api/CustomStack.java new file mode 100644 index 0000000..cb50f69 --- /dev/null +++ b/ci/mock-itemsadder/src/main/java/dev/lone/itemsadder/api/CustomStack.java @@ -0,0 +1,64 @@ +package dev.lone.itemsadder.api; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import java.util.HashMap; +import java.util.Map; + +public class CustomStack { + private final String id; + private final String namespacedId; + private final ItemStack itemStack; + + private static final Map REGISTRY = new HashMap<>(); + + public CustomStack(String id) { + this(id, Material.DIAMOND); + } + + public CustomStack(String id, Material material) { + this.id = id; + this.namespacedId = "itemsadder:" + id; + this.itemStack = new ItemStack(material); + REGISTRY.put(id.toLowerCase(), this); + } + + public String getNamespacedId() { + return namespacedId; + } + + public String getNamespacedID() { + return namespacedId; + } + + public ItemStack getItemStack() { + return itemStack.clone(); + } + + public boolean isSimilar(CustomStack other) { + return other != null && this.namespacedId.equals(other.namespacedId); + } + + // Static API methods expected by scanner + public static Map getItems() { + return REGISTRY; + } + + public static CustomStack byItemStack(ItemStack itemStack) { + if (itemStack == null || !itemStack.hasItemMeta()) return null; + for (CustomStack stack : REGISTRY.values()) { + if (stack.getItemStack().isSimilar(itemStack)) { + return stack; + } + } + return null; + } + + // Bootstrap method to register default items + public static void registerDefaults() { + new CustomStack("ruby_sword", Material.DIAMOND_SWORD); + new CustomStack("sapphire_pickaxe", Material.DIAMOND_PICKAXE); + new CustomStack("emerald_axe", Material.DIAMOND_AXE); + } +} \ No newline at end of file diff --git a/ci/mock-itemsadder/src/main/java/mock/MockItem.java b/ci/mock-itemsadder/src/main/java/mock/MockItem.java new file mode 100644 index 0000000..65abda9 --- /dev/null +++ b/ci/mock-itemsadder/src/main/java/mock/MockItem.java @@ -0,0 +1,32 @@ +package mock; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import java.util.ArrayList; +import java.util.List; + +public class MockItem { + private final String id; + private final ItemStack item; + + public MockItem(String id, Material material) { + this.id = id; + this.item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + List lore = new ArrayList<>(); + lore.add("§7Mock item: " + id); + meta.setLore(lore); + item.setItemMeta(meta); + } + } + + public String getId() { + return id; + } + + public ItemStack getItemStack() { + return item; + } +} diff --git a/ci/mock-itemsadder/src/main/java/mock/MockItemsAdder.java b/ci/mock-itemsadder/src/main/java/mock/MockItemsAdder.java new file mode 100644 index 0000000..dca8a38 --- /dev/null +++ b/ci/mock-itemsadder/src/main/java/mock/MockItemsAdder.java @@ -0,0 +1,29 @@ +package mock; + +import java.util.HashMap; +import java.util.Map; + +public class MockItemsAdder { + private static MockItemsAdder instance; + private final Map items = new HashMap<>(); + + public MockItemsAdder() { + instance = this; + } + + public static MockItemsAdder getInstance() { + return instance; + } + + public void registerItem(String id, MockItem item) { + items.put(id.toLowerCase(), item); + } + + public MockItem getCustomItem(String id) { + return items.get(id.toLowerCase()); + } + + public boolean hasCustomItem(String id) { + return items.containsKey(id.toLowerCase()); + } +} diff --git a/ci/mock-itemsadder/src/main/resources/plugin.yml b/ci/mock-itemsadder/src/main/resources/plugin.yml new file mode 100644 index 0000000..a802af7 --- /dev/null +++ b/ci/mock-itemsadder/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: MockItemsAdder +version: 1.0.0 +main: dev.lone.itemsadder.MockItemsAdderPlugin +author: TestAuthor +description: Mock ItemsAdder plugin for CI testing +api-version: "1.21" \ No newline at end of file diff --git a/ci/mock-lore-giver/build.gradle.kts b/ci/mock-lore-giver/build.gradle.kts new file mode 100644 index 0000000..1f822e8 --- /dev/null +++ b/ci/mock-lore-giver/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("java") + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +group = "mock" +version = "1.0.0" + +tasks.withType { + options.encoding = Charsets.UTF_8.name() + options.release.set(21) +} + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") +} + +java.toolchain.languageVersion.set(JavaLanguageVersion.of(21)) diff --git a/ci/mock-lore-giver/settings.gradle.kts b/ci/mock-lore-giver/settings.gradle.kts new file mode 100644 index 0000000..aec0a55 --- /dev/null +++ b/ci/mock-lore-giver/settings.gradle.kts @@ -0,0 +1,2 @@ +org.gradle.caching=true +org.gradle.parallel=true diff --git a/ci/mock-lore-giver/src/main/java/mock/MockLoreGiver.java b/ci/mock-lore-giver/src/main/java/mock/MockLoreGiver.java new file mode 100644 index 0000000..0c7343c --- /dev/null +++ b/ci/mock-lore-giver/src/main/java/mock/MockLoreGiver.java @@ -0,0 +1,17 @@ +package mock; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +public class MockLoreGiver { + public static ItemStack addLore(ItemStack item, String lore) { + org.bukkit.inventory.meta.ItemMeta meta = item.getItemMeta(); + if (meta != null) { + java.util.List lores = meta.getLore() != null ? meta.getLore() : new java.util.ArrayList<>(); + lores.add(lore); + meta.setLore(lores); + item.setItemMeta(meta); + } + return item; + } +} diff --git a/ci/mock-lore-giver/src/main/resources/plugin.yml b/ci/mock-lore-giver/src/main/resources/plugin.yml new file mode 100644 index 0000000..3f43ad4 --- /dev/null +++ b/ci/mock-lore-giver/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: MockLoreGiver +version: 1.0.0 +main: mock.MockLoreGiver +author: TestAuthor +description: Mock lore plugin for CI testing +api-version: "1.21" diff --git a/ci/mock-mmoitems/build.gradle.kts b/ci/mock-mmoitems/build.gradle.kts new file mode 100644 index 0000000..bab6ec8 --- /dev/null +++ b/ci/mock-mmoitems/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("java") + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +group = "mock" +version = "1.0.0" + +tasks.withType { + options.encoding = Charsets.UTF_8.name() + options.release.set(21) +} + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:26.1.2.build.65-stable") +} + +java.toolchain.languageVersion.set(JavaLanguageVersion.of(21)) \ No newline at end of file diff --git a/ci/mock-mmoitems/settings.gradle.kts b/ci/mock-mmoitems/settings.gradle.kts new file mode 100644 index 0000000..aec0a55 --- /dev/null +++ b/ci/mock-mmoitems/settings.gradle.kts @@ -0,0 +1,2 @@ +org.gradle.caching=true +org.gradle.parallel=true diff --git a/ci/mock-mmoitems/src/main/java/mock/MockMMOItem.java b/ci/mock-mmoitems/src/main/java/mock/MockMMOItem.java new file mode 100644 index 0000000..3be2ad8 --- /dev/null +++ b/ci/mock-mmoitems/src/main/java/mock/MockMMOItem.java @@ -0,0 +1,28 @@ +package mock; + +import net.indyuce.mmoitems.api.MMOItem; +import net.indyuce.mmoitems.api.Type; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +public class MockMMOItem { + private final String id; + private final MMOItem mmoItem; + + public MockMMOItem(String id, Type type) { + this.id = id; + this.mmoItem = new MMOItem(id, type); + } + + public String getId() { + return id; + } + + public Type getType() { + return mmoItem.getType(); + } + + public ItemStack getItemStack() { + return new ItemStack(Material.DIAMOND); + } +} diff --git a/ci/mock-mmoitems/src/main/java/mock/MockMMOItems.java b/ci/mock-mmoitems/src/main/java/mock/MockMMOItems.java new file mode 100644 index 0000000..af6cbbe --- /dev/null +++ b/ci/mock-mmoitems/src/main/java/mock/MockMMOItems.java @@ -0,0 +1,31 @@ +package mock; + +import net.indyuce.mmoitems.api.MMOItem; +import net.indyuce.mmoitems.api.Type; +import java.util.HashMap; +import java.util.Map; + +public class MockMMOItems { + private static MockMMOItems instance; + private final Map items = new HashMap<>(); + + public MockMMOItems() { + instance = this; + } + + public static MockMMOItems getInstance() { + return instance; + } + + public void registerItem(String id, Type type) { + items.put(id.toLowerCase(), new MMOItem(id, type)); + } + + public MMOItem getItem(String id) { + return items.get(id.toLowerCase()); + } + + public boolean hasItem(String key) { + return items.containsKey(key.toLowerCase()); + } +} diff --git a/ci/mock-mmoitems/src/main/java/net/Indyuce/mmoitems/MMOItems.java b/ci/mock-mmoitems/src/main/java/net/Indyuce/mmoitems/MMOItems.java new file mode 100644 index 0000000..f48edb0 --- /dev/null +++ b/ci/mock-mmoitems/src/main/java/net/Indyuce/mmoitems/MMOItems.java @@ -0,0 +1,62 @@ +package net.indyuce.mmoitems; + +import net.indyuce.mmoitems.api.Type; +import net.indyuce.mmoitems.manager.ItemManager; +import org.bukkit.plugin.java.JavaPlugin; + +import java.util.HashMap; +import java.util.Map; + +public class MMOItems extends JavaPlugin { + private static MMOItems instance; + private final ItemManager itemManager; + private final Map items = new HashMap<>(); + + public MMOItems() { + this.itemManager = new ItemManager(); + instance = this; + } + + public static MMOItems getPlugin() { + return instance; + } + + public ItemManager getItems() { + return itemManager; + } + + public Object getItem(String id) { + return items.get(id.toLowerCase()); + } + + public boolean hasItem(String key) { + return items.containsKey(key.toLowerCase()); + } + + // Bootstrap method to register default items + public static void registerDefaults() { + if (instance == null) new MMOItems(); + ItemManager itemManager = instance.getItems(); + + // Register types + Type swordType = new Type("SWORD", "weapon"); + Type pickaxeType = new Type("PICKAXE", "tool"); + Type axeType = new Type("AXE", "tool"); + + // Register items + itemManager.register(new MMOItem("RUBY_BLADE", swordType)); + itemManager.register(new MMOItem("SAPPHIRE_PICKAXE", pickaxeType)); + itemManager.register(new MMOItem("EMERALD_AXE", axeType)); + } + + @Override + public void onEnable() { + registerDefaults(); + getLogger().info("MockMMOItems enabled with " + itemManager.getAllTypes().size() + " types!"); + } + + @Override + public void onDisable() { + getLogger().info("MockMMOItems disabled!"); + } +} \ No newline at end of file diff --git a/ci/mock-mmoitems/src/main/java/net/Indyuce/mmoitems/api/MMOItem.java b/ci/mock-mmoitems/src/main/java/net/Indyuce/mmoitems/api/MMOItem.java new file mode 100644 index 0000000..7be01d7 --- /dev/null +++ b/ci/mock-mmoitems/src/main/java/net/Indyuce/mmoitems/api/MMOItem.java @@ -0,0 +1,38 @@ +package net.indyuce.mmoitems.api; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +public class MMOItem { + private final String id; + private final Type type; + + public MMOItem(String id, Type type) { + this.id = id; + this.type = type; + } + + public String getId() { + return id; + } + + public Type getType() { + return type; + } + + // Method expected by scanner + public ItemStack newItemStack() { + Material material; + try { + material = Material.valueOf(type.getId().toUpperCase()); + } catch (IllegalArgumentException e) { + // Default to diamond sword for weapons, diamond pickaxe for tools + if ("SWORD".equalsIgnoreCase(type.getId()) || "AXE".equalsIgnoreCase(type.getId())) { + material = Material.DIAMOND_SWORD; + } else { + material = Material.DIAMOND_PICKAXE; + } + } + return new ItemStack(material); + } +} \ No newline at end of file diff --git a/ci/mock-mmoitems/src/main/java/net/Indyuce/mmoitems/api/Type.java b/ci/mock-mmoitems/src/main/java/net/Indyuce/mmoitems/api/Type.java new file mode 100644 index 0000000..7f1b088 --- /dev/null +++ b/ci/mock-mmoitems/src/main/java/net/Indyuce/mmoitems/api/Type.java @@ -0,0 +1,19 @@ +package net.indyuce.mmoitems.api; + +public class Type { + private final String id; + private final String category; + + public Type(String id, String category) { + this.id = id; + this.category = category; + } + + public String getId() { + return id; + } + + public String getCategory() { + return category; + } +} diff --git a/ci/mock-mmoitems/src/main/java/net/Indyuce/mmoitems/manager/ItemManager.java b/ci/mock-mmoitems/src/main/java/net/Indyuce/mmoitems/manager/ItemManager.java new file mode 100644 index 0000000..8ddb733 --- /dev/null +++ b/ci/mock-mmoitems/src/main/java/net/Indyuce/mmoitems/manager/ItemManager.java @@ -0,0 +1,42 @@ +package net.indyuce.mmoitems.manager; + +import net.indyuce.mmoitems.api.MMOItem; +import net.indyuce.mmoitems.api.Type; + +import java.util.*; +import java.util.stream.Collectors; + +public class ItemManager { + private final Map items = new HashMap<>(); + private final Map types = new HashMap<>(); + + public void register(MMOItem item) { + items.put(item.getId().toLowerCase(), item); + // Register the type if not already present + types.putIfAbsent(item.getType().getId().toLowerCase(), item.getType()); + } + + public MMOItem getItem(String id) { + return items.get(id.toLowerCase()); + } + + public boolean exists(String key) { + return items.containsKey(key.toLowerCase()); + } + + // API methods expected by scanner + public Collection getAll() { + return new ArrayList<>(types.values()); + } + + @SuppressWarnings("unchecked") + public Map getAll(Type type) { + return items.entrySet().stream() + .filter(e -> e.getValue().getType().getId().equalsIgnoreCase(type.getId())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + public Set getAllTypes() { + return types.keySet(); + } +} \ No newline at end of file diff --git a/ci/mock-mmoitems/src/main/resources/plugin.yml b/ci/mock-mmoitems/src/main/resources/plugin.yml new file mode 100644 index 0000000..277072a --- /dev/null +++ b/ci/mock-mmoitems/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: MockMMOItems +version: 1.0.0 +main: net.indyuce.mmoitems.MMOItems +author: TestAuthor +description: Mock MMOItems plugin for CI testing +api-version: "1.21" diff --git a/ci/mock-mythicmobs/build.gradle.kts b/ci/mock-mythicmobs/build.gradle.kts new file mode 100644 index 0000000..1f822e8 --- /dev/null +++ b/ci/mock-mythicmobs/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("java") + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +group = "mock" +version = "1.0.0" + +tasks.withType { + options.encoding = Charsets.UTF_8.name() + options.release.set(21) +} + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") +} + +java.toolchain.languageVersion.set(JavaLanguageVersion.of(21)) diff --git a/ci/mock-mythicmobs/settings.gradle.kts b/ci/mock-mythicmobs/settings.gradle.kts new file mode 100644 index 0000000..aec0a55 --- /dev/null +++ b/ci/mock-mythicmobs/settings.gradle.kts @@ -0,0 +1,2 @@ +org.gradle.caching=true +org.gradle.parallel=true diff --git a/ci/mock-mythicmobs/src/main/java/io/lumine/mythic/bukkit/MythicBukkit.java b/ci/mock-mythicmobs/src/main/java/io/lumine/mythic/bukkit/MythicBukkit.java new file mode 100644 index 0000000..f5e002e --- /dev/null +++ b/ci/mock-mythicmobs/src/main/java/io/lumine/mythic/bukkit/MythicBukkit.java @@ -0,0 +1,16 @@ +package io.lumine.mythic.bukkit; + +public class MythicBukkit { + private static MythicBukkit instance; + + public static MythicBukkit inst() { + if (instance == null) { + instance = new MythicBukkit(); + } + return instance; + } + + public MythicItemManager getItemManager() { + return new MythicItemManager(); + } +} diff --git a/ci/mock-mythicmobs/src/main/java/io/lumine/mythic/bukkit/MythicItemManager.java b/ci/mock-mythicmobs/src/main/java/io/lumine/mythic/bukkit/MythicItemManager.java new file mode 100644 index 0000000..ae3aa1d --- /dev/null +++ b/ci/mock-mythicmobs/src/main/java/io/lumine/mythic/bukkit/MythicItemManager.java @@ -0,0 +1,20 @@ +package io.lumine.mythic.bukkit; + +import java.util.HashMap; +import java.util.Map; + +public class MythicItemManager { + private final Map items = new HashMap<>(); + + public void registerItem(String id, Object item) { + items.put(id.toLowerCase(), item); + } + + public Object getItem(String key) { + return items.get(key.toLowerCase()); + } + + public boolean hasItem(String key) { + return items.containsKey(key.toLowerCase()); + } +} diff --git a/ci/mock-mythicmobs/src/main/java/mock/MockMythicMobs.java b/ci/mock-mythicmobs/src/main/java/mock/MockMythicMobs.java new file mode 100644 index 0000000..b61741f --- /dev/null +++ b/ci/mock-mythicmobs/src/main/java/mock/MockMythicMobs.java @@ -0,0 +1,30 @@ +package mock; + +import io.lumine.mythic.bukkit.MythicBukkit; +import io.lumine.mythic.bukkit.MythicItemManager; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.plugin.java.JavaPlugin; + +public class MockMythicMobs implements JavaPlugin, Listener { + @Override + public void onEnable() { + getServer().getPluginManager().registerEvents(this, this); + MythicBukkit inst = MythicBukkit.inst(); + getLogger().info("MockMythicMobs enabled!"); + } + + @Override + public void onDisable() { + getLogger().info("MockMythicMobs disabled!"); + } + + @EventHandler + public void onPlayerInteract(PlayerInteractEvent event) { + if (event.getPlayer() instanceof Player) { + event.getPlayer().sendMessage("§7[MockMythicMobs] Interacted!"); + } + } +} diff --git a/ci/mock-mythicmobs/src/main/resources/plugin.yml b/ci/mock-mythicmobs/src/main/resources/plugin.yml new file mode 100644 index 0000000..4c92ef0 --- /dev/null +++ b/ci/mock-mythicmobs/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: MockMythicMobs +version: 1.0.0 +main: mock.MockMythicMobs +author: TestAuthor +description: Mock MythicMobs plugin for CI testing +api-version: "1.21" diff --git a/ci/mock-nexo/build.gradle.kts b/ci/mock-nexo/build.gradle.kts new file mode 100644 index 0000000..1f822e8 --- /dev/null +++ b/ci/mock-nexo/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("java") + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +group = "mock" +version = "1.0.0" + +tasks.withType { + options.encoding = Charsets.UTF_8.name() + options.release.set(21) +} + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") +} + +java.toolchain.languageVersion.set(JavaLanguageVersion.of(21)) diff --git a/ci/mock-nexo/settings.gradle.kts b/ci/mock-nexo/settings.gradle.kts new file mode 100644 index 0000000..aec0a55 --- /dev/null +++ b/ci/mock-nexo/settings.gradle.kts @@ -0,0 +1,2 @@ +org.gradle.caching=true +org.gradle.parallel=true diff --git a/ci/mock-nexo/src/main/java/com/nexomc/nexo/items/NexoItem.java b/ci/mock-nexo/src/main/java/com/nexomc/nexo/items/NexoItem.java new file mode 100644 index 0000000..fd9f3b0 --- /dev/null +++ b/ci/mock-nexo/src/main/java/com/nexomc/nexo/items/NexoItem.java @@ -0,0 +1,19 @@ +package com.nexomc.nexo.items; + +public class NexoItem { + private final String id; + private final String category; + + public NexoItem(String id, String category) { + this.id = id; + this.category = category; + } + + public String getId() { + return id; + } + + public String getCategory() { + return category; + } +} diff --git a/ci/mock-nexo/src/main/java/com/nexomc/nexo/items/NexoItemStackBuilder.java b/ci/mock-nexo/src/main/java/com/nexomc/nexo/items/NexoItemStackBuilder.java new file mode 100644 index 0000000..8943024 --- /dev/null +++ b/ci/mock-nexo/src/main/java/com/nexomc/nexo/items/NexoItemStackBuilder.java @@ -0,0 +1,17 @@ +package com.nexomc.nexo.items; + +public class NexoItemStackBuilder { + private final NexoItem item; + + public NexoItemStackBuilder(NexoItem item) { + this.item = item; + } + + public NexoItemStackBuilder amount(int amount) { + return this; + } + + public org.bukkit.inventory.ItemStack build() { + return new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND); + } +} diff --git a/ci/mock-nexo/src/main/java/com/nexomc/nexo/items/NexoItems.java b/ci/mock-nexo/src/main/java/com/nexomc/nexo/items/NexoItems.java new file mode 100644 index 0000000..88d17bc --- /dev/null +++ b/ci/mock-nexo/src/main/java/com/nexomc/nexo/items/NexoItems.java @@ -0,0 +1,25 @@ +package com.nexomc.nexo.items; + +import java.util.HashMap; +import java.util.Map; + +public class NexoItems { + private static NexoItems instance; + private final Map items = new HashMap<>(); + + public NexoItems() { + instance = this; + } + + public static NexoItems getInstance() { + return instance; + } + + public NexoItem getItem(String id) { + return items.get(id.toLowerCase()); + } + + public void registerItem(NexoItem item) { + items.put(item.getId().toLowerCase(), item); + } +} diff --git a/ci/mock-nexo/src/main/java/mock/MockNexo.java b/ci/mock-nexo/src/main/java/mock/MockNexo.java new file mode 100644 index 0000000..75731fd --- /dev/null +++ b/ci/mock-nexo/src/main/java/mock/MockNexo.java @@ -0,0 +1,30 @@ +package mock; + +import com.nexomc.nexo.items.NexoItem; +import com.nexomc.nexo.items.NexoItems; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.plugin.java.JavaPlugin; + +public class MockNexo implements JavaPlugin, Listener { + @Override + public void onEnable() { + getServer().getPluginManager().registerEvents(this, this); + NexoItems.getInstance(); + getLogger().info("MockNexo enabled!"); + } + + @Override + public void onDisable() { + getLogger().info("MockNexo disabled!"); + } + + @EventHandler + public void onInteract(PlayerInteractEvent event) { + if (event.getPlayer() instanceof Player) { + event.getPlayer().sendMessage("§7[MockNexo] Interacted!"); + } + } +} diff --git a/ci/mock-nexo/src/main/resources/plugin.yml b/ci/mock-nexo/src/main/resources/plugin.yml new file mode 100644 index 0000000..1f5ee3f --- /dev/null +++ b/ci/mock-nexo/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: MockNexo +version: 1.0.0 +main: mock.MockNexo +author: TestAuthor +description: Mock Nexo plugin for CI testing +api-version: "1.21" diff --git a/ci/mock-oraxen/build.gradle.kts b/ci/mock-oraxen/build.gradle.kts new file mode 100644 index 0000000..bab6ec8 --- /dev/null +++ b/ci/mock-oraxen/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("java") + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +group = "mock" +version = "1.0.0" + +tasks.withType { + options.encoding = Charsets.UTF_8.name() + options.release.set(21) +} + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:26.1.2.build.65-stable") +} + +java.toolchain.languageVersion.set(JavaLanguageVersion.of(21)) \ No newline at end of file diff --git a/ci/mock-oraxen/settings.gradle.kts b/ci/mock-oraxen/settings.gradle.kts new file mode 100644 index 0000000..aec0a55 --- /dev/null +++ b/ci/mock-oraxen/settings.gradle.kts @@ -0,0 +1,2 @@ +org.gradle.caching=true +org.gradle.parallel=true diff --git a/ci/mock-oraxen/src/main/java/io/th0rgal/oraxen/api/OraxenItem.java b/ci/mock-oraxen/src/main/java/io/th0rgal/oraxen/api/OraxenItem.java new file mode 100644 index 0000000..251abe7 --- /dev/null +++ b/ci/mock-oraxen/src/main/java/io/th0rgal/oraxen/api/OraxenItem.java @@ -0,0 +1,19 @@ +package io.th0rgal.oraxen.api; + +public class OraxenItem { + private final String id; + private final String material; + + public OraxenItem(String id, String material) { + this.id = id; + this.material = material; + } + + public String getId() { + return id; + } + + public String getMaterial() { + return material; + } +} diff --git a/ci/mock-oraxen/src/main/java/io/th0rgal/oraxen/api/OraxenItems.java b/ci/mock-oraxen/src/main/java/io/th0rgal/oraxen/api/OraxenItems.java new file mode 100644 index 0000000..b26fd49 --- /dev/null +++ b/ci/mock-oraxen/src/main/java/io/th0rgal/oraxen/api/OraxenItems.java @@ -0,0 +1,55 @@ +package io.th0rgal.oraxen.api; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +public class OraxenItems { + private static OraxenItems instance; + private final Map items = new HashMap<>(); + + public OraxenItems() { + instance = this; + } + + public static OraxenItems getInstance() { + return instance; + } + + public void registerItem(String id, OraxenItem item) { + items.put(id.toLowerCase(), item); + } + + public OraxenItem getItem(String id) { + return items.get(id.toLowerCase()); + } + + public boolean hasItem(String key) { + return items.containsKey(key.toLowerCase()); + } + + // Static API methods expected by scanner + public static Set getItems() { + if (instance == null) return new HashSet<>(); + return new HashSet<>(instance.items.keySet()); + } + + public static ItemBuilder getItemById(String id) { + if (instance == null) return null; + OraxenItem item = instance.items.get(id.toLowerCase()); + if (item == null) return null; + return new ItemBuilder(item); + } + + // Bootstrap method to register default items + public static void registerDefaults() { + if (instance == null) new OraxenItems(); + instance.registerItem("ruby_blade", new OraxenItem("ruby_blade", "DIAMOND_SWORD")); + instance.registerItem("sapphire_pickaxe", new OraxenItem("sapphire_pickaxe", "DIAMOND_PICKAXE")); + instance.registerItem("emerald_axe", new OraxenItem("emerald_axe", "DIAMOND_AXE")); + } +} \ No newline at end of file diff --git a/ci/mock-oraxen/src/main/java/io/th0rgal/oraxen/items/model/ItemBuilder.java b/ci/mock-oraxen/src/main/java/io/th0rgal/oraxen/items/model/ItemBuilder.java new file mode 100644 index 0000000..ca6c010 --- /dev/null +++ b/ci/mock-oraxen/src/main/java/io/th0rgal/oraxen/items/model/ItemBuilder.java @@ -0,0 +1,23 @@ +package io.th0rgal.oraxen.items.model; + +import io.th0rgal.oraxen.api.OraxenItem; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +public class ItemBuilder { + private final OraxenItem oraxenItem; + + public ItemBuilder(OraxenItem oraxenItem) { + this.oraxenItem = oraxenItem; + } + + public ItemStack build() { + Material material; + try { + material = Material.valueOf(oraxenItem.getMaterial()); + } catch (IllegalArgumentException e) { + material = Material.DIAMOND; + } + return new ItemStack(material); + } +} \ No newline at end of file diff --git a/ci/mock-oraxen/src/main/java/mock/MockOraxen.java b/ci/mock-oraxen/src/main/java/mock/MockOraxen.java new file mode 100644 index 0000000..891259a --- /dev/null +++ b/ci/mock-oraxen/src/main/java/mock/MockOraxen.java @@ -0,0 +1,29 @@ +package mock; + +import io.th0rgal.oraxen.api.OraxenItems; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.plugin.java.JavaPlugin; + +public class MockOraxen extends JavaPlugin implements Listener { + @Override + public void onEnable() { + getServer().getPluginManager().registerEvents(this, this); + OraxenItems.registerDefaults(); + getLogger().info("MockOraxen enabled with " + OraxenItems.getItems().size() + " items!"); + } + + @Override + public void onDisable() { + getLogger().info("MockOraxen disabled!"); + } + + @EventHandler + public void onInteract(PlayerInteractEvent event) { + if (event.getPlayer() instanceof Player) { + event.getPlayer().sendMessage("§7[MockOraxen] Interacted!"); + } + } +} \ No newline at end of file diff --git a/ci/mock-oraxen/src/main/java/mock/MockOraxenItem.java b/ci/mock-oraxen/src/main/java/mock/MockOraxenItem.java new file mode 100644 index 0000000..7d9dd34 --- /dev/null +++ b/ci/mock-oraxen/src/main/java/mock/MockOraxenItem.java @@ -0,0 +1,23 @@ +package mock; + +import io.th0rgal.oraxen.api.OraxenItem; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +public class MockOraxenItem { + private final String id; + private final OraxenItem oraxenItem; + + public MockOraxenItem(String id) { + this.id = id; + this.oraxenItem = new OraxenItem(id, Material.DIAMOND.name()); + } + + public String getId() { + return id; + } + + public ItemStack build() { + return new ItemStack(Material.DIAMOND); + } +} diff --git a/ci/mock-oraxen/src/main/resources/plugin.yml b/ci/mock-oraxen/src/main/resources/plugin.yml new file mode 100644 index 0000000..1303c31 --- /dev/null +++ b/ci/mock-oraxen/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: MockOraxen +version: 1.0.0 +main: mock.MockOraxen +author: TestAuthor +description: Mock Oraxen plugin for CI testing +api-version: "1.21" diff --git a/ci/mock-pdc-giver/build.gradle.kts b/ci/mock-pdc-giver/build.gradle.kts new file mode 100644 index 0000000..1f822e8 --- /dev/null +++ b/ci/mock-pdc-giver/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("java") + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +group = "mock" +version = "1.0.0" + +tasks.withType { + options.encoding = Charsets.UTF_8.name() + options.release.set(21) +} + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") +} + +java.toolchain.languageVersion.set(JavaLanguageVersion.of(21)) diff --git a/ci/mock-pdc-giver/settings.gradle.kts b/ci/mock-pdc-giver/settings.gradle.kts new file mode 100644 index 0000000..aec0a55 --- /dev/null +++ b/ci/mock-pdc-giver/settings.gradle.kts @@ -0,0 +1,2 @@ +org.gradle.caching=true +org.gradle.parallel=true diff --git a/ci/mock-pdc-giver/src/main/java/mock/MockPDCGiver.java b/ci/mock-pdc-giver/src/main/java/mock/MockPDCGiver.java new file mode 100644 index 0000000..19c074c --- /dev/null +++ b/ci/mock-pdc-giver/src/main/java/mock/MockPDCGiver.java @@ -0,0 +1,35 @@ +package mock; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.plugin.java.JavaPlugin; + +public class MockPDCGiver implements JavaPlugin, Listener { + @Override +n public void onEnable() { + getCommand("mockpdc").setExecutor(this); + getServer().getPluginManager().registerEvents(this, this); + getLogger().info("MockPDCGiver enabled!"); + } + + @Override + public void onDisable() { + getLogger().info("MockPDCGiver disabled!"); + } + + @Override + public boolean onCommand(org.bukkit.command.CommandSender sender, org.bukkit.command.Command cmd, String label, String[] args) { + if (cmd.getName().equalsIgnoreCase("mockpdc") && sender instanceof Player p) { + p.sendMessage("§aMockPDC data given!"); + return true; + } + return false; + } + + @EventHandler + public void onJoin(PlayerJoinEvent e) { + e.getPlayer().sendMessage("§7[MockPDCGiver] Loaded!"); + } +} diff --git a/ci/mock-pdc-giver/src/main/resources/plugin.yml b/ci/mock-pdc-giver/src/main/resources/plugin.yml new file mode 100644 index 0000000..9516d8a --- /dev/null +++ b/ci/mock-pdc-giver/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: MockPDCGiver +version: 1.0.0 +main: mock.MockPDCGiver +author: TestAuthor +description: Mock PDC giver plugin for CI +api-version: "1.21" diff --git a/ci/mock-stress-itemsadder/build.gradle.kts b/ci/mock-stress-itemsadder/build.gradle.kts new file mode 100644 index 0000000..1f822e8 --- /dev/null +++ b/ci/mock-stress-itemsadder/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("java") + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +group = "mock" +version = "1.0.0" + +tasks.withType { + options.encoding = Charsets.UTF_8.name() + options.release.set(21) +} + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") +} + +java.toolchain.languageVersion.set(JavaLanguageVersion.of(21)) diff --git a/ci/mock-stress-itemsadder/settings.gradle.kts b/ci/mock-stress-itemsadder/settings.gradle.kts new file mode 100644 index 0000000..aec0a55 --- /dev/null +++ b/ci/mock-stress-itemsadder/settings.gradle.kts @@ -0,0 +1,2 @@ +org.gradle.caching=true +org.gradle.parallel=true diff --git a/ci/mock-stress-itemsadder/src/main/java/dev/lone/itemsadder/api/CustomStack.java b/ci/mock-stress-itemsadder/src/main/java/dev/lone/itemsadder/api/CustomStack.java new file mode 100644 index 0000000..971d5b2 --- /dev/null +++ b/ci/mock-stress-itemsadder/src/main/java/dev/lone/itemsadder/api/CustomStack.java @@ -0,0 +1,13 @@ +package dev.lone.itemsadder.api; + +public class CustomStack { + private final String id; + + public CustomStack(String id) { + this.id = id; + } + + public String getId() { + return id; + } +} diff --git a/ci/mock-stress-itemsadder/src/main/java/mock/MockItem.java b/ci/mock-stress-itemsadder/src/main/java/mock/MockItem.java new file mode 100644 index 0000000..0c6f741 --- /dev/null +++ b/ci/mock-stress-itemsadder/src/main/java/mock/MockItem.java @@ -0,0 +1,22 @@ +package mock; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +public class MockItem { + private final String id; + private final ItemStack stack; + + public MockItem(String id, Material mat) { + this.id = id; + this.stack = new ItemStack(mat); + } + + public String getId() { + return id; + } + + public ItemStack getStack() { + return stack; + } +} diff --git a/ci/mock-stress-itemsadder/src/main/java/mock/MockStressItemsAdder.java b/ci/mock-stress-itemsadder/src/main/java/mock/MockStressItemsAdder.java new file mode 100644 index 0000000..c286a9c --- /dev/null +++ b/ci/mock-stress-itemsadder/src/main/java/mock/MockStressItemsAdder.java @@ -0,0 +1,25 @@ +package mock; + +import java.util.HashMap; +import java.util.Map; + +public class MockStressItemsAdder { + private static MockStressItemsAdder instance; + private final Map items = new HashMap<>(); + + public MockStressItemsAdder() { + instance = this; + } + + public static MockStressItemsAdder getInstance() { + return instance; + } + + public void registerItem(String id, Object item) { + items.put(id.toLowerCase(), item); + } + + public Object getItem(String key) { + return items.get(key.toLowerCase()); + } +} diff --git a/ci/mock-stress-itemsadder/src/main/resources/plugin.yml b/ci/mock-stress-itemsadder/src/main/resources/plugin.yml new file mode 100644 index 0000000..4568dcd --- /dev/null +++ b/ci/mock-stress-itemsadder/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: MockStressItemsAdder +version: 1.0.0 +main: mock.MockStressItemsAdder +author: TestAuthor +description: Mock stress test ItemsAdder plugin +api-version: "1.21" diff --git a/ci/mock-sxitem/build.gradle.kts b/ci/mock-sxitem/build.gradle.kts new file mode 100644 index 0000000..1f822e8 --- /dev/null +++ b/ci/mock-sxitem/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("java") + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +group = "mock" +version = "1.0.0" + +tasks.withType { + options.encoding = Charsets.UTF_8.name() + options.release.set(21) +} + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") +} + +java.toolchain.languageVersion.set(JavaLanguageVersion.of(21)) diff --git a/ci/mock-sxitem/settings.gradle.kts b/ci/mock-sxitem/settings.gradle.kts new file mode 100644 index 0000000..aec0a55 --- /dev/null +++ b/ci/mock-sxitem/settings.gradle.kts @@ -0,0 +1,2 @@ +org.gradle.caching=true +org.gradle.parallel=true diff --git a/ci/mock-sxitem/src/main/java/com/sucy/sxitem/SXItem.java b/ci/mock-sxitem/src/main/java/com/sucy/sxitem/SXItem.java new file mode 100644 index 0000000..f4956dd --- /dev/null +++ b/ci/mock-sxitem/src/main/java/com/sucy/sxitem/SXItem.java @@ -0,0 +1,15 @@ +package com.sucy.sxitem; + +public class SXItem { + private final String id; + private final String name; + + public SXItem(String id, String name) { + this.id = id; + this.name = name; + } + + public String getId() { + return id; + } +} diff --git a/ci/mock-sxitem/src/main/java/com/sucy/sxitem/SXItemManager.java b/ci/mock-sxitem/src/main/java/com/sucy/sxitem/SXItemManager.java new file mode 100644 index 0000000..699ecd0 --- /dev/null +++ b/ci/mock-sxitem/src/main/java/com/sucy/sxitem/SXItemManager.java @@ -0,0 +1,16 @@ +package com.sucy.sxitem; + +import java.util.HashMap; +import java.util.Map; + +public class SXItemManager { + private final Map items = new HashMap<>(); + + public void register(SXItem item) { + items.put(item.getId(), item); + } + + public SXItem getItem(String id) { + return items.get(id); + } +} diff --git a/ci/mock-sxitem/src/main/java/mock/MockSXItem.java b/ci/mock-sxitem/src/main/java/mock/MockSXItem.java new file mode 100644 index 0000000..8d2982f --- /dev/null +++ b/ci/mock-sxitem/src/main/java/mock/MockSXItem.java @@ -0,0 +1,32 @@ +package mock; + +import com.sucy.sxitem.SXItem; +import com.sucy.sxitem.SXItemManager; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.plugin.java.JavaPlugin; + +public class MockSXItem implements JavaPlugin, Listener { + private SXItemManager manager; + + @Override + public void onEnable() { + manager = new SXItemManager(); + getServer().getPluginManager().registerEvents(this, this); + getLogger().info("MockSXItem enabled!"); + } + + @Override + public void onDisable() { + getLogger().info("MockSXItem disabled!"); + } + + @EventHandler + public void onInteract(PlayerInteractEvent event) { + if (event.getPlayer() instanceof Player) { + event.getPlayer().sendMessage("§7[MockSXItem] Interacted!"); + } + } +} diff --git a/ci/mock-sxitem/src/main/java/mock/SXItemData.java b/ci/mock-sxitem/src/main/java/mock/SXItemData.java new file mode 100644 index 0000000..9c65a8f --- /dev/null +++ b/ci/mock-sxitem/src/main/java/mock/SXItemData.java @@ -0,0 +1,19 @@ +package mock; + +public class SXItemData { + private final String id; + private final String name; + + public SXItemData(String id, String name) { + this.id = id; + this.name = name; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/ci/mock-sxitem/src/main/resources/plugin.yml b/ci/mock-sxitem/src/main/resources/plugin.yml new file mode 100644 index 0000000..005ea3e --- /dev/null +++ b/ci/mock-sxitem/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: MockSXItem +version: 1.0.0 +main: mock.MockSXItem +author: TestAuthor +description: Mock SX-Item plugin for CI testing +api-version: "1.21" diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..ff219d0 --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/patchnotes.md b/patchnotes.md index 14b1b67..de7030c 100644 --- a/patchnotes.md +++ b/patchnotes.md @@ -1,175 +1,145 @@ # Aurelium - Patch Notes +## v1.5.0 - Custom Item Scanner & Stability Improvements + +**Aurelium now automatically discovers custom items from popular third-party plugins and integrates them seamlessly into your server market—no manual configuration required.** + +### New - Auto Custom Item Detection (Scanner) + +- **UnifiedItemScanner**: Automatically detects custom items from installed third-party plugins on server startup and via `/customitems scan` + - Supported plugins (via reflection, zero hard dependencies): ItemsAdder, Oraxen, MMOItems, MythicMobs, ExecutableItems, Nexo, SX-Item + - Each plugin API is accessed via reflection only—no compile-time dependencies required +- **Cross-Plugin Deduplication**: If the same item is registered by multiple plugins (e.g., ItemsAdder ruby_sword and MMOItems SWORD:RUBY_BLADE), it is deduplicated by canonical ID and stored as a single entry in `custom_items` +- **Config Override Sync**: Discovered items are written to `config.yml` under `discovered-items:` with source plugin, display name, material type, and default buy/sell prices + - Server owners can edit prices/flags in config, and changes persist across restarts +- **Database schema v2**: Added `custom_items` table for persistent custom item tracking with automatic v1-to-v2 migration +- **Thread-Safe Scanning**: All scan operations run async with proper locking to avoid race conditions during startup +- Custom items appear in the market with proper display names and configurable pricing + +### New - /customitems Command + +- **Full management commands** for discovered custom items: + - `/customitems scan` — Force rescan of all supported plugins + - `/customitems list` — View all discovered custom items + - `/customitems info ` — Show details for a specific item + - `/customitems reload` — Reload config overrides from disk + - `/customitems toggle ` — Enable or disable a discovered item in the market + - `/customitems price [sell]` — Set buy/sell prices for a discovered item + +### Fixes + +- **DatabaseManager DDL Propagation**: `createTables()` now re-throws `SQLException` if `custom_items` table creation fails, preventing schema version mismatch on fresh MySQL installs +- **Cloud Dashboard Retry Logic**: HTTP 4xx/5xx errors stop retrying immediately (permanent errors); only transient errors (network, DNS) retry with backoff +- **CustomItemRegistry Concurrency**: Fixed race conditions in `register()`, `upsert()`, and `clear()` — all write operations now use proper read-write locking +- **MarketItems Price Clamping**: Fixed inverted floor/ceiling clamping when `buyPrice` was unset (-1 sentinel), preventing price recovery drift toward -1 +- **Auction Display Names**: Auction messages now show custom display names instead of raw material types (uses `PlainTextComponentSerializer` for safe Component handling) +- **MySQL 8.0.20+ Compatibility**: Replaced deprecated `VALUES(col)` syntax with modern `AS new` alias syntax in all upsert queries + +### Testing + +- Expanded MySQL CI test suite: scanner integration, dedup accuracy, schema migration, custom_items persistence +- Mineflayer in-game tests: 96/96 pass covering economy commands, auction lifecycle, and customitems subcommands +- All CI jobs pass: build, smoke-test, ingame-test, mysql-test, scanner-mysql, custom-item-detection, custom-items-persistence + +### 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 + +--- + +## v1.4.5 - MySQL & Auction House Fixes + +**MySQL 8.0.20+ compatibility and auction display name improvements.** + +### Fixes + +- **MySQL 8.0.20+ Compatibility**: Replaced deprecated `VALUES(col)` syntax with modern `AS new` alias syntax in all upsert queries +- **Auction Custom Display Names**: Auction messages now show custom item display names instead of raw material types (uses `PlainTextComponentSerializer` for safe Component handling) +- **PreparedStatement Param Mismatch**: MySQL upserts now only set the parameters they actually use (4th param was SQLite-only) + +### Testing + +- Added expanded MySQL CI test suite (10 tests) running against MySQL 8.0 service container +- All 4 CI jobs pass: build, smoke-test, ingame-test, mysql-test + +### 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 + +--- + ## 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 +- `/bal` variants, `/eco` admin commands, player-only command rejection +- All tests pass on every push -### 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 + +- Session tokens now use cryptographically secure `SecureRandom` (256-bit) instead of `UUID.randomUUID()` ### Performance -- Cloud dashboard registration retries no longer block a `ForkJoinPool` thread for 15 seconds between attempts, replaced `Thread.sleep(15_000)` with Bukkit's non-blocking `runTaskLaterAsynchronously` scheduler -- Offline earnings cleanup now uses a single bulk `DELETE` SQL query instead of N+1 individual queries when a player joins, significantly reducing database load on servers with many offline earnings records + +- Cloud sync retries no longer block ForkJoinPool threads (uses Bukkit scheduler) +- Offline earnings cleanup uses bulk DELETE instead of N+1 queries ### Fixes -- Added proper exception logging to previously empty `catch` blocks in `ShopGUI` and `CloudSyncManager`, making debugging much easier -- Fixed a bug in `ShopGUI` where `target` was used instead of `clicker` for certain player interactions -- **BungeeCord/Velocity Sync**: Fixed a major bug where player balances could stay "stale" when switching servers due to permanent RAM caching. Player data is now refreshed from MySQL immediately upon joining a new server instance. -### Internal -- Bumped version in `pom.xml` to `1.4.2` to ensure consistent builds across Maven and Gradle. -- Updated `README.md` to clarify that MySQL is mandatory for cross-server synchronization and that Market Prices remain per-server for regional economy support. +- Fixed BungeeCord/Velocity balance sync — player data now refreshes from MySQL on server switch +- Added proper exception logging to previously empty catch blocks + +--- ## v1.4.1 - Web Stability Hotfix ### Fixes -- Cloud sync now automatically reconnects to the web dashboard if the render server restarts (Fixes 403 Invalid server ID error) + +- Cloud sync automatically reconnects if render server restarts (Fixes 403 Invalid server ID) + +--- ## v1.4.0 - Security, Enchantments & Cleanup ### New -- Enchantment books now have individual prices based on rarity and level (Mending = 35k, Sharpness V = 60k, etc.) + +- Enchantment books have individual prices based on rarity and level - Added 1.21.11 enchantments: Breach, Density, Wind Burst -- Web dashboard balance updates instantly after buying/selling in-game -- `/bal` now suggests currencies and online players in tab -- Auto database backups when the plugin updates -- Auto schema migrations on startup +- Web dashboard balance updates instantly after in-game transactions ### Fixes -- `/bal` was checking the wrong permission — non-op players couldn't use it -- SellGUI sometimes showed a different total than what you actually got paid -- Market GUI prices now update right after a transaction instead of lagging behind -- AH cancellation visual flicker on shift-click -- Collection bin no longer drops items on the ground if your inventory is full -- "All Items" button was showing items outside the configured categories -- NullPointerException when browsing "All Items" -- Cloud sync no longer spams HTML in console when the server is waking up + +- `/bal` permission check fixed +- SellGUI total display fixed +- Market GUI prices update after transaction +- AH cancellation flicker fixed +- Collection bin item drop fix +- "All Items" button filter fix +- Cloud sync HTML spam fix ### Security -- Economy, market, and auction transactions are now atomic (no more duping from race conditions) -- Auction bids use price-checked SQL to prevent out-of-order bid corruption -- Web purchases are deduplicated to prevent double-spending -- All GUIs lock down shift-click and drag to prevent inventory exploits -- SellGUI locks the price at review time so it can't change mid-transaction -- CORS is now a configurable whitelist instead of wildcard + +- Economy/market/auction transactions are atomic (no duping) +- Auction bids use price-checked SQL +- Web purchases deduplicated +- All GUIs lock shift-click and drag +- SellGUI locks price at review time +- CORS is configurable whitelist ### 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! + +- All money math uses BigDecimal +- Market item prices moved to `market-items` in config diff --git a/pom.xml b/pom.xml index 5069453..42b4395 100644 --- a/pom.xml +++ b/pom.xml @@ -1,99 +1,47 @@ - - 4.0.0 + + + 4.0.0 + com.aureleconomy + aurelium + 1.5.0 + jar - com.aureleconomy - Aurelium - 1.4.3 - jar + + 25 + 25 + UTF-8 + 26.1.2 + - Aurelium + + + papermc + https://repo.papermc.io/repository/maven-public/ + + - - 21 - UTF-8 - + + + io.papermc.paper + paper-api + 1.21-R0.1-SNAPSHOT + provided + + - - - - 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 - - - - - - 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 - - + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 25 + 25 + + + + diff --git a/src/main/java/com/aureleconomy/AurelEconomy.java b/src/main/java/com/aureleconomy/AurelEconomy.java index 80a2f0a..64fdd20 100644 --- a/src/main/java/com/aureleconomy/AurelEconomy.java +++ b/src/main/java/com/aureleconomy/AurelEconomy.java @@ -1,6 +1,9 @@ package com.aureleconomy; import com.aureleconomy.database.DatabaseManager; +import com.aureleconomy.scanner.CustomItemRegistry; +import com.aureleconomy.scanner.UnifiedItemScanner; +import com.aureleconomy.scanner.ItemDiscoveryListener; import com.aureleconomy.economy.EconomyManager; import com.aureleconomy.economy.VaultEconomy; import net.milkbowl.vault.economy.Economy; @@ -18,7 +21,7 @@ public class AurelEconomy extends JavaPlugin { public static final String CONF_DB_ENABLED = "web.enabled"; public static final String CONF_WEB_MODE = "web.mode"; public static final String CONF_DEFAULT_CURRENCY = "economy.default-currency"; - + // Default Values private static final String WEB_MODE_CLOUD = "cloud"; private static final String WEB_MODE_LOCAL = "local"; @@ -33,7 +36,9 @@ public class AurelEconomy extends JavaPlugin { private VaultEconomy vaultEconomy; private com.aureleconomy.web.WebServer webServer; private com.aureleconomy.web.CloudSyncManager cloudSync; - + private CustomItemRegistry customItemRegistry; + private UnifiedItemScanner unifiedScanner; + private final java.util.Set activeViewers = java.util.Collections .newSetFromMap(new java.util.concurrent.ConcurrentHashMap<>()); @@ -66,13 +71,53 @@ public void onEnable() { } // Initialize Managers - economyManager = new EconomyManager(this); + economyManager = new EconomyManager(this); marketManager = new com.aureleconomy.market.MarketManager(this); auctionManager = new com.aureleconomy.auction.AuctionManager(this); chatPromptManager = new com.aureleconomy.utils.ChatPromptManager(this); orderManager = new com.aureleconomy.orders.OrderManager(this); orderManager.loadOrders(); + // Initialize custom item system + if (getConfig().getBoolean("custom-items.enabled", true)) { + this.customItemRegistry = new CustomItemRegistry(this); + this.unifiedScanner = new UnifiedItemScanner(this, customItemRegistry); + + // Phase 1: Load previously discovered items from database + customItemRegistry.loadFromDatabase(databaseManager); + customItemRegistry.loadConfigOverrides(); + + // Phase 2: Plugin API scan (delayed 1s for other plugins to load) + boolean scanOnStartup = getConfig().getBoolean("custom-items.scan-on-startup", true); + if (scanOnStartup) { + getServer().getScheduler().runTaskLater(this, () -> { + if (unifiedScanner != null) { + unifiedScanner.scanAllPluginAPIs(); + unifiedScanner.scanPlayerInventories(); + getLogger().info("[CustomItems] Scan complete: " + customItemRegistry.getTotalItems() + + " unique items, " + customItemRegistry.getDuplicatesPrevented() + " duplicates prevented"); + } + }, 20L); + } else { + getLogger().info("[CustomItems] Startup scan disabled by config (custom-items.scan-on-startup=false)"); + } + + // Phase 3: Register runtime detection listeners + getServer().getPluginManager().registerEvents(new ItemDiscoveryListener(this, unifiedScanner), this); + + // Phase 4: Periodic rescan + int scanInterval = getConfig().getInt("custom-items.scan-interval-minutes", 10); + new org.bukkit.scheduler.BukkitRunnable() { + @Override + public void run() { + if (unifiedScanner != null) { + unifiedScanner.scanPlayerInventories(); + customItemRegistry.saveToDatabase(databaseManager); + } + } + }.runTaskTimer(this, 20L * 60L * scanInterval, 20L * 60L * scanInterval); + } + // Register Vault Hook if (getServer().getPluginManager().getPlugin("Vault") != null) { vaultEconomy = new VaultEconomy(this, economyManager); @@ -84,7 +129,7 @@ public void onEnable() { // Register Commands registerCommands(); - + // Register Listeners getServer().getPluginManager().registerEvents(new com.aureleconomy.listeners.GUIListener(this), this); getServer().getPluginManager().registerEvents(new com.aureleconomy.listeners.SpawnerListener(this), this); @@ -96,6 +141,7 @@ public void onEnable() { // Periodic Market Price Persistence (Every 5 minutes) getServer().getScheduler().runTaskTimerAsynchronously(this, () -> { if (marketManager != null) marketManager.persistPrices(); + if (customItemRegistry != null) customItemRegistry.saveToDatabase(databaseManager); }, 6000L, 6000L); getComponentLogger().info("AurelEconomy has been enabled!"); @@ -112,11 +158,18 @@ private void registerCommands() { getCommand("ah").setExecutor(new com.aureleconomy.commands.AuctionCommand(this)); getCommand("sell").setExecutor(new com.aureleconomy.commands.SellCommand(this)); getCommand("stocks").setExecutor(new com.aureleconomy.commands.StocksCommand(this)); - + com.aureleconomy.commands.OrdersCommand ordersCmd = new com.aureleconomy.commands.OrdersCommand(this); getCommand("orders").setExecutor(ordersCmd); getCommand("orders").setTabCompleter(ordersCmd); + // Custom Items command + com.aureleconomy.commands.CustomItemsCommand customItemsCmd = new com.aureleconomy.commands.CustomItemsCommand(this); + if (getCommand("customitems") != null) { + getCommand("customitems").setExecutor(customItemsCmd); + getCommand("customitems").setTabCompleter(customItemsCmd); + } + if (getCommand("web") != null) { getCommand("web").setExecutor(new com.aureleconomy.commands.WebCommand(this)); } @@ -131,15 +184,16 @@ private void startGuiUpdateTask() { activeViewers.remove(p); continue; } - + org.bukkit.inventory.InventoryView view = p.getOpenInventory(); Object holder = view.getTopInventory().getHolder(); - + if (holder instanceof com.aureleconomy.gui.StocksGUI gui) gui.refresh(); else if (holder instanceof com.aureleconomy.gui.MarketGUI gui) gui.refresh(); else if (holder instanceof com.aureleconomy.gui.AuctionGUI gui) gui.refresh(); else if (holder instanceof com.aureleconomy.gui.OrderMaterialGUI gui) gui.refresh(); else if (holder instanceof com.aureleconomy.gui.OrdersGUI gui) gui.refresh(); + else if (holder instanceof com.aureleconomy.gui.CustomItemsGUI gui) gui.setupItems(); else if (holder == null) activeViewers.remove(p); } }, 20L, 20L); @@ -160,6 +214,7 @@ private void initializeWebServices() { @Override public void onDisable() { + // Stop web services and clean up sessions if (webServer != null) webServer.stop(); if (cloudSync != null) cloudSync.stop(); if (marketManager != null) marketManager.persistPrices(); @@ -177,6 +232,8 @@ public void onDisable() { public void addViewer(org.bukkit.entity.Player player) { activeViewers.add(player); } public void removeViewer(org.bukkit.entity.Player player) { activeViewers.remove(player); } public com.aureleconomy.web.WebServer getWebServer() { return webServer; } + public CustomItemRegistry getCustomItemRegistry() { return customItemRegistry; } + public UnifiedItemScanner getUnifiedScanner() { return unifiedScanner; } public com.aureleconomy.web.CloudSyncManager getCloudSync() { return cloudSync; } private void upgradeConfig() { diff --git a/src/main/java/com/aureleconomy/auction/AuctionManager.java b/src/main/java/com/aureleconomy/auction/AuctionManager.java index 77856a8..e564abe 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; /** @@ -58,7 +59,7 @@ public AuctionManager(AurelEconomy plugin) { private void loadAuctions() { try (PreparedStatement ps = plugin.getDatabaseManager().getConnection() - .prepareStatement("SELECT * FROM auctions WHERE ended = 0")) { + .prepareStatement("SELECT id, seller_uuid, item_data, price, currency, is_bin, expiration, highest_bidder_uuid, ended, collected, listing_fee, start_time FROM auctions WHERE ended = 0")) { ResultSet rs = ps.executeQuery(); while (rs.next()) { activeAuctions.add(mapResultSet(rs)); @@ -104,6 +105,8 @@ public void listAuction(UUID seller, ItemStack item, BigDecimal price, String cu long now = System.currentTimeMillis(); long expiration = now + durationMillis; + final String cachedDisplayName = getItemDisplayName(item); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( "INSERT INTO auctions (seller_uuid, item_data, price, currency, is_bin, expiration, listing_fee, start_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", @@ -140,7 +143,8 @@ public void listAuction(UUID seller, ItemStack item, BigDecimal price, String cu public void bid(com.aureleconomy.auction.AuctionItem auction, UUID bidder, BigDecimal amount) { String currency = auction.getCurrency(); - // Perform atomic update in DB first to ensure we actually won the bid slot + final String cachedDisplayName = getItemDisplayName(auction.getItem()); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { try (PreparedStatement ps = plugin.getDatabaseManager().getConnection() .prepareStatement("UPDATE auctions SET highest_bidder_uuid = ?, price = ? WHERE id = ? AND (price < ? OR highest_bidder_uuid IS NULL)")) { @@ -148,14 +152,13 @@ public void bid(com.aureleconomy.auction.AuctionItem auction, UUID bidder, BigDe ps.setBigDecimal(2, amount); ps.setInt(3, auction.getId()); ps.setBigDecimal(4, amount); - + int affectedRows = ps.executeUpdate(); if (affectedRows > 0) { - // We successfully placed the bid synchronized (auction) { UUID previousBidder = auction.getHighestBidder(); BigDecimal previousPrice = auction.getPrice(); - + auction.setPrice(amount); auction.setHighestBidder(bidder); @@ -164,20 +167,16 @@ 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, cachedDisplayName, formatted), NamedTextColor.YELLOW)); } } } com.aureleconomy.gui.AuctionGUI.refreshAllViewers(); } else { - // Bid failed (someone else bid higher already or same amount) Player p = Bukkit.getPlayer(bidder); if (p != null) { p.sendMessage(Component.text("Your bid was too late! Someone else already bid higher.", NamedTextColor.RED)); - // Refund immediately if the money was already taken (GUI usually handles this, - // but if called from web, we need to be careful) } - // For web purchases, failure will be caught by the executePurchases logic } } catch (SQLException e) { plugin.getComponentLogger().error("Database error during bidding", e); @@ -217,8 +216,8 @@ public void cancelAuction(com.aureleconomy.auction.AuctionItem ai, Player player .prepareStatement("UPDATE auctions SET ended = 1 WHERE id = ? AND ended = 0")) { ps.setInt(1, ai.getId()); int rows = ps.executeUpdate(); - - if (rows == 0) return; // Already ended + + if (rows == 0) return; Bukkit.getScheduler().runTask(plugin, (Runnable) () -> { if (finalRefund.compareTo(BigDecimal.ZERO) > 0) { @@ -253,6 +252,8 @@ public void endAuction(com.aureleconomy.auction.AuctionItem auction) { activeAuctions.remove(auction); } + final String cachedDisplayName = getItemDisplayName(auction.getItem()); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { try (PreparedStatement ps = plugin.getDatabaseManager().getConnection() .prepareStatement("UPDATE auctions SET ended = 1 WHERE id = ?")) { @@ -280,7 +281,7 @@ public void endAuction(com.aureleconomy.auction.AuctionItem auction) { + plugin.getEconomyManager().getFormattedWithSymbol(finalPrice, auction.getCurrency()), NamedTextColor.GREEN)); } else { - logOfflineEarning(auction.getSeller(), finalPrice, auction.getItem()); + logOfflineEarning(auction.getSeller(), finalPrice, auction.getItem(), cachedDisplayName); } } else if (seller != null) { seller.sendMessage(Component.text("Your auction expired without bids. Collect items in /ah collect.", @@ -288,17 +289,14 @@ public void endAuction(com.aureleconomy.auction.AuctionItem auction) { } } - private void logOfflineEarning(UUID uuid, BigDecimal amount, ItemStack item) { + private void logOfflineEarning(UUID uuid, BigDecimal amount, ItemStack item, String displayName) { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( "INSERT INTO offline_earnings (uuid, amount, item_display, timestamp) VALUES (?, ?, ?, ?)")) { 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 display = itemName + " (x" + item.getAmount() + ")"; + String display = displayName + " (x" + item.getAmount() + ")"; ps.setString(3, display); ps.setLong(4, System.currentTimeMillis()); @@ -340,7 +338,7 @@ public com.aureleconomy.auction.AuctionItem getAuctionById(int id) { public List getCollectionBin(UUID playerUUID) { List items = new ArrayList<>(); try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( - "SELECT * FROM auctions WHERE collected = 0 AND ended = 1 AND ((seller_uuid = ? AND highest_bidder_uuid IS NULL) OR (highest_bidder_uuid = ?))")) { + "SELECT id, seller_uuid, item_data, price, currency, is_bin, expiration, highest_bidder_uuid, ended, collected, listing_fee, start_time FROM auctions WHERE collected = 0 AND ended = 1 AND ((seller_uuid = ? AND highest_bidder_uuid IS NULL) OR (highest_bidder_uuid = ?))")) { ps.setString(1, playerUUID.toString()); ps.setString(2, playerUUID.toString()); ResultSet rs = ps.executeQuery(); @@ -357,7 +355,7 @@ public void getOffersForSeller(UUID seller, Consumer> callback) { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { List offers = new ArrayList<>(); try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( - "SELECT o.* FROM auction_offers o JOIN auctions a ON o.auction_id = a.id WHERE a.seller_uuid = ? AND o.status = 'PENDING'")) { + "SELECT o.id, o.auction_id, o.bidder_uuid, o.amount, o.currency, o.status, o.timestamp FROM auction_offers o JOIN auctions a ON o.auction_id = a.id WHERE a.seller_uuid = ? AND o.status = 'PENDING'")) { ps.setString(1, seller.toString()); ResultSet rs = ps.executeQuery(); while (rs.next()) { @@ -387,6 +385,8 @@ public void makeOffer(com.aureleconomy.auction.AuctionItem ai, Player bidder, Bi return; } + final String cachedDisplayName = getItemDisplayName(ai.getItem()); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( "INSERT INTO auction_offers (auction_id, bidder_uuid, amount, status, timestamp) VALUES (?, ?, ?, ?, ?)")) { @@ -402,7 +402,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, cachedDisplayName), NamedTextColor.GOLD)); } } catch (SQLException e) { plugin.getComponentLogger().error("Database error making offer", e); @@ -415,7 +415,7 @@ public void acceptOffer(int offerId, Player seller) { try { Offer offer = null; try (PreparedStatement ps = plugin.getDatabaseManager().getConnection() - .prepareStatement("SELECT * FROM auction_offers WHERE id = ?")) { + .prepareStatement("SELECT id, auction_id, bidder_uuid, amount, currency, status, timestamp FROM auction_offers WHERE id = ?")) { ps.setInt(1, offerId); ResultSet rs = ps.executeQuery(); if (rs.next()) { @@ -431,9 +431,10 @@ public void acceptOffer(int offerId, Player seller) { if (ai == null) return; + final String cachedDisplayName = getItemDisplayName(ai.getItem()); + Offer finalOffer = offer; Bukkit.getScheduler().runTask(plugin, (Runnable) () -> { - // Atomic status update to PREVENT double-acceptance if (updateOfferStatusAtomic(offerId, OfferStatus.ACCEPTED)) { if (plugin.getEconomyManager().has(Bukkit.getOfflinePlayer(finalOffer.getBidder()), finalOffer.getAmount(), ai.getCurrency())) { @@ -448,7 +449,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, cachedDisplayName), NamedTextColor.GREEN)); } else { bidder.sendMessage(Component.text( @@ -477,9 +478,6 @@ public void declineOffer(int offerId, Player seller) { seller.sendMessage(Component.text("Offer declined.", NamedTextColor.YELLOW)); } - /** - * Atomically claims an auction for purchase. Returns true if successful. - */ public boolean claimAuctionAtomic(int id) { try (PreparedStatement ps = plugin.getDatabaseManager().getConnection() .prepareStatement("UPDATE auctions SET ended = 1 WHERE id = ? AND ended = 0")) { @@ -516,12 +514,6 @@ public void updateOfferStatus(int offerId, OfferStatus status) { }); } - /** - * Atomically marks an auction as collected. Returns true if the update was - * successful - * (item was not already collected), false if already collected or on error. - * This prevents item duplication from rapid clicking. - */ public boolean markCollectedAtomic(int id) { try (PreparedStatement ps = plugin.getDatabaseManager().getConnection() .prepareStatement("UPDATE auctions SET collected = 1 WHERE id = ? AND collected = 0")) { @@ -558,8 +550,8 @@ public void sendToCollectionBin(UUID playerUUID, ItemStack item) { ps.setLong(6, System.currentTimeMillis()); ps.setBigDecimal(7, BigDecimal.ZERO); ps.setLong(8, System.currentTimeMillis()); - ps.setBoolean(9, true); // Ended - ps.setBoolean(10, false); // Not collected + ps.setBoolean(9, true); + ps.setBoolean(10, false); ps.executeUpdate(); com.aureleconomy.gui.AuctionGUI.refreshAllViewers(); } catch (SQLException e) { @@ -568,6 +560,44 @@ public void sendToCollectionBin(UUID playerUUID, ItemStack item) { }); } + /** + * Returns the display name of an item, preferring custom display name over material name. + * Thread-safe: uses simplified fallback when called from async threads. + */ + String getItemDisplayName(ItemStack item) { + if (Bukkit.isPrimaryThread()) { + com.aureleconomy.scanner.CustomItemRegistry registry = plugin.getCustomItemRegistry(); + if (registry != null) { + java.util.Optional customId = registry.resolveItemId(item); + if (customId.isPresent()) { + com.aureleconomy.scanner.CustomMarketItem customItem = registry.getById(customId.get()); + if (customItem != null && customItem.getDisplayName() != null && !customItem.getDisplayName().isEmpty()) { + return customItem.getDisplayName(); + } + } + } + if (item.hasItemMeta()) { + ItemMeta meta = item.getItemMeta(); + 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); + } + } + } + } + // Fallback for async context: avoid ItemMeta reads + // Format MATERIAL_NAME as "Material Name" for display + String typeName = item.getType().name(); + StringBuilder formatted = new StringBuilder(); + for (String part : typeName.split("_")) { + if (!formatted.isEmpty()) formatted.append(" "); + formatted.append(part.charAt(0)).append(part.substring(1).toLowerCase()); + } + return formatted.toString(); + } + 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..da25558 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,8 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command return true; } currency = args[3]; - if (!plugin.getConfig().getConfigurationSection("economy.currencies").contains(currency)) { + var currencies = plugin.getConfig().getConfigurationSection("economy.currencies"); + if (currencies == null || !currencies.contains(currency)) { player.sendMessage(Component.text("Invalid currency: " + currency, NamedTextColor.RED)); return true; } diff --git a/src/main/java/com/aureleconomy/commands/CustomItemsCommand.java b/src/main/java/com/aureleconomy/commands/CustomItemsCommand.java new file mode 100644 index 0000000..fa0bef5 --- /dev/null +++ b/src/main/java/com/aureleconomy/commands/CustomItemsCommand.java @@ -0,0 +1,314 @@ +package com.aureleconomy.commands; + +import com.aureleconomy.AurelEconomy; +import com.aureleconomy.scanner.CustomItemRegistry; +import com.aureleconomy.scanner.CustomMarketItem; +import com.aureleconomy.scanner.DiscoveryMethod; +import com.aureleconomy.scanner.UnifiedItemScanner; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabExecutor; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * Admin command for managing discovered custom items. + * /customitems scan - Trigger an immediate full rescan + * /customitems list - List all discovered custom items + * /customitems info - Show details about a custom item + * /customitems reload - Reload custom items from database + rescan + * /customitems toggle - Enable/disable a custom item in the market + * /customitems price - Set custom pricing + */ +public class CustomItemsCommand implements TabExecutor { + + private static final MiniMessage MM = MiniMessage.miniMessage(); + private final AurelEconomy plugin; + + public CustomItemsCommand(AurelEconomy plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, + @NotNull String label, @NotNull String[] args) { + if (!sender.hasPermission("aureleconomy.admin")) { + sender.sendMessage(Component.text("You don't have permission to use this command.", NamedTextColor.RED)); + return true; + } + + if (args.length == 0) { + sendUsage(sender); + return true; + } + + CustomItemRegistry registry = plugin.getCustomItemRegistry(); + if (registry == null) { + sender.sendMessage(Component.text("Custom item system is not initialized.", NamedTextColor.RED)); + return true; + } + + switch (args[0].toLowerCase()) { + case "scan" -> handleScan(sender, registry); + case "list" -> handleList(sender, registry, args); + case "info" -> handleInfo(sender, registry, args); + case "reload" -> handleReload(sender, registry); + case "toggle" -> handleToggle(sender, registry, args); + case "price" -> handlePrice(sender, registry, args); + default -> sendUsage(sender); + } + return true; + } + + private void handleScan(CommandSender sender, CustomItemRegistry registry) { + UnifiedItemScanner scanner = plugin.getUnifiedScanner(); + if (scanner == null) { + sender.sendMessage(Component.text("Scanner not initialized.", NamedTextColor.RED)); + return; + } + + sender.sendMessage(MM.deserialize("[CustomItems] Starting full rescan...")); + + // Run scan on next tick to ensure we're on main thread where needed + plugin.getServer().getScheduler().runTaskLater(plugin, () -> { + int before = registry.getTotalItems(); + scanner.scanAllPluginAPIs(); + scanner.scanPlayerInventories(); + int after = registry.getTotalItems(); + + sender.sendMessage(MM.deserialize("[CustomItems] Scan complete! " + + after + " unique items, " + registry.getDuplicatesPrevented() + " duplicates prevented" + + (after > before ? " (" + (after - before) + " new)" : "") + "")); + + // Persist to database + registry.saveToDatabase(plugin.getDatabaseManager()); + }, 1L); + } + + private void handleList(CommandSender sender, CustomItemRegistry registry, String[] args) { + if (registry.isEmpty()) { + sender.sendMessage(MM.deserialize("[CustomItems] No custom items discovered yet.")); + return; + } + + int page = 1; + if (args.length > 1) { + try { page = Integer.parseInt(args[1]); } catch (NumberFormatException ignored) {} + } + + int perPage = 10; + List allItems = new ArrayList<>(registry.getAllItems()); + int totalPages = Math.max(1, (int) Math.ceil((double) allItems.size() / perPage)); + // Fix: clamp page to valid range [1, totalPages] to prevent negative index crash + page = Math.max(1, Math.min(page, totalPages)); + + sender.sendMessage(MM.deserialize("Custom Items (Page " + page + "/" + totalPages + + " - " + allItems.size() + " items)")); + + int start = (page - 1) * perPage; + int end = Math.min(start + perPage, allItems.size()); + + for (int i = start; i < end; i++) { + CustomMarketItem item = allItems.get(i); + String enabled = item.isEnabled() ? "ON" : "OFF"; + String price = item.getBuyPrice().compareTo(BigDecimal.ZERO) >= 0 + ? "" + item.getBuyPrice() + "/" + item.getSellPrice() + "" + : "unset"; + + sender.sendMessage(MM.deserialize(" - " + item.getCanonicalId() + + " from " + item.getSourcePlugin() + " " + enabled + + " price: " + price)); + } + + if (page < totalPages) { + sender.sendMessage(MM.deserialize("Use /customitems list " + (page + 1) + " for next page.")); + } + } + + private void handleInfo(CommandSender sender, CustomItemRegistry registry, String[] args) { + if (args.length < 2) { + sender.sendMessage(Component.text("Usage: /customitems info ", NamedTextColor.YELLOW)); + return; + } + + String id = args[1]; + CustomMarketItem item = registry.getById(id); + if (item == null) { + sender.sendMessage(MM.deserialize("No custom item found with ID: " + id + "")); + return; + } + + Set methods = registry.getDiscoveryMethods(id); + + sender.sendMessage(MM.deserialize("=== Custom Item Info ===")); + sender.sendMessage(MM.deserialize(" ID: " + item.getCanonicalId() + "")); + sender.sendMessage(MM.deserialize(" Source: " + item.getSourcePlugin() + "")); + sender.sendMessage(MM.deserialize(" Display Name: " + item.getDisplayName() + "")); + sender.sendMessage(MM.deserialize(" Material: " + item.getItemStack().getType().name() + "")); + sender.sendMessage(MM.deserialize(" Category: " + item.getCategory() + "")); + sender.sendMessage(MM.deserialize(" Enabled: " + (item.isEnabled() ? "Yes" : "No"))); + sender.sendMessage(MM.deserialize(" Buy Price: " + item.getBuyPrice() + "")); + sender.sendMessage(MM.deserialize(" Sell Price: " + item.getSellPrice() + "")); + sender.sendMessage(MM.deserialize(" PDC Key: " + (item.getPdcKey() != null ? item.getPdcKey() : "N/A") + "")); + sender.sendMessage(MM.deserialize(" Model Data Key: " + (item.getModelDataKey() != null ? item.getModelDataKey() : "N/A") + "")); + sender.sendMessage(MM.deserialize(" Lore Hash: " + (item.getLoreHash() != null ? item.getLoreHash() : "N/A") + "")); + sender.sendMessage(MM.deserialize(" Plugin Native ID: " + (item.getPluginNativeId() != null ? item.getPluginNativeId() : "N/A") + "")); + sender.sendMessage(MM.deserialize(" Discovery Methods: " + + methods.stream().map(DiscoveryMethod::getDisplayName).reduce((a, b) -> a + ", " + b).orElse("None") + "")); + } + + private void handleReload(CommandSender sender, CustomItemRegistry registry) { + sender.sendMessage(MM.deserialize("[CustomItems] Reloading from database...")); + + // Clear registry and reload + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + registry.clear(); + registry.loadFromDatabase(plugin.getDatabaseManager()); + + plugin.getServer().getScheduler().runTaskLater(plugin, () -> { + UnifiedItemScanner scanner = plugin.getUnifiedScanner(); + if (scanner != null) { + scanner.scanAllPluginAPIs(); + scanner.scanPlayerInventories(); + } + sender.sendMessage(MM.deserialize("[CustomItems] Reload complete! " + + registry.getTotalItems() + " items loaded.")); + }, 20L); + }); + } + + private void handleToggle(CommandSender sender, CustomItemRegistry registry, String[] args) { + if (args.length < 2) { + sender.sendMessage(Component.text("Usage: /customitems toggle ", NamedTextColor.YELLOW)); + return; + } + + String id = args[1]; + CustomMarketItem item = registry.getById(id); + if (item == null) { + sender.sendMessage(MM.deserialize("No custom item found with ID: " + id + "")); + return; + } + + boolean newState = !item.isEnabled(); + // Rebuild item with toggled enabled state + CustomMarketItem updated = new CustomMarketItem.Builder() + .canonicalId(item.getCanonicalId()) + .itemStack(item.getItemStack()) + .sourcePlugin(item.getSourcePlugin()) + .displayName(item.getDisplayName()) + .pdcKey(item.getPdcKey()) + .modelDataKey(item.getModelDataKey()) + .loreHash(item.getLoreHash()) + .pluginNativeId(item.getPluginNativeId()) + .category(item.getCategory()) + .buyPrice(item.getBuyPrice()) + .sellPrice(item.getSellPrice()) + .enabled(newState) + .build(); + + // Replace in registry using upsert (fix: was register, should be upsert) + registry.upsert(updated); + + String stateStr = newState ? "enabled" : "disabled"; + sender.sendMessage(MM.deserialize("[CustomItems] " + id + " is now " + stateStr)); + + registry.saveToDatabase(plugin.getDatabaseManager()); + } + + private void handlePrice(CommandSender sender, CustomItemRegistry registry, String[] args) { + if (args.length < 4) { + sender.sendMessage(Component.text("Usage: /customitems price ", NamedTextColor.YELLOW)); + return; + } + + String id = args[1]; + CustomMarketItem item = registry.getById(id); + if (item == null) { + sender.sendMessage(MM.deserialize("No custom item found with ID: " + id + "")); + return; + } + + try { + double buy = Double.parseDouble(args[2]); + double sell = Double.parseDouble(args[3]); + + // Fix: validate prices are finite and non-negative + if (Double.isNaN(buy) || Double.isInfinite(buy) || buy < 0) { + sender.sendMessage(Component.text("Buy price must be a non-negative finite number.", NamedTextColor.RED)); + return; + } + if (Double.isNaN(sell) || Double.isInfinite(sell) || sell < 0) { + sender.sendMessage(Component.text("Sell price must be a non-negative finite number.", NamedTextColor.RED)); + return; + } + + // Rebuild item with new prices + CustomMarketItem updated = new CustomMarketItem.Builder() + .canonicalId(item.getCanonicalId()) + .itemStack(item.getItemStack()) + .sourcePlugin(item.getSourcePlugin()) + .displayName(item.getDisplayName()) + .pdcKey(item.getPdcKey()) + .modelDataKey(item.getModelDataKey()) + .loreHash(item.getLoreHash()) + .pluginNativeId(item.getPluginNativeId()) + .category(item.getCategory()) + .buyPrice(BigDecimal.valueOf(buy)) + .sellPrice(BigDecimal.valueOf(sell)) + .enabled(item.isEnabled()) + .build(); + + Set methods = registry.getDiscoveryMethods(id); + registry.upsert(updated); + registry.updateCustomItemPrice(plugin.getDatabaseManager(), id, buy, sell); + + sender.sendMessage(MM.deserialize("[CustomItems] " + id + " price set: " + + "Buy: " + buy + " Sell: " + sell + "")); + } catch (NumberFormatException e) { + sender.sendMessage(Component.text("Invalid price format. Use numbers.", NamedTextColor.RED)); + } + } + + private void sendUsage(CommandSender sender) { + sender.sendMessage(MM.deserialize("Custom Items Commands")); + sender.sendMessage(MM.deserialize("/customitems scan - Trigger an immediate rescan")); + sender.sendMessage(MM.deserialize("/customitems list - List all discovered items")); + sender.sendMessage(MM.deserialize("/customitems info - Show item details")); + sender.sendMessage(MM.deserialize("/customitems reload - Reload from database + rescan")); + sender.sendMessage(MM.deserialize("/customitems toggle - Enable/disable item")); + sender.sendMessage(MM.deserialize("/customitems price - Set pricing")); + } + + @Override + public @Nullable List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, + @NotNull String label, @NotNull String[] args) { + if (!sender.hasPermission("aureleconomy.admin")) return List.of(); + + if (args.length == 1) { + return List.of("scan", "list", "info", "reload", "toggle", "price"); + } + + CustomItemRegistry registry = plugin.getCustomItemRegistry(); + if (registry == null) return List.of(); + + if (args.length == 2 && (args[0].equalsIgnoreCase("info") || args[0].equalsIgnoreCase("toggle") + || args[0].equalsIgnoreCase("price"))) { + return new ArrayList<>(registry.getAllItems().stream() + .map(CustomMarketItem::getCanonicalId) + .filter(id -> id.toLowerCase().startsWith(args[1].toLowerCase())) + .toList()); + } + + return List.of(); + } +} diff --git a/src/main/java/com/aureleconomy/commands/EconomyCommand.java b/src/main/java/com/aureleconomy/commands/EconomyCommand.java index 0e09885..eb7c51e 100644 --- a/src/main/java/com/aureleconomy/commands/EconomyCommand.java +++ b/src/main/java/com/aureleconomy/commands/EconomyCommand.java @@ -30,7 +30,7 @@ public EconomyCommand(AurelEconomy 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!"); + // currencies section missing from config - fallback to default currency return currency.equals(plugin.getEconomyManager().getDefaultCurrency()); } return section.contains(currency); @@ -322,8 +322,9 @@ private void handleEco(CommandSender sender, String[] args) { @Override public @Nullable List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + org.bukkit.configuration.ConfigurationSection currencySection = plugin.getConfig().getConfigurationSection("economy.currencies"); List currencies = new ArrayList<>( - plugin.getConfig().getConfigurationSection("economy.currencies").getKeys(false)); + currencySection != null ? currencySection.getKeys(false) : java.util.Set.of()); if (label.equalsIgnoreCase("bal") || label.equalsIgnoreCase("balance") || label.equalsIgnoreCase("money")) { if (args.length == 1) { diff --git a/src/main/java/com/aureleconomy/database/DatabaseManager.java b/src/main/java/com/aureleconomy/database/DatabaseManager.java index 71f5801..fd01821 100644 --- a/src/main/java/com/aureleconomy/database/DatabaseManager.java +++ b/src/main/java/com/aureleconomy/database/DatabaseManager.java @@ -11,301 +11,379 @@ public class DatabaseManager { - private final AurelEconomy plugin; - private Connection connection; - private String databaseType; - - public DatabaseManager(AurelEconomy plugin) { - this.plugin = plugin; - this.databaseType = plugin.getConfig().getString("database.type", "sqlite").toLowerCase(); - } - - private static final int LATEST_SCHEMA_VERSION = 1; - - public boolean initialize() { - try { - if ("mysql".equals(databaseType)) { - initializeMySQL(); - } else { - initializeSQLite(); - } - createTables(); - runMigrations(); - return true; - } catch (SQLException e) { - plugin.getComponentLogger().error("Could not initialize database (" + databaseType + ")!", e); - return false; - } - } - - public void backupDatabase(String version) { - if (!"sqlite".equals(databaseType)) - return; - - File dbFile = new File(plugin.getDataFolder(), plugin.getConfig().getString("database.file", "database.db")); - if (!dbFile.exists()) - return; - - File backupFolder = new File(plugin.getDataFolder(), "backups"); - if (!backupFolder.exists()) { - backupFolder.mkdirs(); - } - - File backupFile = new File(backupFolder, "database_v" + version + "_" + System.currentTimeMillis() + ".db"); - try { - java.nio.file.Files.copy(dbFile.toPath(), backupFile.toPath(), - java.nio.file.StandardCopyOption.REPLACE_EXISTING); - plugin.getComponentLogger().info("Database backup created: " + backupFile.getName()); - } catch (java.io.IOException e) { - plugin.getComponentLogger().error("Failed to create database backup!", e); - } - } - - private void initializeSQLite() throws SQLException { - File dataFolder = new File(plugin.getDataFolder(), - plugin.getConfig().getString("database.file", "database.db")); - if (!dataFolder.getParentFile().exists()) { - dataFolder.getParentFile().mkdirs(); - } - - connection = DriverManager.getConnection("jdbc:sqlite:" + dataFolder.getAbsolutePath()); - - try (Statement stmt = connection.createStatement()) { - stmt.execute("PRAGMA journal_mode=WAL;"); - stmt.execute("PRAGMA busy_timeout=30000;"); - stmt.execute("PRAGMA synchronous=NORMAL;"); - stmt.execute("PRAGMA cache_size=-10000;"); - } - } - - private void initializeMySQL() throws SQLException { - FileConfiguration config = plugin.getConfig(); - String host = config.getString("database.mysql.host", "localhost"); - int port = config.getInt("database.mysql.port", 3306); - String database = config.getString("database.mysql.database", "aurelium"); - String username = config.getString("database.mysql.username", "root"); - String password = config.getString("database.mysql.password", ""); - - String url = "jdbc:mysql://" + host + ":" + port + "/" + database + "?autoReconnect=true&useSSL=false"; - connection = DriverManager.getConnection(url, username, password); - } - - private void createTables() { - String autoIncrement = "mysql".equals(databaseType) ? "INT AUTO_INCREMENT PRIMARY KEY" - : "INTEGER PRIMARY KEY AUTOINCREMENT"; - - try (Statement statement = connection.createStatement()) { - statement.execute("CREATE TABLE IF NOT EXISTS database_info (" + - "version INTEGER PRIMARY KEY" + - ");"); - - statement.execute("CREATE TABLE IF NOT EXISTS players (" + - "uuid VARCHAR(36) PRIMARY KEY, " + - "name VARCHAR(16), " + - "gui_style VARCHAR(16) DEFAULT 'MODERN'" + - ");"); - - statement.execute("CREATE TABLE IF NOT EXISTS player_balances (" + - "uuid VARCHAR(36), " + - "currency VARCHAR(32), " + - "balance DOUBLE NOT NULL DEFAULT 0.0, " + - "PRIMARY KEY (uuid, currency)" + - ");"); - - migrateLegacyBalances(); - - statement.execute("CREATE TABLE IF NOT EXISTS auctions (" + - "id " + autoIncrement + ", " + - "seller_uuid VARCHAR(36), " + - "item_data TEXT, " + - "price DOUBLE, " + - "currency VARCHAR(32), " + - "is_bin BOOLEAN, " + - "expiration LONG, " + - "highest_bidder_uuid VARCHAR(36), " + - "ended BOOLEAN DEFAULT 0, " + - "collected BOOLEAN DEFAULT 0, " + - "listing_fee DOUBLE DEFAULT 0.0, " + - "start_time LONG" + - ");"); - - statement.execute("CREATE TABLE IF NOT EXISTS offline_earnings (" + - "id " + autoIncrement + ", " + - "uuid VARCHAR(36), " + - "amount DOUBLE, " + - "currency VARCHAR(32), " + - "item_display VARCHAR(64), " + - "timestamp LONG" + - ");"); - - statement.execute("CREATE TABLE IF NOT EXISTS buy_orders (" + - "id " + autoIncrement + ", " + - "buyer_uuid VARCHAR(36), " + - "material VARCHAR(64), " + - "amount_requested INTEGER, " + - "amount_filled INTEGER DEFAULT 0, " + - "price_per_piece DOUBLE, " + - "currency VARCHAR(32), " + - "status VARCHAR(16) DEFAULT 'ACTIVE'" + - ");"); - - statement.execute("CREATE TABLE IF NOT EXISTS price_history (" + - "id " + autoIncrement + ", " + - "item_key VARCHAR(128), " + - "buy_price DOUBLE, " + - "sell_price DOUBLE, " + - "timestamp LONG" + - ")"); - - createOffersTable(autoIncrement); - - } catch (SQLException e) { - plugin.getComponentLogger().error("Could not create tables for " + databaseType + "!", e); - } - } - - private void runMigrations() { - int currentVersion = getDatabaseVersion(); - if (currentVersion >= LATEST_SCHEMA_VERSION) - return; - - plugin.getComponentLogger().info("Database outdated (v" + currentVersion - + "). Starting automatic migration to v" + LATEST_SCHEMA_VERSION + "..."); - - try { - connection.setAutoCommit(false); - - for (int i = currentVersion + 1; i <= LATEST_SCHEMA_VERSION; i++) { - plugin.getComponentLogger().info("Applying database migration v" + i + "..."); - applyMigration(i); - } - - updateDatabaseVersion(LATEST_SCHEMA_VERSION); - connection.commit(); - plugin.getComponentLogger().info("Database migration completed successfully."); - } catch (SQLException e) { - try { - connection.rollback(); - } catch (SQLException ex) { - /* ignored */ } - plugin.getComponentLogger().error("Database migration FAILED! Some features might be broken.", e); - } finally { - try { - connection.setAutoCommit(true); - } catch (SQLException ex) { - /* ignored */ } - } - } - - private int getDatabaseVersion() { - try (Statement statement = connection.createStatement()) { - var rs = statement.executeQuery("SELECT version FROM database_info LIMIT 1"); - if (rs.next()) - return rs.getInt("version"); - } catch (SQLException e) { - } - return 0; - } - - private void updateDatabaseVersion(int version) throws SQLException { - try (Statement statement = connection.createStatement()) { - statement.execute("DELETE FROM database_info;"); - statement.execute("INSERT INTO database_info (version) VALUES (" + version + ");"); - } - } - - private void applyMigration(int version) throws SQLException { - switch (version) { - case 1: - addColumnIfNotExists("players", "gui_style", "VARCHAR(16) DEFAULT 'MODERN'"); - addColumnIfNotExists("auctions", "listing_fee", "DOUBLE DEFAULT 0.0"); - addColumnIfNotExists("auctions", "start_time", "LONG"); - addColumnIfNotExists("auctions", "currency", "VARCHAR(32)"); - addColumnIfNotExists("offline_earnings", "currency", "VARCHAR(32)"); - addColumnIfNotExists("buy_orders", "currency", "VARCHAR(32)"); - addColumnIfNotExists("auction_offers", "currency", "VARCHAR(32)"); - break; - } - } - - private void createOffersTable(String autoIncrement) { - try (Statement statement = connection.createStatement()) { - statement.execute("CREATE TABLE IF NOT EXISTS auction_offers (" + - "id " + autoIncrement + ", " + - "auction_id INTEGER, " + - "bidder_uuid VARCHAR(36), " + - "amount DOUBLE, " + - "currency VARCHAR(32), " + - "status VARCHAR(16) DEFAULT 'PENDING', " + - "timestamp LONG, " + - "FOREIGN KEY(auction_id) REFERENCES auctions(id)" + - ");"); - } catch (SQLException e) { - plugin.getComponentLogger().error("Could not create offers table for " + databaseType + "!", e); - } - } - - private boolean migrationChecked = false; - - private void migrateLegacyBalances() { - if (migrationChecked) - return; - migrationChecked = true; - - try (Statement statement = connection.createStatement()) { - statement.executeQuery("SELECT balance FROM players LIMIT 1"); - - plugin.getComponentLogger() - .info("Legacy single-currency database detected. Migrating to multi-currency system..."); - String defaultCurrency = plugin.getConfig().getString("economy.default-currency", "Aurels"); - - statement.execute("INSERT INTO player_balances (uuid, currency, balance) " + - "SELECT uuid, '" + defaultCurrency + "', balance FROM players " + - "WHERE uuid NOT IN (SELECT uuid FROM player_balances WHERE currency = '" + defaultCurrency + "');"); - - try { - statement.execute("ALTER TABLE players DROP COLUMN balance;"); - } catch (SQLException dropError) { - } - - plugin.getComponentLogger().info("Multi-currency database migration completed successfully."); - } catch (SQLException e) { - } - } - - public Connection getConnection() { - try { - if (connection == null || connection.isClosed()) { - if ("mysql".equals(databaseType)) { - initializeMySQL(); - } else { - initializeSQLite(); - } - } - } catch (SQLException e) { - plugin.getComponentLogger().error("Failed to re-establish " + databaseType + " database connection!", e); - } - return connection; - } - - public void close() { - try { - if (connection != null && !connection.isClosed()) { - connection.close(); - } - } catch (SQLException e) { - plugin.getComponentLogger().error("Could not close database connection!", e); - } - } - - private void addColumnIfNotExists(String table, String column, String type) throws SQLException { - try (Statement statement = connection.createStatement()) { - try { - statement.executeQuery("SELECT " + column + " FROM " + table + " LIMIT 1"); - return; - } catch (SQLException e) { - } - statement.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + type); - } - } + private final AurelEconomy plugin; + private Connection connection; + private String databaseType; + // Lock for serializing all async DB writes to prevent concurrent Connection use + private final Object dbWriteLock = new Object(); + + public DatabaseManager(AurelEconomy plugin) { + this.plugin = 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); + } + + /** + * Returns the lock object for serializing async DB write operations. + * All async tasks that use getConnection() for writes should synchronize on this. + *
+ * synchronized (dbManager.getWriteLock()) {
+ * try (PreparedStatement ps = dbManager.getConnection().prepareStatement(...)) { ... }
+ * }
+ * 
+ */ + public Object getWriteLock() { + return dbWriteLock; + } + + private static final int LATEST_SCHEMA_VERSION = 2; + + public boolean initialize() { + try { + if ("mysql".equals(databaseType)) { + initializeMySQL(); + } else { + initializeSQLite(); + } + createTables(); + runMigrations(); + return true; + } catch (SQLException e) { + plugin.getComponentLogger().error("Could not initialize database (" + databaseType + ")!", e); + return false; + } + } + + public void backupDatabase(String version) { + if (!"sqlite".equals(databaseType)) + return; + + File dbFile = new File(plugin.getDataFolder(), plugin.getConfig().getString("database.file", "database.db")); + if (!dbFile.exists()) + return; + + File backupFolder = new File(plugin.getDataFolder(), "backups"); + if (!backupFolder.exists()) { + backupFolder.mkdirs(); + } + + File backupFile = new File(backupFolder, "database_v" + version + "_" + System.currentTimeMillis() + ".db"); + try { + java.nio.file.Files.copy(dbFile.toPath(), backupFile.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + plugin.getComponentLogger().info("Database backup created: " + backupFile.getName()); + } catch (java.io.IOException e) { + plugin.getComponentLogger().error("Failed to create database backup!", e); + } + } + + private void initializeSQLite() throws SQLException { + File dataFolder = new File(plugin.getDataFolder(), + plugin.getConfig().getString("database.file", "database.db")); + if (!dataFolder.getParentFile().exists()) { + dataFolder.getParentFile().mkdirs(); + } + + connection = DriverManager.getConnection("jdbc:sqlite:" + dataFolder.getAbsolutePath()); + + try (Statement stmt = connection.createStatement()) { + stmt.execute("PRAGMA journal_mode=WAL;"); + stmt.execute("PRAGMA busy_timeout=30000;"); + stmt.execute("PRAGMA synchronous=NORMAL;"); + stmt.execute("PRAGMA cache_size=-10000;"); + } + } + + private void initializeMySQL() throws SQLException { + FileConfiguration config = plugin.getConfig(); + String host = config.getString("database.mysql.host", "localhost"); + int port = config.getInt("database.mysql.port", 3306); + String database = config.getString("database.mysql.database", "aurelium"); + String username = config.getString("database.mysql.username", "root"); + String password = config.getString("database.mysql.password", ""); + + String url = "jdbc:mysql://" + host + ":" + port + "/" + database + "?autoReconnect=true&useSSL=false&allowPublicKeyRetrieval=true"; + connection = DriverManager.getConnection(url, username, password); + } + + private void createTables() throws SQLException { + String autoIncrement = "mysql".equals(databaseType) ? "INT AUTO_INCREMENT PRIMARY KEY" + : "INTEGER PRIMARY KEY AUTOINCREMENT"; + + try (Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE IF NOT EXISTS database_info (" + + "version INTEGER PRIMARY KEY" + + ");"); + + statement.execute("CREATE TABLE IF NOT EXISTS players (" + + "uuid VARCHAR(36) PRIMARY KEY, " + + "name VARCHAR(16), " + + "gui_style VARCHAR(16) DEFAULT 'MODERN'" + + ");"); + + statement.execute("CREATE TABLE IF NOT EXISTS player_balances (" + + "uuid VARCHAR(36), " + + "currency VARCHAR(32), " + + "balance DOUBLE NOT NULL DEFAULT 0.0, " + + "PRIMARY KEY (uuid, currency)" + + ");"); + + migrateLegacyBalances(); + + statement.execute("CREATE TABLE IF NOT EXISTS auctions (" + + "id " + autoIncrement + ", " + + "seller_uuid VARCHAR(36), " + + "item_data TEXT, " + + "price DOUBLE, " + + "currency VARCHAR(32), " + + "is_bin BOOLEAN, " + + "expiration LONG, " + + "highest_bidder_uuid VARCHAR(36), " + + "ended BOOLEAN DEFAULT 0, " + + "collected BOOLEAN DEFAULT 0, " + + "listing_fee DOUBLE DEFAULT 0.0, " + + "start_time LONG" + + ");"); + + statement.execute("CREATE TABLE IF NOT EXISTS offline_earnings (" + + "id " + autoIncrement + ", " + + "uuid VARCHAR(36), " + + "amount DOUBLE, " + + "currency VARCHAR(32), " + + "item_display VARCHAR(64), " + + "timestamp LONG" + + ");"); + + statement.execute("CREATE TABLE IF NOT EXISTS buy_orders (" + + "id " + autoIncrement + ", " + + "buyer_uuid VARCHAR(36), " + + "material VARCHAR(64), " + + "amount_requested INTEGER, " + + "amount_filled INTEGER DEFAULT 0, " + + "price_per_piece DOUBLE, " + + "currency VARCHAR(32), " + + "status VARCHAR(16) DEFAULT 'ACTIVE'" + + ");"); + + statement.execute("CREATE TABLE IF NOT EXISTS price_history (" + + "id " + autoIncrement + ", " + + "item_key VARCHAR(128), " + + "buy_price DOUBLE, " + + "sell_price DOUBLE, " + + "timestamp LONG" + + ")"); + + createOffersTable(autoIncrement); + createCustomItemsTable(); + + } catch (SQLException e) { + plugin.getComponentLogger().error("Could not create tables for " + databaseType + "!", e); + throw e; // Propagate so initialize() returns false instead of silently succeeding + + } +} + + private void runMigrations() { + int currentVersion = getDatabaseVersion(); + if (currentVersion >= LATEST_SCHEMA_VERSION) + return; + + plugin.getComponentLogger().info("Database outdated (v" + currentVersion + + "). Starting automatic migration to v" + LATEST_SCHEMA_VERSION + "..."); + + try { + connection.setAutoCommit(false); + + for (int i = currentVersion + 1; i <= LATEST_SCHEMA_VERSION; i++) { + plugin.getComponentLogger().info("Applying database migration v" + i + "..."); + applyMigration(i); + } + + updateDatabaseVersion(LATEST_SCHEMA_VERSION); + connection.commit(); + plugin.getComponentLogger().info("Database migration completed successfully."); + } catch (SQLException e) { + try { + connection.rollback(); + } catch (SQLException ex) { + /* ignored */ + } + plugin.getComponentLogger().error("Database migration FAILED! Some features might be broken.", e); + } finally { + try { + connection.setAutoCommit(true); + } catch (SQLException ex) { + /* ignored */ + } + } + } + + private int getDatabaseVersion() { + try (Statement statement = connection.createStatement()) { + var rs = statement.executeQuery("SELECT version FROM database_info LIMIT 1"); + if (rs.next()) + return rs.getInt("version"); + } catch (SQLException e) { + } + return 0; + } + + private void updateDatabaseVersion(int version) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute("DELETE FROM database_info;"); + statement.execute("INSERT INTO database_info (version) VALUES (" + version + ");"); + } + } + + private void applyMigration(int version) throws SQLException { + switch (version) { + case 1: + addColumnIfNotExists("players", "gui_style", "VARCHAR(16) DEFAULT 'MODERN'"); + addColumnIfNotExists("auctions", "listing_fee", "DOUBLE DEFAULT 0.0"); + addColumnIfNotExists("auctions", "start_time", "LONG"); + addColumnIfNotExists("auctions", "currency", "VARCHAR(32)"); + addColumnIfNotExists("offline_earnings", "currency", "VARCHAR(32)"); + addColumnIfNotExists("buy_orders", "currency", "VARCHAR(32)"); + addColumnIfNotExists("auction_offers", "currency", "VARCHAR(32)"); + break; + case 2: + // Fix: propagate DDL failure — throw instead of swallowing + createCustomItemsTable(); + break; + } + } + + private void createOffersTable(String autoIncrement) { + try (Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE IF NOT EXISTS auction_offers (" + + "id " + autoIncrement + ", " + + "auction_id INTEGER, " + + "bidder_uuid VARCHAR(36), " + + "amount DOUBLE, " + + "currency VARCHAR(32), " + + "status VARCHAR(16) DEFAULT 'PENDING', " + + "timestamp LONG, " + + "FOREIGN KEY(auction_id) REFERENCES auctions(id)" + + ");"); + } catch (SQLException e) { + plugin.getComponentLogger().error("Could not create offers table for " + databaseType + "!", e); + } + } + + private boolean migrationChecked = false; + + private void migrateLegacyBalances() { + if (migrationChecked) + return; + migrationChecked = true; + + try (Statement statement = connection.createStatement()) { + statement.executeQuery("SELECT balance FROM players LIMIT 1"); + + plugin.getComponentLogger() + .info("Legacy single-currency database detected. Migrating to multi-currency system..."); + String defaultCurrency = plugin.getConfig().getString("economy.default-currency", "Aurels"); + + statement.execute("INSERT INTO player_balances (uuid, currency, balance) " + + "SELECT uuid, '" + defaultCurrency + "', balance FROM players " + + "WHERE uuid NOT IN (SELECT uuid FROM player_balances WHERE currency = '" + defaultCurrency + "');"); + + try { + statement.execute("ALTER TABLE players DROP COLUMN balance;"); + } catch (SQLException dropError) { + } + + plugin.getComponentLogger().info("Multi-currency database migration completed successfully."); + } catch (SQLException e) { + } + } + + public Connection getConnection() { + try { + if (connection == null || connection.isClosed()) { + if ("mysql".equals(databaseType)) { + initializeMySQL(); + } else { + initializeSQLite(); + } + } + } catch (SQLException e) { + plugin.getComponentLogger().error("Failed to re-establish " + databaseType + " database connection!", e); + } + return connection; + } + + public void close() { + try { + if (connection != null && !connection.isClosed()) { + connection.close(); + } + } catch (SQLException e) { + plugin.getComponentLogger().error("Could not close database connection!", e); + } + } + + private void addColumnIfNotExists(String table, String column, String type) throws SQLException { + try (Statement statement = connection.createStatement()) { + try { + statement.executeQuery("SELECT " + column + " FROM " + table + " LIMIT 1"); + return; + } catch (SQLException e) { + } + statement.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + type); + } + } + + /** + * Creates the custom_items table for both MySQL and SQLite. + * Propagates SQLException so callers (createTables, migration v2) can handle failure. + */ + private void createCustomItemsTable() throws SQLException { + try (Statement statement = connection.createStatement()) { + if ("mysql".equals(databaseType)) { + statement.execute("CREATE TABLE IF NOT EXISTS custom_items (" + + "canonical_id VARCHAR(255) PRIMARY KEY, " + + "source_plugin VARCHAR(64) NOT NULL, " + + "display_name VARCHAR(256), " + + "item_data TEXT NOT NULL, " + + "pdc_key VARCHAR(255), " + + "model_data_key VARCHAR(128), " + + "lore_hash VARCHAR(64), " + + "plugin_native_id VARCHAR(255), " + + "category VARCHAR(64), " + + "buy_price DOUBLE DEFAULT -1, " + + "sell_price DOUBLE DEFAULT -1, " + + "enabled TINYINT(1) DEFAULT 1, " + + "discovery_methods VARCHAR(256), " + + "first_discovered BIGINT NOT NULL, " + + "last_seen BIGINT NOT NULL" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + } else { + statement.execute("CREATE TABLE IF NOT EXISTS custom_items (" + + "canonical_id TEXT PRIMARY KEY, " + + "source_plugin TEXT NOT NULL, " + + "display_name TEXT, " + + "item_data TEXT NOT NULL, " + + "pdc_key TEXT, " + + "model_data_key TEXT, " + + "lore_hash TEXT, " + + "plugin_native_id TEXT, " + + "category TEXT, " + + "buy_price REAL DEFAULT -1, " + + "sell_price REAL DEFAULT -1, " + + "enabled INTEGER DEFAULT 1, " + + "discovery_methods TEXT, " + + "first_discovered INTEGER NOT NULL, " + + "last_seen INTEGER NOT NULL" + + ")"); + } + } + +} } diff --git a/src/main/java/com/aureleconomy/economy/EconomyManager.java b/src/main/java/com/aureleconomy/economy/EconomyManager.java index d26eebc..6fd0929 100644 --- a/src/main/java/com/aureleconomy/economy/EconomyManager.java +++ b/src/main/java/com/aureleconomy/economy/EconomyManager.java @@ -14,303 +14,313 @@ 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 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( - "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); - } - }); - } - - 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( - "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); - } - - /** - * 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( - "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); - - scheduleAsyncWrite(() -> { - 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(); + // Thread-safe: use ConcurrentHashMap atomic operations to avoid race + // between cache read and DB fallback + ConcurrentHashMap userBalances = balanceCache.get(uuid); + if (userBalances != null) { + BigDecimal cached = userBalances.get(currency); + if (cached != null) { + return cached; + } + } + + 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); + + BigDecimal current = getBalanceFromCache(uuid, currency); + BigDecimal newBalance = current.add(normalizedAmount); + balanceCache.computeIfAbsent(uuid, k -> new ConcurrentHashMap<>()).put(currency, newBalance); + + scheduleAsyncWrite(() -> { + synchronized (plugin.getDatabaseManager().getWriteLock()) { + try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( + plugin.getDatabaseManager().isMySQL() + ? "INSERT INTO player_balances (uuid, currency, balance) VALUES (?, ?, ?) AS new ON DUPLICATE KEY UPDATE player_balances.balance = player_balances.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; + } + + BigDecimal newBalance = currentBalance.subtract(normalizedAmount).max(BigDecimal.ZERO); + balanceCache.computeIfAbsent(uuid, k -> new ConcurrentHashMap<>()).put(currency, newBalance); + + scheduleAsyncWrite(() -> { + synchronized (plugin.getDatabaseManager().getWriteLock()) { + 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); + + if (currentBalance.compareTo(normalizedAmount) < 0) { + return false; + } + + BigDecimal newBalance = currentBalance.subtract(normalizedAmount).max(BigDecimal.ZERO); + balanceCache.computeIfAbsent(uuid, k -> new ConcurrentHashMap<>()).put(currency, newBalance); + + scheduleAsyncWrite(() -> { + synchronized (plugin.getDatabaseManager().getWriteLock()) { + 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 withdrawIfSufficient", e); + loadBalance(uuid, currency); + } + } + }); + + return true; + } + + 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. + * Thread-safe: uses ConcurrentHashMap atomic operations. + */ + private BigDecimal getBalanceFromCache(UUID uuid, String currency) { + ConcurrentHashMap userBalances = balanceCache.get(uuid); + if (userBalances != null) { + BigDecimal cached = userBalances.get(currency); + if (cached != null) { + return cached; + } + } + return BigDecimal.ZERO; + } + + 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 player_balances.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(() -> { + synchronized (plugin.getDatabaseManager().getWriteLock()) { + try (PreparedStatement ps = plugin.getDatabaseManager().getConnection().prepareStatement( + plugin.getDatabaseManager().isMySQL() + ? "INSERT INTO player_balances (uuid, currency, balance) VALUES (?, ?, ?) AS new ON DUPLICATE KEY UPDATE player_balances.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/gui/CustomItemsGUI.java b/src/main/java/com/aureleconomy/gui/CustomItemsGUI.java new file mode 100644 index 0000000..8984b95 --- /dev/null +++ b/src/main/java/com/aureleconomy/gui/CustomItemsGUI.java @@ -0,0 +1,334 @@ +package com.aureleconomy.gui; + +import com.aureleconomy.AurelEconomy; +import com.aureleconomy.scanner.CustomItemRegistry; +import com.aureleconomy.scanner.CustomMarketItem; +import com.aureleconomy.scanner.DiscoveryMethod; +import com.aureleconomy.utils.ItemBuilder; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.math.BigDecimal; +import java.util.*; + +/** + * Admin GUI for browsing and managing discovered custom items. + * Paginated display with click-to-view details, toggle, and price editing. + */ +public class CustomItemsGUI extends GUIHolder { + + private static final MiniMessage MM = MiniMessage.miniMessage(); + private static final int ITEMS_PER_PAGE = 45; + + private final AurelEconomy plugin; + private final int page; + private final Map itemSlots = new HashMap<>(); + private CustomMarketItem selectedItem = null; + + public CustomItemsGUI(AurelEconomy plugin, int page) { + this.plugin = plugin; + this.page = page; + this.inventory = plugin.getServer().createInventory(this, 54, + MM.deserialize("Custom Items")); + setupItems(); + } + + public void setupItems() { + itemSlots.clear(); + inventory.clear(); + // Fix: clear selectedItem on list rebuild to avoid stale state + selectedItem = null; + + CustomItemRegistry registry = plugin.getCustomItemRegistry(); + if (registry == null) return; + + List allItems = new ArrayList<>(registry.getAllItems()); + int totalPages = Math.max(1, (int) Math.ceil((double) allItems.size() / ITEMS_PER_PAGE)); + + // Navigation bar + inventory.setItem(45, new ItemBuilder(Material.BARRIER) + .name(MM.deserialize("Back").decoration(TextDecoration.ITALIC, false)) + .build()); + + inventory.setItem(49, new ItemBuilder(Material.BOOK) + .name(MM.deserialize("Page " + (page + 1) + "/" + totalPages + "") + .decoration(TextDecoration.ITALIC, false)) + .lore(Component.text(allItems.size() + " custom items discovered", NamedTextColor.GRAY)) + .build()); + + if (page > 0) { + inventory.setItem(48, new ItemBuilder(Material.SPECTRAL_ARROW) + .name(MM.deserialize("Previous Page").decoration(TextDecoration.ITALIC, false)) + .build()); + } + if (page < totalPages - 1) { + inventory.setItem(50, new ItemBuilder(Material.SPECTRAL_ARROW) + .name(MM.deserialize("Next Page").decoration(TextDecoration.ITALIC, false)) + .build()); + } + + // Rescan button + inventory.setItem(53, new ItemBuilder(Material.COMPASS) + .name(MM.deserialize("Rescan").decoration(TextDecoration.ITALIC, false)) + .lore(Component.text("Click to trigger a full rescan", NamedTextColor.GRAY)) + .build()); + + // Items + int start = page * ITEMS_PER_PAGE; + int end = Math.min(start + ITEMS_PER_PAGE, allItems.size()); + + for (int i = start; i < end; i++) { + CustomMarketItem item = allItems.get(i); + int slot = i - start; + + ItemStack display = item.getItemStack().clone(); + ItemMeta meta = display.getItemMeta(); + if (meta == null) continue; + + meta.displayName(MM.deserialize("" + item.getDisplayName() + "") + .decoration(TextDecoration.ITALIC, false)); + + List lore = new ArrayList<>(); + lore.add(Component.empty()); + lore.add(MM.deserialize("ID: " + item.getCanonicalId() + "") + .decoration(TextDecoration.ITALIC, false)); + lore.add(MM.deserialize("Source: " + item.getSourcePlugin() + "") + .decoration(TextDecoration.ITALIC, false)); + lore.add(MM.deserialize("Category: " + item.getCategory() + "") + .decoration(TextDecoration.ITALIC, false)); + + if (item.getBuyPrice().compareTo(BigDecimal.ZERO) >= 0) { + lore.add(MM.deserialize("Buy: " + item.getBuyPrice() + " Sell: " + item.getSellPrice() + "") + .decoration(TextDecoration.ITALIC, false)); + } else { + lore.add(MM.deserialize("Price: unset") + .decoration(TextDecoration.ITALIC, false)); + } + + Set methods = plugin.getCustomItemRegistry().getDiscoveryMethods(item.getCanonicalId()); + String methodStr = methods.stream().map(DiscoveryMethod::getDisplayName).reduce((a, b) -> a + ", " + b).orElse(""); + lore.add(MM.deserialize("Found by: " + methodStr + "") + .decoration(TextDecoration.ITALIC, false)); + + String status = item.isEnabled() ? "Enabled" : "Disabled"; + lore.add(Component.empty()); + lore.add(MM.deserialize("Click for details " + status) + .decoration(TextDecoration.ITALIC, false)); + + meta.lore(lore); + display.setItemMeta(meta); + inventory.setItem(slot, display); + itemSlots.put(slot, item); + } + } + + private void showDetail(Player player, CustomMarketItem item) { + selectedItem = item; + inventory.clear(); + itemSlots.clear(); + + ItemStack display = item.getItemStack().clone(); + ItemMeta meta = display.getItemMeta(); + if (meta != null) { + meta.displayName(MM.deserialize("" + item.getDisplayName() + "") + .decoration(TextDecoration.ITALIC, false)); + List lore = new ArrayList<>(); + lore.add(Component.empty()); + lore.add(MM.deserialize("Canonical ID: " + item.getCanonicalId() + "").decoration(TextDecoration.ITALIC, false)); + lore.add(MM.deserialize("Source Plugin: " + item.getSourcePlugin() + "").decoration(TextDecoration.ITALIC, false)); + lore.add(MM.deserialize("Material: " + item.getItemStack().getType().name() + "").decoration(TextDecoration.ITALIC, false)); + lore.add(MM.deserialize("Category: " + item.getCategory() + "").decoration(TextDecoration.ITALIC, false)); + lore.add(MM.deserialize("PDC Key: " + (item.getPdcKey() != null ? item.getPdcKey() : "N/A") + "").decoration(TextDecoration.ITALIC, false)); + lore.add(MM.deserialize("Model Data Key: " + (item.getModelDataKey() != null ? item.getModelDataKey() : "N/A") + "").decoration(TextDecoration.ITALIC, false)); + lore.add(MM.deserialize("Lore Hash: " + (item.getLoreHash() != null ? item.getLoreHash() : "N/A") + "").decoration(TextDecoration.ITALIC, false)); + lore.add(MM.deserialize("Plugin Native ID: " + (item.getPluginNativeId() != null ? item.getPluginNativeId() : "N/A") + "").decoration(TextDecoration.ITALIC, false)); + if (item.getBuyPrice().compareTo(BigDecimal.ZERO) >= 0) { + lore.add(MM.deserialize("Buy Price: " + item.getBuyPrice() + "").decoration(TextDecoration.ITALIC, false)); + lore.add(MM.deserialize("Sell Price: " + item.getSellPrice() + "").decoration(TextDecoration.ITALIC, false)); + } + Set methods = plugin.getCustomItemRegistry().getDiscoveryMethods(item.getCanonicalId()); + String methodStr = methods.stream().map(DiscoveryMethod::getDisplayName).reduce((a, b) -> a + ", " + b).orElse("None"); + lore.add(MM.deserialize("Discovery Methods: " + methodStr + "").decoration(TextDecoration.ITALIC, false)); + meta.lore(lore); + display.setItemMeta(meta); + } + inventory.setItem(13, display); + + // Toggle button + Material toggleMat = item.isEnabled() ? Material.LIME_DYE : Material.GRAY_DYE; + String toggleText = item.isEnabled() ? "Disable" : "Enable"; + inventory.setItem(29, new ItemBuilder(toggleMat) + .name(MM.deserialize(toggleText).decoration(TextDecoration.ITALIC, false)) + .lore(Component.text("Click to toggle in market", NamedTextColor.GRAY)) + .build()); + + // Edit price button + inventory.setItem(33, new ItemBuilder(Material.GOLD_NUGGET) + .name(MM.deserialize("Edit Price").decoration(TextDecoration.ITALIC, false)) + .lore(Component.text("Click to set buy/sell price", NamedTextColor.GRAY)) + .build()); + + // Back button + inventory.setItem(45, new ItemBuilder(Material.BARRIER) + .name(MM.deserialize("Back").decoration(TextDecoration.ITALIC, false)) + .build()); + } + + @Override + public void handleClick(InventoryClickEvent event) { + event.setCancelled(true); + Player player = (Player) event.getWhoClicked(); + + // Fix: use getRawSlot() to only process clicks in the top (GUI) inventory, + // ignoring clicks in the player's own inventory + int rawSlot = event.getRawSlot(); + if (rawSlot < 0 || rawSlot >= inventory.getSize()) { + // Click was in the player's own inventory area — ignore + return; + } + + if (selectedItem != null) { + // Detail view — refresh selectedItem from registry to avoid stale state + CustomMarketItem currentItem = plugin.getCustomItemRegistry().getById(selectedItem.getCanonicalId()); + if (currentItem == null) { + // Item was removed, go back to list + selectedItem = null; + setupItems(); + return; + } + selectedItem = currentItem; + + if (rawSlot == 45) { + // Back to list + selectedItem = null; + setupItems(); + } else if (rawSlot == 29) { + // Toggle + handleToggle(player, selectedItem); + // Refresh detail view with updated item + CustomMarketItem updated = plugin.getCustomItemRegistry().getById(selectedItem.getCanonicalId()); + if (updated != null) selectedItem = updated; + showDetail(player, selectedItem); + } else if (rawSlot == 33) { + // Edit price via chat prompt + handleEditPrice(player, selectedItem); + } + return; + } + + // List view + if (rawSlot == 45) { + player.closeInventory(); + } else if (rawSlot == 48 && page > 0) { + new CustomItemsGUI(plugin, page - 1).open(player); + } else if (rawSlot == 50) { + CustomItemRegistry registry = plugin.getCustomItemRegistry(); + if (registry != null) { + int totalPages = Math.max(1, (int) Math.ceil((double) registry.getTotalItems() / ITEMS_PER_PAGE)); + if (page < totalPages - 1) { + new CustomItemsGUI(plugin, page + 1).open(player); + } + } + } else if (rawSlot == 53) { + // Rescan + player.sendMessage(MM.deserialize("[CustomItems] Rescanning...")); + plugin.getServer().getScheduler().runTaskLater(plugin, () -> { + if (plugin.getUnifiedScanner() != null) { + plugin.getUnifiedScanner().scanAllPluginAPIs(); + plugin.getUnifiedScanner().scanPlayerInventories(); + } + player.sendMessage(MM.deserialize("[CustomItems] Rescan complete!")); + new CustomItemsGUI(plugin, 0).open(player); + }, 1L); + } else if (itemSlots.containsKey(rawSlot)) { + showDetail(player, itemSlots.get(rawSlot)); + } + } + + private void handleToggle(Player player, CustomMarketItem item) { + boolean newState = !item.isEnabled(); + CustomMarketItem updated = new CustomMarketItem.Builder() + .canonicalId(item.getCanonicalId()) + .itemStack(item.getItemStack()) + .sourcePlugin(item.getSourcePlugin()) + .displayName(item.getDisplayName()) + .pdcKey(item.getPdcKey()) + .modelDataKey(item.getModelDataKey()) + .loreHash(item.getLoreHash()) + .pluginNativeId(item.getPluginNativeId()) + .category(item.getCategory()) + .buyPrice(item.getBuyPrice()) + .sellPrice(item.getSellPrice()) + .enabled(newState) + .build(); + + // Fix: use upsert instead of register to properly update existing items + plugin.getCustomItemRegistry().upsert(updated); + plugin.getCustomItemRegistry().saveToDatabase(plugin.getDatabaseManager()); + + player.playSound(player.getLocation(), Sound.UI_BUTTON_CLICK, 0.5f, newState ? 1.2f : 0.8f); + String stateStr = newState ? "enabled" : "disabled"; + player.sendMessage(MM.deserialize("[CustomItems] " + item.getCanonicalId() + " " + stateStr)); + } + + private void handleEditPrice(Player player, CustomMarketItem item) { + player.closeInventory(); + player.sendMessage(MM.deserialize("Set Buy Price (type number or 'cancel')")); + plugin.getChatPromptManager().prompt(player, (buyInput) -> { + if (buyInput.equalsIgnoreCase("cancel")) { + new CustomItemsGUI(plugin, 0).open(player); + return; + } + try { + double buy = Double.parseDouble(buyInput); + // Fix: validate buy price + if (Double.isNaN(buy) || Double.isInfinite(buy) || buy < 0) { + player.sendMessage(MM.deserialize("Buy price must be a non-negative finite number.")); + new CustomItemsGUI(plugin, 0).open(player); + return; + } + player.sendMessage(MM.deserialize("Set Sell Price (type number or 'cancel')")); + plugin.getChatPromptManager().prompt(player, (sellInput) -> { + if (sellInput.equalsIgnoreCase("cancel")) { + new CustomItemsGUI(plugin, 0).open(player); + return; + } + try { + double sell = Double.parseDouble(sellInput); + // Fix: validate sell price + if (Double.isNaN(sell) || Double.isInfinite(sell) || sell < 0) { + player.sendMessage(MM.deserialize("Sell price must be a non-negative finite number.")); + new CustomItemsGUI(plugin, 0).open(player); + return; + } + plugin.getCustomItemRegistry().updateCustomItemPrice(plugin.getDatabaseManager(), + item.getCanonicalId(), buy, sell); + player.sendMessage(MM.deserialize("[CustomItems] Price set for " + + item.getCanonicalId() + ": Buy=" + buy + " Sell=" + sell + "")); + new CustomItemsGUI(plugin, 0).open(player); + } catch (NumberFormatException e) { + player.sendMessage(MM.deserialize("Invalid price. Reopen the GUI.")); + } + }); + } catch (NumberFormatException e) { + player.sendMessage(MM.deserialize("Invalid price. Reopen the GUI.")); + } + }); + } + + public static void refreshAllViewers(org.bukkit.Server server) { + for (Player player : server.getOnlinePlayers()) { + if (player.getOpenInventory().getTopInventory().getHolder() instanceof CustomItemsGUI gui) { + gui.setupItems(); + } + } + } +} diff --git a/src/main/java/com/aureleconomy/listeners/JoinListener.java b/src/main/java/com/aureleconomy/listeners/JoinListener.java index 1be4266..adc2cdc 100644 --- a/src/main/java/com/aureleconomy/listeners/JoinListener.java +++ b/src/main/java/com/aureleconomy/listeners/JoinListener.java @@ -7,6 +7,7 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; import java.math.BigDecimal; import java.sql.PreparedStatement; @@ -25,12 +26,12 @@ public JoinListener(AurelEconomy plugin) { @EventHandler public void onJoin(PlayerJoinEvent event) { UUID uuid = event.getPlayer().getUniqueId(); - + plugin.getEconomyManager().invalidateCache(uuid); Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { try (PreparedStatement ps = plugin.getDatabaseManager().getConnection() - .prepareStatement("SELECT * FROM offline_earnings WHERE uuid = ?")) { + .prepareStatement("SELECT uuid, amount, item_display, timestamp FROM offline_earnings WHERE uuid = ?")) { ps.setString(1, uuid.toString()); ResultSet rs = ps.executeQuery(); @@ -61,6 +62,14 @@ public void onJoin(PlayerJoinEvent event) { }); } + @EventHandler + public void onQuit(PlayerQuitEvent event) { + // Invalidate web session on disconnect for security + if (plugin.getWebServer() != null && plugin.getWebServer().getSessionManager() != null) { + plugin.getWebServer().getSessionManager().invalidate(event.getPlayer().getUniqueId()); + } + } + private void deleteAllRecordsForUUID(UUID uuid) { try (PreparedStatement ps = plugin.getDatabaseManager().getConnection() .prepareStatement("DELETE FROM offline_earnings WHERE uuid = ?")) { diff --git a/src/main/java/com/aureleconomy/market/MarketItems.java b/src/main/java/com/aureleconomy/market/MarketItems.java index 3ce76db..5b77afb 100644 --- a/src/main/java/com/aureleconomy/market/MarketItems.java +++ b/src/main/java/com/aureleconomy/market/MarketItems.java @@ -31,7 +31,8 @@ public enum Category { BUILDING(Material.BRICKS, "Building Blocks"), DECORATION(Material.PAINTING, "Decoration"), ENCHANTMENTS(Material.ENCHANTED_BOOK, "Enchantment Books"), - ALL_ITEMS(Material.COMPASS, "All Items (Searchable)"); + CUSTOM_ITEMS(Material.NETHER_STAR, "Custom Items"), + ALL_ITEMS(Material.COMPASS, "All Items (Searchable)"); public final Material icon; public final String name; diff --git a/src/main/java/com/aureleconomy/market/MarketManager.java b/src/main/java/com/aureleconomy/market/MarketManager.java index e23c1c6..54ea6a3 100644 --- a/src/main/java/com/aureleconomy/market/MarketManager.java +++ b/src/main/java/com/aureleconomy/market/MarketManager.java @@ -2,6 +2,7 @@ import com.aureleconomy.AurelEconomy; import com.aureleconomy.market.MarketItems.Category; +import com.aureleconomy.scanner.CustomMarketItem; import com.aureleconomy.market.MarketItems.MarketEntry; import org.bukkit.Material; import org.bukkit.configuration.file.FileConfiguration; @@ -14,318 +15,359 @@ public class MarketManager { - private final AurelEconomy plugin; - private final Set blacklist = ConcurrentHashMap.newKeySet(); - private final Map entryCache = new ConcurrentHashMap<>(); - private final Map buyPrices = new ConcurrentHashMap<>(); - private final Map sellPrices = new ConcurrentHashMap<>(); - private final Map itemCurrencies = new ConcurrentHashMap<>(); - private final Set alertedItems = ConcurrentHashMap.newKeySet(); - private BigDecimal priceIncreaseRate; - private BigDecimal priceDecreaseRate; - private boolean dynamicPricing; - private boolean alertsEnabled; - private BigDecimal alertMinBasePrice; - private BigDecimal alertThresholdPercent; - private boolean configModified = false; - private BigDecimal priceFloorPercent; - private BigDecimal priceCeilingPercent; - private boolean recoveryEnabled; - private BigDecimal recoveryRate; - private BigDecimal defaultSellRatio; - - public MarketManager(AurelEconomy plugin) { - this.plugin = plugin; - loadBlacklist(); - cacheAlertSettings(); - initializePrices(); - } - - private void cacheAlertSettings() { - dynamicPricing = plugin.getConfig().getBoolean("market.dynamic-pricing", true); - alertsEnabled = plugin.getConfig().getBoolean("market.alerts.enabled", true); - alertMinBasePrice = BigDecimal.valueOf(plugin.getConfig().getDouble("market.alerts.min-base-price", 200.0)); - alertThresholdPercent = BigDecimal.valueOf(plugin.getConfig().getDouble("market.alerts.threshold", 0.5)); - priceFloorPercent = BigDecimal.valueOf(plugin.getConfig().getDouble("market.price-floor", 0.2)); - priceCeilingPercent = BigDecimal.valueOf(plugin.getConfig().getDouble("market.price-ceiling", 5.0)); - recoveryEnabled = plugin.getConfig().getBoolean("market.price-recovery.enabled", true); - recoveryRate = BigDecimal.valueOf(plugin.getConfig().getDouble("market.price-recovery.rate", 0.01)); - int recoveryInterval = plugin.getConfig().getInt("market.price-recovery.interval-minutes", 10); - defaultSellRatio = BigDecimal.valueOf(plugin.getConfig().getDouble("market.default-sell-ratio", 0.5)); - - // Schedule passive price recovery - if (recoveryEnabled && dynamicPricing) { - long ticks = recoveryInterval * 60L * 20L; // minutes -> ticks - plugin.getServer().getScheduler().runTaskTimer(plugin, this::recoverPrices, ticks, ticks); - } - } - - private void loadBlacklist() { - blacklist.clear(); - for (String materialName : plugin.getConfig().getStringList("market.blacklist")) { - try { - blacklist.add(Material.valueOf(materialName.toUpperCase())); - } catch (IllegalArgumentException e) { - plugin.getComponentLogger().warn("Invalid material in blacklist: " + materialName); - } - } - } - - private void initializePrices() { - FileConfiguration config = plugin.getConfig(); - - priceIncreaseRate = BigDecimal.valueOf(config.getDouble("market.price-increase-per-buy", 0.001)); - priceDecreaseRate = BigDecimal.valueOf(config.getDouble("market.price-decrease-per-sell", 0.001)); - - for (Category cat : Category.values()) { - if (cat == Category.ALL_ITEMS) continue; - for (MarketEntry entry : MarketItems.getItems(cat)) { - processEntry(entry, config); - } - } - for (MarketEntry entry : MarketItems.getItems(Category.ALL_ITEMS)) { - String key = (entry.customName != null) ? entry.material.name() + "_" + entry.customName.replace(" ", "_").toUpperCase() - : entry.material.name(); - if (!entryCache.containsKey(key)) { - processEntry(entry, config); - } - } - - if (configModified) { - plugin.saveConfig(); - configModified = false; - } - } - - private void processEntry(MarketEntry entry, FileConfiguration config) { - String key = entry.material.name(); - if (entry.customName != null) { - key = entry.material.name() + "_" + entry.customName.replace(" ", "_").toUpperCase(); - } - - entryCache.put(key, entry); - - String basePath = "market-items." + key.replace(" ", "_").toUpperCase(); - - BigDecimal configBuy = config.contains(basePath + ".buy") ? BigDecimal.valueOf(config.getDouble(basePath + ".buy")) : BigDecimal.ZERO; - if (!config.contains(basePath + ".buy") - || (configBuy.compareTo(BigDecimal.ONE) == 0 && entry.price.compareTo(BigDecimal.ONE) > 0)) { - config.set(basePath + ".buy", entry.price.doubleValue()); - configModified = true; - configBuy = entry.price; - } - - BigDecimal configSell; - if (!config.contains(basePath + ".sell")) { - if (entry.customSellPrice != null) { - configSell = entry.customSellPrice; - } else { - BigDecimal sellRatio = BigDecimal.valueOf(plugin.getConfig().getDouble("market.default-sell-ratio", 0.5)); - configSell = entry.price.multiply(sellRatio); - } - config.set(basePath + ".sell", configSell.doubleValue()); - configModified = true; - } else { - configSell = BigDecimal.valueOf(config.getDouble(basePath + ".sell")); - } - - buyPrices.put(key, configBuy); - sellPrices.put(key, configSell); - - String curPath = basePath + ".currency"; - if (!config.contains(curPath)) { - config.set(curPath, plugin.getEconomyManager().getDefaultCurrency()); - configModified = true; - } - itemCurrencies.put(key, config.getString(curPath, plugin.getEconomyManager().getDefaultCurrency())); - } - - public void onTransaction(String materialName, boolean isBuy, int amount) { - if (!dynamicPricing) { - return; - } - - buyPrices.compute(materialName, (key, currentBuy) -> { - if (currentBuy == null) currentBuy = BigDecimal.ZERO; - - BigDecimal updatedPrice; - if (isBuy) { - // Demand up -> Price up - double multiplier = Math.pow(1 + priceIncreaseRate.doubleValue(), amount); - updatedPrice = currentBuy.multiply(BigDecimal.valueOf(multiplier)); - } else { - // Supply up -> Price down - double multiplier = Math.pow(1 - priceDecreaseRate.doubleValue(), amount); - updatedPrice = currentBuy.multiply(BigDecimal.valueOf(multiplier)); - } - - // Clamp to floor/ceiling - MarketEntry entry = entryCache.get(materialName); - if (entry != null) { - BigDecimal floor = entry.price.multiply(priceFloorPercent); - BigDecimal ceiling = entry.price.multiply(priceCeilingPercent); - if (updatedPrice.compareTo(floor) < 0) updatedPrice = floor; - if (updatedPrice.compareTo(ceiling) > 0) updatedPrice = ceiling; - } - - String basePath = "market-items." + materialName.replace(" ", "_").toUpperCase(); - plugin.getConfig().set(basePath + ".buy", updatedPrice.doubleValue()); - configModified = true; - - // Check for alerts - checkPriceAlert(materialName, updatedPrice); - - return updatedPrice; - }); - } - - private void checkPriceAlert(String key, BigDecimal newPrice) { - if (!alertsEnabled) { - return; - } - - MarketEntry entry = entryCache.get(key); - if (entry == null) - return; - - if (entry.price.compareTo(alertMinBasePrice) < 0) { - return; - } - - BigDecimal alertThreshold = entry.price.multiply(alertThresholdPercent); - BigDecimal resetThreshold = entry.price.multiply(alertThresholdPercent.add(BigDecimal.valueOf(0.1))); - - if (newPrice.compareTo(alertThreshold) <= 0 && !alertedItems.contains(key)) { - // Broadcast crash - String displayName = entry.customName != null ? entry.customName : entry.material.name().replace("_", " "); - net.kyori.adventure.text.Component message = net.kyori.adventure.text.Component.text() - .append(net.kyori.adventure.text.Component.text("[Market] ", - net.kyori.adventure.text.format.NamedTextColor.GOLD)) - .append(net.kyori.adventure.text.Component.text("📉 CRASH ALERT: ", - net.kyori.adventure.text.format.NamedTextColor.RED)) - .append(net.kyori.adventure.text.Component.text(displayName, - net.kyori.adventure.text.format.NamedTextColor.AQUA)) - .append(net.kyori.adventure.text.Component.text(" is now at a massive discount! ", - net.kyori.adventure.text.format.NamedTextColor.GRAY)) - .append(net.kyori.adventure.text.Component.text(plugin.getEconomyManager().getFormattedWithSymbol(newPrice, plugin.getEconomyManager().getDefaultCurrency()), - net.kyori.adventure.text.format.NamedTextColor.GREEN)) - .build(); - - plugin.getServer().broadcast(message); - alertedItems.add(key); - } else if (newPrice.compareTo(resetThreshold) > 0 && alertedItems.contains(key)) { - // Reset alert for next time - alertedItems.remove(key); - } - } - - public void persistPrices() { - if (configModified) { - plugin.saveConfig(); - configModified = false; - } - } - - private void recoverPrices() { - for (Map.Entry e : entryCache.entrySet()) { - String key = e.getKey(); - BigDecimal basePrice = e.getValue().price; - BigDecimal currentPrice = getBuyPrice(key); - - if (currentPrice.compareTo(basePrice) == 0) - continue; - - BigDecimal gap = basePrice.subtract(currentPrice); - BigDecimal adjustment = gap.multiply(recoveryRate); - BigDecimal newPrice = currentPrice.add(adjustment); - - // Snap to base if close enough - if (gap.abs().compareTo(BigDecimal.valueOf(0.01)) < 0) { - newPrice = basePrice; - } - - buyPrices.put(key, newPrice); - String basePath = "market-items." + key.replace(" ", "_").toUpperCase(); - plugin.getConfig().set(basePath + ".buy", newPrice.doubleValue()); - configModified = true; - } - } - - public Map getEntryCache() { - return entryCache; - } - - public boolean isBlacklisted(Material material) { - return blacklist.contains(material); - } - - public BigDecimal getBuyPrice(Material material) { - return getBuyPrice(material.name()); - } - - public BigDecimal getBuyPrice(String key) { - return buyPrices.getOrDefault(key, BigDecimal.ZERO); - } - - public BigDecimal getSellPrice(Material material) { - return getSellPrice(material.name()); - } - - public BigDecimal getSellPrice(String key) { - BigDecimal configSell = sellPrices.getOrDefault(key, BigDecimal.ZERO); - if (configSell.compareTo(BigDecimal.ZERO) <= 0) - return BigDecimal.ZERO; - - MarketEntry entry = entryCache.get(key); - if (entry == null) - return configSell; - - BigDecimal baseBuyPrice = entry.price; - if (baseBuyPrice.compareTo(BigDecimal.ZERO) <= 0) - return BigDecimal.ZERO; - - BigDecimal currentBuyPrice = getBuyPrice(key); - - // Calculate the ratio saved in config - BigDecimal ratio = configSell.divide(baseBuyPrice, 4, RoundingMode.HALF_UP); - - if (ratio.compareTo(BigDecimal.valueOf(0.95)) > 0) { - ratio = defaultSellRatio; - } - - BigDecimal dynamicSellPrice = currentBuyPrice.multiply(ratio); - - // Sell can never exceed buy - if (dynamicSellPrice.compareTo(currentBuyPrice) >= 0) { - dynamicSellPrice = currentBuyPrice.multiply(BigDecimal.valueOf(0.99)); - } - - return dynamicSellPrice; - } - - public String getCurrency(String key) { - return itemCurrencies.getOrDefault(key, plugin.getEconomyManager().getDefaultCurrency()); - } - - public String getCurrency(Material material) { - return getCurrency(material.name()); - } - - public java.util.List getItemsByCategory(String categoryName) { - for (Category cat : Category.values()) { - if (cat.name.equalsIgnoreCase(categoryName) || cat.name().equalsIgnoreCase(categoryName)) { - return MarketItems.getItems(cat); - } - } - return new java.util.ArrayList<>(); - } - - public java.util.List getOrderItemsByCategory(String categoryName) { - for (Category cat : Category.values()) { - if (cat.name.equalsIgnoreCase(categoryName) || cat.name().equalsIgnoreCase(categoryName)) { - return MarketItems.getOrderItems(cat); - } - } - return new java.util.ArrayList<>(); - } + private final AurelEconomy plugin; + private final Set blacklist = ConcurrentHashMap.newKeySet(); + private final Map entryCache = new ConcurrentHashMap<>(); + private final Map buyPrices = new ConcurrentHashMap<>(); + private final Map sellPrices = new ConcurrentHashMap<>(); + private final Map itemCurrencies = new ConcurrentHashMap<>(); + private final Set alertedItems = ConcurrentHashMap.newKeySet(); + private BigDecimal priceIncreaseRate; + private BigDecimal priceDecreaseRate; + private boolean dynamicPricing; + private boolean alertsEnabled; + private BigDecimal alertMinBasePrice; + private BigDecimal alertThresholdPercent; + private boolean configModified = false; + private BigDecimal priceFloorPercent; + private BigDecimal priceCeilingPercent; + private boolean recoveryEnabled; + private BigDecimal recoveryRate; + private BigDecimal defaultSellRatio; + + public MarketManager(AurelEconomy plugin) { + this.plugin = plugin; + loadBlacklist(); + cacheAlertSettings(); + initializePrices(); + } + + private void cacheAlertSettings() { + dynamicPricing = plugin.getConfig().getBoolean("market.dynamic-pricing", true); + alertsEnabled = plugin.getConfig().getBoolean("market.alerts.enabled", true); + alertMinBasePrice = BigDecimal.valueOf(plugin.getConfig().getDouble("market.alerts.min-base-price", 200.0)); + alertThresholdPercent = BigDecimal.valueOf(plugin.getConfig().getDouble("market.alerts.threshold", 0.5)); + priceFloorPercent = BigDecimal.valueOf(plugin.getConfig().getDouble("market.price-floor", 0.2)); + priceCeilingPercent = BigDecimal.valueOf(plugin.getConfig().getDouble("market.price-ceiling", 5.0)); + recoveryEnabled = plugin.getConfig().getBoolean("market.price-recovery.enabled", true); + recoveryRate = BigDecimal.valueOf(plugin.getConfig().getDouble("market.price-recovery.rate", 0.01)); + int recoveryInterval = plugin.getConfig().getInt("market.price-recovery.interval-minutes", 10); + defaultSellRatio = BigDecimal.valueOf(plugin.getConfig().getDouble("market.default-sell-ratio", 0.5)); + + // Schedule passive price recovery + if (recoveryEnabled && dynamicPricing) { + long ticks = recoveryInterval * 60L * 20L; // minutes -> ticks + plugin.getServer().getScheduler().runTaskTimer(plugin, this::recoverPrices, ticks, ticks); + } + } + + private void loadBlacklist() { + blacklist.clear(); + for (String materialName : plugin.getConfig().getStringList("market.blacklist")) { + try { + blacklist.add(Material.valueOf(materialName.toUpperCase())); + } catch (IllegalArgumentException e) { + plugin.getComponentLogger().warn("Invalid material in blacklist: " + materialName); + } + } + } + + private void initializePrices() { + FileConfiguration config = plugin.getConfig(); + + priceIncreaseRate = BigDecimal.valueOf(config.getDouble("market.price-increase-per-buy", 0.001)); + priceDecreaseRate = BigDecimal.valueOf(config.getDouble("market.price-decrease-per-sell", 0.001)); + + for (Category cat : Category.values()) { + if (cat == Category.ALL_ITEMS) continue; + for (MarketEntry entry : MarketItems.getItems(cat)) { + processEntry(entry, config); + } + } + for (MarketEntry entry : MarketItems.getItems(Category.ALL_ITEMS)) { + String key = (entry.customName != null) ? entry.material.name() + "_" + entry.customName.replace(" ", "_").toUpperCase() + : entry.material.name(); + if (!entryCache.containsKey(key)) { + processEntry(entry, config); + } + } + + if (configModified) { + plugin.saveConfig(); + configModified = false; + } + } + + private void processEntry(MarketEntry entry, FileConfiguration config) { + String key = entry.material.name(); + if (entry.customName != null) { + key = entry.material.name() + "_" + entry.customName.replace(" ", "_").toUpperCase(); + } + + entryCache.put(key, entry); + + String basePath = "market-items." + key.replace(" ", "_").toUpperCase(); + + BigDecimal configBuy = config.contains(basePath + ".buy") ? BigDecimal.valueOf(config.getDouble(basePath + ".buy")) : BigDecimal.ZERO; + if (!config.contains(basePath + ".buy") + || (configBuy.compareTo(BigDecimal.ONE) == 0 && entry.price.compareTo(BigDecimal.ONE) > 0)) { + config.set(basePath + ".buy", entry.price.doubleValue()); + configModified = true; + configBuy = entry.price; + } + + BigDecimal configSell; + if (!config.contains(basePath + ".sell")) { + if (entry.customSellPrice != null) { + configSell = entry.customSellPrice; + } else { + BigDecimal sellRatio = BigDecimal.valueOf(plugin.getConfig().getDouble("market.default-sell-ratio", 0.5)); + configSell = entry.price.multiply(sellRatio); + } + config.set(basePath + ".sell", configSell.doubleValue()); + configModified = true; + } else { + configSell = BigDecimal.valueOf(config.getDouble(basePath + ".sell")); + } + + buyPrices.put(key, configBuy); + sellPrices.put(key, configSell); + + String curPath = basePath + ".currency"; + if (!config.contains(curPath)) { + config.set(curPath, plugin.getEconomyManager().getDefaultCurrency()); + configModified = true; + } + itemCurrencies.put(key, config.getString(curPath, plugin.getEconomyManager().getDefaultCurrency())); + } + + public void onTransaction(String materialName, boolean isBuy, int amount) { + if (!dynamicPricing) { + return; + } + + buyPrices.compute(materialName, (key, currentBuy) -> { + if (currentBuy == null) currentBuy = BigDecimal.ZERO; + + BigDecimal updatedPrice; + if (isBuy) { + // Demand up -> Price up + double multiplier = Math.pow(1 + priceIncreaseRate.doubleValue(), amount); + updatedPrice = currentBuy.multiply(BigDecimal.valueOf(multiplier)); + } else { + // Supply up -> Price down + double multiplier = Math.pow(1 - priceDecreaseRate.doubleValue(), amount); + updatedPrice = currentBuy.multiply(BigDecimal.valueOf(multiplier)); + } + + // Clamp to floor/ceiling + MarketEntry entry = entryCache.get(materialName); + if (entry != null) { + BigDecimal floor = entry.price.multiply(priceFloorPercent); + BigDecimal ceiling = entry.price.multiply(priceCeilingPercent); + if (updatedPrice.compareTo(floor) < 0) updatedPrice = floor; + if (updatedPrice.compareTo(ceiling) > 0) updatedPrice = ceiling; + } + + String basePath = "market-items." + materialName.replace(" ", "_").toUpperCase(); + plugin.getConfig().set(basePath + ".buy", updatedPrice.doubleValue()); + configModified = true; + + // Check for alerts + checkPriceAlert(materialName, updatedPrice); + + return updatedPrice; + }); + } + + private void checkPriceAlert(String key, BigDecimal newPrice) { + if (!alertsEnabled) { + return; + } + + MarketEntry entry = entryCache.get(key); + if (entry == null) + return; + + if (entry.price.compareTo(alertMinBasePrice) < 0) { + return; + } + + BigDecimal alertThreshold = entry.price.multiply(alertThresholdPercent); + BigDecimal resetThreshold = entry.price.multiply(alertThresholdPercent.add(BigDecimal.valueOf(0.1))); + + if (newPrice.compareTo(alertThreshold) <= 0 && !alertedItems.contains(key)) { + // Broadcast crash + String displayName = entry.customName != null ? entry.customName : entry.material.name().replace("_", " "); + net.kyori.adventure.text.Component message = net.kyori.adventure.text.Component.text() + .append(net.kyori.adventure.text.Component.text("[Market] ", + net.kyori.adventure.text.format.NamedTextColor.GOLD)) + .append(net.kyori.adventure.text.Component.text("📉 CRASH ALERT: ", + net.kyori.adventure.text.format.NamedTextColor.RED)) + .append(net.kyori.adventure.text.Component.text(displayName, + net.kyori.adventure.text.format.NamedTextColor.AQUA)) + .append(net.kyori.adventure.text.Component.text(" is now at a massive discount! ", + net.kyori.adventure.text.format.NamedTextColor.GRAY)) + .append(net.kyori.adventure.text.Component.text(plugin.getEconomyManager().getFormattedWithSymbol(newPrice, plugin.getEconomyManager().getDefaultCurrency()), + net.kyori.adventure.text.format.NamedTextColor.GREEN)) + .build(); + + plugin.getServer().broadcast(message); + alertedItems.add(key); + } else if (newPrice.compareTo(resetThreshold) > 0 && alertedItems.contains(key)) { + // Reset alert for next time + alertedItems.remove(key); + } + } + + public void persistPrices() { + if (configModified) { + plugin.saveConfig(); + configModified = false; + } + } + + private void recoverPrices() { + for (Map.Entry e : entryCache.entrySet()) { + String key = e.getKey(); + BigDecimal basePrice = e.getValue().price; + BigDecimal currentPrice = getBuyPrice(key); + + if (currentPrice.compareTo(basePrice) == 0) + continue; + + BigDecimal gap = basePrice.subtract(currentPrice); + BigDecimal adjustment = gap.multiply(recoveryRate); + BigDecimal newPrice = currentPrice.add(adjustment); + + // Snap to base if close enough + if (gap.abs().compareTo(BigDecimal.valueOf(0.01)) < 0) { + newPrice = basePrice; + } + + buyPrices.put(key, newPrice); + String basePath = "market-items." + key.replace(" ", "_").toUpperCase(); + plugin.getConfig().set(basePath + ".buy", newPrice.doubleValue()); + configModified = true; + } + } + + public Map getEntryCache() { + return entryCache; + } + + public boolean isBlacklisted(Material material) { + return blacklist.contains(material); + } + + public BigDecimal getBuyPrice(Material material) { + return getBuyPrice(material.name()); + } + + public BigDecimal getBuyPrice(String key) { + return buyPrices.getOrDefault(key, BigDecimal.ZERO); + } + + public BigDecimal getSellPrice(Material material) { + return getSellPrice(material.name()); + } + + public BigDecimal getSellPrice(String key) { + BigDecimal configSell = sellPrices.getOrDefault(key, BigDecimal.ZERO); + if (configSell.compareTo(BigDecimal.ZERO) <= 0) + return BigDecimal.ZERO; + + MarketEntry entry = entryCache.get(key); + if (entry == null) + return configSell; + + BigDecimal baseBuyPrice = entry.price; + if (baseBuyPrice.compareTo(BigDecimal.ZERO) <= 0) + return BigDecimal.ZERO; + + BigDecimal currentBuyPrice = getBuyPrice(key); + + // Calculate the ratio saved in config + BigDecimal ratio = configSell.divide(baseBuyPrice, 4, RoundingMode.HALF_UP); + + if (ratio.compareTo(BigDecimal.valueOf(0.95)) > 0) { + ratio = defaultSellRatio; + } + + BigDecimal dynamicSellPrice = currentBuyPrice.multiply(ratio); + + // Sell can never exceed buy + if (dynamicSellPrice.compareTo(currentBuyPrice) >= 0) { + dynamicSellPrice = currentBuyPrice.multiply(BigDecimal.valueOf(0.99)); + } + + return dynamicSellPrice; + } + + public String getCurrency(String key) { + return itemCurrencies.getOrDefault(key, plugin.getEconomyManager().getDefaultCurrency()); + } + + public String getCurrency(Material material) { + return getCurrency(material.name()); + } + + public java.util.List getItemsByCategory(String categoryName) { + for (Category cat : Category.values()) { + if (cat.name.equalsIgnoreCase(categoryName) || cat.name().equalsIgnoreCase(categoryName)) { + return MarketItems.getItems(cat); + } + } + return new java.util.ArrayList<>(); + } + + public java.util.List getOrderItemsByCategory(String categoryName) { + for (Category cat : Category.values()) { + if (cat.name.equalsIgnoreCase(categoryName) || cat.name().equalsIgnoreCase(categoryName)) { + return MarketItems.getOrderItems(cat); + } + } + return new java.util.ArrayList<>(); + } + + /** + * Add a custom item to the market system. + * Uses the effective buy price as base (never uses negative sentinel -1). + */ + public void addCustomMarketItem(String canonicalId, CustomMarketItem customItem) { + String key = canonicalId; + + // Resolve effective prices: treat -1 (unset sentinel) as "use vanilla material price" + BigDecimal effectiveBuyPrice = customItem.getBuyPrice(); + if (effectiveBuyPrice.compareTo(BigDecimal.ZERO) < 0) { + effectiveBuyPrice = getBuyPrice(customItem.getItemStack().getType()); + if (effectiveBuyPrice.compareTo(BigDecimal.ZERO) <= 0) { + // Last resort: set a small positive default so base price is never negative + effectiveBuyPrice = BigDecimal.ONE; + } + } + + BigDecimal effectiveSellPrice = customItem.getSellPrice(); + if (effectiveSellPrice.compareTo(BigDecimal.ZERO) < 0) { + effectiveSellPrice = effectiveBuyPrice.multiply(defaultSellRatio); + } + + if (!entryCache.containsKey(key)) { + MarketEntry entry = new MarketEntry(customItem.getItemStack().getType(), + effectiveBuyPrice.doubleValue()); + entryCache.put(key, entry); + } + + if (!buyPrices.containsKey(key) || effectiveBuyPrice.compareTo(BigDecimal.ZERO) > 0) { + buyPrices.put(key, effectiveBuyPrice); + } + if (!sellPrices.containsKey(key) || effectiveSellPrice.compareTo(BigDecimal.ZERO) > 0) { + sellPrices.put(key, effectiveSellPrice); + } + + if (!itemCurrencies.containsKey(key)) { + itemCurrencies.put(key, plugin.getEconomyManager().getDefaultCurrency()); + } + } + } diff --git a/src/main/java/com/aureleconomy/scanner/CustomItemRegistry.java b/src/main/java/com/aureleconomy/scanner/CustomItemRegistry.java new file mode 100644 index 0000000..a64af7f --- /dev/null +++ b/src/main/java/com/aureleconomy/scanner/CustomItemRegistry.java @@ -0,0 +1,594 @@ +package com.aureleconomy.scanner; + +import com.aureleconomy.AurelEconomy; +import com.aureleconomy.database.DatabaseManager; +import com.aureleconomy.market.MarketItems.Category; +import com.aureleconomy.market.MarketManager; +import org.bukkit.inventory.ItemStack; +import org.bukkit.NamespacedKey; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; + +import java.math.BigDecimal; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The deduplication core. Maintains the canonical registry of all discovered + * custom items and provides 5-way dedup lookup to prevent duplicate entries + * even when the same item is found by multiple detection methods. + */ +public class CustomItemRegistry { + + private final AurelEconomy plugin; + + // Primary store: canonical ID -> item + private final ConcurrentHashMap itemsById = new ConcurrentHashMap<>(); + + // 5 dedup lookup maps + private final ConcurrentHashMap pdcKeyToId = new ConcurrentHashMap<>(); + private final ConcurrentHashMap modelDataToId = new ConcurrentHashMap<>(); + private final ConcurrentHashMap loreHashToId = new ConcurrentHashMap<>(); + private final ConcurrentHashMap pluginNativeIdToId = new ConcurrentHashMap<>(); + private final ConcurrentHashMap itemHashToId = new ConcurrentHashMap<>(); + + // Discovery tracking: which methods found each item + private final ConcurrentHashMap> discoveryMethods = new ConcurrentHashMap<>(); + + // Duplicate counter + private final java.util.concurrent.atomic.AtomicLong duplicatesPrevented = new java.util.concurrent.atomic.AtomicLong(0); + + public CustomItemRegistry(AurelEconomy plugin) { + this.plugin = plugin; + } + + /** + * Register a custom item with deduplication. Returns a RegistrationResult + * indicating whether the item was newly registered or was a duplicate. + */ + public RegistrationResult register(CustomMarketItem item, DiscoveryMethod method) { + String canonicalId = item.getCanonicalId(); + + // 1. Check primary store + if (itemsById.containsKey(canonicalId)) { + addDiscoveryMethod(canonicalId, method); + duplicatesPrevented.incrementAndGet(); + return RegistrationResult.alreadyExists(canonicalId, method); + } + + // 2. Check PDC key + if (item.getPdcKey() != null && pdcKeyToId.containsKey(item.getPdcKey())) { + String existingId = pdcKeyToId.get(item.getPdcKey()); + addDiscoveryMethod(existingId, method); + duplicatesPrevented.incrementAndGet(); + return RegistrationResult.alreadyExists(existingId, method); + } + + // 3. Check model data key + if (item.getModelDataKey() != null && modelDataToId.containsKey(item.getModelDataKey())) { + String existingId = modelDataToId.get(item.getModelDataKey()); + addDiscoveryMethod(existingId, method); + duplicatesPrevented.incrementAndGet(); + return RegistrationResult.alreadyExists(existingId, method); + } + + // 4. Check lore hash + if (item.getLoreHash() != null && loreHashToId.containsKey(item.getLoreHash())) { + String existingId = loreHashToId.get(item.getLoreHash()); + addDiscoveryMethod(existingId, method); + duplicatesPrevented.incrementAndGet(); + return RegistrationResult.alreadyExists(existingId, method); + } + + // 5. Check plugin native ID + if (item.getPluginNativeId() != null && pluginNativeIdToId.containsKey(item.getPluginNativeId())) { + String existingId = pluginNativeIdToId.get(item.getPluginNativeId()); + addDiscoveryMethod(existingId, method); + duplicatesPrevented.incrementAndGet(); + return RegistrationResult.alreadyExists(existingId, method); + } + + // 6. Check item hash + isSimilar + String itemHash = computeItemHash(item.getItemStack()); + if (itemHashToId.containsKey(itemHash)) { + String existingId = itemHashToId.get(itemHash); + CustomMarketItem existingItem = itemsById.get(existingId); + if (existingItem != null && existingItem.getItemStack().isSimilar(item.getItemStack())) { + addDiscoveryMethod(existingId, method); + duplicatesPrevented.incrementAndGet(); + return RegistrationResult.alreadyExists(existingId, method); + } + } + + // NEW ITEM - insert into all stores (synchronized to prevent race between check and insert) + synchronized (this) { + // Fix: double-check ALL dedup keys under lock, not just canonicalId + // This prevents two concurrent registrations with different IDs but same + // PDC/model/lore/native/hash from both passing pre-checks and getting inserted + + if (itemsById.containsKey(canonicalId)) { + addDiscoveryMethod(canonicalId, method); + duplicatesPrevented.incrementAndGet(); + return RegistrationResult.alreadyExists(canonicalId, method); + } + + if (item.getPdcKey() != null && pdcKeyToId.containsKey(item.getPdcKey())) { + String existingId = pdcKeyToId.get(item.getPdcKey()); + addDiscoveryMethod(existingId, method); + duplicatesPrevented.incrementAndGet(); + return RegistrationResult.alreadyExists(existingId, method); + } + + if (item.getModelDataKey() != null && modelDataToId.containsKey(item.getModelDataKey())) { + String existingId = modelDataToId.get(item.getModelDataKey()); + addDiscoveryMethod(existingId, method); + duplicatesPrevented.incrementAndGet(); + return RegistrationResult.alreadyExists(existingId, method); + } + + if (item.getLoreHash() != null && loreHashToId.containsKey(item.getLoreHash())) { + String existingId = loreHashToId.get(item.getLoreHash()); + addDiscoveryMethod(existingId, method); + duplicatesPrevented.incrementAndGet(); + return RegistrationResult.alreadyExists(existingId, method); + } + + if (item.getPluginNativeId() != null && pluginNativeIdToId.containsKey(item.getPluginNativeId())) { + String existingId = pluginNativeIdToId.get(item.getPluginNativeId()); + addDiscoveryMethod(existingId, method); + duplicatesPrevented.incrementAndGet(); + return RegistrationResult.alreadyExists(existingId, method); + } + + // Re-check item hash under lock too + String lockedItemHash = itemHash; // already computed above + if (itemHashToId.containsKey(lockedItemHash)) { + String existingId = itemHashToId.get(lockedItemHash); + CustomMarketItem existingItem = itemsById.get(existingId); + if (existingItem != null && existingItem.getItemStack().isSimilar(item.getItemStack())) { + addDiscoveryMethod(existingId, method); + duplicatesPrevented.incrementAndGet(); + return RegistrationResult.alreadyExists(existingId, method); + } + } + + itemsById.put(canonicalId, item); + if (item.getPdcKey() != null) pdcKeyToId.put(item.getPdcKey(), canonicalId); + if (item.getModelDataKey() != null) modelDataToId.put(item.getModelDataKey(), canonicalId); + if (item.getLoreHash() != null) loreHashToId.put(item.getLoreHash(), canonicalId); + if (item.getPluginNativeId() != null) pluginNativeIdToId.put(item.getPluginNativeId(), canonicalId); + itemHashToId.put(lockedItemHash, canonicalId); + addDiscoveryMethod(canonicalId, method); + } + + // Add to market if auto-add is enabled and price is set + if (plugin.getConfig().getBoolean("custom-items.auto-add-to-market", true)) { + MarketManager marketManager = plugin.getMarketManager(); + if (marketManager != null) { + marketManager.addCustomMarketItem(canonicalId, item); + } + } + + return RegistrationResult.newlyRegistered(canonicalId, method); + } + + private void addDiscoveryMethod(String canonicalId, DiscoveryMethod method) { + discoveryMethods.computeIfAbsent(canonicalId, k -> ConcurrentHashMap.newKeySet()).add(method); + } + + /** + * Update an existing custom item in-place (for toggle/price changes). + * Unlike register(), this overwrites the existing entry instead of + * returning alreadyExists. + */ + public void upsert(CustomMarketItem item) { + String canonicalId = item.getCanonicalId(); + synchronized (this) { + // Remove old dedup keys from previous version + CustomMarketItem old = itemsById.get(canonicalId); + if (old != null) { + if (old.getPdcKey() != null) pdcKeyToId.remove(old.getPdcKey()); + if (old.getModelDataKey() != null) modelDataToId.remove(old.getModelDataKey()); + if (old.getLoreHash() != null) loreHashToId.remove(old.getLoreHash()); + if (old.getPluginNativeId() != null) pluginNativeIdToId.remove(old.getPluginNativeId()); + itemHashToId.remove(computeItemHash(old.getItemStack())); + } + // Replace in primary store + itemsById.put(canonicalId, item); + // Re-index dedup keys with new values + if (item.getPdcKey() != null) pdcKeyToId.put(item.getPdcKey(), canonicalId); + if (item.getModelDataKey() != null) modelDataToId.put(item.getModelDataKey(), canonicalId); + if (item.getLoreHash() != null) loreHashToId.put(item.getLoreHash(), canonicalId); + if (item.getPluginNativeId() != null) pluginNativeIdToId.put(item.getPluginNativeId(), canonicalId); + itemHashToId.put(computeItemHash(item.getItemStack()), canonicalId); + } + } + + /** + * Clear all in-memory maps. Used before reload to avoid stale entries. + */ + public void clear() { + synchronized (this) { + itemsById.clear(); + pdcKeyToId.clear(); + modelDataToId.clear(); + loreHashToId.clear(); + pluginNativeIdToId.clear(); + itemHashToId.clear(); + discoveryMethods.clear(); + } + } + + /** + * Compute a deep hash of an ItemStack for dedup purposes. + * Hashes: Material, ItemMeta, CustomModelData, Lore, all PDC keys+values. + */ + public String computeItemHash(ItemStack item) { + if (item == null) return "null"; + int hash = item.getType().hashCode(); + if (item.hasItemMeta()) { + hash = 31 * hash + item.getItemMeta().hashCode(); + org.bukkit.inventory.meta.ItemMeta meta = item.getItemMeta(); + if (meta.hasCustomModelData()) { + hash = 31 * hash + meta.getCustomModelData(); + } + if (meta.hasLore() && meta.lore() != null) { + hash = 31 * hash + meta.lore().hashCode(); + } + PersistentDataContainer pdc = meta.getPersistentDataContainer(); + for (NamespacedKey key : pdc.getKeys()) { + hash = 31 * hash + key.hashCode(); + if (pdc.has(key, PersistentDataType.STRING)) { + String value = pdc.get(key, PersistentDataType.STRING); + if (value != null) hash = 31 * hash + value.hashCode(); + } + } + } + return String.valueOf(hash); + } + + /** + * Try to resolve a canonical ID from an ItemStack by checking all dedup keys. + * Tries PDC first (most reliable), then model data, then lore hash, then item hash. + */ + public Optional resolveItemId(ItemStack item) { + if (item == null || item.getType().isAir()) return Optional.empty(); + + // PDC lookup (most reliable) + if (item.hasItemMeta()) { + PersistentDataContainer pdc = item.getItemMeta().getPersistentDataContainer(); + for (NamespacedKey key : pdc.getKeys()) { + String ns = key.getNamespace(); + if (!"minecraft".equals(ns) && !"aureleconomy".equals(ns)) { + String pdcKey = ns + ":" + key.getKey(); + String id = pdcKeyToId.get(pdcKey); + if (id != null) return Optional.of(id); + } + } + } + + // Model data lookup + if (item.hasItemMeta() && item.getItemMeta().hasCustomModelData()) { + String mdKey = item.getType().name() + ":" + item.getItemMeta().getCustomModelData(); + String id = modelDataToId.get(mdKey); + if (id != null) return Optional.of(id); + } + + // Lore hash lookup + if (item.hasItemMeta() && item.getItemMeta().hasLore() && item.getItemMeta().lore() != null) { + String lHash = String.valueOf(item.getItemMeta().lore().hashCode()); + String id = loreHashToId.get(lHash); + if (id != null) return Optional.of(id); + } + + // Item hash lookup + String iHash = computeItemHash(item); + String id = itemHashToId.get(iHash); + if (id != null) { + // Extra safety: confirm isSimilar + CustomMarketItem existing = itemsById.get(id); + if (existing != null && existing.getItemStack().isSimilar(item)) { + return Optional.of(id); + } + } + + return Optional.empty(); + } + + /** + * Load previously discovered items from database. + */ + public void loadFromDatabase(DatabaseManager dbManager) { + try (PreparedStatement ps = dbManager.getConnection().prepareStatement( + "SELECT * FROM custom_items WHERE enabled = 1")) { + ResultSet rs = ps.executeQuery(); + int loaded = 0; + while (rs.next()) { + String canonicalId = rs.getString("canonical_id"); + String sourcePlugin = rs.getString("source_plugin"); + String displayName = rs.getString("display_name"); + String itemData = rs.getString("item_data"); + String pdcKey = rs.getString("pdc_key"); + String modelDataKey = rs.getString("model_data_key"); + String loreHash = rs.getString("lore_hash"); + String pluginNativeId = rs.getString("plugin_native_id"); + String category = rs.getString("category"); + double buyPrice = rs.getDouble("buy_price"); + double sellPrice = rs.getDouble("sell_price"); + String methodsStr = rs.getString("discovery_methods"); + + ItemStack itemStack; + try { + itemStack = ItemStack.deserializeBytes( + org.yaml.snakeyaml.external.biz.base64Coder.Base64Coder.decodeLines(itemData)); + } catch (Exception e) { + plugin.getComponentLogger().warn("[CustomItems] Failed to deserialize item: " + canonicalId); + continue; + } + + CustomMarketItem item = new CustomMarketItem.Builder() + .canonicalId(canonicalId) + .itemStack(itemStack) + .sourcePlugin(sourcePlugin) + .displayName(displayName != null ? displayName : itemStack.getType().name()) + .pdcKey(pdcKey) + .modelDataKey(modelDataKey) + .loreHash(loreHash) + .pluginNativeId(pluginNativeId) + .category(category != null ? category : "CUSTOM_ITEMS") + .buyPrice(buyPrice >= 0 ? BigDecimal.valueOf(buyPrice) : BigDecimal.valueOf(-1)) + .sellPrice(sellPrice >= 0 ? BigDecimal.valueOf(sellPrice) : BigDecimal.valueOf(-1)) + .enabled(true) + .build(); + + // Directly insert into maps (bypass register to avoid re-adding to market) + itemsById.put(canonicalId, item); + if (pdcKey != null) pdcKeyToId.put(pdcKey, canonicalId); + if (modelDataKey != null) modelDataToId.put(modelDataKey, canonicalId); + if (loreHash != null) loreHashToId.put(loreHash, canonicalId); + if (pluginNativeId != null) pluginNativeIdToId.put(pluginNativeId, canonicalId); + itemHashToId.put(computeItemHash(itemStack), canonicalId); + + // Restore discovery methods + Set methods = ConcurrentHashMap.newKeySet(); + if (methodsStr != null && !methodsStr.isEmpty()) { + for (String m : methodsStr.split(",")) { + try { methods.add(DiscoveryMethod.valueOf(m.trim())); } catch (IllegalArgumentException ignored) {} + } + } + discoveryMethods.put(canonicalId, methods); + loaded++; + } + if (loaded > 0) { + plugin.getComponentLogger().info("[CustomItems] Loaded " + loaded + " items from database."); + } + } catch (SQLException e) { + plugin.getComponentLogger().error("[CustomItems] Failed to load custom items from database", e); + } + } + + /** + * Save all discovered items to database asynchronously. + */ + public void saveToDatabase(DatabaseManager dbManager) { + org.bukkit.Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + synchronized (dbManager.getWriteLock()) { + for (Map.Entry entry : itemsById.entrySet()) { + try { + saveCustomItem(dbManager, entry.getKey(), entry.getValue(), + discoveryMethods.getOrDefault(entry.getKey(), Collections.emptySet())); + } catch (SQLException e) { + plugin.getComponentLogger().error("[CustomItems] Failed to save item: " + entry.getKey(), e); + } + } + } + }); + } + + private void saveCustomItem(DatabaseManager dbManager, String canonicalId, CustomMarketItem item, + Set methods) throws SQLException { + String itemData = org.yaml.snakeyaml.external.biz.base64Coder.Base64Coder.encodeLines( + item.getItemStack().serializeAsBytes()); + String methodsStr = methods.stream().map(DiscoveryMethod::name).reduce((a, b) -> a + "," + b).orElse(""); + + if (dbManager.isMySQL()) { + try (PreparedStatement ps = dbManager.getConnection().prepareStatement( + "INSERT INTO custom_items (canonical_id, source_plugin, display_name, item_data, pdc_key, " + + "model_data_key, lore_hash, plugin_native_id, category, buy_price, sell_price, enabled, " + + "discovery_methods, first_discovered, last_seen) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE display_name=?, item_data=?, pdc_key=?, model_data_key=?, " + + "lore_hash=?, plugin_native_id=?, category=?, buy_price=?, sell_price=?, enabled=?, " + + "discovery_methods=?, last_seen=?")) { + long now = System.currentTimeMillis(); + long firstDiscovered = now; // will be overwritten by ON DUPLICATE KEY for existing + populateInsertPS(ps, canonicalId, item, itemData, methodsStr, now); + ps.setLong(15, now); + // ON DUPLICATE KEY UPDATE params (indices 16+) + ps.setString(16, item.getDisplayName()); + ps.setString(17, itemData); + ps.setString(18, item.getPdcKey()); + ps.setString(19, item.getModelDataKey()); + ps.setString(20, item.getLoreHash()); + ps.setString(21, item.getPluginNativeId()); + ps.setString(22, item.getCategory()); + ps.setDouble(23, item.getBuyPrice().doubleValue()); + ps.setDouble(24, item.getSellPrice().doubleValue()); + ps.setBoolean(25, item.isEnabled()); + ps.setString(26, methodsStr); + ps.setLong(27, now); + ps.executeUpdate(); + } + } else { + try (PreparedStatement ps = dbManager.getConnection().prepareStatement( + "INSERT OR REPLACE INTO custom_items (canonical_id, source_plugin, display_name, item_data, " + + "pdc_key, model_data_key, lore_hash, plugin_native_id, category, buy_price, sell_price, " + + "enabled, discovery_methods, first_discovered, last_seen) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")) { + long now = System.currentTimeMillis(); + // For SQLite INSERT OR REPLACE, preserve first_discovered if item exists + long firstDiscovered = now; + try (PreparedStatement lookup = dbManager.getConnection().prepareStatement( + "SELECT first_discovered FROM custom_items WHERE canonical_id = ?")) { + lookup.setString(1, canonicalId); + ResultSet rs = lookup.executeQuery(); + if (rs.next()) firstDiscovered = rs.getLong("first_discovered"); + } + populateInsertPS(ps, canonicalId, item, itemData, methodsStr, firstDiscovered); + ps.setLong(15, now); + ps.executeUpdate(); + } + } + } + + private void populateInsertPS(PreparedStatement ps, String canonicalId, CustomMarketItem item, + String itemData, String methodsStr, long firstDiscovered) throws SQLException { + ps.setString(1, canonicalId); + ps.setString(2, item.getSourcePlugin()); + ps.setString(3, item.getDisplayName()); + ps.setString(4, itemData); + ps.setString(5, item.getPdcKey()); + ps.setString(6, item.getModelDataKey()); + ps.setString(7, item.getLoreHash()); + ps.setString(8, item.getPluginNativeId()); + ps.setString(9, item.getCategory()); + ps.setDouble(10, item.getBuyPrice().doubleValue()); + ps.setDouble(11, item.getSellPrice().doubleValue()); + ps.setBoolean(12, item.isEnabled()); + ps.setString(13, methodsStr); + ps.setLong(14, firstDiscovered); + } + + /** + * Delete a custom item from registry and database. + */ + public void deleteCustomItem(DatabaseManager dbManager, String canonicalId) { + CustomMarketItem removed = itemsById.remove(canonicalId); + if (removed != null) { + if (removed.getPdcKey() != null) pdcKeyToId.remove(removed.getPdcKey()); + if (removed.getModelDataKey() != null) modelDataToId.remove(removed.getModelDataKey()); + if (removed.getLoreHash() != null) loreHashToId.remove(removed.getLoreHash()); + if (removed.getPluginNativeId() != null) pluginNativeIdToId.remove(removed.getPluginNativeId()); + itemHashToId.remove(computeItemHash(removed.getItemStack())); + discoveryMethods.remove(canonicalId); + } + org.bukkit.Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + synchronized (dbManager.getWriteLock()) { + try (PreparedStatement ps = dbManager.getConnection().prepareStatement( + "DELETE FROM custom_items WHERE canonical_id = ?")) { + ps.setString(1, canonicalId); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getComponentLogger().error("[CustomItems] Failed to delete item: " + canonicalId, e); + } + } + }); + } + + /** + * Update prices for a custom item in the database. + */ + public void updateCustomItemPrice(DatabaseManager dbManager, String canonicalId, double buyPrice, double sellPrice) { + org.bukkit.Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + synchronized (dbManager.getWriteLock()) { + try (PreparedStatement ps = dbManager.getConnection().prepareStatement( + "UPDATE custom_items SET buy_price = ?, sell_price = ? WHERE canonical_id = ?")) { + ps.setDouble(1, buyPrice); + ps.setDouble(2, sellPrice); + ps.setString(3, canonicalId); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getComponentLogger().error("[CustomItems] Failed to update price for: " + canonicalId, e); + } + } + }); + } + + // Query methods + public Collection getAllItems() { return Collections.unmodifiableCollection(itemsById.values()); } + public CustomMarketItem getById(String canonicalId) { return itemsById.get(canonicalId); } + public Set getDiscoveryMethods(String canonicalId) { + return Collections.unmodifiableSet(discoveryMethods.getOrDefault(canonicalId, Collections.emptySet())); + } + public int getTotalItems() { return itemsById.size(); } + public long getDuplicatesPrevented() { return duplicatesPrevented.get(); } + public boolean isEmpty() { return itemsById.isEmpty(); } + /** + * Sync discovered items to config.yml so admins can see and configure them. + * Called after each scan cycle. Only writes items not already in config. + */ + public void syncToConfig() { + org.bukkit.configuration.file.YamlConfiguration config = (org.bukkit.configuration.file.YamlConfiguration) plugin.getConfig(); + boolean changed = false; + + for (java.util.Map.Entry entry : itemsById.entrySet()) { + String canonicalId = entry.getKey(); + CustomMarketItem item = entry.getValue(); + String path = "discovered-items." + canonicalId; + + // Only add if not already in config (admin may have customized it) + if (!config.contains(path)) { + config.set(path + ".source-plugin", item.getSourcePlugin()); + config.set(path + ".display-name", item.getDisplayName()); + config.set(path + ".category", item.getCategory()); + config.set(path + ".buy-price", item.getBuyPrice().doubleValue()); + config.set(path + ".sell-price", item.getSellPrice().doubleValue()); + config.set(path + ".enabled", item.isEnabled()); + if (item.getPdcKey() != null) config.set(path + ".pdc-key", item.getPdcKey()); + if (item.getModelDataKey() != null) config.set(path + ".model-data-key", item.getModelDataKey()); + if (item.getPluginNativeId() != null) config.set(path + ".plugin-native-id", item.getPluginNativeId()); + changed = true; + } + } + + if (changed) { + plugin.saveConfig(); + plugin.getComponentLogger().info("[CustomItems] Config updated with newly discovered items."); + } + } + + /** + * Load discovered item overrides from config.yml. + * Admins can customize prices, enabled status, etc. in config. + * These override database values on startup. + * Fix: use upsert() to re-index dedup maps when overrides change keys. + */ + public void loadConfigOverrides() { + org.bukkit.configuration.ConfigurationSection section = plugin.getConfig().getConfigurationSection("discovered-items"); + if (section == null) return; + + for (String canonicalId : section.getKeys(false)) { + if (!itemsById.containsKey(canonicalId)) continue; + CustomMarketItem existing = itemsById.get(canonicalId); + String path = "discovered-items." + canonicalId; + + CustomMarketItem.Builder builder = new CustomMarketItem.Builder() + .canonicalId(canonicalId) + .itemStack(existing.getItemStack()) + .sourcePlugin(section.getString(path + ".source-plugin", existing.getSourcePlugin())) + .displayName(section.getString(path + ".display-name", existing.getDisplayName())) + .category(section.getString(path + ".category", existing.getCategory())) + .enabled(section.getBoolean(path + ".enabled", existing.isEnabled())); + + double buyPrice = section.getDouble(path + ".buy-price", existing.getBuyPrice().doubleValue()); + double sellPrice = section.getDouble(path + ".sell-price", existing.getSellPrice().doubleValue()); + builder.buyPrice(java.math.BigDecimal.valueOf(buyPrice)); + builder.sellPrice(java.math.BigDecimal.valueOf(sellPrice)); + + String pdcKey = section.getString(path + ".pdc-key"); + if (pdcKey != null) builder.pdcKey(pdcKey); + String modelDataKey = section.getString(path + ".model-data-key"); + if (modelDataKey != null) builder.modelDataKey(modelDataKey); + String pluginNativeId = section.getString(path + ".plugin-native-id"); + if (pluginNativeId != null) builder.pluginNativeId(pluginNativeId); + + // Fix: use upsert() instead of direct itemsById.put() + // so that dedup indexes (pdcKeyToId, modelDataToId, etc.) are kept consistent + upsert(builder.build()); + } + } + +} diff --git a/src/main/java/com/aureleconomy/scanner/CustomMarketItem.java b/src/main/java/com/aureleconomy/scanner/CustomMarketItem.java new file mode 100644 index 0000000..63be168 --- /dev/null +++ b/src/main/java/com/aureleconomy/scanner/CustomMarketItem.java @@ -0,0 +1,148 @@ +package com.aureleconomy.scanner; + +import org.bukkit.inventory.ItemStack; + +import java.math.BigDecimal; + +/** + * Data model representing a discovered custom item, ready for registration + * in the market and auction systems. Uses a Builder pattern consistent with + * AuctionItem's builder style. + * + * Price sentinel: buyPrice/sellPrice of -1 means "unset / use default". + * All other negative values are rejected by the builder. + */ +public class CustomMarketItem { + + /** Sentinel value indicating the price has not been explicitly set. */ + public static final BigDecimal PRICE_UNSET = BigDecimal.valueOf(-1); + + /** Convenience factory matching the test-call convention. */ + public static Builder builder() { + return new Builder(); + } + + private final String canonicalId; + private final ItemStack itemStack; + private final String sourcePlugin; + private final String displayName; + private final String pdcKey; + private final String modelDataKey; + private final String loreHash; + private final String pluginNativeId; + private final String category; + private final BigDecimal buyPrice; + private final BigDecimal sellPrice; + private final boolean enabled; + + private CustomMarketItem(Builder builder) { + this.canonicalId = builder.canonicalId; + this.itemStack = builder.itemStack; + this.sourcePlugin = builder.sourcePlugin; + this.displayName = builder.displayName; + this.pluginNativeId = builder.pluginNativeId; + this.pdcKey = builder.pdcKey; + this.modelDataKey = builder.modelDataKey; + this.loreHash = builder.loreHash; + this.category = builder.category; + this.buyPrice = builder.buyPrice; + this.sellPrice = builder.sellPrice; + this.enabled = builder.enabled; + } + + public String getCanonicalId() { return canonicalId; } + public ItemStack getItemStack() { return itemStack != null ? itemStack.clone() : null; } + public String getSourcePlugin() { return sourcePlugin; } + public String getDisplayName() { return displayName; } + public String getPdcKey() { return pdcKey; } + public String getModelDataKey() { return modelDataKey; } + public String getLoreHash() { return loreHash; } + public String getPluginNativeId() { return pluginNativeId; } + public String getCategory() { return category; } + public BigDecimal getBuyPrice() { return buyPrice; } + public BigDecimal getSellPrice() { return sellPrice; } + public boolean isEnabled() { return enabled; } + + /** + * Returns whether this item's buy price has been explicitly set + * (i.e. is not the PRICE_UNSET sentinel). + */ + public boolean hasBuyPrice() { return buyPrice.compareTo(PRICE_UNSET) != 0; } + + /** + * Returns whether this item's sell price has been explicitly set + * (i.e. is not the PRICE_UNSET sentinel). + */ + public boolean hasSellPrice() { return sellPrice.compareTo(PRICE_UNSET) != 0; } + + public static class Builder { + private String canonicalId; + private ItemStack itemStack; + private String sourcePlugin = "Unknown"; + private String displayName = ""; + private String pdcKey; + private String modelDataKey; + private String loreHash; + private String pluginNativeId; + private String category = "CUSTOM_ITEMS"; + private BigDecimal buyPrice = PRICE_UNSET; + private BigDecimal sellPrice = PRICE_UNSET; + private boolean enabled = true; + + public Builder canonicalId(String canonicalId) { this.canonicalId = canonicalId; return this; } + public Builder itemStack(ItemStack itemStack) { this.itemStack = itemStack != null ? itemStack.clone() : null; return this; } + public Builder sourcePlugin(String sourcePlugin) { this.sourcePlugin = sourcePlugin; return this; } + public Builder displayName(String displayName) { this.displayName = displayName; return this; } + public Builder pdcKey(String pdcKey) { this.pdcKey = pdcKey; return this; } + public Builder modelDataKey(String modelDataKey) { this.modelDataKey = modelDataKey; return this; } + public Builder loreHash(String loreHash) { this.loreHash = loreHash; return this; } + public Builder pluginNativeId(String pluginNativeId) { this.pluginNativeId = pluginNativeId; return this; } + public Builder category(String category) { this.category = category; return this; } + + /** + * Set the buy price. Use -1 (PRICE_UNSET) to indicate "not set / use default". + * Any other negative value is rejected. + */ + public Builder buyPrice(BigDecimal buyPrice) { + if (buyPrice != null && buyPrice.compareTo(PRICE_UNSET) != 0 && buyPrice.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("buyPrice must be >= 0 or PRICE_UNSET (-1), got: " + buyPrice); + } + this.buyPrice = buyPrice; + return this; + } + + /** + * Set the sell price. Use -1 (PRICE_UNSET) to indicate "not set / use default". + * Any other negative value is rejected. + */ + public Builder sellPrice(BigDecimal sellPrice) { + if (sellPrice != null && sellPrice.compareTo(PRICE_UNSET) != 0 && sellPrice.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("sellPrice must be >= 0 or PRICE_UNSET (-1), got: " + sellPrice); + } + this.sellPrice = sellPrice; + return this; + } + + public Builder enabled(boolean enabled) { this.enabled = enabled; return this; } + + public CustomMarketItem build() { + if (canonicalId == null || canonicalId.isEmpty()) { + throw new IllegalStateException("canonicalId is required"); + } + if (canonicalId.length() > 128) { + throw new IllegalStateException("canonicalId too long (max 128 chars): " + canonicalId.length()); + } + if (canonicalId.contains(" ") || canonicalId.contains("\t") || canonicalId.contains("\n")) { + throw new IllegalStateException("canonicalId must not contain whitespace: '" + canonicalId + "'"); + } + if (itemStack == null) { + throw new IllegalStateException("itemStack is required"); + } + // Default empty displayName to material name if not set + if (displayName == null || displayName.isEmpty()) { + this.displayName = itemStack.getType().name(); + } + return new CustomMarketItem(this); + } + } +} diff --git a/src/main/java/com/aureleconomy/scanner/DiscoveryMethod.java b/src/main/java/com/aureleconomy/scanner/DiscoveryMethod.java new file mode 100644 index 0000000..f6faf13 --- /dev/null +++ b/src/main/java/com/aureleconomy/scanner/DiscoveryMethod.java @@ -0,0 +1,31 @@ +package com.aureleconomy.scanner; + +/** + * Enum representing the different methods by which a custom item can be discovered. + * Each method has a human-readable display name for use in GUIs and commands. + */ +public enum DiscoveryMethod { + + PLUGIN_API_ITEMSADDER("ItemsAdder API"), + PLUGIN_API_ORAXEN("Oraxen API"), + PLUGIN_API_MMOITEMS("MMOItems API"), + PLUGIN_API_MYTHICMOBS("MythicMobs API"), + PLUGIN_API_EXECUTABLE_ITEMS("ExecutableItems API"), + PLUGIN_API_NEXO("Nexo API"), + PLUGIN_API_SX_ITEM("SX-Item API"), + PDC_SCAN("PDC Scan"), + CUSTOM_MODEL_DATA("CustomModelData"), + LORE_PATTERN("Lore Pattern"), + INVENTORY_SCAN("Inventory Scan"), + INTERACTION_DETECT("Player Interaction"); + + private final String displayName; + + DiscoveryMethod(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/src/main/java/com/aureleconomy/scanner/ItemDiscoveryListener.java b/src/main/java/com/aureleconomy/scanner/ItemDiscoveryListener.java new file mode 100644 index 0000000..c2779ce --- /dev/null +++ b/src/main/java/com/aureleconomy/scanner/ItemDiscoveryListener.java @@ -0,0 +1,123 @@ +package com.aureleconomy.scanner; + +import com.aureleconomy.AurelEconomy; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.event.inventory.CraftItemEvent; +import org.bukkit.inventory.ItemStack; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Runtime detection of custom items via player interactions. + * All events use MONITOR priority and ignore cancelled events + * to avoid interfering with gameplay. + * + * Respects the interaction-detect config flag and rate-limits + * scanning per-player to avoid main-thread lag from reflection. + */ +public class ItemDiscoveryListener implements Listener { + + private final UnifiedItemScanner scanner; + private final AurelEconomy plugin; + + // Rate-limit: max 1 scan per player per 2 seconds + private final ConcurrentHashMap lastScan = new ConcurrentHashMap<>(); + private static final long SCAN_COOLDOWN_MS = 2000; + + public ItemDiscoveryListener(AurelEconomy plugin, UnifiedItemScanner scanner) { + this.plugin = plugin; + this.scanner = scanner; + } + + private boolean isInteractionDetectEnabled() { + return plugin.getConfig().getBoolean("custom-items.discovery-methods.interaction-detect", true); + } + + private boolean shouldScan(org.bukkit.entity.Player player) { + if (!isInteractionDetectEnabled()) return false; + AtomicLong last = lastScan.computeIfAbsent(player.getUniqueId(), k -> new AtomicLong(0)); + long now = System.currentTimeMillis(); + long prev = last.get(); + if (now - prev < SCAN_COOLDOWN_MS) return false; + return last.compareAndSet(prev, now); + } + + private void tryScan(org.bukkit.entity.Player player, ItemStack item) { + if (item == null || item.getType().isAir()) return; + if (!shouldScan(player)) return; + scanner.scanSingleItem(item, DiscoveryMethod.INTERACTION_DETECT); + } + + /** + * Scan a single item from an inventory-open event, respecting the per-player + * rate limit. Only scans the first eligible item (not the entire inventory) + * to avoid main-thread lag from reflection-heavy scans on large inventories. + */ + private void tryScanFromInventory(org.bukkit.entity.Player player, ItemStack item) { + if (item == null || item.getType().isAir()) return; + if (!shouldScan(player)) return; + scanner.scanSingleItem(item, DiscoveryMethod.INTERACTION_DETECT); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerInteract(PlayerInteractEvent event) { + tryScan(event.getPlayer(), event.getItem()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInventoryClick(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof org.bukkit.entity.Player player)) return; + ItemStack current = event.getCurrentItem(); + if (current != null && !current.getType().isAir()) { + tryScan(player, current); + } + ItemStack cursor = event.getCursor(); + if (cursor != null && !cursor.getType().isAir()) { + tryScan(player, cursor); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onEntityPickupItem(EntityPickupItemEvent event) { + if (!(event.getEntity() instanceof org.bukkit.entity.Player player)) return; + ItemStack item = event.getItem().getItemStack(); + tryScan(player, item); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onCraftItem(CraftItemEvent event) { + if (!(event.getWhoClicked() instanceof org.bukkit.entity.Player player)) return; + ItemStack result = event.getRecipe().getResult(); + tryScan(player, result); + } + + /** + * When a player opens an inventory, scan the first eligible custom item found. + * Uses the same per-player rate limit as other events to prevent lag from + * reflection-heavy scans on large inventories (e.g. 54-slot chests). + * Only scans one item per cooldown window to keep main-thread impact minimal. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInventoryOpen(InventoryOpenEvent event) { + if (!(event.getPlayer() instanceof org.bukkit.entity.Player player)) return; + if (!isInteractionDetectEnabled()) return; + // Scan only the first eligible item — rate limit ensures we don't + // flood the scanner if the player rapidly opens inventories + for (ItemStack item : event.getInventory().getContents()) { + if (item != null && !item.getType().isAir()) { + tryScanFromInventory(player, item); + // After scanning one item, the rate limit is consumed for this player. + // Subsequent items in the same inventory will be scanned on the next + // open (after cooldown) or via click/pickup events. + break; + } + } + } +} diff --git a/src/main/java/com/aureleconomy/scanner/RegistrationResult.java b/src/main/java/com/aureleconomy/scanner/RegistrationResult.java new file mode 100644 index 0000000..b290933 --- /dev/null +++ b/src/main/java/com/aureleconomy/scanner/RegistrationResult.java @@ -0,0 +1,31 @@ +package com.aureleconomy.scanner; + +/** + * Result of a registration attempt in CustomItemRegistry. + * Indicates whether the item was newly registered or was a duplicate prevented. + */ +public class RegistrationResult { + + private final String canonicalId; + private final boolean isNew; + private final DiscoveryMethod method; + + private RegistrationResult(String canonicalId, boolean isNew, DiscoveryMethod method) { + this.canonicalId = canonicalId; + this.isNew = isNew; + this.method = method; + } + + public static RegistrationResult newlyRegistered(String id, DiscoveryMethod method) { + return new RegistrationResult(id, true, method); + } + + public static RegistrationResult alreadyExists(String id, DiscoveryMethod method) { + return new RegistrationResult(id, false, method); + } + + public String getCanonicalId() { return canonicalId; } + public boolean isNew() { return isNew; } + public boolean isDuplicate() { return !isNew; } + public DiscoveryMethod getMethod() { return method; } +} diff --git a/src/main/java/com/aureleconomy/scanner/UnifiedItemScanner.java b/src/main/java/com/aureleconomy/scanner/UnifiedItemScanner.java new file mode 100644 index 0000000..32689f4 --- /dev/null +++ b/src/main/java/com/aureleconomy/scanner/UnifiedItemScanner.java @@ -0,0 +1,734 @@ +package com.aureleconomy.scanner; + +import com.aureleconomy.AurelEconomy; +import com.aureleconomy.market.MarketItems.Category; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.NamespacedKey; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; + +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.util.*; +import java.util.logging.Level; + +/** + * Unified item scanner implementing all 6 detection methods. + * Uses reflection for all soft-depend plugin APIs to avoid compile-time dependencies. + */ +public class UnifiedItemScanner { + + private final AurelEconomy plugin; + private final CustomItemRegistry registry; + + // Config flags + private final boolean methodPluginApi; + private final boolean methodPdcScan; + private final boolean methodCustomModelData; + private final boolean methodLorePattern; + private final boolean methodInventoryScan; + private final boolean methodInteractionDetect; + private final Set excludedNamespaces; + private final double defaultPriceMultiplier; + + public UnifiedItemScanner(AurelEconomy plugin, CustomItemRegistry registry) { + this.plugin = plugin; + this.registry = registry; + + this.methodPluginApi = plugin.getConfig().getBoolean("custom-items.discovery-methods.plugin-api", true); + this.methodPdcScan = plugin.getConfig().getBoolean("custom-items.discovery-methods.pdc-scan", true); + this.methodCustomModelData = plugin.getConfig().getBoolean("custom-items.discovery-methods.custom-model-data", true); + this.methodLorePattern = plugin.getConfig().getBoolean("custom-items.discovery-methods.lore-pattern", true); + this.methodInventoryScan = plugin.getConfig().getBoolean("custom-items.discovery-methods.inventory-scan", true); + this.methodInteractionDetect = plugin.getConfig().getBoolean("custom-items.discovery-methods.interaction-detect", true); + this.excludedNamespaces = new HashSet<>(plugin.getConfig().getStringList("custom-items.excluded-namespaces")); + if (excludedNamespaces.isEmpty()) { + excludedNamespaces.add("minecraft"); + excludedNamespaces.add("aureleconomy"); + } + this.defaultPriceMultiplier = plugin.getConfig().getDouble("custom-items.default-price-multiplier", 1.5); + } + + /** + * Validate that a reflected object is actually a safe Bukkit ItemStack. + * Prevents RCE from malicious plugins returning gadget chains via reflection. + */ + private static boolean isValidItemStack(Object obj) { + if (obj == null) return false; + if (!(obj instanceof org.bukkit.inventory.ItemStack)) return false; + try { + org.bukkit.inventory.ItemStack stack = (org.bukkit.inventory.ItemStack) obj; + stack.getType(); + return true; + } catch (ClassCastException | NullPointerException e) { + return false; + } + } + + // ===== METHOD 1: Plugin-Specific API Scanning ===== + + public void scanAllPluginAPIs() { + if (!methodPluginApi) { + plugin.getLogger().info("[CustomItems] Plugin API scanning disabled in config"); + return; + } + plugin.getLogger().info("[CustomItems] Starting plugin API scan..."); + scanItemsAdder(); + scanOraxen(); + scanMMOItems(); + scanMythicMobs(); + scanExecutableItems(); + scanNexo(); + scanSXItem(); + plugin.getLogger().info("[CustomItems] Plugin API scan finished. Registry: " + registry.getTotalItems() + " items"); + } + + private void scanItemsAdder() { + if (Bukkit.getPluginManager().getPlugin("ItemsAdder") == null) { plugin.getLogger().fine("[CustomItems] ItemsAdder not found, skipping"); return; } + try { + Class customStackClass = Class.forName("dev.lone.itemsadder.api.CustomStack"); + Method getItemsMethod = customStackClass.getMethod("getItems"); + Method getItemStackMethod = customStackClass.getMethod("getItemStack"); + Method getNamespacedIDMethod = customStackClass.getMethod("getNamespacedID"); + + @SuppressWarnings("unchecked") + Map items = (Map) getItemsMethod.invoke(null); + if (items == null) return; + + for (Map.Entry entry : items.entrySet()) { + try { + Object customStack = entry.getValue(); + Object rawStack = getItemStackMethod.invoke(customStack); + if (!isValidItemStack(rawStack)) { plugin.getLogger().fine("[Scanner] ItemsAdder returned invalid ItemStack, skipping"); continue; } + ItemStack itemStack = (ItemStack) rawStack; + String nativeId = (String) getNamespacedIDMethod.invoke(customStack); + if (itemStack == null || nativeId == null) continue; + + CustomMarketItem item = buildCustomItem(itemStack, nativeId, "ItemsAdder", + DiscoveryMethod.PLUGIN_API_ITEMSADDER); + registry.register(item, DiscoveryMethod.PLUGIN_API_ITEMSADDER); + } catch (Exception e) { + plugin.getLogger().log(Level.FINE, "[Scanner] ItemsAdder item scan error", e); + } + } + } catch (ClassNotFoundException | NoClassDefFoundError e) { + // Plugin not present or API changed, skip + } catch (Exception e) { + plugin.getComponentLogger().warn("[Scanner] ItemsAdder scan failed: " + e.getMessage()); + } + } + + private void scanOraxen() { + if (Bukkit.getPluginManager().getPlugin("Oraxen") == null) { plugin.getLogger().fine("[CustomItems] Oraxen not found, skipping"); return; } + try { + Class oraxenItemsClass = Class.forName("io.th0rgal.oraxen.api.OraxenItems"); + Method getItemsMethod = oraxenItemsClass.getMethod("getItems"); + Method getItemByIdMethod = oraxenItemsClass.getMethod("getItemById", String.class); + Method buildMethod = Class.forName("io.th0rgal.oraxen.items.model.ItemBuilder").getMethod("build"); + + @SuppressWarnings("unchecked") + Set ids = (Set) getItemsMethod.invoke(null); + if (ids == null) return; + + for (String id : ids) { + try { + Object itemBuilder = getItemByIdMethod.invoke(null, id); + if (itemBuilder == null) continue; + Object rawStack = buildMethod.invoke(itemBuilder); + if (!isValidItemStack(rawStack)) { plugin.getLogger().fine("[Scanner] Oraxen returned invalid ItemStack, skipping"); continue; } + ItemStack itemStack = (ItemStack) rawStack; + if (itemStack == null) continue; + + CustomMarketItem item = buildCustomItem(itemStack, "oraxen:" + id, "Oraxen", + DiscoveryMethod.PLUGIN_API_ORAXEN); + registry.register(item, DiscoveryMethod.PLUGIN_API_ORAXEN); + } catch (Exception e) { + plugin.getLogger().log(Level.FINE, "[Scanner] Oraxen item scan error", e); + } + } + } catch (ClassNotFoundException | NoClassDefFoundError e) { + // Skip + } catch (Exception e) { + plugin.getComponentLogger().warn("[Scanner] Oraxen scan failed: " + e.getMessage()); + } + } + + private void scanMMOItems() { + if (Bukkit.getPluginManager().getPlugin("MMOItems") == null) { plugin.getLogger().fine("[CustomItems] MMOItems not found, skipping"); return; } + try { + Class mmoItemsPlugin = Class.forName("net.Indyuce.mmoitems.MMOItems"); + Method getPluginMethod = mmoItemsPlugin.getMethod("getPlugin"); + Object mmoPlugin = getPluginMethod.invoke(null); + + Class itemManagerClass = Class.forName("net.Indyuce.mmoitems.manager.ItemManager"); + Class typeClass = Class.forName("net.Indyuce.mmoitems.api.Type"); + Method getTypesMethod = itemManagerClass.getMethod("getAll"); + + Method getItemManagerMethod = mmoItemsPlugin.getMethod("getItems"); + Object itemManager = getItemManagerMethod.invoke(mmoPlugin); + + @SuppressWarnings("unchecked") + Collection types = (Collection) getTypesMethod.invoke(itemManager); + if (types == null) return; + + Method getIdMethod = typeClass.getMethod("getId"); + Method getAllItemsMethod = itemManagerClass.getMethod("getAll", typeClass); + + for (Object type : types) { + try { + String typeId = (String) getIdMethod.invoke(type); + @SuppressWarnings("unchecked") + Map items = (Map) getAllItemsMethod.invoke(itemManager, type); + if (items == null) continue; + + for (Map.Entry entry : items.entrySet()) { + try { + Object mmoItem = entry.getValue(); + Method newItemStackMethod = mmoItem.getClass().getMethod("newItemStack"); + Object rawStack = newItemStackMethod.invoke(mmoItem); + if (!isValidItemStack(rawStack)) { plugin.getLogger().fine("[Scanner] MMOItems returned invalid ItemStack, skipping"); continue; } + ItemStack itemStack = (ItemStack) rawStack; + if (itemStack == null) continue; + + String nativeId = typeId.toLowerCase() + ":" + entry.getKey().toLowerCase(); + CustomMarketItem item = buildCustomItem(itemStack, nativeId, "MMOItems", + DiscoveryMethod.PLUGIN_API_MMOITEMS); + registry.register(item, DiscoveryMethod.PLUGIN_API_MMOITEMS); + } catch (Exception e) { plugin.getLogger().fine("[Scanner] MMOItems item scan skipped: " + e.getClass().getSimpleName() + " - " + e.getMessage()); } + } + } catch (Exception e) { plugin.getLogger().fine("[Scanner] MMOItems type scan skipped: " + e.getClass().getSimpleName() + " - " + e.getMessage()); } + } + } catch (ClassNotFoundException | NoClassDefFoundError e) { + // Skip + } catch (Exception e) { + plugin.getComponentLogger().warn("[Scanner] MMOItems scan failed: " + e.getMessage()); + } + } + + private void scanMythicMobs() { + if (Bukkit.getPluginManager().getPlugin("MythicMobs") == null) { plugin.getLogger().fine("[CustomItems] MythicMobs not found, skipping"); return; } + try { + Class mythicBukkitClass = Class.forName("io.lumine.mythic.bukkit.BukkitAdapter"); + Class mythicPluginClass = Class.forName("io.lumine.mythic.bukkit.MythicBukkit"); + Method instMethod = mythicPluginClass.getMethod("inst"); + Object inst = instMethod.invoke(null); + + Method getItemManagerMethod = mythicPluginClass.getMethod("getItemManager"); + Object itemManager = getItemManagerMethod.invoke(inst); + + Method getItemNamesMethod = itemManager.getClass().getMethod("getItemNames"); + Method getItemStackMethod = itemManager.getClass().getMethod("getItemStack", String.class); + + @SuppressWarnings("unchecked") + Collection names = (Collection) getItemNamesMethod.invoke(itemManager); + if (names == null) return; + + for (String name : names) { + try { + Object optItem = getItemStackMethod.invoke(itemManager, name); + if (optItem == null) continue; + Method orElseMethod = optItem.getClass().getMethod("orElse", Object.class); + Object rawStack = orElseMethod.invoke(optItem, (Object) null); + if (!isValidItemStack(rawStack)) { plugin.getLogger().fine("[Scanner] MythicMobs returned invalid ItemStack, skipping"); continue; } + ItemStack itemStack = (ItemStack) rawStack; + if (itemStack == null) continue; + + CustomMarketItem item = buildCustomItem(itemStack, "mythicmobs:" + name, "MythicMobs", + DiscoveryMethod.PLUGIN_API_MYTHICMOBS); + registry.register(item, DiscoveryMethod.PLUGIN_API_MYTHICMOBS); + } catch (Exception e) { plugin.getLogger().fine("[Scanner] MythicMobs item scan skipped: " + e.getClass().getSimpleName() + " - " + e.getMessage()); } + } + } catch (ClassNotFoundException | NoClassDefFoundError e) { + // Skip + } catch (Exception e) { + plugin.getComponentLogger().warn("[Scanner] MythicMobs scan failed: " + e.getMessage()); + } + } + + private void scanExecutableItems() { + if (Bukkit.getPluginManager().getPlugin("ExecutableItems") == null) { plugin.getLogger().fine("[CustomItems] ExecutableItems not found, skipping"); return; } + try { + Class apiClass = Class.forName("com.ssomar.score.api.executableitems.ExecutableItemsAPI"); + Method getInstanceMethod = apiClass.getMethod("getInstance"); + Object apiInstance = getInstanceMethod.invoke(null); + Method getManagerMethod = apiClass.getMethod("getExecutableItemsManager"); + Object manager = getManagerMethod.invoke(apiInstance); + + Method getAllMethod = manager.getClass().getMethod("getAllExecutableItems"); + @SuppressWarnings("unchecked") + Collection items = (Collection) getAllMethod.invoke(manager); + if (items == null) return; + + for (Object eiItemObj : items) { + try { + Method getIdMethod = eiItemObj.getClass().getMethod("getId"); + Method buildItemMethod = eiItemObj.getClass().getMethod("buildItem"); + String id = (String) getIdMethod.invoke(eiItemObj); + Object rawStack = buildItemMethod.invoke(eiItemObj); + if (!isValidItemStack(rawStack)) { plugin.getLogger().fine("[Scanner] ExecutableItems returned invalid ItemStack, skipping"); continue; } + ItemStack itemStack = (ItemStack) rawStack; + if (itemStack == null || id == null) continue; + + CustomMarketItem item = buildCustomItem(itemStack, "executableitems:" + id, "ExecutableItems", DiscoveryMethod.PLUGIN_API_EXECUTABLE_ITEMS); + registry.register(item, DiscoveryMethod.PLUGIN_API_EXECUTABLE_ITEMS); + } catch (Exception e) { + plugin.getLogger().log(Level.FINE, "[Scanner] ExecutableItems item scan error", e); + } + } + plugin.getComponentLogger().info("[Scanner] ExecutableItems scan complete: " + items.size() + " items found"); + } catch (ClassNotFoundException | NoClassDefFoundError e) { + scanExecutableItemsLegacy(); + } catch (Exception e) { + plugin.getComponentLogger().warn("[Scanner] ExecutableItems scan failed: " + e.getMessage()); + } + } + + private void scanExecutableItemsLegacy() { + try { + Class eiPluginClass = Class.forName("com.ssomar.executableitems.ExecutableItems"); + Method getPluginMethod = eiPluginClass.getMethod("getPlugin"); + Object eiPlugin = getPluginMethod.invoke(null); + Method getItemManagerMethod = eiPluginClass.getMethod("getItemManager"); + Object itemManager = getItemManagerMethod.invoke(eiPlugin); + Method getAllItemsMethod = itemManager.getClass().getMethod("getAllItems"); + @SuppressWarnings("unchecked") + Collection items = (Collection) getAllItemsMethod.invoke(itemManager); + if (items == null) return; + for (Object eiItem : items) { + try { + Method getIdMethod = eiItem.getClass().getMethod("getId"); + Method buildItemMethod = eiItem.getClass().getMethod("buildItem", int.class); + String id = (String) getIdMethod.invoke(eiItem); + Object rawStack = buildItemMethod.invoke(eiItem, 1); + if (!isValidItemStack(rawStack)) { plugin.getLogger().fine("[Scanner] ExecutableItems (legacy) returned invalid ItemStack, skipping"); continue; } + ItemStack itemStack = (ItemStack) rawStack; + if (itemStack == null || id == null) continue; + CustomMarketItem item = buildCustomItem(itemStack, "executableitems:" + id, "ExecutableItems", DiscoveryMethod.PLUGIN_API_EXECUTABLE_ITEMS); + registry.register(item, DiscoveryMethod.PLUGIN_API_EXECUTABLE_ITEMS); + } catch (Exception e) { plugin.getLogger().fine("[Scanner] ExecutableItems legacy item scan skipped: " + e.getClass().getSimpleName() + " - " + e.getMessage()); } + } + plugin.getComponentLogger().info("[Scanner] ExecutableItems (legacy) scan complete"); + } catch (ClassNotFoundException | NoClassDefFoundError e) { + // Plugin not present, skip silently + } catch (Exception e) { + plugin.getComponentLogger().warn("[Scanner] ExecutableItems legacy scan failed: " + e.getMessage()); + } + } + + private void scanNexo() { + if (Bukkit.getPluginManager().getPlugin("Nexo") == null) { plugin.getLogger().fine("[CustomItems] Nexo not found, skipping"); return; } + try { + Class nexoItemsClass = Class.forName("com.nexomc.nexo.items.NexoItems"); + Method valuesMethod = nexoItemsClass.getMethod("values"); + Method getItemStackMethod = nexoItemsClass.getMethod("getItemStack"); + + @SuppressWarnings("unchecked") + Collection items = (Collection) valuesMethod.invoke(null); + if (items == null) return; + + Method getIdMethod = nexoItemsClass.getMethod("getId"); + for (Object nexoItem : items) { + try { + Object rawStack = getItemStackMethod.invoke(nexoItem); + if (!isValidItemStack(rawStack)) { plugin.getLogger().fine("[Scanner] Nexo returned invalid ItemStack, skipping"); continue; } + ItemStack itemStack = (ItemStack) rawStack; + String id = (String) getIdMethod.invoke(nexoItem); + if (itemStack == null || id == null) continue; + + CustomMarketItem item = buildCustomItem(itemStack, "nexo:" + id, "Nexo", + DiscoveryMethod.PLUGIN_API_NEXO); + registry.register(item, DiscoveryMethod.PLUGIN_API_NEXO); + } catch (Exception e) { plugin.getLogger().fine("[Scanner] Nexo item scan skipped: " + e.getClass().getSimpleName() + " - " + e.getMessage()); } + } + } catch (ClassNotFoundException | NoClassDefFoundError e) { + // Skip + } catch (Exception e) { + plugin.getComponentLogger().warn("[Scanner] Nexo scan failed: " + e.getMessage()); + } + } + + private void scanSXItem() { + if (Bukkit.getPluginManager().getPlugin("SX-Item") == null) { plugin.getLogger().fine("[CustomItems] SX-Item not found, skipping"); return; } + try { + Class sxPluginClass = Class.forName("com.sucy.sxitem.SXItem"); + Method getPluginMethod = sxPluginClass.getMethod("getPlugin"); + Object sxPlugin = getPluginMethod.invoke(null); + + Method getItemManagerMethod = sxPluginClass.getMethod("getItemManager"); + Object itemManager = getItemManagerMethod.invoke(sxPlugin); + + Method getAllItemsMethod = itemManager.getClass().getMethod("getAllItems"); + @SuppressWarnings("unchecked") + Map items = (Map) getAllItemsMethod.invoke(itemManager); + if (items == null) return; + + for (Map.Entry entry : items.entrySet()) { + try { + Method createItemMethod = entry.getValue().getClass().getMethod("createItem"); + Object rawStack = createItemMethod.invoke(entry.getValue()); + if (!isValidItemStack(rawStack)) { plugin.getLogger().fine("[Scanner] SX-Item returned invalid ItemStack, skipping"); continue; } + ItemStack itemStack = (ItemStack) rawStack; + if (itemStack == null) continue; + + CustomMarketItem item = buildCustomItem(itemStack, "sxitem:" + entry.getKey(), "SX-Item", + DiscoveryMethod.PLUGIN_API_SX_ITEM); + registry.register(item, DiscoveryMethod.PLUGIN_API_SX_ITEM); + } catch (Exception e) { plugin.getLogger().fine("[Scanner] SX-Item item scan skipped: " + e.getClass().getSimpleName() + " - " + e.getMessage()); } + } + } catch (ClassNotFoundException | NoClassDefFoundError e) { + // Skip + } catch (Exception e) { + plugin.getComponentLogger().warn("[Scanner] SX-Item scan failed: " + e.getMessage()); + } + } + + // ===== METHOD 2: PersistentDataContainer Scan ===== + + public void scanViaPDC(ItemStack item) { + if (!methodPdcScan) return; + if (item == null || item.getType().isAir() || !item.hasItemMeta()) return; + + PersistentDataContainer pdc = item.getItemMeta().getPersistentDataContainer(); + if (pdc.isEmpty()) return; + + for (NamespacedKey key : pdc.getKeys()) { + String ns = key.getNamespace(); + if (excludedNamespaces.contains(ns)) continue; + + String pdcKey = ns + ":" + key.getKey(); + String canonicalId = ns + ":" + item.getType().name().toLowerCase() + "_" + key.getKey(); + + String displayName = resolveDisplayName(item); + BigDecimal basePrice = estimatePrice(item); + BigDecimal buyPrice = basePrice.multiply(BigDecimal.valueOf(defaultPriceMultiplier)); + + CustomMarketItem customItem = new CustomMarketItem.Builder() + .canonicalId(canonicalId) + .itemStack(item) + .sourcePlugin(detectPluginFromNamespace(ns)) + .displayName(displayName) + .pdcKey(pdcKey) + .modelDataKey(extractModelDataKey(item)) + .loreHash(extractLoreHash(item)) + .category(autoAssignCategory(item).name()) + .buyPrice(buyPrice) + .sellPrice(basePrice) + .build(); + + registry.register(customItem, DiscoveryMethod.PDC_SCAN); + } + } + + // ===== METHOD 3: CustomModelData Scan ===== + + public void scanViaModelData(ItemStack item) { + if (!methodCustomModelData) return; + if (item == null || item.getType().isAir() || !item.hasItemMeta()) return; + + ItemMeta meta = item.getItemMeta(); + if (!meta.hasCustomModelData() || meta.getCustomModelData() <= 0) return; + + String modelDataKey = item.getType().name() + ":" + meta.getCustomModelData(); + String canonicalId = "modeldata:" + modelDataKey.toLowerCase(); + + String displayName = resolveDisplayName(item); + BigDecimal basePrice = estimatePrice(item); + BigDecimal buyPrice = basePrice.multiply(BigDecimal.valueOf(defaultPriceMultiplier)); + + CustomMarketItem customItem = new CustomMarketItem.Builder() + .canonicalId(canonicalId) + .itemStack(item) + .sourcePlugin("CustomModelData") + .displayName(displayName) + .pdcKey(extractPdcKey(item)) + .modelDataKey(modelDataKey) + .loreHash(extractLoreHash(item)) + .category(autoAssignCategory(item).name()) + .buyPrice(buyPrice) + .sellPrice(basePrice) + .build(); + + registry.register(customItem, DiscoveryMethod.CUSTOM_MODEL_DATA); + } + + // ===== METHOD 4: Lore Pattern Scan ===== + + public void scanViaLore(ItemStack item) { + if (!methodLorePattern) return; + if (item == null || item.getType().isAir() || !item.hasItemMeta()) return; + + ItemMeta meta = item.getItemMeta(); + if (!meta.hasLore() || meta.lore() == null || meta.lore().isEmpty()) return; + + List lore = meta.lore(); + boolean isCustom = false; + + for (net.kyori.adventure.text.Component line : lore) { + String plain = net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText() + .serialize(line); + if (plain.contains("\u00a7x")) { isCustom = true; break; } + if (plain.contains("CustomItem:") || plain.contains("ItemsAdder") || plain.contains("Oraxen") + || plain.contains("MMOItems") || plain.contains("ExecutableItems")) { + isCustom = true; + break; + } + } + + if (!isCustom) { + PersistentDataContainer pdc = meta.getPersistentDataContainer(); + for (NamespacedKey key : pdc.getKeys()) { + if (!excludedNamespaces.contains(key.getNamespace())) { + isCustom = true; + break; + } + } + } + + if (!isCustom) return; + + String loreHash = String.valueOf(lore.hashCode()); + String canonicalId = "lore:" + item.getType().name().toLowerCase() + "_" + loreHash; + + String displayName = resolveDisplayName(item); + BigDecimal basePrice = estimatePrice(item); + BigDecimal buyPrice = basePrice.multiply(BigDecimal.valueOf(defaultPriceMultiplier)); + + CustomMarketItem customItem = new CustomMarketItem.Builder() + .canonicalId(canonicalId) + .itemStack(item) + .sourcePlugin("LorePattern") + .displayName(displayName) + .pdcKey(extractPdcKey(item)) + .modelDataKey(extractModelDataKey(item)) + .loreHash(loreHash) + .category(autoAssignCategory(item).name()) + .buyPrice(buyPrice) + .sellPrice(basePrice) + .build(); + + registry.register(customItem, DiscoveryMethod.LORE_PATTERN); + } + + // ===== METHOD 5: Player Inventory Scan ===== + + public void scanPlayerInventories() { + if (!methodInventoryScan) return; + + for (Player player : Bukkit.getOnlinePlayers()) { + for (ItemStack item : player.getInventory().getContents()) { + scanSingleItem(item, DiscoveryMethod.INVENTORY_SCAN); + } + for (ItemStack item : player.getEnderChest().getContents()) { + scanSingleItem(item, DiscoveryMethod.INVENTORY_SCAN); + } + } + } + + // ===== METHOD 6: Scan single item ===== + + public void scanSingleItem(ItemStack item, DiscoveryMethod method) { + if (item == null || item.getType().isAir()) return; + + scanViaPDC(item); + scanViaModelData(item); + scanViaLore(item); + + if (methodPluginApi) { + scanItemAgainstPluginAPIs(item); + } + } + + private void scanItemAgainstPluginAPIs(ItemStack item) { + // ItemsAdder + try { + Class customStackClass = Class.forName("dev.lone.itemsadder.api.CustomStack"); + Method byItemStack = customStackClass.getMethod("byItemStack", ItemStack.class); + Object result = byItemStack.invoke(null, item); + if (result != null) { + Method getId = customStackClass.getMethod("getNamespacedID"); + String id = (String) getId.invoke(result); + if (id != null) { + CustomMarketItem customItem = buildCustomItem(item, id, "ItemsAdder", + DiscoveryMethod.PLUGIN_API_ITEMSADDER); + registry.register(customItem, DiscoveryMethod.PLUGIN_API_ITEMSADDER); + } + } + } catch (ClassNotFoundException | NoClassDefFoundError ignored) { + } catch (Exception e) { plugin.getLogger().fine("[Scanner] ItemsAdder byItemStack scan skipped: " + e.getClass().getSimpleName()); } + + // Oraxen + try { + Class oraxenItemsClass = Class.forName("io.th0rgal.oraxen.api.OraxenItems"); + Method getIdByItem = oraxenItemsClass.getMethod("getIdByItem", ItemStack.class); + String id = (String) getIdByItem.invoke(null, item); + if (id != null && !id.isEmpty()) { + CustomMarketItem customItem = buildCustomItem(item, "oraxen:" + id, "Oraxen", + DiscoveryMethod.PLUGIN_API_ORAXEN); + registry.register(customItem, DiscoveryMethod.PLUGIN_API_ORAXEN); + } + } catch (ClassNotFoundException | NoClassDefFoundError ignored) { + } catch (Exception e) { plugin.getLogger().fine("[Scanner] Oraxen byItem scan skipped: " + e.getClass().getSimpleName()); } + + // Nexo + try { + Class nexoItemsClass = Class.forName("com.nexomc.nexo.items.NexoItems"); + Method getByItem = nexoItemsClass.getMethod("getByItem", ItemStack.class); + Object result = getByItem.invoke(null, item); + if (result != null) { + Method getId = result.getClass().getMethod("getId"); + String id = (String) getId.invoke(result); + if (id != null) { + CustomMarketItem customItem = buildCustomItem(item, "nexo:" + id, "Nexo", + DiscoveryMethod.PLUGIN_API_NEXO); + registry.register(customItem, DiscoveryMethod.PLUGIN_API_NEXO); + } + } + } catch (ClassNotFoundException | NoClassDefFoundError ignored) { + } catch (Exception e) { plugin.getLogger().fine("[Scanner] Nexo byItem scan skipped: " + e.getClass().getSimpleName()); } + } + + // ===== Utility Methods ===== + + private CustomMarketItem buildCustomItem(ItemStack item, String nativeId, String sourcePlugin, + DiscoveryMethod method) { + String displayName = resolveDisplayName(item); + BigDecimal basePrice = estimatePrice(item); + BigDecimal buyPrice = basePrice.multiply(BigDecimal.valueOf(defaultPriceMultiplier)); + + return new CustomMarketItem.Builder() + .canonicalId(nativeId) + .itemStack(item) + .sourcePlugin(sourcePlugin) + .displayName(displayName) + .pdcKey(extractPdcKey(item)) + .modelDataKey(extractModelDataKey(item)) + .loreHash(extractLoreHash(item)) + .pluginNativeId(nativeId) + .category(autoAssignCategory(item).name()) + .buyPrice(buyPrice) + .sellPrice(basePrice) + .build(); + } + + public String extractPdcKey(ItemStack item) { + if (item == null || !item.hasItemMeta()) return null; + PersistentDataContainer pdc = item.getItemMeta().getPersistentDataContainer(); + for (NamespacedKey key : pdc.getKeys()) { + if (!excludedNamespaces.contains(key.getNamespace())) { + return key.getNamespace() + ":" + key.getKey(); + } + } + return null; + } + + public String extractModelDataKey(ItemStack item) { + if (item == null || !item.hasItemMeta()) return null; + ItemMeta meta = item.getItemMeta(); + if (meta.hasCustomModelData() && meta.getCustomModelData() > 0) { + return item.getType().name() + ":" + meta.getCustomModelData(); + } + return null; + } + + public String extractLoreHash(ItemStack item) { + if (item == null || !item.hasItemMeta()) return null; + ItemMeta meta = item.getItemMeta(); + if (meta.hasLore() && meta.lore() != null && !meta.lore().isEmpty()) { + return String.valueOf(meta.lore().hashCode()); + } + return null; + } + + public static String detectPluginFromNamespace(String namespace) { + return switch (namespace) { + case "itemsadder" -> "ItemsAdder"; + case "oraxen" -> "Oraxen"; + case "mmoitems" -> "MMOItems"; + case "mythicmobs" -> "MythicMobs"; + case "executableitems" -> "ExecutableItems"; + case "nexo" -> "Nexo"; + case "sxitem" -> "SX-Item"; + default -> namespace; + }; + } + + public Category autoAssignCategory(ItemStack item) { + if (item == null) return Category.CUSTOM_ITEMS; + Material mat = item.getType(); + + if (mat.name().contains("SWORD") || mat.name().contains("AXE") || mat.name().contains("PICKAXE") + || mat.name().contains("SHOVEL") || mat.name().contains("HOE") || mat.name().contains("BOW") + || mat.name().contains("TRIDENT") || mat.name().contains("MACE") || mat.name().contains("CROSSBOW")) + return Category.TOOLS_WEAPONS; + if (mat.isEdible() || mat.name().contains("SEED") || mat.name().contains("CROP") + || mat.name().contains("WHEAT") || mat.name().contains("CARROT") || mat.name().contains("POTATO")) + return Category.FOOD_FARMING; + if (mat.name().contains("DIAMOND") || mat.name().contains("EMERALD") || mat.name().contains("GOLD") + || mat.name().contains("IRON") || mat.name().contains("LAPIS") || mat.name().contains("REDSTONE") + || mat.name().contains("QUARTZ") || mat.name().contains("AMETHYST") || mat.name().contains("NETHERITE")) + return Category.MINERALS_ORES; + if (mat.name().contains("SPAWN") || mat.name().contains("EGG")) + return Category.SPAWNERS; + if (mat.name().contains("LOG") || mat.name().contains("PLANK") || mat.name().contains("WOOD") + || mat.name().contains("STICK") || mat.name().contains("BAMBOO")) + return Category.WOOD; + if (mat.name().contains("COPPER") || mat.name().contains("RAW")) + return Category.COPPER; + if (mat.name().contains("REDSTONE") || mat.name().contains("REPEATER") + || mat.name().contains("COMPARATOR") || mat.name().contains("PISTON")) + return Category.REDSTONE; + if (mat.name().contains("WOOL") || mat.name().contains("CONCRETE") || mat.name().contains("TERRACOTTA") + || mat.name().contains("GLAZED") || mat.name().contains("STAINED")) + return Category.COLORS; + if (mat.name().contains("STONE") || mat.name().contains("BRICK") || mat.name().contains("COBBLESTONE") + || mat.name().contains("SANDSTONE") || mat.name().contains("DEEPSLATE")) + return Category.BUILDING; + if (mat.name().contains("BANNER") || mat.name().contains("PAINTING") || mat.name().contains("ITEM_FRAME") + || mat.name().contains("FLOWER") || mat.name().contains("POT")) + return Category.DECORATION; + + return Category.CUSTOM_ITEMS; + } + + /** + * Resolve display name from Component displayName -> plain text, or fallback to formatted material name. + * Includes Adventure Component hashCode to distinguish items with same plain text + * but different color codes, preventing false dedup collisions. + */ + private String resolveDisplayName(ItemStack item) { + if (item.hasItemMeta()) { + ItemMeta meta = item.getItemMeta(); + if (meta.hasDisplayName() || meta.displayName() != null) { + net.kyori.adventure.text.Component display = meta.displayName(); + if (display != null) { + String plain = net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText() + .serialize(display); + return plain + "|" + Integer.toHexString(display.hashCode()); + } + } + } + return formatMaterialName(item.getType()); + } + + private String formatMaterialName(Material mat) { + String name = mat.name().replace("_", " ").toLowerCase(); + StringBuilder sb = new StringBuilder(); + for (String word : name.split(" ")) { + if (!word.isEmpty()) sb.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)).append(" "); + } + return sb.toString().trim(); + } + + private BigDecimal estimatePrice(ItemStack item) { + Material mat = item.getType(); + if (mat.name().contains("NETHERITE")) return BigDecimal.valueOf(500); + if (mat.name().contains("DIAMOND")) return BigDecimal.valueOf(300); + if (mat.name().contains("EMERALD")) return BigDecimal.valueOf(200); + if (mat.name().contains("GOLD") || mat.name().contains("IRON")) return BigDecimal.valueOf(100); + return BigDecimal.valueOf(50); + } +} diff --git a/src/main/java/com/aureleconomy/web/ApiHandler.java b/src/main/java/com/aureleconomy/web/ApiHandler.java index 9b589e7..61d38ff 100644 --- a/src/main/java/com/aureleconomy/web/ApiHandler.java +++ b/src/main/java/com/aureleconomy/web/ApiHandler.java @@ -2,6 +2,8 @@ import com.aureleconomy.AurelEconomy; import com.aureleconomy.market.MarketItems; +import com.aureleconomy.scanner.CustomItemRegistry; +import com.aureleconomy.scanner.CustomMarketItem; import com.aureleconomy.market.MarketItems.Category; import com.aureleconomy.market.MarketItems.MarketEntry; import com.sun.net.httpserver.HttpExchange; @@ -40,7 +42,7 @@ public void handle(HttpExchange exchange) throws IOException { // CORS Security: Whitelist-based validation String origin = exchange.getRequestHeaders().getFirst("Origin"); List allowedOrigins = plugin.getConfig().getStringList("web.local.cors-allowed-origins"); - + if (origin != null && !allowedOrigins.isEmpty()) { if (allowedOrigins.contains(origin)) { exchange.getResponseHeaders().set("Access-Control-Allow-Origin", origin); @@ -88,15 +90,12 @@ private void handlePlayer(HttpExchange exchange, UUID uuid) throws IOException { OfflinePlayer player = Bukkit.getOfflinePlayer(uuid); String name = player.getName() != null ? player.getName() : uuid.toString(); - // Build balances JSON for all configured currencies String defaultCurrency = plugin.getEconomyManager().getDefaultCurrency(); Map currencies = new LinkedHashMap<>(); - // Default currency balance BigDecimal defaultBal = plugin.getEconomyManager().getBalance(player, defaultCurrency); currencies.put(defaultCurrency, defaultBal); - // Additional currencies from config if (plugin.getConfig().isConfigurationSection("economy.currencies")) { for (String currencyName : plugin.getConfig().getConfigurationSection("economy.currencies") .getKeys(false)) { @@ -213,6 +212,11 @@ private void handleBuy(HttpExchange exchange, UUID playerUuid, Map 64) amount = 1; @@ -227,10 +231,9 @@ private void handleBuy(HttpExchange exchange, UUID playerUuid, Map 0) json.append(","); + String name = item.getDisplayName() != null ? item.getDisplayName() : item.getCanonicalId(); + String material = item.getItemStack().getType().name().toLowerCase(); + BigDecimal buy = item.getBuyPrice(); + BigDecimal sell = item.getSellPrice(); + String currency = plugin.getEconomyManager().getDefaultCurrency(); + String symbol = plugin.getEconomyManager().getCurrencySymbol(currency); + + json.append("{\"id\":").append(jsonStr(item.getCanonicalId())); + json.append(",\"name\":").append(jsonStr(name)); + json.append(",\"material\":").append(jsonStr(material)); + json.append(",\"buyPrice\":").append(buy.doubleValue()); + json.append(",\"sellPrice\":").append(sell.doubleValue()); + json.append(",\"currency\":").append(jsonStr(currency)); + json.append(",\"currencySymbol\":").append(jsonStr(symbol)); + json.append("}"); + } + json.append("]"); + sendJson(exchange, 200, json.toString()); + } + // ── Helpers ─────────────────────────────────────────────────────── private String buildItemsJson(List items, int page, int totalPages, int totalItems) { @@ -371,7 +405,25 @@ private static String jsonStr(String s) { return "\"" + escapeJson(s) + "\""; } + /** + * Escape JSON string values. Covers all required control characters. + */ private static String escapeJson(String s) { - return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + .replace("\b", "\\b") + .replace("\f", "\\f"); + } + + /** + * Validate itemKey format: must be namespace:key or plain material name. + * Prevents injection of special characters into SQL queries or item lookups. + */ + private static boolean isValidItemKey(String itemKey) { + if (itemKey == null || itemKey.isEmpty()) return false; + return itemKey.matches("^[a-zA-Z0-9_.-]+(:[a-zA-Z0-9_./-]+)?$"); } } diff --git a/src/main/java/com/aureleconomy/web/CloudSyncManager.java b/src/main/java/com/aureleconomy/web/CloudSyncManager.java index 746a02b..93856cf 100644 --- a/src/main/java/com/aureleconomy/web/CloudSyncManager.java +++ b/src/main/java/com/aureleconomy/web/CloudSyncManager.java @@ -4,6 +4,8 @@ import com.aureleconomy.market.MarketItems; import com.aureleconomy.market.MarketItems.Category; import com.aureleconomy.market.MarketItems.MarketEntry; +import com.aureleconomy.scanner.CustomItemRegistry; +import com.aureleconomy.scanner.CustomMarketItem; import net.kyori.adventure.text.minimessage.MiniMessage; import org.bukkit.Bukkit; import org.bukkit.Material; @@ -51,13 +53,12 @@ public class CloudSyncManager { public CloudSyncManager(AurelEconomy plugin) { this.plugin = plugin; this.http = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(60)) // 60s for Render cold start + .connectTimeout(Duration.ofSeconds(60)) .build(); this.baseUrl = plugin.getConfig().getString("web.cloud.url", "https://aurelium-web.onrender.com"); this.syncInterval = plugin.getConfig().getInt("web.cloud.sync-interval", 30); - // Auto-generate server-id and api-key if missing String id = plugin.getConfig().getString("web.cloud.server-id", ""); String key = plugin.getConfig().getString("web.cloud.api-key", ""); @@ -78,23 +79,17 @@ public CloudSyncManager(AurelEconomy plugin) { // ── Lifecycle ──────────────────────────────────────────────────── - /** Register with the Render server and start sync loop. */ public void start() { - // Register asynchronously with retries (Render free tier can take 30-60s to - // wake) attemptRegistration(1); - // Sync market data periodically (async) — also retries registration if needed long syncTicks = syncInterval * 20L; syncTask = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, () -> { if (!registered) { - // Try to register on each sync tick if not yet registered try { register(); registered = true; plugin.getComponentLogger().info("Cloud dashboard registered (late) — server ID: " + serverId); } catch (Exception ignored) { - // Late registration attempt failure during periodic sync return; } } @@ -110,7 +105,6 @@ public void start() { } }, syncTicks, syncTicks); - // Poll for pending purchases every 2 seconds (on main thread for safety) purchaseTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> { if (!registered) return; @@ -124,24 +118,20 @@ public void start() { return Collections.>emptyList(); } }).thenAccept(pending -> { - // Execute purchases on main thread Bukkit.getScheduler().runTask(plugin, () -> { executePurchases(pending); - // Trigger an immediate sync after processing purchases to update prices/balances on web if (!pending.isEmpty()) { CompletableFuture.runAsync(() -> { try { syncMarketData(); } catch (Exception ignored) { - // Optional immediate sync failure after purchase } }); } }); }); - }, 40L, 40L); // 2 seconds + }, 40L, 40L); - // Record price snapshots every 10 minutes for stock charts priceHistoryTask = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, () -> { if (!registered) return; @@ -150,28 +140,33 @@ public void start() { } catch (Exception e) { plugin.getComponentLogger().warn("Price history snapshot failed: " + e.getMessage()); } - }, 200L, 12000L); // Start after 10s, repeat every 10 min + }, 200L, 12000L); } - private void attemptRegistration(int attempt) { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { try { - plugin.getComponentLogger().info("Cloud dashboard: registering (attempt " + attempt + "/5)..."); + plugin.getComponentLogger().info("Cloud dashboard: registering (attempt " + attempt + "/3)..."); register(); registered = true; plugin.getComponentLogger().info("Cloud dashboard registered — server ID: " + serverId); - // Do an initial sync immediately try { syncMarketData(); } catch (Exception ignored) { } } catch (Exception e) { - plugin.getComponentLogger().warn("Registration attempt " + attempt + " failed: " + e.getMessage()); - if (attempt < 5) { - Bukkit.getScheduler().runTaskLaterAsynchronously(plugin, () -> attemptRegistration(attempt + 1), 300L); + String msg = e.getMessage(); + if (msg != null && (msg.contains("HTTP 4") || msg.contains("HTTP 5") || msg.contains("http 4") || msg.contains("http 5"))) { + plugin.getComponentLogger().warn("Cloud dashboard registration failed: " + msg); + plugin.getComponentLogger().info("Cloud dashboard disabled. Set web.cloud.url in config if you have a dashboard."); } else { - plugin.getComponentLogger().error("Failed to register with cloud dashboard after 5 attempts at " + baseUrl); + plugin.getComponentLogger().warn("Registration attempt " + attempt + " failed (transient): " + msg); + if (attempt < 3) { + long delay = 300L * attempt; + Bukkit.getScheduler().runTaskLaterAsynchronously(plugin, () -> attemptRegistration(attempt + 1), delay); + } else { + plugin.getComponentLogger().warn("Cloud dashboard registration gave up after " + attempt + " attempts"); + } } } }); @@ -194,13 +189,11 @@ public String getServerId() { return serverId; } - /** Build the dashboard URL for a player session. Posts session data async. */ public String createSessionUrl(Player player) { byte[] tokenBytes = new byte[32]; SECURE_RANDOM.nextBytes(tokenBytes); String token = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); - // Post session to Render asynchronously — frontend will retry until ready CompletableFuture.runAsync(() -> { try { String sessionJson = buildPlayerJson(player, token); @@ -213,19 +206,14 @@ public String createSessionUrl(Player player) { return baseUrl + "/shop/" + serverId + "?token=" + token; } - /** Pushes updated player balance to the web dashboard immediately. */ public void updatePlayerBalance(Player player) { if (!registered) return; - + CompletableFuture.runAsync(() -> { try { - // We use /api/session-update or similar if it exists, - // but the current server also accepts session posts for existing tokens. - // However, the cleanest way is a dedicated balance update. String json = buildPlayerJson(player, null); postJson("/api/session-update", json); } catch (Exception e) { - // Silent failure for periodic updates to avoid log spam } }); } @@ -250,15 +238,15 @@ private String buildPlayerJson(Player player, String token) { StringBuilder json = new StringBuilder(); json.append("{\"playerUuid\":\"").append(player.getUniqueId()).append("\"") - .append(",\"playerName\":\"").append(escJson(player.getName())).append("\"") - .append(",\"balances\":").append(balancesJson) - .append(",\"defaultCurrency\":\"").append(escJson(defaultCurrency)).append("\"") - .append(",\"serverId\":\"").append(escJson(serverId)).append("\""); - + .append(",\"playerName\":\"").append(escJson(player.getName())).append("\"") + .append(",\"balances\":").append(balancesJson) + .append(",\"defaultCurrency\":\"").append(escJson(defaultCurrency)).append("\"") + .append(",\"serverId\":\"").append(escJson(serverId)).append("\""); + if (token != null) { json.append(",\"token\":\"").append(token).append("\""); } - + json.append("}"); return json.toString(); } @@ -270,8 +258,8 @@ private void register() throws Exception { StringBuilder json = new StringBuilder(); json.append("{\"serverId\":\"").append(escJson(serverId)).append("\"") - .append(",\"apiKey\":\"").append(escJson(apiKey)).append("\"") - .append(",\"serverName\":\"").append(escJson(serverName)).append("\"}"); + .append(",\"apiKey\":\"").append(escJson(apiKey)).append("\"") + .append(",\"serverName\":\"").append(escJson(serverName)).append("\"}"); postJson("/api/register", json.toString()); } @@ -341,7 +329,7 @@ private void syncMarketData() throws Exception { } json.append("}"); - // ── Auctions ──────────────────────────────────────────────── + // ── Auctions json.append(",\"auctions\":["); List auctions = plugin.getAuctionManager().getActiveAuctions(); for (int i = 0; i < auctions.size(); i++) { @@ -350,7 +338,6 @@ private void syncMarketData() throws Exception { json.append(","); String sellerName = resolvePlayerName(ai.getSeller()); String itemName = ai.getItem().getType().name().replace("_", " "); - // Use custom display name if present if (ai.getItem().hasItemMeta() && ai.getItem().getItemMeta().hasDisplayName()) { itemName = net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer .plainText().serialize(ai.getItem().getItemMeta().displayName()); @@ -374,7 +361,7 @@ private void syncMarketData() throws Exception { } json.append("]"); - // ── Buy Orders ────────────────────────────────────────────── + // ── Buy Orders json.append(",\"orders\":["); var orders = plugin.getOrderManager().getActiveOrders(); int oi = 0; @@ -398,13 +385,12 @@ private void syncMarketData() throws Exception { } json.append("]"); - // ── Stocks / Price Tracker ────────────────────────────────── + // ── Stocks / Price Tracker json.append(",\"stocks\":["); List allStocks = new ArrayList<>(plugin.getMarketManager().getEntryCache().values()); for (int i = 0; i < allStocks.size(); i++) { MarketEntry entry = allStocks.get(i); - // Skip blacklisted items if (plugin.getMarketManager().isBlacklisted(entry.material)) { continue; } @@ -421,14 +407,12 @@ private void syncMarketData() throws Exception { BigDecimal sellPrice = plugin.getMarketManager().getSellPrice(priceKey); BigDecimal basePrice = entry.price; - // Non-market items: use last sold price if (basePrice.compareTo(BigDecimal.ONE) == 0 && buyPrice.compareTo(BigDecimal.ONE) <= 0) { BigDecimal lastSold = plugin.getOrderManager().getLastSoldPrice(priceKey); if (lastSold != null) { buyPrice = lastSold; sellPrice = lastSold; } else if (buyPrice.compareTo(BigDecimal.ONE) == 0) { - // It's unvalued buyPrice = BigDecimal.ZERO; sellPrice = BigDecimal.ZERO; } @@ -436,7 +420,6 @@ private void syncMarketData() throws Exception { BigDecimal change = BigDecimal.ZERO; if (basePrice.compareTo(BigDecimal.ZERO) > 0 && buyPrice.compareTo(BigDecimal.ZERO) > 0 && basePrice.compareTo(BigDecimal.ONE) != 0) { - // ((buyPrice - basePrice) / basePrice) * 100 change = buyPrice.subtract(basePrice) .multiply(BigDecimal.valueOf(100)) .divide(basePrice, 4, RoundingMode.HALF_UP); @@ -459,7 +442,34 @@ private void syncMarketData() throws Exception { } json.append("]"); - // ── Price History (for charts) ────────────────────────────── + // ── Custom Items (scanner) + CustomItemRegistry registry = plugin.getCustomItemRegistry(); + boolean hasCustomItems = registry != null && !registry.isEmpty(); + json.append(",\"hasCustomItems\":").append(hasCustomItems); + if (hasCustomItems) { + json.append(",\"customItems\":["); + int ci = 0; + for (CustomMarketItem cmi : registry.getAllItems()) { + if (ci++ > 0) json.append(","); + String cName = cmi.getDisplayName() != null ? cmi.getDisplayName() : cmi.getCanonicalId(); + String material = cmi.getItemStack().getType().name().toLowerCase(); + BigDecimal buy = cmi.getBuyPrice(); + BigDecimal sell = cmi.getSellPrice(); + String currency = plugin.getEconomyManager().getDefaultCurrency(); + String symbol = plugin.getEconomyManager().getCurrencySymbol(currency); + json.append("{\"id\":\"").append(escJson(cmi.getCanonicalId())).append("\""); + json.append(",\"name\":\"").append(escJson(cName)).append("\""); + json.append(",\"material\":\"").append(escJson(material)).append("\""); + json.append(",\"buyPrice\":").append(buy.doubleValue()); + json.append(",\"sellPrice\":").append(sell.doubleValue()); + json.append(",\"currency\":\"").append(escJson(currency)).append("\""); + json.append(",\"currencySymbol\":\"").append(escJson(symbol)).append("\""); + json.append("}"); + } + json.append("]"); + } + + // ── Price History json.append(",\"priceHistory\":"); json.append(loadPriceHistoryJson()); @@ -468,7 +478,6 @@ private void syncMarketData() throws Exception { postJson("/api/sync", json.toString()); } - /** Resolve a UUID to a player name (online check + Bukkit cache). */ private String resolvePlayerName(java.util.UUID uuid) { org.bukkit.entity.Player online = Bukkit.getPlayer(uuid); if (online != null) @@ -479,7 +488,6 @@ private String resolvePlayerName(java.util.UUID uuid) { // ── Price History ──────────────────────────────────────────────── - /** Record a snapshot of all item prices into the database. */ private void recordPriceSnapshot() { long now = System.currentTimeMillis(); List allItems = MarketItems.getItems(Category.ALL_ITEMS).stream() @@ -487,14 +495,13 @@ private void recordPriceSnapshot() { .toList(); try (var conn = plugin.getDatabaseManager().getConnection(); - var ps = conn.prepareStatement( - "INSERT INTO price_history (item_key, buy_price, sell_price, timestamp) VALUES (?, ?, ?, ?)")) { + var ps = conn.prepareStatement( + "INSERT INTO price_history (item_key, buy_price, sell_price, timestamp) VALUES (?, ?, ?, ?)")) { for (MarketEntry entry : allItems) { String priceKey = (entry.customName != null) ? entry.customName : entry.material.name(); BigDecimal buyPrice = plugin.getMarketManager().getBuyPrice(priceKey); BigDecimal sellPrice = plugin.getMarketManager().getSellPrice(priceKey); - // Skip non-market items with default prices if (entry.price.compareTo(BigDecimal.ONE) == 0 && buyPrice.compareTo(BigDecimal.ONE) == 0) continue; @@ -509,9 +516,8 @@ private void recordPriceSnapshot() { plugin.getComponentLogger().warn("Price history record failed: " + e.getMessage()); } - // Clean old data (keep 7 days) try (var conn = plugin.getDatabaseManager().getConnection(); - var ps = conn.prepareStatement("DELETE FROM price_history WHERE timestamp < ?")) { + var ps = conn.prepareStatement("DELETE FROM price_history WHERE timestamp < ?")) { ps.setLong(1, now - 7L * 24 * 60 * 60 * 1000); ps.executeUpdate(); } catch (Exception e) { @@ -519,18 +525,13 @@ private void recordPriceSnapshot() { } } - /** - * Load price history from DB as JSON object: { "DIAMOND": [{t:123,b:50,s:40}, - * ...], ... } - */ private String loadPriceHistoryJson() { StringBuilder sb = new StringBuilder("{"); - // Query last 7 days, grouped by item long cutoff = System.currentTimeMillis() - 7L * 24 * 60 * 60 * 1000; try (var conn = plugin.getDatabaseManager().getConnection(); - var ps = conn.prepareStatement( - "SELECT item_key, buy_price, sell_price, timestamp FROM price_history " + - "WHERE timestamp > ? ORDER BY timestamp ASC")) { + var ps = conn.prepareStatement( + "SELECT item_key, buy_price, sell_price, timestamp FROM price_history " + + "WHERE timestamp > ? ORDER BY timestamp ASC")) { ps.setLong(1, cutoff); var rs = ps.executeQuery(); @@ -542,9 +543,9 @@ private String loadPriceHistoryJson() { long ts = rs.getLong("timestamp"); StringBuilder entry = new StringBuilder(); entry.append("{\"t\":").append(ts) - .append(",\"b\":").append(bp) - .append(",\"s\":").append(sp) - .append("}"); + .append(",\"b\":").append(bp) + .append(",\"s\":").append(sp) + .append("}"); grouped.computeIfAbsent(key, k -> new ArrayList<>()) .add(entry.toString()); } @@ -567,9 +568,6 @@ private String loadPriceHistoryJson() { // ── Purchase Polling ───────────────────────────────────────────── private List> fetchPendingPurchases() throws Exception { - // The sync endpoint returns pending purchases in its response - // But we can also use a dedicated call — for now, sync response is enough - // This manual poll is a fallback String url = baseUrl + "/api/sync?serverId=" + serverId; HttpRequest req = HttpRequest.newBuilder() .uri(URI.create(url)) @@ -584,7 +582,6 @@ private List> fetchPendingPurchases() throws Exception { if (resp.statusCode() != 200) return Collections.emptyList(); - // Parse pendingPurchases from response String body = resp.body(); if (body.contains("\"pendingPurchases\":[")) { return parsePendingPurchases(body); @@ -617,8 +614,7 @@ private void executePurchases(List> pending) { } else { executeMarketWebBuy(player, purchase, purchaseId); } - - // Periodically clear old processed purchases (e.g., if set gets too large) + if (processedPurchases.size() > 1000) { processedPurchases.clear(); } @@ -633,7 +629,6 @@ private void executeAuctionWebBid(Player player, Map purchase, S try { auctionId = Integer.parseInt(purchase.get("auctionId").toString()); } catch (Exception ignored) { - // Fallback parsing for auctionId } } @@ -644,7 +639,6 @@ private void executeAuctionWebBid(Player player, Map purchase, S try { amount = new BigDecimal(purchase.get("amount").toString()); } catch (Exception ignored) { - // Fallback parsing for amount } } @@ -675,7 +669,6 @@ private void executeAuctionWebBid(Player player, Map purchase, S return; } - // ATOMIC CLAIM if (!plugin.getAuctionManager().claimAuctionAtomic(auction.getId())) { confirmPurchase(purchaseId, false, BigDecimal.ZERO, "Auction already sold"); return; @@ -710,7 +703,6 @@ private void executeMarketWebBuy(Player player, Map purchase, St String itemKey = (String) purchase.get("item"); int amount = ((Number) purchase.get("amount")).intValue(); - // Resolve item BigDecimal buyPrice; String currency; Material material; @@ -743,7 +735,6 @@ private void executeMarketWebBuy(Player player, Map purchase, St return; } - // Execute purchase plugin.getEconomyManager().withdraw(player, totalCost, currency); player.getInventory().addItem(toGive); plugin.getMarketManager().onTransaction(itemKey, true, amount); @@ -767,7 +758,6 @@ private void executeOrderWebFill(Player seller, Map purchase, St try { orderId = Integer.parseInt((String) purchase.get("orderId")); } catch (Exception ignored) { - // Fallback parsing for orderId } } @@ -778,7 +768,6 @@ private void executeOrderWebFill(Player seller, Map purchase, St try { amount = Integer.parseInt((String) purchase.get("amount")); } catch (Exception ignored) { - // Fallback parsing for amount } } @@ -787,7 +776,6 @@ private void executeOrderWebFill(Player seller, Map purchase, St return; } - // Find the active order com.aureleconomy.orders.BuyOrder order = null; for (com.aureleconomy.orders.BuyOrder o : plugin.getOrderManager().getActiveOrders()) { if (o.getId() == orderId) { @@ -806,7 +794,6 @@ private void executeOrderWebFill(Player seller, Map purchase, St return; } - // Verify seller has the items int playerHas = 0; for (ItemStack item : seller.getInventory().getContents()) { if (item != null && item.getType() == order.getMaterial()) { @@ -832,10 +819,10 @@ private void confirmPurchase(String purchaseId, boolean success, BigDecimal newB try { StringBuilder json = new StringBuilder(); json.append("{\"purchaseId\":\"").append(escJson(purchaseId)).append("\"") - .append(",\"serverId\":\"").append(escJson(serverId)).append("\"") - .append(",\"success\":").append(success) - .append(",\"newBalance\":").append(newBalance != null ? newBalance.doubleValue() : 0) - .append(",\"spent\":\"").append(escJson(spent)).append("\"}"); + .append(",\"serverId\":\"").append(escJson(serverId)).append("\"") + .append(",\"success\":").append(success) + .append(",\"newBalance\":").append(newBalance != null ? newBalance.doubleValue() : 0) + .append(",\"spent\":\"").append(escJson(spent)).append("\"}"); postJson("/api/confirm-purchase", json.toString()); } catch (Exception e) { plugin.getComponentLogger().warn("Failed to confirm purchase: " + e.getMessage()); @@ -858,7 +845,6 @@ private String postJson(String endpoint, String json) throws Exception { if (resp.statusCode() >= 400) { String body = resp.body(); if (resp.statusCode() == 503 && body.contains("\"queued\":true")) { - // ... (existing queue logic) int posIndex = body.indexOf("\"position\":"); String pos = "?"; if (posIndex != -1) { @@ -870,29 +856,31 @@ private String postJson(String endpoint, String json) throws Exception { throw new RuntimeException("Dashboard Waitlist active. Waiting in queue (Position: " + pos + ")."); } - // If Render server restarted, it loses memory and returns 403. Force re-registration. if (resp.statusCode() == 403 && body.contains("Invalid server ID or API key")) { this.registered = false; } - // Truncate body if it's too long (Prevents HTML spam in logs) String snippet = body.length() > 200 ? body.substring(0, 200) + "..." : body; throw new RuntimeException("HTTP " + resp.statusCode() + " (" + endpoint + "): " + snippet); } return resp.body(); } + /** + * Escape JSON string values. Covers all required control characters. + */ private static String escJson(String s) { if (s == null) return ""; - return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + .replace("\b", "\\b") + .replace("\f", "\\f"); } - /** - * Very simple JSON parser for the pendingPurchases array. - * Avoids external dependencies (Gson, Jackson). - */ - private List> parsePendingPurchases(String jsonBody) { List> result = new ArrayList<>(); @@ -906,7 +894,6 @@ private List> parsePendingPurchases(String jsonBody) { String arrStr = jsonBody.substring(arrStart + 1, arrEnd).trim(); if (arrStr.isEmpty()) return result; - // Split by "},{" but handle whitespace String[] objects = arrStr.split("\\}\\s*,\\s*\\{"); for (String obj : objects) { obj = obj.replace("{", "").replace("}", "").trim(); diff --git a/src/main/java/com/aureleconomy/web/WebServer.java b/src/main/java/com/aureleconomy/web/WebServer.java index 63148bc..61ab3d8 100644 --- a/src/main/java/com/aureleconomy/web/WebServer.java +++ b/src/main/java/com/aureleconomy/web/WebServer.java @@ -24,7 +24,6 @@ public class WebServer { private final int port; private boolean started = false; - // Content-type mappings private static final Map MIME_TYPES = Map.of( "html", "text/html; charset=utf-8", "css", "text/css; charset=utf-8", @@ -37,7 +36,7 @@ public class WebServer { public WebServer(AurelEconomy plugin) { this.plugin = plugin; this.port = plugin.getConfig().getInt("web.local.port", 8585); - this.sessionManager = new WebSessionManager(60); // Hardcoded 60 minutes + this.sessionManager = new WebSessionManager(plugin, 60); } public boolean start() { @@ -45,15 +44,12 @@ public boolean start() { server = HttpServer.create(new InetSocketAddress("0.0.0.0", port), 0); server.setExecutor(Executors.newFixedThreadPool(4)); - // API routes ApiHandler apiHandler = new ApiHandler(plugin, sessionManager); server.createContext("/api/", apiHandler); - // Static file serving (frontend) server.createContext("/", exchange -> { String path = exchange.getRequestURI().getPath(); - // Default to index.html if (path.equals("/") || path.isEmpty()) { path = "/index.html"; } @@ -66,10 +62,6 @@ public boolean start() { plugin.getComponentLogger().info("Web dashboard started successfully on port " + port + " — open http://localhost:" + port + " in your browser"); - // Schedule session cleanup every 60 seconds - plugin.getServer().getScheduler().runTaskTimerAsynchronously(plugin, - sessionManager::cleanup, 1200L, 1200L); - return true; } catch (Exception e) { started = false; @@ -85,6 +77,10 @@ public void stop() { started = false; plugin.getComponentLogger().info("Web dashboard stopped."); } + // Shutdown session cleanup task and clear sessions + if (sessionManager != null) { + sessionManager.shutdown(); + } } public boolean isRunning() { @@ -101,13 +97,11 @@ public int getPort() { /** Serve a file from src/main/resources/web/ inside the JAR. */ private void serveStaticFile(HttpExchange exchange, String path) throws IOException { - // Sanitize path to prevent directory traversal String sanitized = path.replace("..", "").replaceAll("[^a-zA-Z0-9/._-]", ""); String resourcePath = "web" + sanitized; try (InputStream is = plugin.getClass().getClassLoader().getResourceAsStream(resourcePath)) { if (is == null) { - // 404 String notFound = "

404 Not Found

"; byte[] bytes = notFound.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); @@ -120,7 +114,6 @@ private void serveStaticFile(HttpExchange exchange, String path) throws IOExcept byte[] data = is.readAllBytes(); - // Determine content type from extension String ext = sanitized.contains(".") ? sanitized.substring(sanitized.lastIndexOf('.') + 1) : "html"; String contentType = MIME_TYPES.getOrDefault(ext, "application/octet-stream"); diff --git a/src/main/java/com/aureleconomy/web/WebSessionManager.java b/src/main/java/com/aureleconomy/web/WebSessionManager.java index d1e0e54..d4f1b2e 100644 --- a/src/main/java/com/aureleconomy/web/WebSessionManager.java +++ b/src/main/java/com/aureleconomy/web/WebSessionManager.java @@ -6,9 +6,13 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; + /** * Manages authentication sessions for the web dashboard. * Each player gets a unique token via /web that maps to their UUID. + * Includes scheduled periodic cleanup to prevent memory leaks. */ public class WebSessionManager { @@ -16,22 +20,49 @@ public class WebSessionManager { private final Map playerTokens = new ConcurrentHashMap<>(); private final SecureRandom random = new SecureRandom(); private final long timeoutMs; + private final JavaPlugin plugin; + private BukkitTask cleanupTask; - public WebSessionManager(long timeoutMinutes) { + public WebSessionManager(JavaPlugin plugin, long timeoutMinutes) { + this.plugin = plugin; this.timeoutMs = timeoutMinutes * 60 * 1000; + startCleanupTask(); + } + + /** + * Start a periodic cleanup task that removes expired sessions every 5 minutes. + * Prevents memory leaks from sessions that are never validated again. + */ + private void startCleanupTask() { + this.cleanupTask = plugin.getServer().getScheduler().runTaskTimerAsynchronously( + plugin, + this::cleanup, + 6000L, + 6000L + ); + } + + /** + * Stop the cleanup task. Call on plugin disable. + */ + public void shutdown() { + if (cleanupTask != null) { + cleanupTask.cancel(); + cleanupTask = null; + } + sessions.clear(); + playerTokens.clear(); } /** * Generate a new session token for a player (invalidates any previous session). */ public String createSession(UUID playerUuid) { - // Revoke existing session String existing = playerTokens.get(playerUuid); if (existing != null) { sessions.remove(existing); } - // Generate 32-byte random token byte[] bytes = new byte[32]; random.nextBytes(bytes); String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); @@ -52,14 +83,12 @@ public UUID validate(String token) { if (session == null) return null; - // Check expiration if (System.currentTimeMillis() - session.lastActivity > timeoutMs) { sessions.remove(token); playerTokens.remove(session.playerUuid); return null; } - // Refresh activity session.lastActivity = System.currentTimeMillis(); return session.playerUuid; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 843bf69..beecfe5 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -125,6 +125,41 @@ web: api-key: "" sync-interval: 30 + +# --- Custom Item Scanner --- +custom-items: + enabled: true + scan-on-startup: true + scan-interval-minutes: 10 + auto-add-to-market: true + default-price-multiplier: 1.5 + discovery-methods: + plugin-api: true + pdc-scan: true + custom-model-data: true + lore-pattern: true + inventory-scan: true + interaction-detect: true + excluded-namespaces: + - minecraft + - aureleconomy + + + # --- 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. # Delete an entry to reset it to its default price on next restart. + +# --- Discovered Custom Items --- +# Items below are auto-populated when custom item plugins (ItemsAdder, Oraxen, etc.) are detected. +# You can override prices, enable/disable items, or change display names here. +# Changes take effect on next server restart. Delete an entry to reset it to scanner defaults. +# Example: +# itemsadder:ruby_sword: +# source-plugin: ItemsAdder +# display-name: "Ruby Sword" +# category: CUSTOM_ITEMS +# buy-price: 500.0 +# sell-price: 250.0 +# enabled: true +discovered-items: {} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 767c80d..4fd8f95 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,20 +1,18 @@ name: Aurelium -version: '1.4.3' +version: '1.5.0' main: com.aureleconomy.AurelEconomy api-version: '26.1' description: Economy plugin with market, auction house, and web dashboard. authors: - APPLEPIE6969 -softdepend: [Vault] +softdepend: [Vault, ItemsAdder, Oraxen, MMOItems, MythicMobs, ExecutableItems, Nexo, SX-Item] commands: bal: description: Check your balance usage: /bal [player] permission: aureleconomy.bal - aliases: - - balance - - money + aliases: [balance, money] pay: description: Send money to someone usage: /pay @@ -27,8 +25,7 @@ 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 @@ -40,8 +37,7 @@ commands: stocks: description: View item price trends usage: /stocks - aliases: - - stonks + aliases: [stonks] eco: description: Admin money commands usage: /eco [player] @@ -50,6 +46,10 @@ commands: description: Open the web dashboard usage: /web permission: aureleconomy.web + customitems: + description: Manage discovered custom items + usage: /customitems + permission: aureleconomy.admin permissions: aureleconomy.admin: @@ -80,4 +80,4 @@ permissions: default: true libraries: - - org.xerial:sqlite-jdbc:3.45.3.0 + - org.xerial:sqlite-jdbc:3.45.3.0 \ No newline at end of file diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 24f45e0..cf7e4ac 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -14,6 +14,7 @@ currentPage: 0, totalPages: 1, searchQuery: '', + hasCustomItems: false, searchTimeout: null, selectedItem: null, }; @@ -238,7 +239,48 @@ // ── Search ─────────────────────────────────────────────────────── - function handleSearch(query) { + function renderCustomItems(items) { + dom.grid.innerHTML = ''; + if (!items || items.length === 0) { + dom.grid.style.display = 'none'; + dom.emptyState.style.display = 'block'; + return; + } + dom.grid.style.display = ''; + dom.emptyState.style.display = 'none'; + items.forEach(item => { + const card = document.createElement('div'); + card.className = 'item-card'; + card.innerHTML = ` +
+
+ +
+
${escHtml(item.name)}
+
+ + `; + card.addEventListener('click', () => openCustomBuyModal(item)); + dom.grid.appendChild(card); + }); +} + +function openCustomBuyModal(item) { + state.selectedItem = { + key: item.id, + name: item.name, + material: item.material, + price: item.buyPrice, + priceFormatted: item.priceFormatted || (item.currencySymbol || '') + item.buyPrice.toLocaleString('en-US', { minimumFractionDigits: 2 }), + currency: item.currency + }; + openBuyModal(state.selectedItem); +} + +function handleSearch(query) { state.searchQuery = query; state.currentPage = 0; diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 42daadc..8f46048 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -86,7 +86,29 @@

No items found

- + + + +