From 511d0e8d4baa3d3d4aface8190285e4533fbc76f Mon Sep 17 00:00:00 2001 From: Dakroach Date: Sun, 21 Jun 2026 11:51:38 -0700 Subject: [PATCH] feat: Implement minigame statistics tracking and persistence - Add MinigameStatsDamageSystem to record assists when players damage each other. - Introduce MinigameStatsDeathSystem to track kills, deaths, and assists on player death. - Create MinigameStatsListener to bridge minigame events with the stats service. - Develop MinigameStatsService to manage live stats tracking and persistence. - Implement PlayerStatAccumulator for per-player stat accumulation. - Define StatKeys for well-known stat keys used in tracking. - Create database models: GameRecord, ParticipantRecord, and MinigameStatsRepository for data persistence. - Add SqliteStatsDatabase for SQLite backend implementation. - Establish StatsDatabase interface for database connection management. - Update language file to improve clarity in minigame messaging. --- .../kewwbec/minigames/MinigameCorePlugin.java | 42 ++- .../minigames/service/MinigameServices.java | 4 +- .../minigames/stats/GameStatsSession.java | 54 ++++ .../stats/MinigameStatsDamageSystem.java | 78 +++++ .../stats/MinigameStatsDeathSystem.java | 100 ++++++ .../stats/MinigameStatsListener.java | 51 +++ .../minigames/stats/MinigameStatsService.java | 302 ++++++++++++++++++ .../stats/PlayerStatAccumulator.java | 61 ++++ .../net/kewwbec/minigames/stats/StatKeys.java | 35 ++ .../minigames/stats/db/GameRecord.java | 20 ++ .../stats/db/MinigameStatsRepository.java | 229 +++++++++++++ .../minigames/stats/db/ParticipantRecord.java | 25 ++ .../stats/db/SqliteStatsDatabase.java | 160 ++++++++++ .../minigames/stats/db/StatsDatabase.java | 38 +++ .../Server/Languages/en-US/server.lang | 2 +- 15 files changed, 1198 insertions(+), 3 deletions(-) create mode 100644 src/main/java/net/kewwbec/minigames/stats/GameStatsSession.java create mode 100644 src/main/java/net/kewwbec/minigames/stats/MinigameStatsDamageSystem.java create mode 100644 src/main/java/net/kewwbec/minigames/stats/MinigameStatsDeathSystem.java create mode 100644 src/main/java/net/kewwbec/minigames/stats/MinigameStatsListener.java create mode 100644 src/main/java/net/kewwbec/minigames/stats/MinigameStatsService.java create mode 100644 src/main/java/net/kewwbec/minigames/stats/PlayerStatAccumulator.java create mode 100644 src/main/java/net/kewwbec/minigames/stats/StatKeys.java create mode 100644 src/main/java/net/kewwbec/minigames/stats/db/GameRecord.java create mode 100644 src/main/java/net/kewwbec/minigames/stats/db/MinigameStatsRepository.java create mode 100644 src/main/java/net/kewwbec/minigames/stats/db/ParticipantRecord.java create mode 100644 src/main/java/net/kewwbec/minigames/stats/db/SqliteStatsDatabase.java create mode 100644 src/main/java/net/kewwbec/minigames/stats/db/StatsDatabase.java diff --git a/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java b/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java index 1a4c9a1..829d1f9 100644 --- a/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java +++ b/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java @@ -24,6 +24,14 @@ import net.kewwbec.minigames.service.MinigameMapDiscoveryService; import net.kewwbec.minigames.service.MinigameQueueService; import net.kewwbec.minigames.service.MinigameServices; import net.kewwbec.minigames.service.MinigameVolumeSignalService; +import net.kewwbec.minigames.stats.MinigameStatsDamageSystem; +import net.kewwbec.minigames.stats.MinigameStatsDeathSystem; +import net.kewwbec.minigames.stats.MinigameStatsListener; +import net.kewwbec.minigames.stats.MinigameStatsService; +import net.kewwbec.minigames.stats.db.MinigameStatsRepository; +import net.kewwbec.minigames.stats.db.SqliteStatsDatabase; +import net.kewwbec.minigames.stats.db.StatsDatabase; +import net.kewwbec.minigames.runtime.MinigameEvent; import com.hypixel.hytale.server.core.event.events.player.PlayerConnectEvent; import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent; import net.kewwbec.minigames.system.MinigameConnectionListener; @@ -108,6 +116,7 @@ public final class MinigameCorePlugin extends JavaPlugin { private static MinigameCorePlugin instance; private MinigameServices services; private MinigameQueueUIService queueUIService; + private StatsDatabase statsDatabase; private PacketFilter duplicatePacketFilter; private ComponentType lockMountComponentType; private final java.util.List ownEffectTypeIds = new java.util.ArrayList<>(); @@ -155,8 +164,13 @@ public final class MinigameCorePlugin extends JavaPlugin { getEntityStoreRegistry().registerSystem(new MinigameHudSystem(hudService)); var queueService = new MinigameQueueService(); runtimeService.setQueueService(queueService); + var statsService = setupStats(adapters); this.services = new MinigameServices(minigameService, runtimeService, adapters, - new MinigameMapDiscoveryService(), queueService, signals, menuService); + new MinigameMapDiscoveryService(), queueService, signals, menuService, statsService); + getEntityStoreRegistry().registerSystem(new MinigameStatsDeathSystem()); + getEntityStoreRegistry().registerSystem(new MinigameStatsDamageSystem()); + var statsListener = new MinigameStatsListener(statsService, runtimeService); + getEventRegistry().registerGlobal(MinigameEvent.class, statsListener::onMinigameEvent); getEntityStoreRegistry().registerSystem(new MinigamePregameSystem(services)); this.queueUIService = new MinigameQueueUIService(services); this.queueUIService.setPlayerAdapter(adapters); @@ -180,12 +194,38 @@ public final class MinigameCorePlugin extends JavaPlugin { + " trigger conditions and " + registeredTriggerEffectCount() + " trigger effects."); } + /** + * Builds the stats service. The SQLite store lives under the plugin data directory now; the + * pivot to the network-wide MySQL pool only swaps the {@link StatsDatabase} implementation here. + * A database failure degrades gracefully: stats are still tracked in memory, just not persisted. + */ + private MinigameStatsService setupStats(@Nonnull HytaleServerAdapters adapters) { + MinigameStatsRepository repository = null; + try { + java.nio.file.Path dir = getDataDirectory(); + java.nio.file.Files.createDirectories(dir); + String url = "jdbc:sqlite:" + dir.resolve("minigame-stats.db"); + SqliteStatsDatabase db = new SqliteStatsDatabase(url); + db.start(); + this.statsDatabase = db; + repository = new MinigameStatsRepository(db); + } catch (Exception e) { + ((HytaleLogger.Api) LOGGER.at(Level.SEVERE).withCause(e)) + .log("Failed to initialize minigame stats database; stats will be tracked but not persisted"); + } + return new MinigameStatsService(repository, adapters::getDisplayName); + } + @Override public void shutdown() { if (services != null) { services.runtime().shutdownAll("minigames.runtime.reason.plugin_shutdown"); services.signals().shutdown(); } + if (statsDatabase != null) { + statsDatabase.close(); + statsDatabase = null; + } if (duplicatePacketFilter != null) { PacketAdapters.deregisterInbound(duplicatePacketFilter); duplicatePacketFilter = null; diff --git a/src/main/java/net/kewwbec/minigames/service/MinigameServices.java b/src/main/java/net/kewwbec/minigames/service/MinigameServices.java index 7e3797a..cdb3bf6 100644 --- a/src/main/java/net/kewwbec/minigames/service/MinigameServices.java +++ b/src/main/java/net/kewwbec/minigames/service/MinigameServices.java @@ -2,6 +2,7 @@ package net.kewwbec.minigames.service; import net.kewwbec.minigames.adapter.HytaleServerAdapters; import net.kewwbec.minigames.runtime.MinigameRuntimeService; +import net.kewwbec.minigames.stats.MinigameStatsService; import net.kewwbec.minigames.ui.MinigameMenuService; public record MinigameServices( @@ -11,6 +12,7 @@ public record MinigameServices( MinigameMapDiscoveryService maps, MinigameQueueService queue, MinigameVolumeSignalService signals, - MinigameMenuService menus + MinigameMenuService menus, + MinigameStatsService stats ) { } diff --git a/src/main/java/net/kewwbec/minigames/stats/GameStatsSession.java b/src/main/java/net/kewwbec/minigames/stats/GameStatsSession.java new file mode 100644 index 0000000..a909028 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/stats/GameStatsSession.java @@ -0,0 +1,54 @@ +package net.kewwbec.minigames.stats; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * In-memory record of everything tracked during one running game. One session exists per active + * runtime; it is created at game start and discarded once persisted at game end. + */ +public final class GameStatsSession { + private final String gameId; + private final String runtimeId; + private final String minigameId; + private final String mapId; + private final String variantId; + private final Instant startedAt; + private int roundCount; + private final Map players = new LinkedHashMap<>(); + private final Map attributes = new LinkedHashMap<>(); + + public GameStatsSession(String gameId, String runtimeId, String minigameId, String mapId, String variantId, Instant startedAt) { + this.gameId = gameId; + this.runtimeId = runtimeId; + this.minigameId = minigameId; + this.mapId = mapId; + this.variantId = variantId; + this.startedAt = startedAt; + } + + public String gameId() { return gameId; } + public String runtimeId() { return runtimeId; } + public String minigameId() { return minigameId; } + public String mapId() { return mapId; } + public String variantId() { return variantId; } + public Instant startedAt() { return startedAt; } + + public int roundCount() { return roundCount; } + public void roundCount(int roundCount) { this.roundCount = roundCount; } + + /** Get-or-create the accumulator for a player. */ + public PlayerStatAccumulator player(String playerId) { + return players.computeIfAbsent(playerId, PlayerStatAccumulator::new); + } + + public Map players() { + return players; + } + + /** Game-level extras (winner, end reason, custom game-wide tallies). */ + public Map attributes() { + return attributes; + } +} diff --git a/src/main/java/net/kewwbec/minigames/stats/MinigameStatsDamageSystem.java b/src/main/java/net/kewwbec/minigames/stats/MinigameStatsDamageSystem.java new file mode 100644 index 0000000..6c603fe --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/stats/MinigameStatsDamageSystem.java @@ -0,0 +1,78 @@ +package net.kewwbec.minigames.stats; + +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.SystemGroup; +import com.hypixel.hytale.component.query.Query; +import com.hypixel.hytale.server.core.modules.entity.damage.Damage; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageEventSystem; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageModule; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import net.kewwbec.minigames.MinigameCorePlugin; +import net.kewwbec.minigames.service.MinigameServices; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Feeds the assist heuristic: every time one player damages another player who is in a minigame, the + * attacker is recorded against the victim with a timestamp. On death, {@link MinigameStatsDeathSystem} + * credits an assist to each recent damager other than the killer (see + * {@link MinigameStatsService#drainAssisters}). Runs in the inspect phase so it observes the final, + * post-mitigation damage amount. + */ +public final class MinigameStatsDamageSystem extends DamageEventSystem { + + @Nonnull + private static final Query QUERY = PlayerRef.getComponentType(); + + @Nonnull + @Override + public Query getQuery() { + return QUERY; + } + + @Nullable + @Override + public SystemGroup getGroup() { + return DamageModule.get().getInspectDamageGroup(); + } + + @Override + public void handle( + int index, + @Nonnull ArchetypeChunk archetypeChunk, + @Nonnull Store store, + @Nonnull CommandBuffer commandBuffer, + @Nonnull Damage damage + ) { + if (damage.getAmount() <= 0.0F || !(damage.getSource() instanceof Damage.EntitySource source)) { + return; + } + Ref attackerRef = source.getRef(); + if (!attackerRef.isValid()) { + return; + } + PlayerRef attacker = commandBuffer.getComponent(attackerRef, PlayerRef.getComponentType()); + if (attacker == null) { + return; // environmental / NPC damage; not an assist source + } + PlayerRef victim = archetypeChunk.getComponent(index, PlayerRef.getComponentType()); + if (victim == null) { + return; + } + String victimId = victim.getUuid().toString(); + String attackerId = attacker.getUuid().toString(); + if (victimId.equals(attackerId)) { + return; + } + MinigameServices services = MinigameCorePlugin.getServices(); + if (services == null || services.runtime().runtimeForPlayer(victimId).isEmpty()) { + return; // victim not in a minigame + } + services.stats().recordDamage(victimId, attackerId); + } +} diff --git a/src/main/java/net/kewwbec/minigames/stats/MinigameStatsDeathSystem.java b/src/main/java/net/kewwbec/minigames/stats/MinigameStatsDeathSystem.java new file mode 100644 index 0000000..a247a05 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/stats/MinigameStatsDeathSystem.java @@ -0,0 +1,100 @@ +package net.kewwbec.minigames.stats; + +import com.hypixel.hytale.component.Archetype; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.query.Query; +import com.hypixel.hytale.server.core.entity.UUIDComponent; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.modules.entity.damage.Damage; +import com.hypixel.hytale.server.core.modules.entity.damage.DeathComponent; +import com.hypixel.hytale.server.core.modules.entity.damage.DeathSystems; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import net.kewwbec.minigames.MinigameCorePlugin; +import net.kewwbec.minigames.runtime.MinigameRuntime; +import net.kewwbec.minigames.service.MinigameServices; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Records KDA into the live stats session on every death. This is the source of truth for + * kills/deaths/assists, independent of whether a scoring volume was involved (the existing + * {@code MinigameKillScoreSystem} only fires when a kill-score volume matches). A death is only + * recorded when the victim is a player currently in a minigame runtime; a kill is credited when the + * killer is a different player in that same runtime. + */ +public final class MinigameStatsDeathSystem extends DeathSystems.OnDeathSystem { + + @Nonnull + private static final Query QUERY = + Archetype.of(DeathComponent.getComponentType(), TransformComponent.getComponentType()); + + @Nonnull + @Override + public Query getQuery() { + return QUERY; + } + + @Override + public void onComponentAdded( + @Nonnull Ref victimRef, + @Nonnull DeathComponent component, + @Nonnull Store store, + @Nonnull CommandBuffer commandBuffer + ) { + String victimId = playerId(store, victimRef); + if (victimId == null || victimId.isBlank()) { + return; // non-player death (NPC/mob); not part of player KDA + } + + MinigameServices services = MinigameCorePlugin.getServices(); + if (services == null) { + return; + } + MinigameRuntime runtime = services.runtime().runtimeForPlayer(victimId).orElse(null); + if (runtime == null) { + return; // victim not in a minigame + } + + services.stats().recordDeath(runtime.runtimeId(), victimId); + + String killerId = attackerPlayerId(store, component); + if (killerId != null && !killerId.isBlank() && !killerId.equals(victimId) + && runtime.players().containsKey(killerId)) { + services.stats().recordKill(runtime.runtimeId(), killerId); + } + + // Credit an assist to every other recent damager who is still in this game. + for (String assisterId : services.stats().drainAssisters(victimId, killerId)) { + if (runtime.players().containsKey(assisterId)) { + services.stats().recordAssist(runtime.runtimeId(), assisterId); + } + } + } + + @Nullable + private static String attackerPlayerId(@Nonnull Store store, @Nonnull DeathComponent component) { + Damage deathInfo = component.getDeathInfo(); + if (deathInfo == null || !(deathInfo.getSource() instanceof Damage.EntitySource source)) { + return null; + } + Ref attackerRef = source.getRef(); + if (!attackerRef.isValid()) { + return null; + } + return playerId(store, attackerRef); + } + + @Nullable + private static String playerId(@Nonnull Store store, @Nonnull Ref ref) { + PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType()); + if (player != null) { + return player.getUuid().toString(); + } + UUIDComponent uuid = store.getComponent(ref, UUIDComponent.getComponentType()); + return uuid != null ? uuid.getUuid().toString() : null; + } +} diff --git a/src/main/java/net/kewwbec/minigames/stats/MinigameStatsListener.java b/src/main/java/net/kewwbec/minigames/stats/MinigameStatsListener.java new file mode 100644 index 0000000..568df18 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/stats/MinigameStatsListener.java @@ -0,0 +1,51 @@ +package net.kewwbec.minigames.stats; + +import net.kewwbec.minigames.runtime.MinigameEvent; +import net.kewwbec.minigames.runtime.MinigameRuntime; +import net.kewwbec.minigames.runtime.MinigameRuntimeService; + +import javax.annotation.Nonnull; +import java.util.Map; + +/** + * Bridges the {@code MinigameEvent} bus to the stats service. Registered globally so it observes + * every minigame's lifecycle. All callbacks run synchronously on the dispatching world thread, + * which for {@code on_game_end} is guaranteed to be before the runtime state is cleared. + */ +public final class MinigameStatsListener { + + private final MinigameStatsService stats; + private final MinigameRuntimeService runtime; + + public MinigameStatsListener(@Nonnull MinigameStatsService stats, @Nonnull MinigameRuntimeService runtime) { + this.stats = stats; + this.runtime = runtime; + } + + public void onMinigameEvent(@Nonnull MinigameEvent event) { + Map payload = event.payload(); + String runtimeId = string(payload.get("runtime_id")); + if (runtimeId == null) { + return; + } + switch (event.eventId()) { + case "on_game_start" -> withRuntime(runtimeId, stats::beginGame); + case "on_round_end" -> withRuntime(runtimeId, rt -> stats.recordRoundPlacement(rt, intValue(payload.get("round")))); + case "on_game_end" -> withRuntime(runtimeId, rt -> stats.finishGame(rt, string(payload.get("reason")))); + case "on_game_cancelled" -> stats.abortGame(runtimeId); + default -> { /* not a stats-relevant event */ } + } + } + + private void withRuntime(String runtimeId, java.util.function.Consumer action) { + runtime.runtimeById(runtimeId).ifPresent(action); + } + + private static String string(Object value) { + return value instanceof String s && !s.isBlank() ? s : null; + } + + private static int intValue(Object value) { + return value instanceof Number n ? n.intValue() : 0; + } +} diff --git a/src/main/java/net/kewwbec/minigames/stats/MinigameStatsService.java b/src/main/java/net/kewwbec/minigames/stats/MinigameStatsService.java new file mode 100644 index 0000000..7a282d2 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/stats/MinigameStatsService.java @@ -0,0 +1,302 @@ +package net.kewwbec.minigames.stats; + +import com.google.gson.Gson; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.HytaleServer; +import net.kewwbec.minigames.model.PlayerMinigameState; +import net.kewwbec.minigames.model.TeamState; +import net.kewwbec.minigames.runtime.MinigameRuntime; +import net.kewwbec.minigames.runtime.Rankings; +import net.kewwbec.minigames.stats.db.GameRecord; +import net.kewwbec.minigames.stats.db.MinigameStatsRepository; +import net.kewwbec.minigames.stats.db.ParticipantRecord; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.UnaryOperator; +import java.util.logging.Level; + +import static net.kewwbec.minigames.model.Enums.PlayerStatus; +import static net.kewwbec.minigames.model.Enums.WinCondition; + +/** + * Tracks everything that happens during a running minigame and persists it at game end. All stats + * are open-ended named counters (see {@link StatKeys}); recording a brand-new stat anywhere in the + * codebase is a single {@link #incrementStat} / {@link #setPlayerAttribute} call with no schema or + * model change. A known core subset is promoted to typed columns at persist time; everything else + * is written to {@code extras_json} automatically. + * + *

Live mutation happens on the runtime's home-world thread (same thread the lifecycle hooks and + * death system run on), so the per-runtime maps need no locking beyond the concurrent session map. + * The database write is handed off to {@link HytaleServer#SCHEDULED_EXECUTOR} so the game thread + * never blocks on I/O. + */ +public final class MinigameStatsService { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + private static final Gson GSON = new Gson(); + + /** A damager is credited with an assist if their last hit landed within this window before the death. */ + private static final long ASSIST_WINDOW_MS = 10_000L; + + private final Map sessions = new ConcurrentHashMap<>(); + /** victimId -> (attackerId -> last-hit epoch millis). Populated by the damage system, drained on death. */ + private final Map> recentDamage = new ConcurrentHashMap<>(); + @Nullable private final MinigameStatsRepository repository; + private final UnaryOperator usernameLookup; + + /** + * @param repository persistence target; if {@code null} (e.g. DB unavailable) stats are still + * tracked in memory but never written. + * @param usernameLookup resolves a player id to a display name (e.g. {@code adapters::getDisplayName}). + */ + public MinigameStatsService(@Nullable MinigameStatsRepository repository, @Nonnull UnaryOperator usernameLookup) { + this.repository = repository; + this.usernameLookup = usernameLookup; + } + + // --- lifecycle ------------------------------------------------------------------------------- + + /** Starts tracking a runtime. Safe to call more than once; the first call wins. */ + public void beginGame(@Nonnull MinigameRuntime runtime) { + sessions.computeIfAbsent(runtime.runtimeId(), id -> new GameStatsSession( + UUID.randomUUID().toString(), + runtime.runtimeId(), + runtime.minigameId(), + runtime.mapId(), + runtime.variantId(), + Instant.now() + )); + } + + /** Drops a session without persisting (cancelled pregames, etc.). */ + public void abortGame(@Nonnull String runtimeId) { + sessions.remove(runtimeId); + } + + @Nullable + public GameStatsSession session(@Nonnull String runtimeId) { + return sessions.get(runtimeId); + } + + // --- recording (open-ended; add new stats with no schema change) ----------------------------- + + public void incrementStat(@Nonnull String runtimeId, @Nonnull String playerId, @Nonnull String key, long amount) { + GameStatsSession session = sessions.get(runtimeId); + if (session != null) { + session.player(playerId).increment(key, amount); + } + } + + public void setPlayerAttribute(@Nonnull String runtimeId, @Nonnull String playerId, @Nonnull String key, @Nonnull Object value) { + GameStatsSession session = sessions.get(runtimeId); + if (session != null) { + session.player(playerId).putAttribute(key, value); + } + } + + public void recordKill(@Nonnull String runtimeId, @Nonnull String playerId) { + incrementStat(runtimeId, playerId, StatKeys.KILLS, 1); + } + + public void recordDeath(@Nonnull String runtimeId, @Nonnull String playerId) { + incrementStat(runtimeId, playerId, StatKeys.DEATHS, 1); + } + + public void recordAssist(@Nonnull String runtimeId, @Nonnull String playerId) { + incrementStat(runtimeId, playerId, StatKeys.ASSISTS, 1); + } + + /** Records that {@code attackerId} damaged {@code victimId} (assist heuristic input). */ + public void recordDamage(@Nonnull String victimId, @Nonnull String attackerId) { + if (victimId.equals(attackerId)) { + return; + } + recentDamage.computeIfAbsent(victimId, k -> new ConcurrentHashMap<>()) + .put(attackerId, System.currentTimeMillis()); + } + + /** + * Returns the attackers who damaged {@code victimId} within the assist window, excluding the + * killer, and clears the victim's recorded damage. Called by the death system to award assists. + */ + @Nonnull + public List drainAssisters(@Nonnull String victimId, @Nullable String killerId) { + Map hits = recentDamage.remove(victimId); + if (hits == null || hits.isEmpty()) { + return List.of(); + } + long cutoff = System.currentTimeMillis() - ASSIST_WINDOW_MS; + List assisters = new ArrayList<>(); + for (Map.Entry e : hits.entrySet()) { + if (e.getValue() >= cutoff && !e.getKey().equals(killerId)) { + assisters.add(e.getKey()); + } + } + return assisters; + } + + /** Snapshots the current ranking as each player's placement for {@code roundNumber}. */ + public void recordRoundPlacement(@Nonnull MinigameRuntime runtime, int roundNumber) { + GameStatsSession session = sessions.get(runtime.runtimeId()); + if (session == null) { + return; + } + session.roundCount(Math.max(session.roundCount(), roundNumber)); + List ranked = Rankings.rankedPlayerIds(runtime, false); + for (int i = 0; i < ranked.size(); i++) { + String playerId = ranked.get(i); + @SuppressWarnings("unchecked") + Map byRound = (Map) session.player(playerId) + .attributes().computeIfAbsent(StatKeys.ATTR_ROUND_PLACEMENTS, k -> new LinkedHashMap()); + byRound.put(Integer.toString(roundNumber), i + 1); + } + } + + // --- finish ---------------------------------------------------------------------------------- + + /** + * Finalizes scores/placement/win from the live runtime, builds immutable records, and hands them + * to the database off-thread. Must be called while the runtime state is still intact (i.e. during + * the {@code on_game_end} dispatch, before cleanup). + */ + public void finishGame(@Nonnull MinigameRuntime runtime, @Nullable String reason) { + GameStatsSession session = sessions.remove(runtime.runtimeId()); + if (session == null) { + return; + } + runtime.players().keySet().forEach(recentDamage::remove); + Instant endedAt = Instant.now(); + + List ranked = Rankings.rankedPlayerIds(runtime, false); + Map placement = new LinkedHashMap<>(); + for (int i = 0; i < ranked.size(); i++) { + placement.put(ranked.get(i), i + 1); + } + String winnerPlayerId = ranked.isEmpty() ? null : ranked.get(0); + String winnerTeamId = resolveWinningTeam(runtime); + + List participants = new ArrayList<>(); + for (PlayerMinigameState state : runtime.players().values()) { + if (state.status() == PlayerStatus.SPECTATOR) { + continue; + } + String playerId = state.playerId(); + PlayerStatAccumulator acc = session.player(playerId); + acc.teamId(state.teamId()); + + Integer place = placement.get(playerId); + boolean win = winnerTeamId != null + ? winnerTeamId.equals(state.teamId()) + : (place != null && place == 1); + long playtimeMs = Math.max(0, endedAt.toEpochMilli() - state.joinedAt().toEpochMilli()); + + // Promote authoritative finals into the counter bag so they flow through one code path. + acc.set(StatKeys.SCORE, state.score()); + acc.set(StatKeys.WIN, win ? 1 : 0); + acc.set(StatKeys.PLAYTIME_MS, playtimeMs); + if (place != null) { + acc.set(StatKeys.PLACEMENT, place); + } + + participants.add(new ParticipantRecord( + session.gameId(), + playerId, + safeUsername(playerId), + session.minigameId(), + state.teamId(), + place, + win, + acc.get(StatKeys.KILLS), + acc.get(StatKeys.DEATHS), + acc.get(StatKeys.ASSISTS), + acc.get(StatKeys.SCORE), + playtimeMs, + extrasJson(acc) + )); + } + + if (reason != null) { + session.attributes().put("end_reason", reason); + } + GameRecord game = new GameRecord( + session.gameId(), + session.minigameId(), + session.mapId(), + session.variantId(), + session.roundCount(), + reason, + winnerPlayerId, + winnerTeamId, + session.startedAt(), + endedAt, + session.attributes().isEmpty() ? null : GSON.toJson(session.attributes()) + ); + + persistAsync(game, participants); + } + + private void persistAsync(GameRecord game, List participants) { + if (repository == null) { + return; + } + HytaleServer.SCHEDULED_EXECUTOR.execute(() -> { + try { + repository.persistGame(game, participants); + } catch (RuntimeException e) { + ((HytaleLogger.Api) LOGGER.at(Level.SEVERE).withCause(e)) + .log("Failed to persist minigame stats for game %s (%s)", game.id(), game.minigameId()); + } + }); + } + + // --- helpers --------------------------------------------------------------------------------- + + /** Serializes everything that is not a promoted core column into a JSON blob (null when empty). */ + @Nullable + private static String extrasJson(PlayerStatAccumulator acc) { + Map extras = new LinkedHashMap<>(); + acc.counters().forEach((key, value) -> { + if (!StatKeys.CORE.contains(key)) { + extras.put(key, value); + } + }); + extras.putAll(acc.attributes()); + return extras.isEmpty() ? null : GSON.toJson(extras); + } + + @Nullable + private static String resolveWinningTeam(MinigameRuntime runtime) { + Map teams = runtime.teams(); + if (teams.isEmpty()) { + return null; + } + boolean lowestWins = switch (runtime.definition().getWinCondition()) { + case LOWEST_SCORE, LOWEST_TIME -> true; + default -> false; + }; + TeamState best = null; + for (TeamState team : teams.values()) { + if (best == null + || (lowestWins ? team.score() < best.score() : team.score() > best.score())) { + best = team; + } + } + return best == null ? null : best.teamId(); + } + + private String safeUsername(String playerId) { + try { + return usernameLookup.apply(playerId); + } catch (RuntimeException e) { + return playerId; + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/stats/PlayerStatAccumulator.java b/src/main/java/net/kewwbec/minigames/stats/PlayerStatAccumulator.java new file mode 100644 index 0000000..e63ecdc --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/stats/PlayerStatAccumulator.java @@ -0,0 +1,61 @@ +package net.kewwbec.minigames.stats; + +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Mutable per-player tally for a single game. Holds two open-ended bags: + *

    + *
  • {@code counters} - additive numeric stats (kills, deaths, score, and any future counter).
  • + *
  • {@code attributes} - arbitrary structured values (e.g. per-round placement lists).
  • + *
+ * Nothing here is hard-coded per stat, so new stats need no changes to this class. + */ +public final class PlayerStatAccumulator { + private final String playerId; + @Nullable private String teamId; + private final Map counters = new LinkedHashMap<>(); + private final Map attributes = new LinkedHashMap<>(); + + public PlayerStatAccumulator(String playerId) { + this.playerId = playerId; + } + + public String playerId() { + return playerId; + } + + @Nullable + public String teamId() { + return teamId; + } + + public void teamId(@Nullable String teamId) { + this.teamId = teamId; + } + + public void increment(String key, long amount) { + counters.merge(key, amount, Long::sum); + } + + public void set(String key, long value) { + counters.put(key, value); + } + + public long get(String key) { + return counters.getOrDefault(key, 0L); + } + + public Map counters() { + return counters; + } + + public void putAttribute(String key, Object value) { + attributes.put(key, value); + } + + public Map attributes() { + return attributes; + } +} diff --git a/src/main/java/net/kewwbec/minigames/stats/StatKeys.java b/src/main/java/net/kewwbec/minigames/stats/StatKeys.java new file mode 100644 index 0000000..3b57795 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/stats/StatKeys.java @@ -0,0 +1,35 @@ +package net.kewwbec.minigames.stats; + +import java.util.Set; + +/** + * Well-known stat keys. Every stat the service tracks is just a named counter, so adding a new + * one is as simple as picking a key and calling {@link MinigameStatsService#incrementStat}. The + * keys listed in {@link #CORE} are the only ones promoted to typed database columns; any other + * key is persisted automatically into the participant's {@code extras_json} blob with no schema + * change required. + */ +public final class StatKeys { + private StatKeys() { + } + + public static final String KILLS = "kills"; + public static final String DEATHS = "deaths"; + public static final String ASSISTS = "assists"; + public static final String SCORE = "score"; + /** Final finishing position, 1 = first. Filled in at game end from {@code Rankings}. */ + public static final String PLACEMENT = "placement"; + /** 1 if the player (or their team) won, else 0. Filled in at game end. */ + public static final String WIN = "win"; + /** Milliseconds the player spent in the game (join -> end). Filled in at game end. */ + public static final String PLAYTIME_MS = "playtime_ms"; + + /** + * Stats promoted to dedicated, queryable columns. Everything else lives in {@code extras_json}. + * To promote a future stat to a column, add its key here and add the matching column + migration. + */ + public static final Set CORE = Set.of(KILLS, DEATHS, ASSISTS, SCORE, PLACEMENT, WIN, PLAYTIME_MS); + + /** Non-numeric per-player attribute: ordered list of per-round placements. */ + public static final String ATTR_ROUND_PLACEMENTS = "round_placements"; +} diff --git a/src/main/java/net/kewwbec/minigames/stats/db/GameRecord.java b/src/main/java/net/kewwbec/minigames/stats/db/GameRecord.java new file mode 100644 index 0000000..a442f26 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/stats/db/GameRecord.java @@ -0,0 +1,20 @@ +package net.kewwbec.minigames.stats.db; + +import javax.annotation.Nullable; +import java.time.Instant; + +/** Immutable persistence view of one finished game. {@code extrasJson} carries game-wide extras. */ +public record GameRecord( + String id, + String minigameId, + String mapId, + String variantId, + int roundCount, + @Nullable String endReason, + @Nullable String winnerPlayerId, + @Nullable String winnerTeamId, + Instant startedAt, + Instant endedAt, + @Nullable String extrasJson +) { +} diff --git a/src/main/java/net/kewwbec/minigames/stats/db/MinigameStatsRepository.java b/src/main/java/net/kewwbec/minigames/stats/db/MinigameStatsRepository.java new file mode 100644 index 0000000..50987f3 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/stats/db/MinigameStatsRepository.java @@ -0,0 +1,229 @@ +package net.kewwbec.minigames.stats.db; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; + +/** + * Persists games, per-game participants, and rolled-up lifetime aggregates. All SQL lives here so + * a backend pivot only touches this class plus the {@link StatsDatabase} implementation. The + * {@code GLOBAL} aggregate key gives a fast cross-game leaderboard alongside the per-minigame rows. + */ +public final class MinigameStatsRepository { + + /** Synthetic minigame id used for the cross-game (global) aggregate row. */ + public static final String GLOBAL = "*"; + + private final StatsDatabase db; + + public MinigameStatsRepository(@Nonnull StatsDatabase db) { + this.db = db; + } + + /** Writes the game header, every participant, and both per-minigame and global aggregates in one transaction. */ + public void persistGame(@Nonnull GameRecord game, @Nonnull List participants) { + db.execute(c -> { + boolean previousAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + saveGame(c, game); + for (ParticipantRecord p : participants) { + saveParticipant(c, p); + bumpAggregate(c, p, p.minigameId()); + bumpAggregate(c, p, GLOBAL); + } + c.commit(); + } catch (SQLException | RuntimeException e) { + c.rollback(); + throw e; + } finally { + c.setAutoCommit(previousAutoCommit); + } + }); + } + + private static void saveGame(Connection c, GameRecord g) throws SQLException { + String sql = """ + INSERT INTO minigame_games + (id, minigame_id, map_id, variant_id, round_count, end_reason, winner_player_id, winner_team_id, started_at, ended_at, extras_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + round_count = excluded.round_count, + end_reason = excluded.end_reason, + winner_player_id = excluded.winner_player_id, + winner_team_id = excluded.winner_team_id, + ended_at = excluded.ended_at, + extras_json = excluded.extras_json + """; + try (PreparedStatement p = c.prepareStatement(sql)) { + p.setString(1, g.id()); + p.setString(2, g.minigameId()); + p.setString(3, g.mapId()); + p.setString(4, g.variantId()); + p.setInt(5, g.roundCount()); + p.setString(6, g.endReason()); + p.setString(7, g.winnerPlayerId()); + p.setString(8, g.winnerTeamId()); + p.setString(9, g.startedAt().toString()); + p.setString(10, g.endedAt().toString()); + p.setString(11, g.extrasJson()); + p.executeUpdate(); + } + } + + private static void saveParticipant(Connection c, ParticipantRecord r) throws SQLException { + String sql = """ + INSERT INTO minigame_game_participants + (game_id, player_uuid, player_username, minigame_id, team_id, placement, win, kills, deaths, assists, score, playtime_ms, extras_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(game_id, player_uuid) DO UPDATE SET + player_username = excluded.player_username, + team_id = excluded.team_id, + placement = excluded.placement, + win = excluded.win, + kills = excluded.kills, + deaths = excluded.deaths, + assists = excluded.assists, + score = excluded.score, + playtime_ms = excluded.playtime_ms, + extras_json = excluded.extras_json + """; + try (PreparedStatement p = c.prepareStatement(sql)) { + p.setString(1, r.gameId()); + p.setString(2, r.playerUuid()); + p.setString(3, r.playerUsername()); + p.setString(4, r.minigameId()); + p.setString(5, r.teamId()); + if (r.placement() == null) { + p.setNull(6, Types.INTEGER); + } else { + p.setInt(6, r.placement()); + } + p.setInt(7, r.win() ? 1 : 0); + p.setLong(8, r.kills()); + p.setLong(9, r.deaths()); + p.setLong(10, r.assists()); + p.setLong(11, r.score()); + p.setLong(12, r.playtimeMs()); + p.setString(13, r.extrasJson()); + p.executeUpdate(); + } + } + + /** Increments the lifetime aggregate row for the given (player, minigameId) bucket. */ + private static void bumpAggregate(Connection c, ParticipantRecord r, String minigameId) throws SQLException { + String sql = """ + INSERT INTO minigame_player_stats + (player_uuid, minigame_id, player_username, games_played, wins, losses, kills, deaths, assists, score_total, best_placement, playtime_ms, last_played_at) + VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(player_uuid, minigame_id) DO UPDATE SET + player_username = excluded.player_username, + games_played = games_played + 1, + wins = wins + excluded.wins, + losses = losses + excluded.losses, + kills = kills + excluded.kills, + deaths = deaths + excluded.deaths, + assists = assists + excluded.assists, + score_total = score_total + excluded.score_total, + best_placement = CASE + WHEN excluded.best_placement IS NULL THEN best_placement + WHEN best_placement IS NULL THEN excluded.best_placement + WHEN excluded.best_placement < best_placement THEN excluded.best_placement + ELSE best_placement END, + playtime_ms = playtime_ms + excluded.playtime_ms, + last_played_at = excluded.last_played_at + """; + try (PreparedStatement p = c.prepareStatement(sql)) { + p.setString(1, r.playerUuid()); + p.setString(2, minigameId); + p.setString(3, r.playerUsername()); + p.setInt(4, r.win() ? 1 : 0); + p.setInt(5, r.win() ? 0 : 1); + p.setLong(6, r.kills()); + p.setLong(7, r.deaths()); + p.setLong(8, r.assists()); + p.setLong(9, r.score()); + if (r.placement() == null) { + p.setNull(10, Types.INTEGER); + } else { + p.setInt(10, r.placement()); + } + p.setLong(11, r.playtimeMs()); + p.setString(12, java.time.Instant.now().toString()); + p.executeUpdate(); + } + } + + /** Top players for a minigame (use {@link #GLOBAL} for the cross-game board) ordered by a core column. */ + @Nonnull + public List topPlayers(@Nonnull String minigameId, @Nonnull String orderColumn, int limit) { + String column = sanitizeOrderColumn(orderColumn); + String sql = "SELECT player_uuid, minigame_id, player_username, games_played, wins, losses, kills, deaths, " + + "assists, score_total, best_placement, playtime_ms, last_played_at FROM minigame_player_stats " + + "WHERE minigame_id = ? ORDER BY " + column + " DESC LIMIT ?"; + return db.withConnection(c -> { + try (PreparedStatement p = c.prepareStatement(sql)) { + p.setString(1, minigameId); + p.setInt(2, Math.max(1, limit)); + try (ResultSet rs = p.executeQuery()) { + List rows = new ArrayList<>(); + while (rs.next()) { + rows.add(readAggregate(rs)); + } + return rows; + } + } + }); + } + + private static AggregateRow readAggregate(ResultSet rs) throws SQLException { + int bestPlacement = rs.getInt("best_placement"); + return new AggregateRow( + rs.getString("player_uuid"), + rs.getString("minigame_id"), + rs.getString("player_username"), + rs.getLong("games_played"), + rs.getLong("wins"), + rs.getLong("losses"), + rs.getLong("kills"), + rs.getLong("deaths"), + rs.getLong("assists"), + rs.getLong("score_total"), + rs.wasNull() ? null : bestPlacement, + rs.getLong("playtime_ms"), + rs.getString("last_played_at") + ); + } + + /** Whitelist guard: only the fixed aggregate columns may be sorted on (avoids SQL injection). */ + private static String sanitizeOrderColumn(String requested) { + return switch (requested) { + case "games_played", "wins", "losses", "kills", "deaths", "assists", "score_total", "playtime_ms" -> requested; + default -> "score_total"; + }; + } + + /** Read view of a lifetime aggregate row. */ + public record AggregateRow( + String playerUuid, + String minigameId, + @Nullable String playerUsername, + long gamesPlayed, + long wins, + long losses, + long kills, + long deaths, + long assists, + long scoreTotal, + @Nullable Integer bestPlacement, + long playtimeMs, + @Nullable String lastPlayedAt + ) { + } +} diff --git a/src/main/java/net/kewwbec/minigames/stats/db/ParticipantRecord.java b/src/main/java/net/kewwbec/minigames/stats/db/ParticipantRecord.java new file mode 100644 index 0000000..7a3d32f --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/stats/db/ParticipantRecord.java @@ -0,0 +1,25 @@ +package net.kewwbec.minigames.stats.db; + +import javax.annotation.Nullable; + +/** + * Immutable persistence view of one player's result in one game. The core stats are explicit + * fields; any other tracked stat is serialized into {@code extrasJson}. {@code win} drives the + * win/loss split in the rolled-up aggregate table. + */ +public record ParticipantRecord( + String gameId, + String playerUuid, + @Nullable String playerUsername, + String minigameId, + @Nullable String teamId, + @Nullable Integer placement, + boolean win, + long kills, + long deaths, + long assists, + long score, + long playtimeMs, + @Nullable String extrasJson +) { +} diff --git a/src/main/java/net/kewwbec/minigames/stats/db/SqliteStatsDatabase.java b/src/main/java/net/kewwbec/minigames/stats/db/SqliteStatsDatabase.java new file mode 100644 index 0000000..3f7a0e9 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/stats/db/SqliteStatsDatabase.java @@ -0,0 +1,160 @@ +package net.kewwbec.minigames.stats.db; + +import com.hypixel.hytale.logger.HytaleLogger; + +import javax.annotation.Nonnull; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; + +/** + * SQLite-backed {@link StatsDatabase}. Holds a single shared connection (SQLite serializes writes + * anyway) and runs all repository work against it under a monitor. Mirrors the proven Duel module + * setup. The pivot to MySQL is a matter of supplying a different {@link StatsDatabase} that borrows + * from a pool; the repository and service stay unchanged. + * + *

Schema evolution is intentionally trivial: append a {@code CREATE TABLE IF NOT EXISTS} (or a + * new {@link #addColumnIfMissing} call) to {@link #migrate()} to add a new record type or column. + */ +public final class SqliteStatsDatabase implements StatsDatabase { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final String jdbcUrl; + private Connection connection; + + public SqliteStatsDatabase(@Nonnull String jdbcUrl) { + this.jdbcUrl = jdbcUrl; + } + + public synchronized void start() { + if (connection != null) { + return; + } + try { + Class.forName("org.sqlite.JDBC"); + connection = DriverManager.getConnection(jdbcUrl); + connection.setAutoCommit(true); + migrate(); + LOGGER.at(Level.INFO).log("Minigame stats database ready (%s)", jdbcUrl); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("SQLite JDBC driver is missing from the core-minigames jar", e); + } catch (SQLException e) { + throw new IllegalStateException("Failed to start minigame stats database", e); + } + } + + @Override + public synchronized T withConnection(SqlFunction work) { + if (connection == null) { + start(); + } + try { + return work.apply(connection); + } catch (SQLException e) { + throw new IllegalStateException("Minigame stats database operation failed", e); + } + } + + private void migrate() throws SQLException { + for (String statement : migrations()) { + try (Statement sql = connection.createStatement()) { + sql.execute(statement); + } + } + } + + @SuppressWarnings("unused") + private void addColumnIfMissing(String table, String column, String definition) throws SQLException { + try (Statement sql = connection.createStatement(); + ResultSet rows = sql.executeQuery("PRAGMA table_info(" + table + ")")) { + while (rows.next()) { + if (column.equalsIgnoreCase(rows.getString("name"))) { + return; + } + } + } + try (Statement sql = connection.createStatement()) { + sql.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition); + } + } + + private static List migrations() { + List sql = new ArrayList<>(); + // One row per finished game. + sql.add(""" + CREATE TABLE IF NOT EXISTS minigame_games ( + id TEXT PRIMARY KEY, + minigame_id TEXT NOT NULL, + map_id TEXT, + variant_id TEXT, + round_count INTEGER NOT NULL DEFAULT 0, + end_reason TEXT, + winner_player_id TEXT, + winner_team_id TEXT, + started_at TEXT NOT NULL, + ended_at TEXT NOT NULL, + extras_json TEXT + ) + """); + // One row per player per game (full history). Core stats are columns; the rest is extras_json. + sql.add(""" + CREATE TABLE IF NOT EXISTS minigame_game_participants ( + game_id TEXT NOT NULL, + player_uuid TEXT NOT NULL, + player_username TEXT, + minigame_id TEXT NOT NULL, + team_id TEXT, + placement INTEGER, + win INTEGER NOT NULL DEFAULT 0, + kills INTEGER NOT NULL DEFAULT 0, + deaths INTEGER NOT NULL DEFAULT 0, + assists INTEGER NOT NULL DEFAULT 0, + score INTEGER NOT NULL DEFAULT 0, + playtime_ms INTEGER NOT NULL DEFAULT 0, + extras_json TEXT, + PRIMARY KEY (game_id, player_uuid) + ) + """); + // Rolled-up lifetime totals. minigame_id = '*' holds the cross-game global rollup. + sql.add(""" + CREATE TABLE IF NOT EXISTS minigame_player_stats ( + player_uuid TEXT NOT NULL, + minigame_id TEXT NOT NULL, + player_username TEXT, + games_played INTEGER NOT NULL DEFAULT 0, + wins INTEGER NOT NULL DEFAULT 0, + losses INTEGER NOT NULL DEFAULT 0, + kills INTEGER NOT NULL DEFAULT 0, + deaths INTEGER NOT NULL DEFAULT 0, + assists INTEGER NOT NULL DEFAULT 0, + score_total INTEGER NOT NULL DEFAULT 0, + best_placement INTEGER, + playtime_ms INTEGER NOT NULL DEFAULT 0, + last_played_at TEXT, + extras_json TEXT, + PRIMARY KEY (player_uuid, minigame_id) + ) + """); + return sql; + } + + @Override + public synchronized void close() { + if (connection == null) { + return; + } + try { + connection.close(); + } catch (SQLException e) { + LOGGER.at(Level.WARNING).log("Failed to close minigame stats database: %s", e.getMessage()); + } finally { + connection = null; + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/stats/db/StatsDatabase.java b/src/main/java/net/kewwbec/minigames/stats/db/StatsDatabase.java new file mode 100644 index 0000000..a7bdfd3 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/stats/db/StatsDatabase.java @@ -0,0 +1,38 @@ +package net.kewwbec.minigames.stats.db; + +import java.sql.Connection; +import java.sql.SQLException; + +/** + * Connection lifecycle seam between the stats repository and the underlying database. This is the + * single point that changes when pivoting from the local SQLite backend to the network-wide MySQL + * pool: the repository never touches a {@link Connection} directly, it asks the database to run + * work with one, and each backend manages the connection appropriately (a shared single connection + * for SQLite, a pooled borrow-and-return for MySQL). + */ +public interface StatsDatabase extends AutoCloseable { + + @FunctionalInterface + interface SqlFunction { + T apply(Connection connection) throws SQLException; + } + + @FunctionalInterface + interface SqlConsumer { + void accept(Connection connection) throws SQLException; + } + + /** Runs read/write work with a managed connection and returns its result. */ + T withConnection(SqlFunction work); + + /** Runs write work with a managed connection. */ + default void execute(SqlConsumer work) { + withConnection(connection -> { + work.accept(connection); + return null; + }); + } + + @Override + void close(); +} diff --git a/src/main/resources/Server/Languages/en-US/server.lang b/src/main/resources/Server/Languages/en-US/server.lang index 9b4f9dd..b4962a1 100644 --- a/src/main/resources/Server/Languages/en-US/server.lang +++ b/src/main/resources/Server/Languages/en-US/server.lang @@ -176,7 +176,7 @@ customUI.triggerVolumeEffectEditor.effectType.StartTimer = Start Timer customUI.triggerVolumeEffectEditor.effectType.StopTimer = Stop Timer customUI.triggerVolumeEffectEditor.effectType.SetSpawnPoint = Set Spawn Point customUI.triggerVolumeEffectEditor.effectType.RespawnPlayer = Respawn Player -customUI.triggerVolumeEffectEditor.effectType.SendMinigameMessage = Send Message +customUI.triggerVolumeEffectEditor.effectType.SendMinigameMessage = Send MiniGame Message customUI.triggerVolumeEffectEditor.effectType.AssignTeam = Assign Team customUI.triggerVolumeEffectEditor.effectType.SaveInventory = Save Inventory customUI.triggerVolumeEffectEditor.effectType.RestoreInventory = Restore Inventory