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:
co-authored by
Claude Sonnet 5
parent
d6f1f53681
commit
b56d4b064b
@@ -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
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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_"
|
||||||
|
|||||||
Reference in New Issue
Block a user