200 lines
7.9 KiB
Java
200 lines
7.9 KiB
Java
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;
|
|
import java.sql.DriverManager;
|
|
import java.sql.PreparedStatement;
|
|
import java.sql.ResultSet;
|
|
import java.sql.SQLException;
|
|
import java.sql.Statement;
|
|
import java.util.Map;
|
|
import java.util.UUID;
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
import java.util.concurrent.TimeUnit;
|
|
import java.util.logging.Level;
|
|
|
|
/** 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 static final long ERROR_LOG_INTERVAL_MS = TimeUnit.MINUTES.toMillis(5);
|
|
|
|
private final Plugin plugin;
|
|
private final StorageSettings settings;
|
|
private final String table;
|
|
private Connection connection;
|
|
|
|
/** operation name -> last time we actually logged a failure for it, so a persistently
|
|
* broken database (bad SQL, dropped connection, etc.) logs once immediately for
|
|
* diagnosis and then at most once every {@link #ERROR_LOG_INTERVAL_MS} after that,
|
|
* instead of spamming a full stack trace on every round/query. */
|
|
private final Map<String, Long> lastLoggedAt = new ConcurrentHashMap<>();
|
|
|
|
public StatsDatabase(Plugin plugin, StorageSettings settings) {
|
|
this.plugin = plugin;
|
|
this.settings = settings;
|
|
this.table = settings.isMysql() ? settings.tablePrefix() + "player_stats" : "player_stats";
|
|
}
|
|
|
|
private void logFailure(String operation, UUID uuid, SQLException ex) {
|
|
long now = System.currentTimeMillis();
|
|
Long last = lastLoggedAt.get(operation);
|
|
if (last == null) {
|
|
plugin.getLogger().log(Level.WARNING, "Failed to " + operation + " for " + uuid
|
|
+ " (further repeats of this error are throttled to once per 5 minutes)", ex);
|
|
lastLoggedAt.put(operation, now);
|
|
} else if (now - last >= ERROR_LOG_INTERVAL_MS) {
|
|
plugin.getLogger().warning("Still failing to " + operation + ": "
|
|
+ ex.getClass().getSimpleName() + ": " + ex.getMessage());
|
|
lastLoggedAt.put(operation, now);
|
|
}
|
|
}
|
|
|
|
public synchronized void connect() {
|
|
try {
|
|
if (settings.isMysql()) {
|
|
connectMysql();
|
|
} else {
|
|
connectSqlite();
|
|
}
|
|
try (Statement st = connection.createStatement()) {
|
|
st.execute(createTableStatement());
|
|
}
|
|
} catch (Exception 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 extra = settings.connectionParameters() == null ? "" : settings.connectionParameters().trim();
|
|
StringBuilder url = new StringBuilder("jdbc:mysql://")
|
|
.append(settings.host()).append(':').append(settings.port()).append('/').append(settings.database())
|
|
.append("?autoReconnect=true&characterEncoding=utf8");
|
|
if (!extra.isEmpty()) {
|
|
url.append('&').append(extra);
|
|
}
|
|
connection = DriverManager.getConnection(url.toString(), 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()) {
|
|
connection.close();
|
|
}
|
|
} catch (SQLException ignored) {
|
|
}
|
|
}
|
|
|
|
private synchronized void ensureRow(String uuid) throws SQLException {
|
|
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();
|
|
}
|
|
}
|
|
|
|
public synchronized PlayerStats getStats(UUID uuid) {
|
|
if (connection == null) {
|
|
return PlayerStats.empty(uuid);
|
|
}
|
|
try {
|
|
ensureRow(uuid.toString());
|
|
try (PreparedStatement ps = connection.prepareStatement(
|
|
"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()) {
|
|
return new PlayerStats(uuid, rs.getInt(1), rs.getInt(2), rs.getInt(3), rs.getInt(4), rs.getInt(5));
|
|
}
|
|
}
|
|
}
|
|
} catch (SQLException ex) {
|
|
logFailure("read stats", uuid, ex);
|
|
}
|
|
return PlayerStats.empty(uuid);
|
|
}
|
|
|
|
public synchronized void incrementGamesPlayed(UUID uuid) {
|
|
update(uuid, "games_played = games_played + 1");
|
|
}
|
|
|
|
public synchronized void incrementWins(UUID uuid) {
|
|
update(uuid, "wins = wins + 1");
|
|
}
|
|
|
|
public synchronized void incrementEliminations(UUID uuid) {
|
|
update(uuid, "eliminations = eliminations + 1");
|
|
}
|
|
|
|
public synchronized void recordRoundReached(UUID uuid, int round) {
|
|
if (connection == null) {
|
|
return;
|
|
}
|
|
try {
|
|
ensureRow(uuid.toString());
|
|
// MySQL/MariaDB's MAX() is aggregate-only; the two-argument "greatest of these
|
|
// values" form used by SQLite must be GREATEST() on that dialect instead.
|
|
String greatestFn = settings.isMysql() ? "GREATEST" : "MAX";
|
|
try (PreparedStatement ps = connection.prepareStatement(
|
|
"UPDATE " + table + " SET best_round = " + greatestFn + "(best_round, ?), total_rounds_survived = total_rounds_survived + 1 WHERE uuid=?")) {
|
|
ps.setInt(1, round);
|
|
ps.setString(2, uuid.toString());
|
|
ps.executeUpdate();
|
|
}
|
|
} catch (SQLException ex) {
|
|
logFailure("update round stats", uuid, ex);
|
|
}
|
|
}
|
|
|
|
private void update(UUID uuid, String setClause) {
|
|
if (connection == null) {
|
|
return;
|
|
}
|
|
try {
|
|
ensureRow(uuid.toString());
|
|
try (PreparedStatement ps = connection.prepareStatement(
|
|
"UPDATE " + table + " SET " + setClause + " WHERE uuid=?")) {
|
|
ps.setString(1, uuid.toString());
|
|
ps.executeUpdate();
|
|
}
|
|
} catch (SQLException ex) {
|
|
logFailure("update stats (" + setClause + ")", uuid, ex);
|
|
}
|
|
}
|
|
}
|