fix: close two unbounded map leaks that survived player quit

Обе карты росли без границы и никогда не освобождались.

1. MagicListener.lastCast / lastCastTime
   Писались на каждом применении магии (recordCast) и не удалялись НИКОГДА —
   ни на выходе, ни на смерти. Каждый игрок, хоть раз применивший магию,
   навсегда оставлял две записи. Данные копеечные, но карты растут без
   границы весь срок жизни сервера. Очистка вынесена в общий forget(UUID),
   который зовут и onQuit, и onDeath.

2. TensionManager.battleEnemies
   На выходе не удалялся. Расчёт был на то, что запись исчезнет сама через
   BATTLE_EXPIRE_MS, но эта проверка требует непустого lastCombat — а он
   удалялся в той же строке выше. В итоге запись жила вечно, если в бою был
   противник, который не умирает: босс или запертый моб на ферме. Множество
   не пустеет, lastCombat == null, условие истечения не проверяется, и
   tickBattle обходит эту запись каждый тик до конца жизни сервера.

   Дополнительно tickBattle теперь снимает боевую запись и при lastCombat ==
   null, а не только по таймауту. Это закрывает не только выход, но и любую
   другую рассинхронизацию: без боя и без отметки времени бой не бой.
This commit is contained in:
SashegDev
2026-10-04 13:39:09 +00:00
parent 10894eab37
commit 82bb585f88
2 changed files with 31 additions and 7 deletions
@@ -225,17 +225,29 @@ public class MagicListener implements Listener {
return dmg * 0.7;
}
/**
* Забыть всё состояние игрока. Вызывается и на выходе, и на смерти.
*
* <p>Про lastCast: эти две карты писались на каждом применении магии и
* НИКОГДА не удалялись. Каждый игрок, хоть раз применивший магию, навсегда
* оставлял две записи — на весь срок жизни сервера. Данные там копеечные,
* но карты растут без границы, и их приходится обходить каждый тик.
*/
private void forget(UUID id) {
var task = shieldTasks.remove(id);
if (task != null) task.cancel();
shieldEnd.remove(id);
lastCast.remove(id);
lastCastTime.remove(id);
}
@EventHandler
public void onQuit(org.bukkit.event.player.PlayerQuitEvent e) {
var task = shieldTasks.remove(e.getPlayer().getUniqueId());
if (task != null) task.cancel();
shieldEnd.remove(e.getPlayer().getUniqueId());
forget(e.getPlayer().getUniqueId());
}
@EventHandler
public void onDeath(org.bukkit.event.entity.PlayerDeathEvent e) {
var task = shieldTasks.remove(e.getEntity().getUniqueId());
if (task != null) task.cancel();
shieldEnd.remove(e.getEntity().getUniqueId());
forget(e.getEntity().getUniqueId());
}
}
@@ -212,7 +212,11 @@ public class TensionManager implements Listener {
|| (ent instanceof org.bukkit.entity.LivingEntity le && le.isDead());
});
final Long last = lastCombat.get(pid);
if (set.isEmpty() || (last != null && now - last > BATTLE_EXPIRE_MS)) {
// lastCombat == null означает, что бой не начинался или уже вышел из
// состояния. Множество при этом может быть непустым (противник жив),
// и тогда условие истечения по времени не проверяется вовсе — запись
// остаётся навсегда. Без боя и без отметки времени бой не бой.
if (last == null || set.isEmpty() || (now - last > BATTLE_EXPIRE_MS)) {
if (drop == null) {
drop = new java.util.ArrayList<>(4);
}
@@ -281,6 +285,14 @@ public class TensionManager implements Listener {
tp.remove(id);
lastCombat.remove(id);
lastPct.remove(id);
// Утечка: battleEnemies на выходе не чистился. Расчёт на то, что запись
// исчезнет сама через BATTLE_EXPIRE_MS, но этот путь требует непустого
// lastCombat — а его мы только что удалили строкой выше. В итоге запись
// могла жить вечно: пока в бою есть противник, который не умирает
// (босс на ферме, запертый моб), множество не пустеет, lastCombat равен
// null, и условие истечения не проверяется. Дальше tickBattle обходит
// эту запись каждый тик до конца жизни сервера.
battleEnemies.remove(id);
AbilityCooldown.clearAll(e.getPlayer());
}