Add optional MySQL support for statistics storage
Build / build (push) Successful in 1m15s

This commit is contained in:
Michael Burgess
2026-08-07 06:09:24 -04:00
parent bcb603dadf
commit 1c16f9a83b
7 changed files with 128 additions and 25 deletions
+27 -2
View File
@@ -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-<version>.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.
+4
View File
@@ -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 {
@@ -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);
@@ -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);
}
}
}
@@ -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();
}
@@ -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() {
+12
View File
@@ -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: