From 1c16f9a83b909e959521171f7881f3ddc5aa9514 Mon Sep 17 00:00:00 2001 From: Michael Burgess Date: Fri, 7 Aug 2026 06:09:24 -0400 Subject: [PATCH] Add optional MySQL support for statistics storage --- README.md | 29 ++++++- build.gradle | 4 + .../us/tss3/blockparty/BlockPartyPlugin.java | 2 +- .../tss3/blockparty/config/ConfigManager.java | 23 ++++++ .../blockparty/persistence/StatsDatabase.java | 78 ++++++++++++++----- .../blockparty/persistence/StatsManager.java | 5 +- src/main/resources/config.yml | 12 +++ 7 files changed, 128 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index b964c0e..953b7ab 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,31 @@ Commands run as the console, so no Vault/economy plugin is required by BlockPart if you reference an economy command (like `eco give`) you need that plugin installed separately. `%player%`, `%arena%` and `%round%` are replaced before dispatch. +## Statistics storage + +By default player statistics are stored in a local SQLite file (`plugins/BlockParty/blockparty.db`) — +zero setup required. To use a shared MySQL database instead (useful across a network of servers), +set `storage.type: mysql` in `config.yml`: + +```yaml +storage: + type: mysql + mysql: + host: localhost + port: 3306 + database: blockparty + username: blockparty + password: password + use-ssl: false + table-prefix: "bp_" +``` + +The MySQL JDBC driver (`mysql-connector-j`) is bundled in the plugin jar, so no extra +download is required. The stats table is created automatically on startup if it doesn't +exist. All queries run asynchronously off the main thread regardless of storage type. +Changing `storage.type` requires a server restart (it's read once at startup, not on +`/blockparty reload`). + ## PlaceholderAPI support If PlaceholderAPI is installed, BlockParty registers a `%blockparty_...%` expansion @@ -169,7 +194,7 @@ automatically (soft-dependency — startup never fails if PAPI is absent): ./gradlew clean build ``` -The shaded plugin jar (with `sqlite-jdbc` bundled and relocated) is produced at +The shaded plugin jar (with `sqlite-jdbc` and `mysql-connector-j` bundled) is produced at `build/libs/BlockParty-.jar`. Run unit tests only: @@ -208,6 +233,6 @@ elimination/winner determination, arena config validation, and player-count/join - `listener/` — connection/quit handling and gameplay restrictions. - `scoreboard/` — interval-based sidebar scoreboard. - `reward/` — console-command reward dispatch. -- `persistence/` — async SQLite-backed stats (`StatsDatabase`, `StatsManager`). +- `persistence/` — async stats storage over SQLite or MySQL (`StatsDatabase`, `StatsManager`). - `placeholder/` — optional PlaceholderAPI expansion, loaded only when PAPI is present. - `util/` — sound/location helpers. diff --git a/build.gradle b/build.gradle index 2b8cda1..301afbe 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,7 @@ dependencies { compileOnly 'io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT' compileOnly 'me.clip:placeholderapi:2.11.6' implementation 'org.xerial:sqlite-jdbc:3.47.1.0' + implementation 'com.mysql:mysql-connector-j:9.1.0' testImplementation platform('org.junit:junit-bom:5.11.3') testImplementation 'org.junit.jupiter:junit-jupiter' @@ -46,6 +47,9 @@ shadowJar { archiveClassifier.set('') archiveBaseName.set('BlockParty') relocate 'org.sqlite', 'us.tss3.blockparty.libs.sqlite' + // com.mysql is intentionally left unrelocated: mysql-connector-j is loaded by fully + // qualified class name (com.mysql.cj.jdbc.Driver) at runtime via Class.forName. + mergeServiceFiles() } test { diff --git a/src/main/java/us/tss3/blockparty/BlockPartyPlugin.java b/src/main/java/us/tss3/blockparty/BlockPartyPlugin.java index 981e42f..788f14b 100644 --- a/src/main/java/us/tss3/blockparty/BlockPartyPlugin.java +++ b/src/main/java/us/tss3/blockparty/BlockPartyPlugin.java @@ -42,7 +42,7 @@ public class BlockPartyPlugin extends JavaPlugin implements Listener { this.sessionManager = new SessionManager(); this.soundUtil = new SoundUtil(this); this.rewardManager = new RewardManager(this); - this.statsManager = new StatsManager(this); + this.statsManager = new StatsManager(this, configManager.getStorageSettings()); statsManager.init(); this.arenaManager = new ArenaManager(this); diff --git a/src/main/java/us/tss3/blockparty/config/ConfigManager.java b/src/main/java/us/tss3/blockparty/config/ConfigManager.java index cae4d2a..d7c7d1e 100644 --- a/src/main/java/us/tss3/blockparty/config/ConfigManager.java +++ b/src/main/java/us/tss3/blockparty/config/ConfigManager.java @@ -80,4 +80,27 @@ public class ConfigManager { public boolean isPlaceholderApiEnabled() { return config.getBoolean("integrations.placeholderapi", true); } + + public StorageSettings getStorageSettings() { + String type = config.getString("storage.type", "sqlite"); + return new StorageSettings( + type, + config.getString("storage.mysql.host", "localhost"), + config.getInt("storage.mysql.port", 3306), + config.getString("storage.mysql.database", "blockparty"), + config.getString("storage.mysql.username", "blockparty"), + config.getString("storage.mysql.password", ""), + config.getBoolean("storage.mysql.use-ssl", false), + config.getString("storage.mysql.table-prefix", "bp_") + ); + } + + /** Immutable snapshot of the storage.* config section. */ + public record StorageSettings(String type, String host, int port, String database, + String username, String password, boolean useSsl, String tablePrefix) { + + public boolean isMysql() { + return "mysql".equalsIgnoreCase(type); + } + } } diff --git a/src/main/java/us/tss3/blockparty/persistence/StatsDatabase.java b/src/main/java/us/tss3/blockparty/persistence/StatsDatabase.java index c5853af..489656f 100644 --- a/src/main/java/us/tss3/blockparty/persistence/StatsDatabase.java +++ b/src/main/java/us/tss3/blockparty/persistence/StatsDatabase.java @@ -1,6 +1,7 @@ package us.tss3.blockparty.persistence; import org.bukkit.plugin.Plugin; +import us.tss3.blockparty.config.ConfigManager.StorageSettings; import java.io.File; import java.sql.Connection; @@ -12,37 +13,72 @@ import java.sql.Statement; import java.util.UUID; import java.util.logging.Level; -/** SQLite-backed statistics storage. All public methods are safe to call from any thread; - * callers are expected to invoke them off the main thread (see StatsManager). */ +/** SQLite- or MySQL-backed statistics storage, selected via storage.type in config.yml. + * All public methods are safe to call from any thread; callers are expected to invoke + * them off the main thread (see StatsManager). */ public class StatsDatabase { private final Plugin plugin; + private final StorageSettings settings; + private final String table; private Connection connection; - public StatsDatabase(Plugin plugin) { + public StatsDatabase(Plugin plugin, StorageSettings settings) { this.plugin = plugin; + this.settings = settings; + this.table = settings.isMysql() ? settings.tablePrefix() + "player_stats" : "player_stats"; } public synchronized void connect() { try { - File dbFile = new File(plugin.getDataFolder(), "blockparty.db"); - Class.forName("org.sqlite.JDBC"); - connection = DriverManager.getConnection("jdbc:sqlite:" + dbFile.getAbsolutePath()); + if (settings.isMysql()) { + connectMysql(); + } else { + connectSqlite(); + } try (Statement st = connection.createStatement()) { - st.execute("CREATE TABLE IF NOT EXISTS player_stats (" + - "uuid TEXT PRIMARY KEY," + - "games_played INTEGER NOT NULL DEFAULT 0," + - "wins INTEGER NOT NULL DEFAULT 0," + - "eliminations INTEGER NOT NULL DEFAULT 0," + - "best_round INTEGER NOT NULL DEFAULT 0," + - "total_rounds_survived INTEGER NOT NULL DEFAULT 0" + - ")"); + st.execute(createTableStatement()); } } catch (Exception ex) { - plugin.getLogger().log(Level.SEVERE, "Failed to initialize SQLite stats database", ex); + plugin.getLogger().log(Level.SEVERE, "Failed to initialize " + + (settings.isMysql() ? "MySQL" : "SQLite") + " stats database", ex); } } + private void connectSqlite() throws Exception { + File dbFile = new File(plugin.getDataFolder(), "blockparty.db"); + Class.forName("org.sqlite.JDBC"); + connection = DriverManager.getConnection("jdbc:sqlite:" + dbFile.getAbsolutePath()); + } + + private void connectMysql() throws Exception { + Class.forName("com.mysql.cj.jdbc.Driver"); + String url = "jdbc:mysql://" + settings.host() + ":" + settings.port() + "/" + settings.database() + + "?useSSL=" + settings.useSsl() + "&autoReconnect=true&characterEncoding=utf8"; + connection = DriverManager.getConnection(url, settings.username(), settings.password()); + } + + private String createTableStatement() { + if (settings.isMysql()) { + return "CREATE TABLE IF NOT EXISTS " + table + " (" + + "uuid VARCHAR(36) PRIMARY KEY," + + "games_played INT NOT NULL DEFAULT 0," + + "wins INT NOT NULL DEFAULT 0," + + "eliminations INT NOT NULL DEFAULT 0," + + "best_round INT NOT NULL DEFAULT 0," + + "total_rounds_survived INT NOT NULL DEFAULT 0" + + ")"; + } + return "CREATE TABLE IF NOT EXISTS " + table + " (" + + "uuid TEXT PRIMARY KEY," + + "games_played INTEGER NOT NULL DEFAULT 0," + + "wins INTEGER NOT NULL DEFAULT 0," + + "eliminations INTEGER NOT NULL DEFAULT 0," + + "best_round INTEGER NOT NULL DEFAULT 0," + + "total_rounds_survived INTEGER NOT NULL DEFAULT 0" + + ")"; + } + public synchronized void close() { try { if (connection != null && !connection.isClosed()) { @@ -53,8 +89,10 @@ public class StatsDatabase { } private synchronized void ensureRow(String uuid) throws SQLException { - try (PreparedStatement ps = connection.prepareStatement( - "INSERT OR IGNORE INTO player_stats(uuid) VALUES (?)")) { + String sql = settings.isMysql() + ? "INSERT IGNORE INTO " + table + "(uuid) VALUES (?)" + : "INSERT OR IGNORE INTO " + table + "(uuid) VALUES (?)"; + try (PreparedStatement ps = connection.prepareStatement(sql)) { ps.setString(1, uuid); ps.executeUpdate(); } @@ -67,7 +105,7 @@ public class StatsDatabase { try { ensureRow(uuid.toString()); try (PreparedStatement ps = connection.prepareStatement( - "SELECT games_played, wins, eliminations, best_round, total_rounds_survived FROM player_stats WHERE uuid=?")) { + "SELECT games_played, wins, eliminations, best_round, total_rounds_survived FROM " + table + " WHERE uuid=?")) { ps.setString(1, uuid.toString()); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { @@ -100,7 +138,7 @@ public class StatsDatabase { try { ensureRow(uuid.toString()); try (PreparedStatement ps = connection.prepareStatement( - "UPDATE player_stats SET best_round = MAX(best_round, ?), total_rounds_survived = total_rounds_survived + 1 WHERE uuid=?")) { + "UPDATE " + table + " SET best_round = MAX(best_round, ?), total_rounds_survived = total_rounds_survived + 1 WHERE uuid=?")) { ps.setInt(1, round); ps.setString(2, uuid.toString()); ps.executeUpdate(); @@ -117,7 +155,7 @@ public class StatsDatabase { try { ensureRow(uuid.toString()); try (PreparedStatement ps = connection.prepareStatement( - "UPDATE player_stats SET " + setClause + " WHERE uuid=?")) { + "UPDATE " + table + " SET " + setClause + " WHERE uuid=?")) { ps.setString(1, uuid.toString()); ps.executeUpdate(); } diff --git a/src/main/java/us/tss3/blockparty/persistence/StatsManager.java b/src/main/java/us/tss3/blockparty/persistence/StatsManager.java index d5abb44..cb873f8 100644 --- a/src/main/java/us/tss3/blockparty/persistence/StatsManager.java +++ b/src/main/java/us/tss3/blockparty/persistence/StatsManager.java @@ -1,6 +1,7 @@ package us.tss3.blockparty.persistence; import org.bukkit.plugin.Plugin; +import us.tss3.blockparty.config.ConfigManager.StorageSettings; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -11,9 +12,9 @@ public class StatsManager { private final Plugin plugin; private final StatsDatabase database; - public StatsManager(Plugin plugin) { + public StatsManager(Plugin plugin, StorageSettings settings) { this.plugin = plugin; - this.database = new StatsDatabase(plugin); + this.database = new StatsDatabase(plugin, settings); } public void init() { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index f0133db..b95bc83 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -45,6 +45,18 @@ default-floor-materials: - LIGHT_GRAY_CONCRETE - BROWN_CONCRETE +storage: + # type: sqlite (default, file-based, zero setup) or mysql + type: sqlite + mysql: + host: localhost + port: 3306 + database: blockparty + username: blockparty + password: password + use-ssl: false + table-prefix: "bp_" + rewards: winner: commands: