diff --git a/README.md b/README.md index 04f563a..4b84681 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ fastest lap times over a configurable number of laps. | `/bp join ` / `/bp leave` | Join or leave a race | | `/bp start ` / `/bp stop ` | Force-start or stop a race (admin) | | `/bp list` / `/bp info ` | List races / show race details | +| `/bp stats [player]` | Show races played, wins, total laps, and best time | +| `/bp top [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. @@ -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.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 ``` ./gradlew build ``` -The compiled plugin jar is produced at `build/libs/BoatParty-.jar`. +The compiled plugin jar is produced at `build/libs/BoatParty-.jar`. It bundles +and relocates HikariCP and the MySQL Connector/J driver, so no extra dependency jars are +needed on the server. ## 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) diff --git a/build.gradle.kts b/build.gradle.kts index 3292279..7496952 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,6 @@ plugins { java - id("com.gradleup.shadow") version "8.3.5" + id("com.gradleup.shadow") version "9.0.2" } group = "us.tss3.boatparty" @@ -19,6 +19,9 @@ repositories { dependencies { 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 { @@ -36,6 +39,13 @@ tasks { shadowJar { archiveClassifier.set("") 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 { diff --git a/src/main/java/us/tss3/boatparty/BoatPartyPlugin.java b/src/main/java/us/tss3/boatparty/BoatPartyPlugin.java index 594be2f..31ce411 100644 --- a/src/main/java/us/tss3/boatparty/BoatPartyPlugin.java +++ b/src/main/java/us/tss3/boatparty/BoatPartyPlugin.java @@ -4,10 +4,12 @@ import org.bukkit.plugin.java.JavaPlugin; import us.tss3.boatparty.command.BoatPartyCommand; import us.tss3.boatparty.game.RaceManager; import us.tss3.boatparty.listener.RaceListener; +import us.tss3.boatparty.stats.StatsManager; public final class BoatPartyPlugin extends JavaPlugin { private RaceManager raceManager; + private StatsManager statsManager; @Override public void onEnable() { @@ -16,14 +18,17 @@ public final class BoatPartyPlugin extends JavaPlugin { this.raceManager = new RaceManager(this); 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"); if (command != null) { command.setExecutor(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."); } @@ -34,9 +39,16 @@ public final class BoatPartyPlugin extends JavaPlugin { raceManager.stopAllRaces(); raceManager.save(); } + if (statsManager != null) { + statsManager.close(); + } } public RaceManager getRaceManager() { return raceManager; } + + public StatsManager getStatsManager() { + return statsManager; + } } diff --git a/src/main/java/us/tss3/boatparty/command/BoatPartyCommand.java b/src/main/java/us/tss3/boatparty/command/BoatPartyCommand.java index e8f1c79..a777441 100644 --- a/src/main/java/us/tss3/boatparty/command/BoatPartyCommand.java +++ b/src/main/java/us/tss3/boatparty/command/BoatPartyCommand.java @@ -7,10 +7,15 @@ import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; import org.bukkit.command.TabCompleter; import org.bukkit.entity.Player; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; import us.tss3.boatparty.BoatPartyPlugin; import us.tss3.boatparty.game.Race; import us.tss3.boatparty.game.RaceManager; 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.List; @@ -22,14 +27,16 @@ public final class BoatPartyCommand implements CommandExecutor, TabCompleter { private static final List SUBCOMMANDS = List.of( "create", "delete", "setlobby", "setstart", "addcheckpoint", "removecheckpoint", "setlaps", "setminplayers", "setcountdown", "setradius", "join", "leave", - "start", "stop", "list", "info"); + "start", "stop", "list", "info", "stats", "top"); private final BoatPartyPlugin plugin; 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.raceManager = raceManager; + this.statsManager = statsManager; } @Override @@ -57,6 +64,8 @@ public final class BoatPartyCommand implements CommandExecutor, TabCompleter { case "stop" -> handleStop(sender, args); case "list" -> handleList(sender); case "info" -> handleInfo(sender, args); + case "stats" -> handleStats(sender, args); + case "top" -> handleTop(sender, args); default -> sendHelp(sender); } return true; @@ -305,6 +314,60 @@ public final class BoatPartyCommand implements CommandExecutor, TabCompleter { 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 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) { List lines = List.of( "&b&lBoatParty &7- ice boat racing", @@ -316,7 +379,9 @@ public final class BoatPartyCommand implements CommandExecutor, TabCompleter { "&7/boatparty join|leave ", "&7/boatparty start|stop ", "&7/boatparty list", - "&7/boatparty info "); + "&7/boatparty info ", + "&7/boatparty stats [player]", + "&7/boatparty top [limit]"); for (String line : lines) { sender.sendMessage(Component.text(line.replace("&", "§"))); } diff --git a/src/main/java/us/tss3/boatparty/listener/RaceListener.java b/src/main/java/us/tss3/boatparty/listener/RaceListener.java index 0623bb2..2b834cf 100644 --- a/src/main/java/us/tss3/boatparty/listener/RaceListener.java +++ b/src/main/java/us/tss3/boatparty/listener/RaceListener.java @@ -15,6 +15,7 @@ import us.tss3.boatparty.game.PlayerProgress; import us.tss3.boatparty.game.Race; import us.tss3.boatparty.game.RaceManager; import us.tss3.boatparty.game.RaceState; +import us.tss3.boatparty.stats.StatsManager; import java.util.ArrayList; import java.util.List; @@ -24,10 +25,12 @@ public final class RaceListener implements Listener { private final BoatPartyPlugin plugin; 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.raceManager = raceManager; + this.statsManager = statsManager; } @EventHandler @@ -86,6 +89,9 @@ public final class RaceListener implements Listener { int place = race.getFinishOrder().size(); double seconds = progress.elapsedMillis() / 1000.0; + statsManager.recordFinishAsync(player.getUniqueId(), player.getName(), race.getName(), + place, progress.elapsedMillis(), race.getLaps()); + for (UUID uuid : race.getParticipants()) { Player p = plugin.getServer().getPlayer(uuid); if (p != null) { diff --git a/src/main/java/us/tss3/boatparty/stats/MySqlStatsStorage.java b/src/main/java/us/tss3/boatparty/stats/MySqlStatsStorage.java new file mode 100644 index 0000000..0e7d8d3 --- /dev/null +++ b/src/main/java/us/tss3/boatparty/stats/MySqlStatsStorage.java @@ -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 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 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; + } +} diff --git a/src/main/java/us/tss3/boatparty/stats/PlayerStats.java b/src/main/java/us/tss3/boatparty/stats/PlayerStats.java new file mode 100644 index 0000000..23b99f9 --- /dev/null +++ b/src/main/java/us/tss3/boatparty/stats/PlayerStats.java @@ -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; + } +} diff --git a/src/main/java/us/tss3/boatparty/stats/StatsManager.java b/src/main/java/us/tss3/boatparty/stats/StatsManager.java new file mode 100644 index 0000000..dee4ca8 --- /dev/null +++ b/src/main/java/us/tss3/boatparty/stats/StatsManager.java @@ -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 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> callback) { + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + List entries = storage.getTopTimes(raceName, limit); + plugin.getServer().getScheduler().runTask(plugin, () -> callback.accept(entries)); + }); + } +} diff --git a/src/main/java/us/tss3/boatparty/stats/StatsStorage.java b/src/main/java/us/tss3/boatparty/stats/StatsStorage.java new file mode 100644 index 0000000..9b583b5 --- /dev/null +++ b/src/main/java/us/tss3/boatparty/stats/StatsStorage.java @@ -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 getTopTimes(String raceName, int limit); +} diff --git a/src/main/java/us/tss3/boatparty/stats/TopEntry.java b/src/main/java/us/tss3/boatparty/stats/TopEntry.java new file mode 100644 index 0000000..8645523 --- /dev/null +++ b/src/main/java/us/tss3/boatparty/stats/TopEntry.java @@ -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; + } +} diff --git a/src/main/java/us/tss3/boatparty/stats/YamlStatsStorage.java b/src/main/java/us/tss3/boatparty/stats/YamlStatsStorage.java new file mode 100644 index 0000000..526cd0a --- /dev/null +++ b/src/main/java/us/tss3/boatparty/stats/YamlStatsStorage.java @@ -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 getTopTimes(String raceName, int limit) { + List 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()); + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 7774dd0..191faac 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,4 +1,17 @@ # BoatParty configuration # Per-race settings (laps, min players, countdown, checkpoint radius) are stored -# in races.yml and set via /boatparty commands. This file is reserved for -# future global settings. +# in races.yml and set via /boatparty commands. + +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_"