Files
DeltaMine/src/main/java/me/sashegdev/blackknife/TensionManager.java
T
SashegDev 82bb585f88 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, а не только по таймауту. Это закрывает не только выход, но и любую
   другую рассинхронизацию: без боя и без отметки времени бой не бой.
2026-10-04 13:39:09 +00:00

344 lines
16 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package me.sashegdev.blackknife;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public class TensionManager implements Listener {
private final Map<UUID, Float> tp = new HashMap<>();
final Map<UUID, BossBar> bars = new HashMap<>();
private final Map<UUID, Long> blockStart = new HashMap<>();
private final Map<UUID, Long> lastCombat = new HashMap<>();
private final Map<UUID, java.util.Set<UUID>> battleEnemies = new HashMap<>();
private final Map<UUID, Long> lastDodge = new HashMap<>();
/**
* Кеш владельца ножа: [в руках, где-то в инвентаре].
*
* <p>Ключевой момент — кеш читается ПЕРВЫМ, а не после быстрого пути.
* Раньше {@link #isOwner} сначала звал {@code BlackKnifeItem.is()} на обеих
* руках и только потом смотрел кеш, то есть кеш не срабатывал никогда.
* {@code ItemStack#getItemMeta()} возвращает полную копию ItemMeta вместе с
* PDC — это аллокация и копирование NBT на каждый вызов. А {@link #tickShield}
* зовёт {@code isOwner} каждый тик на каждого игрока, то есть при 60 игроках
* это было 2400 копий ItemMeta в секунду. Теперь на попадание в кеш — ноль
* аллокаций, а пересчёт раз в OWNER_CACHE_TTL.
*/
private final Map<UUID, boolean[]> ownerCache = new HashMap<>();
private final Map<UUID, Long> ownerCacheTime = new HashMap<>();
private final Map<UUID, Integer> lastPct = new HashMap<>();
private static final long OWNER_CACHE_TTL = 2000L;
public static final float MAX_TP = 250f;
/** Бой считается кончившимся после этого простоя без TP-активности. */
private static final long BATTLE_EXPIRE_MS = 180_000L;
private float passiveRegenPerTick = 0.25f;
private long passiveRegenDelayMs = 3000L;
private float hitGain = 7.5f;
private float critGain = 11.25f;
private float shieldGain = 25f;
private long shieldDelayMs = 5000L;
private static final BlackKnifePlugin PLUGIN = BlackKnifePlugin.getInstance();
private float cfgFloat(String key, float def) {
if (PLUGIN == null) return def;
return (float) PLUGIN.getConfig().getDouble("tension." + key, def);
}
private long cfgLong(String key, long def) {
if (PLUGIN == null) return def;
return PLUGIN.getConfig().getLong("tension." + key, def);
}
/** Перечитывает экономику из конфига (на старте и при /blackknife reload). */
public void reloadEconomy() {
passiveRegenPerTick = cfgFloat("passive-regen-per-tick", 0.25f);
passiveRegenDelayMs = cfgLong("passive-regen-delay-ms", 3000L);
hitGain = cfgFloat("hit-gain", 7.5f);
critGain = cfgFloat("crit-gain", 11.25f);
shieldGain = cfgFloat("shield-gain", 25f);
shieldDelayMs = cfgLong("shield-delay-ms", 5000L);
}
public float getHitGain(boolean crit) {
return crit ? critGain : hitGain;
}
public boolean tryGiveDodge(Player pl) {
if (!isInBattle(pl)) return false;
long now = System.currentTimeMillis();
Long last = lastDodge.get(pl.getUniqueId());
if (last != null && now - last < 1000) return false;
lastDodge.put(pl.getUniqueId(), now);
addTP(pl, 2.5f, "DODGE +1%");
return true;
}
public float getTP(Player p) { return tp.getOrDefault(p.getUniqueId(), 0f); }
public void setTP(Player p, float v) {
float clamped = Math.max(0, Math.min(MAX_TP, v));
tp.put(p.getUniqueId(), clamped);
updateBar(p, clamped);
}
public void addTP(Player p, float amount, String reason) {
if (!hasMagic(p)) return;
float cur = Math.min(MAX_TP, getTP(p) + amount);
tp.put(p.getUniqueId(), cur);
lastCombat.put(p.getUniqueId(), System.currentTimeMillis());
updateBar(p, cur);
if (amount > 0) p.sendActionBar(Component.text(String.format("+%.0f%% TP %s", amount / 2.5f, reason)).color(NamedTextColor.YELLOW));
}
public boolean isOwner(Player p) {
return knifeState(p, true);
}
/** Нож в руках — дешевле, нужно превью прицеливания и {@link #hasMagic}. */
public boolean hasKnifeInHands(Player p) {
return knifeState(p, false);
}
private boolean knifeState(Player p, boolean anywhere) {
final UUID id = p.getUniqueId();
final long now = System.currentTimeMillis();
boolean[] cached = ownerCache.get(id);
final Long t = ownerCacheTime.get(id);
if (cached != null && t != null && now - t < OWNER_CACHE_TTL) {
return cached[anywhere ? 1 : 0];
}
final boolean inHands = BlackKnifeItem.is(p.getInventory().getItemInMainHand())
|| BlackKnifeItem.is(p.getInventory().getItemInOffHand());
final boolean has = inHands || hasAnyBlackKnife(p);
ownerCache.put(id, new boolean[]{inHands, has});
ownerCacheTime.put(id, now);
return anywhere ? has : inHands;
}
public void invalidateOwnerCache(Player p) {
ownerCache.remove(p.getUniqueId());
ownerCacheTime.remove(p.getUniqueId());
}
private boolean hasAnyBlackKnife(Player p) {
for (var it : p.getInventory().getContents()) if (BlackKnifeItem.is(it)) return true;
return false;
}
public boolean hasMagic(Player p) {
if (hasKnifeInHands(p)) return true;
if (isOwner(p)) return true;
var mgr = BlackKnifePlugin.getInstance() != null ? BlackKnifePlugin.getInstance().getMagicManager() : null;
return mgr != null && mgr.hasAwakened(p.getUniqueId());
}
/**
* Перерисовка полосы. Вызывается каждый тик на каждого игрока, поэтому
* работает по дешёвому пути: пакет прогресса и новый Component уходят только
* когда изменился целый процент. При регене 0.25/тик это 4 перерисовки в
* секунду вместо 20, а на полной полосе — ноль.
*
* <p>Проценты считаются целочисленно: {@code String.format} на каждом тике
* на каждого игрока это сотни вызовов в секунду ради строки, которую никто
* не может прочитать быстрее, чем за кадр.
*/
private void updateBar(Player p, float tpVal) {
final int pct = (int) Math.round(Math.max(0.0D, Math.min(1.0D, (double) tpVal / MAX_TP)) * 100.0D);
if (lastPct.getOrDefault(p.getUniqueId(), -1) == pct) {
return;
}
lastPct.put(p.getUniqueId(), pct);
BossBar bar = bars.get(p.getUniqueId());
if (bar == null) {
bar = BossBar.bossBar(Component.text("TP 0%"), 0f, BossBar.Color.WHITE, BossBar.Overlay.NOTCHED_10);
bars.put(p.getUniqueId(), bar);
}
bar.progress(pct / 100.0F);
bar.name(Component.text("TP " + pct + "%").color(NamedTextColor.WHITE));
bar.color(BossBar.Color.WHITE);
if (tpVal > 0) bar.addViewer(p); else bar.removeViewer(p);
}
public void showFor(Player p) {
// Полосу создаём здесь, а не через updateBar: updateBar выходит раньше,
// если процент не изменился, и полоса тогда осталась бы null.
BossBar bar = bars.computeIfAbsent(p.getUniqueId(), id ->
BossBar.bossBar(Component.text("TP 0%"), 0f, BossBar.Color.WHITE, BossBar.Overlay.NOTCHED_10));
if (getTP(p) > 0) bar.addViewer(p);
}
public boolean isInBattle(Player p) {
java.util.Set<UUID> set = battleEnemies.get(p.getUniqueId());
return set != null && !set.isEmpty();
}
public void startBattle(Player attacker, org.bukkit.entity.LivingEntity defender) {
battleEnemies.computeIfAbsent(attacker.getUniqueId(), k -> new java.util.HashSet<>()).add(defender.getUniqueId());
lastCombat.put(attacker.getUniqueId(), System.currentTimeMillis());
if (getTP(attacker) > 0) showFor(attacker);
if (defender instanceof Player dp) {
battleEnemies.computeIfAbsent(dp.getUniqueId(), k -> new java.util.HashSet<>()).add(attacker.getUniqueId());
lastCombat.put(dp.getUniqueId(), System.currentTimeMillis());
if (getTP(dp) > 0) showFor(dp);
}
}
public void tickBattle() {
final long now = System.currentTimeMillis();
// Ключи на удаление собираются в список и применяются после обхода.
// Раньше здесь на каждый тик делалась полная копия battleEnemies вместе
// со всеми вложенными сетами — ради того, чтобы итерировать и одновременно
// удалять из той же карты. Это 20 полных аллокаций карты в секунду
// на каждого боя, и копия тем больше, чем длиннее бой.
java.util.List<UUID> drop = null;
for (var e : battleEnemies.entrySet()) {
final UUID pid = e.getKey();
final java.util.Set<UUID> set = e.getValue();
set.removeIf(uuid -> {
final var ent = org.bukkit.Bukkit.getEntity(uuid);
return ent == null || !ent.isValid()
|| (ent instanceof org.bukkit.entity.LivingEntity le && le.isDead());
});
final Long last = lastCombat.get(pid);
// lastCombat == null означает, что бой не начинался или уже вышел из
// состояния. Множество при этом может быть непустым (противник жив),
// и тогда условие истечения по времени не проверяется вовсе — запись
// остаётся навсегда. Без боя и без отметки времени бой не бой.
if (last == null || set.isEmpty() || (now - last > BATTLE_EXPIRE_MS)) {
if (drop == null) {
drop = new java.util.ArrayList<>(4);
}
drop.add(pid);
}
}
if (drop != null) {
for (final UUID pid : drop) {
battleEnemies.remove(pid);
lastCombat.remove(pid);
}
}
}
public boolean hasHeldShield(Player p) {
return p.isBlocking() && (p.getInventory().getItemInMainHand().getType() == org.bukkit.Material.SHIELD || p.getInventory().getItemInOffHand().getType() == org.bukkit.Material.SHIELD);
}
public void tickShield() {
long now = System.currentTimeMillis();
for (Player p : org.bukkit.Bukkit.getOnlinePlayers()) {
boolean blocking = hasHeldShield(p);
if (blocking) {
long start = blockStart.getOrDefault(p.getUniqueId(), now);
if (!blockStart.containsKey(p.getUniqueId())) blockStart.put(p.getUniqueId(), now);
if (now - start >= shieldDelayMs) {
addTP(p, shieldGain, "SHIELD");
blockStart.put(p.getUniqueId(), now);
lastCombat.put(p.getUniqueId(), now);
}
} else {
blockStart.remove(p.getUniqueId());
}
if (isOwner(p)) {
tickPassiveRegen(p, now);
}
}
tickBattle();
}
/** Пассивный реген — только вне боя, иначе TP становится вечной зарплатой. */
private void tickPassiveRegen(Player p, long now) {
final float cur = getTP(p);
if (cur >= MAX_TP) return;
final Long lastCombatAt = lastCombat.get(p.getUniqueId());
if (lastCombatAt != null && now - lastCombatAt < passiveRegenDelayMs) return;
final float next = Math.min(MAX_TP, cur + passiveRegenPerTick);
tp.put(p.getUniqueId(), next);
updateBar(p, next);
}
@EventHandler
public void onJoin(PlayerJoinEvent e) { updateBar(e.getPlayer(), getTP(e.getPlayer())); invalidateOwnerCache(e.getPlayer()); }
@EventHandler
public void onQuit(PlayerQuitEvent e) {
UUID id = e.getPlayer().getUniqueId();
BossBar bar = bars.remove(id);
if (bar != null) bar.removeViewer(e.getPlayer());
ownerCache.remove(id);
ownerCacheTime.remove(id);
blockStart.remove(id);
lastDodge.remove(id);
// TP не персистится: рестарт обнуляет всех, и ливнувший тоже не должен
// унести полосу в памяти до следующего рестарта.
tp.remove(id);
lastCombat.remove(id);
lastPct.remove(id);
// Утечка: battleEnemies на выходе не чистился. Расчёт на то, что запись
// исчезнет сама через BATTLE_EXPIRE_MS, но этот путь требует непустого
// lastCombat — а его мы только что удалили строкой выше. В итоге запись
// могла жить вечно: пока в бою есть противник, который не умирает
// (босс на ферме, запертый моб), множество не пустеет, lastCombat равен
// null, и условие истечения не проверяется. Дальше tickBattle обходит
// эту запись каждый тик до конца жизни сервера.
battleEnemies.remove(id);
AbilityCooldown.clearAll(e.getPlayer());
}
@EventHandler
public void onInvChange(org.bukkit.event.inventory.InventoryClickEvent e) {
if (e.getWhoClicked() instanceof Player p) invalidateOwnerCache(p);
}
@EventHandler
public void onPickup(org.bukkit.event.entity.EntityPickupItemEvent e) {
if (e.getEntity() instanceof Player p) invalidateOwnerCache(p);
}
@EventHandler
public void onDrop(org.bukkit.event.player.PlayerDropItemEvent e) {
invalidateOwnerCache(e.getPlayer());
}
@EventHandler
public void onHeld(org.bukkit.event.player.PlayerItemHeldEvent e) {
invalidateOwnerCache(e.getPlayer());
}
public void hideAll() {
for (var e : new HashMap<>(bars).entrySet()) {
var bar = e.getValue();
var p = org.bukkit.Bukkit.getPlayer(e.getKey());
if (p != null) bar.removeViewer(p);
else {
for (var pl : org.bukkit.Bukkit.getOnlinePlayers()) bar.removeViewer(pl);
}
}
bars.clear();
blockStart.clear();
lastDodge.clear();
}
@EventHandler
public void onDeath(PlayerDeathEvent e) {
Player p = e.getEntity();
tp.put(p.getUniqueId(), 0f);
// Синхронизируем кеш: иначе после смерти updateBar увидит прежний
// процент, решит что ничего не изменилось, и не перерисует полосу.
lastPct.put(p.getUniqueId(), 0);
BossBar bar = bars.get(p.getUniqueId());
if (bar != null) { bar.progress(0); bar.name(Component.text("TP 0%")); bar.removeViewer(p); }
}
}