diff --git a/core/src/main/java/dev/nandi0813/practice/manager/matchhistory/MatchHistory.java b/core/src/main/java/dev/nandi0813/practice/manager/matchhistory/MatchHistory.java new file mode 100644 index 00000000..1a66d140 --- /dev/null +++ b/core/src/main/java/dev/nandi0813/practice/manager/matchhistory/MatchHistory.java @@ -0,0 +1,46 @@ +package dev.nandi0813.practice.manager.matchhistory; + +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Per-player match history model. Analog to {@code Profile}, owning its own + * {@link MatchHistoryFile} for persistence and caching the recent entries. + *

+ * Entries are kept newest-first, capped at {@link #MAX_HISTORY}. + */ +@Getter +@Setter +public class MatchHistory { + + private static final int MAX_HISTORY = 5; + + private final UUID uuid; + private final MatchHistoryFile file; + private final List matches = new ArrayList<>(); + + public MatchHistory(UUID uuid) { + this.uuid = uuid; + this.file = new MatchHistoryFile(this); + } + + /** + * Loads YAML matches into the cache. Returns the loaded matches (newest-first). + */ + public List load() { + file.getData(); + return matches; + } + + /** + * Adds a match to the front of the cache (newest-first), keeping the size within the cap. + */ + public void add(MatchHistoryEntry match) { + matches.addFirst(match); + if (matches.size() > MAX_HISTORY) matches.subList(MAX_HISTORY, matches.size()).clear(); + } +} \ No newline at end of file diff --git a/core/src/main/java/dev/nandi0813/practice/manager/matchhistory/MatchHistoryFile.java b/core/src/main/java/dev/nandi0813/practice/manager/matchhistory/MatchHistoryFile.java index b1aaf315..c1014914 100644 --- a/core/src/main/java/dev/nandi0813/practice/manager/matchhistory/MatchHistoryFile.java +++ b/core/src/main/java/dev/nandi0813/practice/manager/matchhistory/MatchHistoryFile.java @@ -19,11 +19,14 @@ public class MatchHistoryFile extends ConfigFile { private static final int MAX_HISTORY = 5; private static final String ROOT = "matches"; - private final UUID playerUuid; + private final MatchHistory matchHistory; - public MatchHistoryFile(UUID playerUuid) { - super("/match-history/", playerUuid.toString().toLowerCase()); - this.playerUuid = playerUuid; + public MatchHistoryFile(MatchHistory matchHistory) { + super("/match-history/", matchHistory.getUuid().toString().toLowerCase()); + this.matchHistory = matchHistory; + + saveFile(); + reloadFile(); } @Override @@ -31,81 +34,87 @@ public void setData() { saveFile(); } + /** + * Saves a new match and prunes entries beyond the cap. + * Returns the assigned id. + */ + public int saveMatch(MatchHistoryEntry entry) { + int nextId = getNextId(); + setMatch(entry, nextId); + pruneOldMatches(); + saveFile(); + return nextId; + } + + private void setMatch(MatchHistoryEntry entry, int matchId) { + String match = ROOT + "." + matchId; + + config.set(match + ".player_uuid", entry.getPlayerUuid().toString()); + config.set(match + ".opponent_uuid", entry.getOpponentUuid().toString()); + config.set(match + ".player_name", entry.getPlayerName()); + config.set(match + ".opponent_name", entry.getOpponentName()); + config.set(match + ".kit_name", entry.getKitName()); + config.set(match + ".arena_name", entry.getArenaName()); + config.set(match + ".player_score", entry.getPlayerScore()); + config.set(match + ".opponent_score", entry.getOpponentScore()); + config.set(match + ".player_final_health", entry.getPlayerFinalHealth()); + config.set(match + ".opponent_final_health", entry.getOpponentFinalHealth()); + config.set(match + ".winner_uuid", entry.getWinnerUuid() != null ? entry.getWinnerUuid().toString() : ""); + config.set(match + ".match_duration", entry.getMatchDuration()); + config.set(match + ".played_at", entry.getPlayedAt()); + } + @Override public void getData() { - // Data is loaded on demand via loadEntries() + loadMatches(); } - /** - * Loads all stored entries for this player, ordered newest-first. - */ - public List loadEntries() { - List result = new ArrayList<>(); + private void loadMatches() { + matchHistory.getMatches().clear(); ConfigurationSection root = config.getConfigurationSection(ROOT); - if (root == null) return result; + if (root == null) return; for (String key : root.getKeys(false)) { - ConfigurationSection section = root.getConfigurationSection(key); - if (section == null) continue; - - try { - UUID pUuid = UUID.fromString(section.getString("player_uuid", playerUuid.toString())); - UUID oppUuid = UUID.fromString(section.getString("opponent_uuid", "00000000-0000-0000-0000-000000000000")); - String winStr = section.getString("winner_uuid", null); - UUID winnerUuid = (winStr != null && !winStr.isEmpty()) ? UUID.fromString(winStr) : null; - - result.add(new MatchHistoryEntry( - Integer.parseInt(key), - pUuid, - oppUuid, - section.getString("player_name", "Unknown"), - section.getString("opponent_name", "Unknown"), - section.getString("kit_name", "Unknown"), - section.getString("arena_name", "Unknown"), - section.getInt("player_score", 0), - section.getInt("opponent_score", 0), - section.getDouble("player_final_health", 0.0), - section.getDouble("opponent_final_health", 0.0), - winnerUuid, - section.getInt("match_duration", 0), - section.getLong("played_at", System.currentTimeMillis()) - )); - } catch (Exception e) { - Common.sendConsoleMMMessage("[MatchHistory] Skipping corrupt entry " + key - + " for " + playerUuid + ": " + e.getMessage()); + MatchHistoryEntry entry = loadMatch(key); + if (entry != null) { + matchHistory.getMatches().add(entry); } } - result.sort((a, b) -> Integer.compare(b.getMatchId(), a.getMatchId())); - return result; + matchHistory.getMatches().sort((a, b) -> Integer.compare(b.getMatchId(), a.getMatchId())); } - /** - * Saves a new entry and prunes entries beyond the cap. - * Returns the assigned id. - */ - public int saveEntry(MatchHistoryEntry entry) { - int nextId = getNextId(); - String path = ROOT + "." + nextId; - - config.set(path + ".player_uuid", entry.getPlayerUuid().toString()); - config.set(path + ".opponent_uuid", entry.getOpponentUuid().toString()); - config.set(path + ".player_name", entry.getPlayerName()); - config.set(path + ".opponent_name", entry.getOpponentName()); - config.set(path + ".kit_name", entry.getKitName()); - config.set(path + ".arena_name", entry.getArenaName()); - config.set(path + ".player_score", entry.getPlayerScore()); - config.set(path + ".opponent_score", entry.getOpponentScore()); - config.set(path + ".player_final_health", entry.getPlayerFinalHealth()); - config.set(path + ".opponent_final_health", entry.getOpponentFinalHealth()); - config.set(path + ".winner_uuid", entry.getWinnerUuid() != null ? entry.getWinnerUuid().toString() : ""); - config.set(path + ".match_duration", entry.getMatchDuration()); - config.set(path + ".played_at", entry.getPlayedAt()); - - pruneOldEntries(); - saveFile(); - return nextId; + private MatchHistoryEntry loadMatch(String matchId) { + String match = ROOT + "." + matchId; + + try { + return new MatchHistoryEntry( + Integer.parseInt(matchId), + UUID.fromString(config.getString(match + ".player_uuid", matchHistory.getUuid().toString())), + UUID.fromString(config.getString(match + ".opponent_uuid", "00000000-0000-0000-0000-000000000000")), + config.getString(match + ".player_name", "Unknown"), + config.getString(match + ".opponent_name", "Unknown"), + config.getString(match + ".kit_name", "Unknown"), + config.getString(match + ".arena_name", "Unknown"), + config.getInt(match + ".player_score", 0), + config.getInt(match + ".opponent_score", 0), + config.getDouble(match + ".player_final_health", 0.0), + config.getDouble(match + ".opponent_final_health", 0.0), + getWinnerUuid(match), + config.getInt(match + ".match_duration", 0), + config.getLong(match + ".played_at", System.currentTimeMillis()) + ); + } catch (Exception e) { + Common.sendConsoleMMMessage("[MatchHistory] Skipping corrupt entry " + matchId + + " for " + matchHistory.getUuid() + ": " + e.getMessage()); + return null; + } + } + + private UUID getWinnerUuid(String match) { + String winnerUuid = config.getString(match + ".winner_uuid"); + return (winnerUuid != null && !winnerUuid.isEmpty()) ? UUID.fromString(winnerUuid) : null; } private int getNextId() { @@ -119,7 +128,7 @@ private int getNextId() { return max + 1; } - private void pruneOldEntries() { + private void pruneOldMatches() { ConfigurationSection root = config.getConfigurationSection(ROOT); if (root == null) return; @@ -135,4 +144,4 @@ private void pruneOldEntries() { config.set(ROOT + "." + id, null); } } -} +} \ No newline at end of file diff --git a/core/src/main/java/dev/nandi0813/practice/manager/matchhistory/MatchHistoryManager.java b/core/src/main/java/dev/nandi0813/practice/manager/matchhistory/MatchHistoryManager.java index fad2b6b0..15a500b6 100644 --- a/core/src/main/java/dev/nandi0813/practice/manager/matchhistory/MatchHistoryManager.java +++ b/core/src/main/java/dev/nandi0813/practice/manager/matchhistory/MatchHistoryManager.java @@ -19,11 +19,11 @@ /** * Central manager for match history. - * + *

* Storage strategy: * 1. YAML files — always used (match-history/.yml), same pattern as profiles/. * 2. MySQL — also used when connected; logic lives in MysqlManager. - * + *

* Both reads and writes are async. An in-memory cache avoids repeated disk access. */ public class MatchHistoryManager implements Listener { @@ -38,7 +38,15 @@ public static MatchHistoryManager getInstance() { private static final int MAX_HISTORY = 5; /** In-memory cache: player UUID → last MAX_HISTORY entries, newest-first. */ - private final Map> cache = new ConcurrentHashMap<>(); + private final Map matchHistories = new ConcurrentHashMap<>(); + + /** + * Returns the cached per-player {@link MatchHistory}, creating one on demand. + * Analogous to {@code ProfileManager#getProfile(UUID)}. + */ + public MatchHistory getMatchHistory(UUID uuid) { + return matchHistories.computeIfAbsent(uuid, MatchHistory::new); + } private MatchHistoryManager() { Bukkit.getPluginManager().registerEvents(this, ZonePractice.getInstance()); @@ -82,8 +90,8 @@ public void saveMatchAsync(UUID playerUuid, UUID opponentUuid, kitName, arenaName, opponentScore, playerScore, opponentFinalHealth, playerFinalHealth, winnerUuid, matchDuration, now); - addToCache(playerUuid, finalPlayerPov); - addToCache(opponentUuid, finalOpponentPov); + getMatchHistory(playerUuid).add(finalPlayerPov); + getMatchHistory(opponentUuid).add(finalOpponentPov); if (MysqlManager.isConnected(false)) { MysqlManager.saveMatchHistoryAsync( @@ -99,46 +107,33 @@ public void saveMatchAsync(UUID playerUuid, UUID opponentUuid, * Returns from cache if available; otherwise reads YAML (and MySQL as fallback if YAML empty). */ public CompletableFuture> loadHistoryAsync(UUID playerUuid) { - if (cache.containsKey(playerUuid)) { - return CompletableFuture.completedFuture(new ArrayList<>(cache.get(playerUuid))); + MatchHistory history = matchHistories.get(playerUuid); + if (history != null && !history.getMatches().isEmpty()) { + return CompletableFuture.completedFuture(new ArrayList<>(history.getMatches())); } return CompletableFuture.supplyAsync(() -> { - List entries = loadFromYaml(playerUuid); + MatchHistory loaded = getMatchHistory(playerUuid); + List entries = loaded.load(); if (entries.isEmpty() && MysqlManager.isConnected(false)) { entries = MysqlManager.loadMatchHistorySync(playerUuid, MAX_HISTORY); + loaded.getMatches().addAll(entries); } - cache.put(playerUuid, new ArrayList<>(entries)); return entries; }); } private int saveToYaml(UUID uuid, MatchHistoryEntry entry) { try { - return new MatchHistoryFile(uuid).saveEntry(entry); + return getMatchHistory(uuid).getFile().saveMatch(entry); } catch (Exception e) { Common.sendConsoleMMMessage("[MatchHistory] YAML save error for " + uuid + ": " + e.getMessage()); return -1; } } - private List loadFromYaml(UUID uuid) { - try { - return new MatchHistoryFile(uuid).loadEntries(); - } catch (Exception e) { - Common.sendConsoleMMMessage("[MatchHistory] YAML load error for " + uuid + ": " + e.getMessage()); - return Collections.emptyList(); - } - } - - private void addToCache(UUID uuid, MatchHistoryEntry entry) { - List list = cache.computeIfAbsent(uuid, k -> new ArrayList<>()); - list.add(0, entry); // prepend = newest first - if (list.size() > MAX_HISTORY) list.subList(MAX_HISTORY, list.size()).clear(); - } - @EventHandler(priority = EventPriority.LOW) public void onMatchEnd(MatchEndEvent e) { if (!(e.getMatch() instanceof Duel duel)) { diff --git a/core/src/main/java/dev/nandi0813/practice/util/fightmapchange/FightChangeOptimized.java b/core/src/main/java/dev/nandi0813/practice/util/fightmapchange/FightChangeOptimized.java index 6f11ca31..5958706d 100644 --- a/core/src/main/java/dev/nandi0813/practice/util/fightmapchange/FightChangeOptimized.java +++ b/core/src/main/java/dev/nandi0813/practice/util/fightmapchange/FightChangeOptimized.java @@ -559,25 +559,37 @@ private void extinguishFire() { * block at Y=69 hasn't been restored yet. */ private class RollbackTask extends BukkitRunnable { - private final Iterator> iterator; + private Iterator> iterator; private final int maxCheck; private final int maxChange; - private final int totalBlocks; + private int totalBlocks; private int processedBlocks = 0; private boolean isRunning = false; @Nullable private final Runnable onComplete; RollbackTask(int maxCheck, int maxChange, @Nullable Runnable onComplete) { + this.maxCheck = maxCheck; + this.maxChange = maxChange; + this.onComplete = onComplete; + refreshSnapshot(); + } + + /** + * Takes a fresh snapshot of the {@link #blocks} map, ordered bottom-up. + *

+ * A rollback spans multiple ticks. Blocks destroyed during that window (e.g. a + * crystal placed and exploded at the moment the rollback began) are recorded into + * {@link #blocks} AFTER the initial snapshot. Calling this again lets the task pick + * those up instead of losing them when the previous snapshot is exhausted. + */ + private void refreshSnapshot() { // Default ordering is bottom-up (gravity support). Vine-like blocks are // restored top-down so hanging segments do not immediately break. List> sorted = new ArrayList<>(blocks.entrySet()); sorted.sort(rollbackComparator()); this.iterator = sorted.iterator(); - this.maxCheck = maxCheck; - this.maxChange = maxChange; this.totalBlocks = blocks.size(); - this.onComplete = onComplete; } void start() { @@ -620,9 +632,22 @@ public void run() { // Finished rolling back all blocks if (!iterator.hasNext()) { + // Blocks may have been recorded DURING this multi-tick rollback + // (e.g. a crystal placed + exploded at the same moment the rollback + // started). They are not part of the snapshot this task is iterating, + // so re-snapshot and keep going until the live map is truly empty — + // otherwise those holes would be silently lost. + if (!blocks.isEmpty()) { + refreshSnapshot(); + return; + } + this.cancel(); isRunning = false; - blocks.clear(); // Clear the map + + // Sweep again: entities (e.g. an end crystal placed mid-rollback) + // spawned after the initial removeAllEntities() call are caught here. + removeAllEntities(); // Extinguish any fire that spread during the multi-tick rollback extinguishFire(); @@ -632,6 +657,7 @@ public void run() { if (onComplete != null) { onComplete.run(); // already on main thread (runTaskTimer) } + } /* // Log completion metrics @@ -641,7 +667,6 @@ public void run() { processedBlocks, duration, (double) processedBlocks / Math.max(duration, 1), skippedUnloaded )); */ - } } catch (Exception e) { this.cancel(); isRunning = false;