Add player stat collection with pluggable YAML/MySQL storage
CI / build (push) Successful in 1m58s

Tracks races played, wins, total laps, and best times per player and
per race, recorded off the main thread after each finish. Backed by
a StatsStorage interface with two implementations: the default
YamlStatsStorage (stats.yml, zero setup) and MySqlStatsStorage
(HikariCP-pooled, auto-creates its tables, configured via
config.yml). MySQL init failures fall back to YAML rather than
blocking plugin startup. Adds /boatparty stats and /boatparty top
commands. Bundles and relocates HikariCP + MySQL Connector/J via
shadow (bumped to 9.0.2, the prior 8.3.5 couldn't shade Java 25
class files).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Michael Burgess
2026-08-08 09:00:13 -04:00
co-authored by Claude Sonnet 5
parent d6f1f53681
commit b56d4b064b
12 changed files with 581 additions and 11 deletions
+31 -2
View File
@@ -25,6 +25,8 @@ fastest lap times over a configurable number of laps.
| `/bp join <name>` / `/bp leave` | Join or leave a race | | `/bp join <name>` / `/bp leave` | Join or leave a race |
| `/bp start <name>` / `/bp stop <name>` | Force-start or stop a race (admin) | | `/bp start <name>` / `/bp stop <name>` | Force-start or stop a race (admin) |
| `/bp list` / `/bp info <name>` | List races / show race details | | `/bp list` / `/bp info <name>` | List races / show race details |
| `/bp stats [player]` | Show races played, wins, total laps, and best time |
| `/bp top <name> [limit]` | Leaderboard of best times for a race (default top 10) |
The last checkpoint added also serves as the finish line — crossing it completes a lap. The last checkpoint added also serves as the finish line — crossing it completes a lap.
@@ -33,15 +35,42 @@ The last checkpoint added also serves as the finish line — crossing it complet
- `boatparty.admin` (default: op) — configure and control races - `boatparty.admin` (default: op) — configure and control races
- `boatparty.play` (default: true) — join and play races - `boatparty.play` (default: true) — join and play races
## Stat storage
Every race finish records the player's placement, laps, and time. Storage is
configured in `config.yml`:
```yaml
storage:
type: yaml # or "mysql"
mysql:
host: localhost
port: 3306
database: boatparty
username: root
password: ""
use-ssl: false
table-prefix: "boatparty_"
```
`yaml` (default) writes to `stats.yml` in the plugin's data folder — no setup required.
`mysql` pools connections via HikariCP and creates its `players` and `race_times` tables
automatically on first startup. All stat reads/writes happen off the main thread. If the
MySQL connection fails to initialize, the plugin logs an error and falls back to YAML
storage rather than failing to start.
## Building ## Building
``` ```
./gradlew build ./gradlew build
``` ```
The compiled plugin jar is produced at `build/libs/BoatParty-<version>.jar`. The compiled plugin jar is produced at `build/libs/BoatParty-<version>.jar`. It bundles
and relocates HikariCP and the MySQL Connector/J driver, so no extra dependency jars are
needed on the server.
## Requirements ## Requirements
- Java 21+ - Java 21+ to run Gradle; the plugin itself compiles against and targets Java 25
(required by the Paper 26.2 API)
- PaperMC (latest, built against Paper API 26.2) - PaperMC (latest, built against Paper API 26.2)
+11 -1
View File
@@ -1,6 +1,6 @@
plugins { plugins {
java java
id("com.gradleup.shadow") version "8.3.5" id("com.gradleup.shadow") version "9.0.2"
} }
group = "us.tss3.boatparty" group = "us.tss3.boatparty"
@@ -19,6 +19,9 @@ repositories {
dependencies { dependencies {
compileOnly("io.papermc.paper:paper-api:26.2.build.111-stable") compileOnly("io.papermc.paper:paper-api:26.2.build.111-stable")
implementation("com.zaxxer:HikariCP:7.1.0")
implementation("com.mysql:mysql-connector-j:26.7.0")
} }
tasks { tasks {
@@ -36,6 +39,13 @@ tasks {
shadowJar { shadowJar {
archiveClassifier.set("") archiveClassifier.set("")
archiveBaseName.set("BoatParty") archiveBaseName.set("BoatParty")
relocate("com.zaxxer.hikari", "us.tss3.boatparty.libs.hikari")
relocate("com.mysql", "us.tss3.boatparty.libs.mysql")
minimize {
exclude(dependency("com.mysql:mysql-connector-j:.*"))
}
} }
build { build {
@@ -4,10 +4,12 @@ import org.bukkit.plugin.java.JavaPlugin;
import us.tss3.boatparty.command.BoatPartyCommand; import us.tss3.boatparty.command.BoatPartyCommand;
import us.tss3.boatparty.game.RaceManager; import us.tss3.boatparty.game.RaceManager;
import us.tss3.boatparty.listener.RaceListener; import us.tss3.boatparty.listener.RaceListener;
import us.tss3.boatparty.stats.StatsManager;
public final class BoatPartyPlugin extends JavaPlugin { public final class BoatPartyPlugin extends JavaPlugin {
private RaceManager raceManager; private RaceManager raceManager;
private StatsManager statsManager;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -16,14 +18,17 @@ public final class BoatPartyPlugin extends JavaPlugin {
this.raceManager = new RaceManager(this); this.raceManager = new RaceManager(this);
this.raceManager.load(); this.raceManager.load();
BoatPartyCommand commandExecutor = new BoatPartyCommand(this, raceManager); this.statsManager = new StatsManager(this);
this.statsManager.init();
BoatPartyCommand commandExecutor = new BoatPartyCommand(this, raceManager, statsManager);
var command = getCommand("boatparty"); var command = getCommand("boatparty");
if (command != null) { if (command != null) {
command.setExecutor(commandExecutor); command.setExecutor(commandExecutor);
command.setTabCompleter(commandExecutor); command.setTabCompleter(commandExecutor);
} }
getServer().getPluginManager().registerEvents(new RaceListener(this, raceManager), this); getServer().getPluginManager().registerEvents(new RaceListener(this, raceManager, statsManager), this);
getLogger().info("BoatParty enabled - " + raceManager.getRaces().size() + " race(s) loaded."); getLogger().info("BoatParty enabled - " + raceManager.getRaces().size() + " race(s) loaded.");
} }
@@ -34,9 +39,16 @@ public final class BoatPartyPlugin extends JavaPlugin {
raceManager.stopAllRaces(); raceManager.stopAllRaces();
raceManager.save(); raceManager.save();
} }
if (statsManager != null) {
statsManager.close();
}
} }
public RaceManager getRaceManager() { public RaceManager getRaceManager() {
return raceManager; return raceManager;
} }
public StatsManager getStatsManager() {
return statsManager;
}
} }
@@ -7,10 +7,15 @@ import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter; import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import us.tss3.boatparty.BoatPartyPlugin; import us.tss3.boatparty.BoatPartyPlugin;
import us.tss3.boatparty.game.Race; import us.tss3.boatparty.game.Race;
import us.tss3.boatparty.game.RaceManager; import us.tss3.boatparty.game.RaceManager;
import us.tss3.boatparty.game.RaceState; import us.tss3.boatparty.game.RaceState;
import us.tss3.boatparty.stats.PlayerStats;
import us.tss3.boatparty.stats.StatsManager;
import us.tss3.boatparty.stats.TopEntry;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -22,14 +27,16 @@ public final class BoatPartyCommand implements CommandExecutor, TabCompleter {
private static final List<String> SUBCOMMANDS = List.of( private static final List<String> SUBCOMMANDS = List.of(
"create", "delete", "setlobby", "setstart", "addcheckpoint", "removecheckpoint", "create", "delete", "setlobby", "setstart", "addcheckpoint", "removecheckpoint",
"setlaps", "setminplayers", "setcountdown", "setradius", "join", "leave", "setlaps", "setminplayers", "setcountdown", "setradius", "join", "leave",
"start", "stop", "list", "info"); "start", "stop", "list", "info", "stats", "top");
private final BoatPartyPlugin plugin; private final BoatPartyPlugin plugin;
private final RaceManager raceManager; private final RaceManager raceManager;
private final StatsManager statsManager;
public BoatPartyCommand(BoatPartyPlugin plugin, RaceManager raceManager) { public BoatPartyCommand(BoatPartyPlugin plugin, RaceManager raceManager, StatsManager statsManager) {
this.plugin = plugin; this.plugin = plugin;
this.raceManager = raceManager; this.raceManager = raceManager;
this.statsManager = statsManager;
} }
@Override @Override
@@ -57,6 +64,8 @@ public final class BoatPartyCommand implements CommandExecutor, TabCompleter {
case "stop" -> handleStop(sender, args); case "stop" -> handleStop(sender, args);
case "list" -> handleList(sender); case "list" -> handleList(sender);
case "info" -> handleInfo(sender, args); case "info" -> handleInfo(sender, args);
case "stats" -> handleStats(sender, args);
case "top" -> handleTop(sender, args);
default -> sendHelp(sender); default -> sendHelp(sender);
} }
return true; return true;
@@ -305,6 +314,60 @@ public final class BoatPartyCommand implements CommandExecutor, TabCompleter {
msg(sender, "Ready: " + race.isReady()); msg(sender, "Ready: " + race.isReady());
} }
private void handleStats(CommandSender sender, String[] args) {
OfflinePlayer target;
if (args.length >= 2) {
target = Bukkit.getOfflinePlayer(args[1]);
} else {
Player player = requirePlayer(sender);
if (player == null) return;
target = player;
}
final OfflinePlayer finalTarget = target;
statsManager.getStatsAsync(target.getUniqueId(), stats -> sendStats(sender, finalTarget, stats));
}
private void sendStats(CommandSender sender, OfflinePlayer target, PlayerStats stats) {
String name = target.getName() != null ? target.getName() : target.getUniqueId().toString();
msg(sender, "--- Stats for " + name + " ---");
msg(sender, "Races played: " + stats.getRacesPlayed());
msg(sender, "Wins: " + stats.getWins());
msg(sender, "Total laps: " + stats.getTotalLaps());
msg(sender, "Best time: " + (stats.getBestTimeMillis() != null
? String.format("%.2fs", stats.getBestTimeMillis() / 1000.0)
: "N/A"));
}
private void handleTop(CommandSender sender, String[] args) {
Race race = requireRace(sender, args, 1);
if (race == null) return;
int limit = 10;
if (args.length >= 3) {
try {
limit = Math.max(1, Integer.parseInt(args[2]));
} catch (NumberFormatException ignored) {
// fall back to default limit
}
}
int finalLimit = limit;
statsManager.getTopTimesAsync(race.getName(), limit, entries -> sendTop(sender, race, entries, finalLimit));
}
private void sendTop(CommandSender sender, Race race, List<TopEntry> entries, int limit) {
if (entries.isEmpty()) {
msg(sender, "No recorded times for race '" + race.getName() + "' yet.");
return;
}
msg(sender, "--- Top " + Math.min(limit, entries.size()) + " times for " + race.getName() + " ---");
for (int i = 0; i < entries.size(); i++) {
TopEntry entry = entries.get(i);
msg(sender, (i + 1) + ". " + entry.getName() + " - " + String.format("%.2fs", entry.getTimeMillis() / 1000.0));
}
}
private void sendHelp(CommandSender sender) { private void sendHelp(CommandSender sender) {
List<String> lines = List.of( List<String> lines = List.of(
"&b&lBoatParty &7- ice boat racing", "&b&lBoatParty &7- ice boat racing",
@@ -316,7 +379,9 @@ public final class BoatPartyCommand implements CommandExecutor, TabCompleter {
"&7/boatparty join|leave <name>", "&7/boatparty join|leave <name>",
"&7/boatparty start|stop <name>", "&7/boatparty start|stop <name>",
"&7/boatparty list", "&7/boatparty list",
"&7/boatparty info <name>"); "&7/boatparty info <name>",
"&7/boatparty stats [player]",
"&7/boatparty top <name> [limit]");
for (String line : lines) { for (String line : lines) {
sender.sendMessage(Component.text(line.replace("&", "§"))); sender.sendMessage(Component.text(line.replace("&", "§")));
} }
@@ -15,6 +15,7 @@ import us.tss3.boatparty.game.PlayerProgress;
import us.tss3.boatparty.game.Race; import us.tss3.boatparty.game.Race;
import us.tss3.boatparty.game.RaceManager; import us.tss3.boatparty.game.RaceManager;
import us.tss3.boatparty.game.RaceState; import us.tss3.boatparty.game.RaceState;
import us.tss3.boatparty.stats.StatsManager;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -24,10 +25,12 @@ public final class RaceListener implements Listener {
private final BoatPartyPlugin plugin; private final BoatPartyPlugin plugin;
private final RaceManager raceManager; private final RaceManager raceManager;
private final StatsManager statsManager;
public RaceListener(BoatPartyPlugin plugin, RaceManager raceManager) { public RaceListener(BoatPartyPlugin plugin, RaceManager raceManager, StatsManager statsManager) {
this.plugin = plugin; this.plugin = plugin;
this.raceManager = raceManager; this.raceManager = raceManager;
this.statsManager = statsManager;
} }
@EventHandler @EventHandler
@@ -86,6 +89,9 @@ public final class RaceListener implements Listener {
int place = race.getFinishOrder().size(); int place = race.getFinishOrder().size();
double seconds = progress.elapsedMillis() / 1000.0; double seconds = progress.elapsedMillis() / 1000.0;
statsManager.recordFinishAsync(player.getUniqueId(), player.getName(), race.getName(),
place, progress.elapsedMillis(), race.getLaps());
for (UUID uuid : race.getParticipants()) { for (UUID uuid : race.getParticipants()) {
Player p = plugin.getServer().getPlayer(uuid); Player p = plugin.getServer().getPlayer(uuid);
if (p != null) { if (p != null) {
@@ -0,0 +1,162 @@
package us.tss3.boatparty.stats;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.logging.Logger;
public final class MySqlStatsStorage implements StatsStorage {
private final String host;
private final int port;
private final String database;
private final String username;
private final String password;
private final boolean useSsl;
private final String tablePrefix;
private final Logger logger;
private HikariDataSource dataSource;
public MySqlStatsStorage(String host, int port, String database, String username, String password,
boolean useSsl, String tablePrefix, Logger logger) {
this.host = host;
this.port = port;
this.database = database;
this.username = username;
this.password = password;
this.useSsl = useSsl;
this.tablePrefix = tablePrefix;
this.logger = logger;
}
@Override
public void init() throws SQLException {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://" + host + ":" + port + "/" + database
+ "?useSSL=" + useSsl + "&autoReconnect=true&characterEncoding=utf8");
config.setUsername(username);
config.setPassword(password);
config.setDriverClassName("com.mysql.cj.jdbc.Driver");
config.setPoolName("BoatParty-MySQL");
config.setMaximumPoolSize(6);
config.setMinimumIdle(1);
config.setConnectionTimeout(10_000);
config.addDataSourceProperty("cachePrepStmts", "true");
config.addDataSourceProperty("prepStmtCacheSize", "250");
config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");
this.dataSource = new HikariDataSource(config);
try (Connection connection = dataSource.getConnection(); Statement stmt = connection.createStatement()) {
stmt.executeUpdate("""
CREATE TABLE IF NOT EXISTS %splayers (
uuid CHAR(36) PRIMARY KEY,
name VARCHAR(16) NOT NULL,
races_played INT NOT NULL DEFAULT 0,
wins INT NOT NULL DEFAULT 0,
total_laps INT NOT NULL DEFAULT 0,
best_time_ms BIGINT NULL
)""".formatted(tablePrefix));
stmt.executeUpdate("""
CREATE TABLE IF NOT EXISTS %srace_times (
uuid CHAR(36) NOT NULL,
race_name VARCHAR(64) NOT NULL,
time_ms BIGINT NOT NULL,
PRIMARY KEY (uuid, race_name)
)""".formatted(tablePrefix));
}
logger.info("Connected to MySQL stats database at " + host + ":" + port + "/" + database);
}
@Override
public void close() {
if (dataSource != null) {
dataSource.close();
}
}
@Override
public PlayerStats getStats(UUID uuid) {
String sql = "SELECT races_played, wins, total_laps, best_time_ms FROM " + tablePrefix
+ "players WHERE uuid = ?";
try (Connection connection = dataSource.getConnection();
PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, uuid.toString());
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
return PlayerStats.empty(uuid);
}
long best = rs.getLong("best_time_ms");
Long bestBoxed = rs.wasNull() ? null : best;
return new PlayerStats(uuid, rs.getInt("races_played"), rs.getInt("wins"),
rs.getInt("total_laps"), bestBoxed);
}
} catch (SQLException e) {
logger.warning("Failed to load stats for " + uuid + ": " + e.getMessage());
return PlayerStats.empty(uuid);
}
}
@Override
public void recordFinish(UUID uuid, String playerName, String raceName, int place, long timeMillis, int laps) {
String upsertPlayer = "INSERT INTO " + tablePrefix + "players "
+ "(uuid, name, races_played, wins, total_laps, best_time_ms) VALUES (?, ?, 1, ?, ?, ?) "
+ "ON DUPLICATE KEY UPDATE name = VALUES(name), "
+ "races_played = races_played + 1, "
+ "wins = wins + VALUES(wins), "
+ "total_laps = total_laps + VALUES(total_laps), "
+ "best_time_ms = LEAST(COALESCE(best_time_ms, VALUES(best_time_ms)), VALUES(best_time_ms))";
String upsertRaceTime = "INSERT INTO " + tablePrefix + "race_times (uuid, race_name, time_ms) "
+ "VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE time_ms = LEAST(time_ms, VALUES(time_ms))";
try (Connection connection = dataSource.getConnection()) {
try (PreparedStatement ps = connection.prepareStatement(upsertPlayer)) {
ps.setString(1, uuid.toString());
ps.setString(2, playerName);
ps.setInt(3, place == 1 ? 1 : 0);
ps.setInt(4, laps);
ps.setLong(5, timeMillis);
ps.executeUpdate();
}
try (PreparedStatement ps = connection.prepareStatement(upsertRaceTime)) {
ps.setString(1, uuid.toString());
ps.setString(2, raceName);
ps.setLong(3, timeMillis);
ps.executeUpdate();
}
} catch (SQLException e) {
logger.warning("Failed to record race finish for " + uuid + ": " + e.getMessage());
}
}
@Override
public List<TopEntry> getTopTimes(String raceName, int limit) {
String sql = "SELECT rt.uuid, p.name, rt.time_ms FROM " + tablePrefix + "race_times rt "
+ "JOIN " + tablePrefix + "players p ON p.uuid = rt.uuid "
+ "WHERE rt.race_name = ? ORDER BY rt.time_ms ASC LIMIT ?";
List<TopEntry> entries = new ArrayList<>();
try (Connection connection = dataSource.getConnection();
PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, raceName);
ps.setInt(2, limit);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
entries.add(new TopEntry(UUID.fromString(rs.getString("uuid")), rs.getString("name"),
rs.getLong("time_ms")));
}
}
} catch (SQLException e) {
logger.warning("Failed to load top times for " + raceName + ": " + e.getMessage());
}
return entries;
}
}
@@ -0,0 +1,44 @@
package us.tss3.boatparty.stats;
import java.util.UUID;
public final class PlayerStats {
private final UUID uuid;
private final int racesPlayed;
private final int wins;
private final int totalLaps;
private final Long bestTimeMillis;
public PlayerStats(UUID uuid, int racesPlayed, int wins, int totalLaps, Long bestTimeMillis) {
this.uuid = uuid;
this.racesPlayed = racesPlayed;
this.wins = wins;
this.totalLaps = totalLaps;
this.bestTimeMillis = bestTimeMillis;
}
public static PlayerStats empty(UUID uuid) {
return new PlayerStats(uuid, 0, 0, 0, null);
}
public UUID getUuid() {
return uuid;
}
public int getRacesPlayed() {
return racesPlayed;
}
public int getWins() {
return wins;
}
public int getTotalLaps() {
return totalLaps;
}
public Long getBestTimeMillis() {
return bestTimeMillis;
}
}
@@ -0,0 +1,75 @@
package us.tss3.boatparty.stats;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.List;
import java.util.UUID;
import java.util.function.Consumer;
public final class StatsManager {
private final JavaPlugin plugin;
private StatsStorage storage;
public StatsManager(JavaPlugin plugin) {
this.plugin = plugin;
this.storage = buildStorage(plugin);
}
private static StatsStorage buildStorage(JavaPlugin plugin) {
FileConfiguration config = plugin.getConfig();
String type = config.getString("storage.type", "yaml");
if ("mysql".equalsIgnoreCase(type)) {
return new MySqlStatsStorage(
config.getString("storage.mysql.host", "localhost"),
config.getInt("storage.mysql.port", 3306),
config.getString("storage.mysql.database", "boatparty"),
config.getString("storage.mysql.username", "root"),
config.getString("storage.mysql.password", ""),
config.getBoolean("storage.mysql.use-ssl", false),
config.getString("storage.mysql.table-prefix", "boatparty_"),
plugin.getLogger());
}
return new YamlStatsStorage(plugin.getDataFolder(), plugin.getLogger());
}
public void init() {
try {
storage.init();
} catch (Exception e) {
plugin.getLogger().severe("Failed to initialize " + storage.getClass().getSimpleName()
+ ", falling back to YAML stats storage: " + e.getMessage());
storage = new YamlStatsStorage(plugin.getDataFolder(), plugin.getLogger());
try {
storage.init();
} catch (Exception fallbackError) {
throw new IllegalStateException("Failed to initialize fallback YAML stats storage", fallbackError);
}
}
}
public void close() {
storage.close();
}
public void recordFinishAsync(UUID uuid, String playerName, String raceName, int place, long timeMillis, int laps) {
plugin.getServer().getScheduler().runTaskAsynchronously(plugin,
() -> storage.recordFinish(uuid, playerName, raceName, place, timeMillis, laps));
}
public void getStatsAsync(UUID uuid, Consumer<PlayerStats> callback) {
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
PlayerStats stats = storage.getStats(uuid);
plugin.getServer().getScheduler().runTask(plugin, () -> callback.accept(stats));
});
}
public void getTopTimesAsync(String raceName, int limit, Consumer<List<TopEntry>> callback) {
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
List<TopEntry> entries = storage.getTopTimes(raceName, limit);
plugin.getServer().getScheduler().runTask(plugin, () -> callback.accept(entries));
});
}
}
@@ -0,0 +1,21 @@
package us.tss3.boatparty.stats;
import java.util.List;
import java.util.UUID;
/**
* Blocking storage backend for player stats. Callers (StatsManager) are
* responsible for invoking these off the main server thread.
*/
public interface StatsStorage {
void init() throws Exception;
void close();
PlayerStats getStats(UUID uuid);
void recordFinish(UUID uuid, String playerName, String raceName, int place, long timeMillis, int laps);
List<TopEntry> getTopTimes(String raceName, int limit);
}
@@ -0,0 +1,28 @@
package us.tss3.boatparty.stats;
import java.util.UUID;
public final class TopEntry {
private final UUID uuid;
private final String name;
private final long timeMillis;
public TopEntry(UUID uuid, String name, long timeMillis) {
this.uuid = uuid;
this.name = name;
this.timeMillis = timeMillis;
}
public UUID getUuid() {
return uuid;
}
public String getName() {
return name;
}
public long getTimeMillis() {
return timeMillis;
}
}
@@ -0,0 +1,105 @@
package us.tss3.boatparty.stats;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import java.util.logging.Logger;
public final class YamlStatsStorage implements StatsStorage {
private final File file;
private final Logger logger;
private YamlConfiguration yaml;
public YamlStatsStorage(File dataFolder, Logger logger) {
this.file = new File(dataFolder, "stats.yml");
this.logger = logger;
}
@Override
public synchronized void init() {
yaml = file.exists() ? YamlConfiguration.loadConfiguration(file) : new YamlConfiguration();
}
@Override
public void close() {
save();
}
@Override
public synchronized PlayerStats getStats(UUID uuid) {
ConfigurationSection s = yaml.getConfigurationSection("players." + uuid);
if (s == null) {
return PlayerStats.empty(uuid);
}
Long best = s.contains("best-time-ms") ? s.getLong("best-time-ms") : null;
return new PlayerStats(uuid, s.getInt("races-played"), s.getInt("wins"), s.getInt("total-laps"), best);
}
@Override
public synchronized void recordFinish(UUID uuid, String playerName, String raceName, int place, long timeMillis, int laps) {
String base = "players." + uuid;
ConfigurationSection s = yaml.getConfigurationSection(base);
if (s == null) {
s = yaml.createSection(base);
}
s.set("name", playerName);
s.set("races-played", s.getInt("races-played") + 1);
s.set("total-laps", s.getInt("total-laps") + laps);
if (place == 1) {
s.set("wins", s.getInt("wins") + 1);
}
if (!s.contains("best-time-ms") || timeMillis < s.getLong("best-time-ms")) {
s.set("best-time-ms", timeMillis);
}
String raceBase = "race-times." + raceName + "." + uuid;
long existing = yaml.contains(raceBase + ".time-ms") ? yaml.getLong(raceBase + ".time-ms") : Long.MAX_VALUE;
if (timeMillis < existing) {
yaml.set(raceBase + ".time-ms", timeMillis);
yaml.set(raceBase + ".name", playerName);
}
save();
}
@Override
public synchronized List<TopEntry> getTopTimes(String raceName, int limit) {
List<TopEntry> entries = new ArrayList<>();
ConfigurationSection section = yaml.getConfigurationSection("race-times." + raceName);
if (section == null) {
return entries;
}
for (String key : section.getKeys(false)) {
ConfigurationSection e = section.getConfigurationSection(key);
if (e == null) {
continue;
}
try {
UUID uuid = UUID.fromString(key);
entries.add(new TopEntry(uuid, e.getString("name", "?"), e.getLong("time-ms")));
} catch (IllegalArgumentException ignored) {
// skip malformed keys
}
}
entries.sort(Comparator.comparingLong(TopEntry::getTimeMillis));
return entries.size() > limit ? entries.subList(0, limit) : entries;
}
private void save() {
try {
if (!file.getParentFile().exists()) {
file.getParentFile().mkdirs();
}
yaml.save(file);
} catch (IOException e) {
logger.warning("Failed to save stats.yml: " + e.getMessage());
}
}
}
+15 -2
View File
@@ -1,4 +1,17 @@
# BoatParty configuration # BoatParty configuration
# Per-race settings (laps, min players, countdown, checkpoint radius) are stored # Per-race settings (laps, min players, countdown, checkpoint radius) are stored
# in races.yml and set via /boatparty commands. This file is reserved for # in races.yml and set via /boatparty commands.
# future global settings.
storage:
# "yaml" (default, stores in stats.yml) or "mysql"
type: yaml
mysql:
host: localhost
port: 3306
database: boatparty
username: root
password: ""
use-ssl: false
# Prefix applied to all BoatParty tables, e.g. boatparty_players
table-prefix: "boatparty_"