Обе карты росли без границы и никогда не освобождались.
1. MagicListener.lastCast / lastCastTime
Писались на каждом применении магии (recordCast) и не удалялись НИКОГДА —
ни на выходе, ни на смерти. Каждый игрок, хоть раз применивший магию,
навсегда оставлял две записи. Данные копеечные, но карты растут без
границы весь срок жизни сервера. Очистка вынесена в общий forget(UUID),
который зовут и onQuit, и onDeath.
2. TensionManager.battleEnemies
На выходе не удалялся. Расчёт был на то, что запись исчезнет сама через
BATTLE_EXPIRE_MS, но эта проверка требует непустого lastCombat — а он
удалялся в той же строке выше. В итоге запись жила вечно, если в бою был
противник, который не умирает: босс или запертый моб на ферме. Множество
не пустеет, lastCombat == null, условие истечения не проверяется, и
tickBattle обходит эту запись каждый тик до конца жизни сервера.
Дополнительно tickBattle теперь снимает боевую запись и при lastCombat ==
null, а не только по таймауту. Это закрывает не только выход, но и любую
другую рассинхронизацию: без боя и без отметки времени бой не бой.
Цель — не средний тик, а разброс: p95 26.9 мс и пики под 170 мс на 60 ботах
бьют по игрокам сильнее, чем медиана 14.8 мс. Почти весь этот разброс шёл
от мусора в горячих путях.
1. Кеш владельца ножа переехал в начало проверки.
Раньше isOwner() сначала звал BlackKnifeItem.is() на обеих руках и только
потом смотрел ownerCache, то есть кеш не срабатывал никогда. is() обязан
взять ItemMeta, а Bukkit возвращает там полную копию метаданных вместе с
PDC. tickShield() зовёт isOwner каждый тик на каждого игрока: при 60
игроках это 2400 копий ItemMeta в секунду. Теперь на попадание в кеш —
ноль аллокаций, пересчёт раз в 2 с. Кеш хранит оба ответа сразу
(в руках / где-то в инвентаре), потому что hasMagic и превью интересуют
разные вещи.
2. BlackKnifeItem.inHands() вместо пары is() в горячих путях:
7 проверок превью прицеливания и SWOON на каждый удар.
3. DustOptions вынесены в константы Particles.
36 мест создавали new Particle.DustOptions(Color.fromRGB(...)), и почти все
внутри циклов или задач с периодом в несколько тиков. DustOptions
иммутабелен, поэтому один объект на цвет. Отдельно вынесен тернарник,
выбиравший цвет кольца ВНУТРИ цикла в превью SNIPE/TRI: он пересчитывал
один и тот же цвет на каждой из 12-16 итераций.
4. getGazePoint() больше не зовёт getEyeLocation() три раза и getDirection()
два. Метод работает каждые 4 тика на каждого игрока с ножом.
5. Визуал STARS вынесен в StarFx под одну задачу.
Раньше 18 звёзд и до 108 осколков получали по своей задаче с периодом в
один тик — до 126 задач планировщика на одно нажатие, каждая со своим
getNearbyEntities и getNearbyPlayers. Теперь один список под одной
задачей, которая снимает себя сама в простое.
Попутно исправлен баг: EntityTracker.untrack() вызывался на каждом тике,
пока сущность жива, поэтому после первого тика она исчезала из множества
отслеживаемых и EntityTracker.cleanup() её больше не находил. Теперь
untrack вызывается один раз, при удалении.
Measured on zern.cc with 20 mineflayer bots on SNIPE, empty world and then
with 625 entities clustered around them.
getLungeTarget searched a 120x120x120 cube to find one entity inside a 5.7
degree cone, and ran TWO raytraces per entity inside that loop. The second one,
rayTraceBlocks(eye, dir, 120), does not depend on the entity at all: eye, dir
and the distance are loop-invariant, so the same answer was recomputed once per
entity, each time walking up to 120 blocks. On a populated world that is up to
2N raytraces per call, and updatePreview calls it every 4 ticks per player
holding a knife.
Now the query is an AABB along the look direction, the block ray is computed
once before the loop, and hasLineOfSight is only called on candidates in
distance order - usually exactly once. The common case of zero or one target
allocates nothing at all; tracking the two nearest inline avoids the lists and
the sort entirely.
Honest note on the measurement: this fix did NOT show up. 10.5 ms median
before vs 10.8 ms after with 625 entities nearby. Working the numbers back,
that path costs roughly 1% of capacity in this scenario, which is below the
noise floor. The code change is correct by inspection, not by measurement, and
it would matter far more in a world with thousands of entities than 625.
LUNGE is removed. Its shockwave drew points = radius*6 and issued THREE
separate spawnParticle calls per point, so a radius-10 ring sent 180 packets
PER TICK, expanding, per cast. That is gone with the ability.
Particle batching, which is the real cost in this plugin. Every spawnParticle
is one packet per viewer. MagicListener's hit ring was 36 separate calls and
its explosion shell was 72; the OVERHEAD aiming ring was 32 packets every
tick. spawnParticle with count>1 spreads particles inside the offset volume, so
a ring of radius r becomes a filled disc of the same radius - indistinguishable
at half-block scale, and the particle count is unchanged. New Particles helper.
TensionManager.updateBar ran every tick per player: String.format, two
Component.text, and a progress packet. Now it repaints only when the whole
percent changes, and the percent is computed with integer math. At 0.25/tick
regen that is four repaints a second instead of twenty, and zero on a full
bar. The percent cache is cleared on quit and resynced on death so the bar
cannot get stuck.
tickBattle copied the entire battleEnemies map, including every nested set,
twenty times a second - purely so it could iterate and delete from the same
map. Keys are now collected into a list and dropped after the loop.
getMode now collapses an unknown stored mode onto STARS. Without that a knife
issued before LUNGE was removed could not be mode-switched at all: the index
lookup found nothing, stayed at zero, and the knife was stuck.
SWOON: virtual HP deleted. Damage now goes through DamageType.GENERIC_KILL,
which vanilla marks as bypassing armour, enchantments, effects and shields,
and which consumes no armour durability. The old mechanic cancelled the damage
outright and only regenened virtual HP back to 1, so a swoon could never be
escaped by any means.
- cost by mob max HP: clamp(mob-base-cost + mob-cost-per-hp * HP, base, max).
Player cost is flat. Chicken 14.5 -> Zombie 22.5 -> Enderman 32.5 -> Warden 137.
- cooldown inversely proportional to TP spent: trash mob 40t, player 160t.
Moved off Material.NETHERITE_SWORD onto a per-ability key: the material
cooldown is a shared per-material table, so it blocked every netherite sword
in the inventory and any inventory rebuild wiped it. New AbilityCooldown.
- death message detected statelessly from the damage type plus who holds the
knife, so no marker state has to survive the death.
- TP is now checked BEFORE the original hit is cancelled. Cancelling first made
every swing without enough TP a free block: no damage, no gain, no progress.
- 257 -> ~110 lines; the VHP/SWOONED checks in DamageUtil, ParryHandler,
GrenadeAttack and SkyDiveAttack are gone with the mechanic they guarded.
Tension:
- passive regen 0.25/tick (was 2/tick, i.e. 100 TP/s) with a hard 3s pause
after the last combat action.
- hit gain 7.5, crit 11.25 (1.5x base instead of 2x).
- onQuit clears TP. It only dropped bossbars and caches, so a quitting player
left a full bar in memory for the rest of the uptime.
- shield gain and delay moved into config; they were the only remaining
hardcoded economy numbers.
DIVE:
- setGravity(false) for the whole ability. Gravity fought setVelocity(0) every
tick and the flag reaches clients, so server and client now agree and hover
is a real state instead of a fight over movement prediction.
- own gravity constant and an analytic apex; the old code probed velocity three
different ways.
- one state machine with a single exit, so death, wall, logout, respawn and
damage can never leave a player with gravity off. The runnables used to
return without cancel() and leaked a per-tick task per dive.
- ground contact for 3 ticks aborts the dive without damage. Unimplemented
before, so you could dive into the ground, walk to the target and hit them.
- dash 3.0 blocks/tick over 60 ticks (was 2.8/40).
- hover pins X/Z only, camera stays free, no per-tick actionbar spam.
- quitting mid-dive refunds the unused knife cooldown.
- fall damage cancelled only while diving and for 2.5s after, not for the whole
cooldown.
Displays: predictive teleport. A visual is spawned N ticks along its own
trajectory and told to interpolate the teleport over those same N ticks, so the
client never lags behind and the packet count is unchanged. Duration 1 is
effectively no smoothing, which is why the visuals snapped on real ping.
Applied to DIAMOND, DIAMOND_TRI, LUNGE (catalyst, sword, overhead, volley) and
GRENADE. lead-ticks is configurable; 0 restores stock behaviour.