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:
2026-06-21 11:51:38 -07:00
parent ff2e46a0d2
commit 511d0e8d4b
15 changed files with 1198 additions and 3 deletions
@@ -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