From b431e1b74000fbfbacfb785642e84cb8aa59da7b Mon Sep 17 00:00:00 2001 From: lokspel <208148594+lokspel@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:59:12 +0400 Subject: [PATCH 1/4] fix: don't tag self from own anchor/minecart explosions --- .../manager/fight/ffa/FFAListener.java | 33 +++++ .../manager/fight/util/BlockUtil.java | 6 +- .../fight/util/ExplosiveOwnerTracker.java | 118 ++++++++++++++++++ .../manager/fight/util/FightUtil.java | 13 +- .../matchhistory/MatchHistoryManager.java | 24 ---- .../practice/util/CombatLogUtil.java | 16 ++- 6 files changed, 176 insertions(+), 34 deletions(-) create mode 100644 core/src/main/java/dev/nandi0813/practice/manager/fight/util/ExplosiveOwnerTracker.java diff --git a/core/src/main/java/dev/nandi0813/practice/manager/fight/ffa/FFAListener.java b/core/src/main/java/dev/nandi0813/practice/manager/fight/ffa/FFAListener.java index d6bb0ff96..74c75dfef 100644 --- a/core/src/main/java/dev/nandi0813/practice/manager/fight/ffa/FFAListener.java +++ b/core/src/main/java/dev/nandi0813/practice/manager/fight/ffa/FFAListener.java @@ -15,6 +15,7 @@ import dev.nandi0813.practice.util.Cuboid; import dev.nandi0813.practice.util.NumberUtil; import dev.nandi0813.practice.util.fightmapchange.FightChangeOptimized; +import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; @@ -99,6 +100,12 @@ public void onPlayerInteract(PlayerInteractEvent e) { BlockUtil.setMetadata(clickedBlock, "FFA_COMBAT_OWNER", player); } + // The anchor block is destroyed on explosion, so its owner is also kept + // in memory (by block coords) to attribute the blast to whoever set it off. + if (isAnchor) { + ExplosiveOwnerTracker.recordAnchorOwner(clickedBlock.getLocation(), player); + } + if (clickedBlock.getType().equals(Material.TNT)) { if (!ffa.isBuild() || !ffa.isMapBlowable()) { e.setCancelled(true); @@ -112,6 +119,20 @@ public void onPlayerInteract(PlayerInteractEvent e) { } } + @EventHandler + public void onPlayerInteractEntity(PlayerInteractEntityEvent e) { + Player player = e.getPlayer(); + + if (!(e.getRightClicked() instanceof Minecart minecart) + || minecart.getMinecartMaterial() != Material.TNT) return; + + FFA ffa = FFAManager.getInstance().getFFAByPlayer(player); + if (ffa == null || ffa.isPlayerWaitingForKitSelection(player)) return; + + // Attribute the minecart blast to whoever ignited it. + ExplosiveOwnerTracker.recordMinecartOwner(minecart, player); + } + @EventHandler public void onInventoryClick(org.bukkit.event.inventory.InventoryClickEvent e) { if (!(e.getWhoClicked() instanceof Player player)) return; @@ -522,6 +543,18 @@ public void onEntityDamage(EntityDamageEvent e) { "FFA_COMBAT_OWNER", Player.class); } + // Attribute TNT-minecart blasts to whoever ignited them. + if (attacker == null) { + attacker = FightUtil.getKiller(e.getDamageSource().getCausingEntity()); + } + + // Respawn anchors destroy their block on explosion, so getDamager() is null + // and the owner can't be read from the event. Resolve it from the owner + // recorded at the anchor's location when it was set off. + if (attacker == null) { + attacker = ExplosiveOwnerTracker.getAnchorOwner(e.getDamageSource().getSourceLocation()); + } + // Respawn anchors destroy their block on explosion, so getDamager() is null // and the owner can't be read from the event. Fall back to the last recorded // attacker so the tag still applies. diff --git a/core/src/main/java/dev/nandi0813/practice/manager/fight/util/BlockUtil.java b/core/src/main/java/dev/nandi0813/practice/manager/fight/util/BlockUtil.java index 5099f9fcf..1860c9d9f 100644 --- a/core/src/main/java/dev/nandi0813/practice/manager/fight/util/BlockUtil.java +++ b/core/src/main/java/dev/nandi0813/practice/manager/fight/util/BlockUtil.java @@ -10,8 +10,10 @@ import org.bukkit.entity.Entity; import org.bukkit.entity.Item; -public enum BlockUtil { - ; +public final class BlockUtil { + + private BlockUtil() { + } public static void breakBlock(Match match, Block block) { if (match == null) return; diff --git a/core/src/main/java/dev/nandi0813/practice/manager/fight/util/ExplosiveOwnerTracker.java b/core/src/main/java/dev/nandi0813/practice/manager/fight/util/ExplosiveOwnerTracker.java new file mode 100644 index 000000000..2a6a28256 --- /dev/null +++ b/core/src/main/java/dev/nandi0813/practice/manager/fight/util/ExplosiveOwnerTracker.java @@ -0,0 +1,118 @@ +package dev.nandi0813.practice.manager.fight.util; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.Minecart; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class ExplosiveOwnerTracker { + + private ExplosiveOwnerTracker() { + } + + private static final long OWNER_TTL_MS = 5_000L; + + + /** + * TNT minecart UUID -> owner + */ + private static final Map MINECART_OWNERS = new ConcurrentHashMap<>(); + + + /** + * Respawn anchor coordinates -> owner + * World intentionally ignored (old logic). + */ + private static final Map ANCHOR_OWNERS = new ConcurrentHashMap<>(); + + + private record ExplosiveData( + UUID owner, + long createdAt + ) { + } + + + private record BlockPosition( + int x, + int y, + int z + ) { + } + + + public static void recordMinecartOwner(Minecart minecart, Player owner) { + MINECART_OWNERS.put( + minecart.getUniqueId(), + new ExplosiveData( + owner.getUniqueId(), + System.currentTimeMillis() + ) + ); + } + + + public static void recordAnchorOwner(Location location, Player owner) { + ANCHOR_OWNERS.put( + new BlockPosition( + location.getBlockX(), + location.getBlockY(), + location.getBlockZ() + ), + new ExplosiveData( + owner.getUniqueId(), + System.currentTimeMillis() + ) + ); + } + + + public static @Nullable Player getMinecartOwner(Minecart minecart) { + UUID id = minecart.getUniqueId(); + + ExplosiveData data = MINECART_OWNERS.get(id); + + if (data == null) { + return null; + } + + if (expired(data)) { + MINECART_OWNERS.remove(id); + return null; + } + + return Bukkit.getPlayer(data.owner()); + } + + + public static @Nullable Player getAnchorOwner(Location location) { + BlockPosition position = new BlockPosition( + location.getBlockX(), + location.getBlockY(), + location.getBlockZ() + ); + + ExplosiveData data = ANCHOR_OWNERS.get(position); + + if (data == null) { + return null; + } + + if (expired(data)) { + ANCHOR_OWNERS.remove(position); + return null; + } + + return Bukkit.getPlayer(data.owner()); + } + + + private static boolean expired(ExplosiveData data) { + return System.currentTimeMillis() - data.createdAt() > OWNER_TTL_MS; + } +} \ No newline at end of file diff --git a/core/src/main/java/dev/nandi0813/practice/manager/fight/util/FightUtil.java b/core/src/main/java/dev/nandi0813/practice/manager/fight/util/FightUtil.java index 13ccf55e4..823d8acc9 100644 --- a/core/src/main/java/dev/nandi0813/practice/manager/fight/util/FightUtil.java +++ b/core/src/main/java/dev/nandi0813/practice/manager/fight/util/FightUtil.java @@ -7,6 +7,7 @@ import org.bukkit.damage.DamageType; import org.bukkit.entity.Entity; import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Minecart; import org.bukkit.entity.Player; import org.bukkit.entity.Projectile; import org.bukkit.entity.TNTPrimed; @@ -15,8 +16,10 @@ import java.util.ArrayList; import java.util.List; -public enum FightUtil { - ; +public final class FightUtil { + + private FightUtil() { + } public static @Nullable Player getKiller(Entity entity) { if (entity instanceof Player player) { @@ -38,6 +41,12 @@ public enum FightUtil { } } + // TNT minecarts have no owner in the API, so attribute them to whoever + // ignited them (tracked at ignition time). + if (entity instanceof Minecart minecart) { + return ExplosiveOwnerTracker.getMinecartOwner(minecart); + } + return null; } 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 1fcd0ec54..fad2b6b05 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 @@ -44,10 +44,6 @@ private MatchHistoryManager() { Bukkit.getPluginManager().registerEvents(this, ZonePractice.getInstance()); } - // ----------------------------------------------------------------------- - // Public API - // ----------------------------------------------------------------------- - /** * Records a completed 1v1 duel match for both participants asynchronously. * Always writes to YAML; also writes to MySQL when connected (via MysqlManager). @@ -72,7 +68,6 @@ public void saveMatchAsync(UUID playerUuid, UUID opponentUuid, kitName, arenaName, opponentScore, playerScore, opponentFinalHealth, playerFinalHealth, winnerUuid, matchDuration, now); - // --- YAML (always) --- int assignedId = saveToYaml(playerUuid, playerPov); saveToYaml(opponentUuid, opponentPov); @@ -90,7 +85,6 @@ public void saveMatchAsync(UUID playerUuid, UUID opponentUuid, addToCache(playerUuid, finalPlayerPov); addToCache(opponentUuid, finalOpponentPov); - // --- MySQL (delegated to MysqlManager) --- if (MysqlManager.isConnected(false)) { MysqlManager.saveMatchHistoryAsync( playerUuid, opponentUuid, playerName, opponentName, @@ -121,20 +115,6 @@ public CompletableFuture> loadHistoryAsync(UUID playerUu }); } - /** Non-blocking cache read — returns empty list if not yet loaded. */ - public List getCachedHistory(UUID playerUuid) { - return cache.getOrDefault(playerUuid, Collections.emptyList()); - } - - /** Drops the cache for a player so the next load re-reads from disk. */ - public void invalidateCache(UUID playerUuid) { - cache.remove(playerUuid); - } - - // ----------------------------------------------------------------------- - // YAML helpers - // ----------------------------------------------------------------------- - private int saveToYaml(UUID uuid, MatchHistoryEntry entry) { try { return new MatchHistoryFile(uuid).saveEntry(entry); @@ -153,10 +133,6 @@ private List loadFromYaml(UUID uuid) { } } - // ----------------------------------------------------------------------- - // Cache helpers - // ----------------------------------------------------------------------- - private void addToCache(UUID uuid, MatchHistoryEntry entry) { List list = cache.computeIfAbsent(uuid, k -> new ArrayList<>()); list.add(0, entry); // prepend = newest first diff --git a/core/src/main/java/dev/nandi0813/practice/util/CombatLogUtil.java b/core/src/main/java/dev/nandi0813/practice/util/CombatLogUtil.java index 93cd83040..69a9c1159 100644 --- a/core/src/main/java/dev/nandi0813/practice/util/CombatLogUtil.java +++ b/core/src/main/java/dev/nandi0813/practice/util/CombatLogUtil.java @@ -82,17 +82,21 @@ public void tag(Player victim, Player attacker) { if (!isEnabled()) return; + // Anti-relog only applies to real opponent fights. Self-inflicted or + // unresolved damage (your own crystal/anchor/TNT, fall, etc.) must never + // tag the victim. + if (attacker == null || attacker.equals(victim)) + return; + long expiry = System.currentTimeMillis() + tagDuration * 1000L; combatTags.put(victim.getUniqueId(), expiry); - if (attacker != null) { - combatTags.put(attacker.getUniqueId(), expiry); - lastAttackers.put(victim.getUniqueId(), attacker.getUniqueId()); - lastAttackers.put(attacker.getUniqueId(), victim.getUniqueId()); - } + combatTags.put(attacker.getUniqueId(), expiry); + lastAttackers.put(victim.getUniqueId(), attacker.getUniqueId()); + lastAttackers.put(attacker.getUniqueId(), victim.getUniqueId()); if (actionBar) { startActionBarTask(victim); - if (attacker != null && !attacker.getUniqueId().equals(victim.getUniqueId())) + if (!attacker.getUniqueId().equals(victim.getUniqueId())) startActionBarTask(attacker); } } From bc730adee9b8382304898fb1146dcd2e73507312 Mon Sep 17 00:00:00 2001 From: lokspel <208148594+lokspel@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:54:25 +0400 Subject: [PATCH 2/4] feat(config): add SHOW-PLAYERS-IN-TAB option to keep fight players visible in tab --- .../dev/nandi0813/practice/manager/backend/ConfigManager.java | 4 ++++ .../dev/nandi0813/practice/manager/fight/match/Match.java | 3 ++- .../dev/nandi0813/practice/util/entityhider/PlayerHider.java | 3 ++- core/src/main/resources/config.yml | 3 ++- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/dev/nandi0813/practice/manager/backend/ConfigManager.java b/core/src/main/java/dev/nandi0813/practice/manager/backend/ConfigManager.java index 192f9031c..07ac5f43e 100644 --- a/core/src/main/java/dev/nandi0813/practice/manager/backend/ConfigManager.java +++ b/core/src/main/java/dev/nandi0813/practice/manager/backend/ConfigManager.java @@ -62,6 +62,10 @@ public static boolean isMatchChatIsolated() { return getBoolean("CHAT.ISOLATE-MATCH-CHAT"); } + public static boolean isShowMatchPlayersInTab() { + return getBoolean("MATCH-SETTINGS.SHOW-PLAYERS-IN-TAB"); + } + public static int getInt(String loc) { return getConfig().getInt(loc); } diff --git a/core/src/main/java/dev/nandi0813/practice/manager/fight/match/Match.java b/core/src/main/java/dev/nandi0813/practice/manager/fight/match/Match.java index 2a9073eb7..bca6e017b 100644 --- a/core/src/main/java/dev/nandi0813/practice/manager/fight/match/Match.java +++ b/core/src/main/java/dev/nandi0813/practice/manager/fight/match/Match.java @@ -7,6 +7,7 @@ import dev.nandi0813.practice.ZonePractice; import dev.nandi0813.practice.manager.arena.arenas.Arena; import dev.nandi0813.practice.manager.arena.arenas.interfaces.NormalArena; +import dev.nandi0813.practice.manager.backend.ConfigManager; import dev.nandi0813.practice.manager.backend.GUIFile; import dev.nandi0813.practice.manager.backend.LanguageManager; import dev.nandi0813.practice.manager.fight.match.enums.*; @@ -148,7 +149,7 @@ public void startMatch() { for (Player online : Bukkit.getOnlinePlayers()) { if (!this.players.contains(online)) { PlayerHider.getInstance().hidePlayer(player, online, true); - PlayerHider.getInstance().hidePlayer(online, player, false); + PlayerHider.getInstance().hidePlayer(online, player, ConfigManager.isShowMatchPlayersInTab()); } } diff --git a/core/src/main/java/dev/nandi0813/practice/util/entityhider/PlayerHider.java b/core/src/main/java/dev/nandi0813/practice/util/entityhider/PlayerHider.java index bf985588d..83a750445 100644 --- a/core/src/main/java/dev/nandi0813/practice/util/entityhider/PlayerHider.java +++ b/core/src/main/java/dev/nandi0813/practice/util/entityhider/PlayerHider.java @@ -4,6 +4,7 @@ import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfoUpdate; import dev.nandi0813.api.Event.Spectate.Start.MatchSpectateStartEvent; import dev.nandi0813.practice.ZonePractice; +import dev.nandi0813.practice.manager.backend.ConfigManager; import dev.nandi0813.practice.manager.backend.LanguageManager; import dev.nandi0813.practice.manager.fight.match.Match; import dev.nandi0813.practice.manager.fight.match.MatchManager; @@ -55,7 +56,7 @@ public void playerJoin(PlayerJoinEvent e) { * Hide the player from the online. */ if (onlineStatus.equals(ProfileStatus.MATCH) || onlineStatus.equals(ProfileStatus.EVENT) || onlineStatus.equals(ProfileStatus.FFA)) { - hidePlayer(online, player, false); + hidePlayer(online, player, ConfigManager.isShowMatchPlayersInTab()); } else if (!onlineStatus.equals(ProfileStatus.SPECTATE) && onlineProfile.isHidePlayers()) { hidePlayer(online, player, false); } else if (profile.isHideFromPlayers() && !online.hasPermission("zpp.staffmode.see")) { diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index 84e86be93..368d6ed9f 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -1,4 +1,4 @@ -VERSION: 69 +VERSION: 70 # Mysql database setup. MYSQL-DATABASE: @@ -230,6 +230,7 @@ AUTO-SAVE: # # Match settings MATCH-SETTINGS: + SHOW-PLAYERS-IN-TAB: false # If true, players currently in a match/FFA/event remain visible in the tab list for lobby players (otherwise they are hidden). TEAMS: TEAM1: NAME: "[B]" From da89aab1a44a0c6dcf1a67e9a771b321269e75a3 Mon Sep 17 00:00:00 2001 From: lokspel <208148594+lokspel@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:39:23 +0400 Subject: [PATCH 3/4] refactor: align matchhistory package with profile package structure --- .../manager/matchhistory/MatchHistory.java | 46 ++++++ .../matchhistory/MatchHistoryFile.java | 145 ++++++++++-------- .../matchhistory/MatchHistoryManager.java | 45 +++--- 3 files changed, 143 insertions(+), 93 deletions(-) create mode 100644 core/src/main/java/dev/nandi0813/practice/manager/matchhistory/MatchHistory.java 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 000000000..1a66d1401 --- /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 b1aaf3150..c10149148 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 fad2b6b05..15a500b6f 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)) { From d80227ca11e161f2944b5a1db35ce2ec23c262bf Mon Sep 17 00:00:00 2001 From: lokspel <208148594+lokspel@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:00:46 +0400 Subject: [PATCH 4/4] fix: restore blocks added during rollback --- .../fightmapchange/FightChangeOptimized.java | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) 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 6f11ca31e..5958706d6 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;