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;