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.
This commit is contained in:
@@ -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<EntityStore, LockMountComponent> lockMountComponentType;
|
||||
private final java.util.List<String> 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;
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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<String, PlayerStatAccumulator> players = new LinkedHashMap<>();
|
||||
private final Map<String, Object> 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<String, PlayerStatAccumulator> players() {
|
||||
return players;
|
||||
}
|
||||
|
||||
/** Game-level extras (winner, end reason, custom game-wide tallies). */
|
||||
public Map<String, Object> attributes() {
|
||||
return attributes;
|
||||
}
|
||||
}
|
||||
@@ -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<EntityStore> QUERY = PlayerRef.getComponentType();
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public Query<EntityStore> getQuery() {
|
||||
return QUERY;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public SystemGroup<EntityStore> getGroup() {
|
||||
return DamageModule.get().getInspectDamageGroup();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(
|
||||
int index,
|
||||
@Nonnull ArchetypeChunk<EntityStore> archetypeChunk,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull CommandBuffer<EntityStore> commandBuffer,
|
||||
@Nonnull Damage damage
|
||||
) {
|
||||
if (damage.getAmount() <= 0.0F || !(damage.getSource() instanceof Damage.EntitySource source)) {
|
||||
return;
|
||||
}
|
||||
Ref<EntityStore> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<EntityStore> QUERY =
|
||||
Archetype.of(DeathComponent.getComponentType(), TransformComponent.getComponentType());
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public Query<EntityStore> getQuery() {
|
||||
return QUERY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComponentAdded(
|
||||
@Nonnull Ref<EntityStore> victimRef,
|
||||
@Nonnull DeathComponent component,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull CommandBuffer<EntityStore> 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<EntityStore> store, @Nonnull DeathComponent component) {
|
||||
Damage deathInfo = component.getDeathInfo();
|
||||
if (deathInfo == null || !(deathInfo.getSource() instanceof Damage.EntitySource source)) {
|
||||
return null;
|
||||
}
|
||||
Ref<EntityStore> attackerRef = source.getRef();
|
||||
if (!attackerRef.isValid()) {
|
||||
return null;
|
||||
}
|
||||
return playerId(store, attackerRef);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String playerId(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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<MinigameRuntime> 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<String, GameStatsSession> sessions = new ConcurrentHashMap<>();
|
||||
/** victimId -> (attackerId -> last-hit epoch millis). Populated by the damage system, drained on death. */
|
||||
private final Map<String, Map<String, Long>> recentDamage = new ConcurrentHashMap<>();
|
||||
@Nullable private final MinigameStatsRepository repository;
|
||||
private final UnaryOperator<String> 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<String> 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<String> drainAssisters(@Nonnull String victimId, @Nullable String killerId) {
|
||||
Map<String, Long> hits = recentDamage.remove(victimId);
|
||||
if (hits == null || hits.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
long cutoff = System.currentTimeMillis() - ASSIST_WINDOW_MS;
|
||||
List<String> assisters = new ArrayList<>();
|
||||
for (Map.Entry<String, Long> 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<String> ranked = Rankings.rankedPlayerIds(runtime, false);
|
||||
for (int i = 0; i < ranked.size(); i++) {
|
||||
String playerId = ranked.get(i);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Integer> byRound = (Map<String, Integer>) session.player(playerId)
|
||||
.attributes().computeIfAbsent(StatKeys.ATTR_ROUND_PLACEMENTS, k -> new LinkedHashMap<String, Integer>());
|
||||
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<String> ranked = Rankings.rankedPlayerIds(runtime, false);
|
||||
Map<String, Integer> 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<ParticipantRecord> 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<ParticipantRecord> 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<String, Object> 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<String, TeamState> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
* <ul>
|
||||
* <li>{@code counters} - additive numeric stats (kills, deaths, score, and any future counter).</li>
|
||||
* <li>{@code attributes} - arbitrary structured values (e.g. per-round placement lists).</li>
|
||||
* </ul>
|
||||
* 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<String, Long> counters = new LinkedHashMap<>();
|
||||
private final Map<String, Object> 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<String, Long> counters() {
|
||||
return counters;
|
||||
}
|
||||
|
||||
public void putAttribute(String key, Object value) {
|
||||
attributes.put(key, value);
|
||||
}
|
||||
|
||||
public Map<String, Object> attributes() {
|
||||
return attributes;
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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";
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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<ParticipantRecord> 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<AggregateRow> 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<AggregateRow> 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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> T withConnection(SqlFunction<T> 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<String> migrations() {
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
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> T withConnection(SqlFunction<T> work);
|
||||
|
||||
/** Runs write work with a managed connection. */
|
||||
default void execute(SqlConsumer work) {
|
||||
withConnection(connection -> {
|
||||
work.accept(connection);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user