26 Commits

Author SHA1 Message Date
SashegDev 31d7753471 feat(voiddeath): muffled death in the void, local message only
Одиннадцатый этап, последний из задуманных.

Сообщение о смерти в пустоте не уходит в общий чат: забирается через
deathMessage(null) и отправляется только тем, кто в радиусе 100 блоков от места
смерти. Текст сообщения при этом тот же самый, что и обычно.

Убийство кого-то отдельным обработчиком ловить не пришлось: «X убит Y» — это
то же самое событие PlayerDeathEvent, что и «X умер». Различаются они только
текстом, который и так передаётся как есть.

Крик визера: громкость 0.2, дальность доставки 150 блоков. Отдельного
ENTITY_WITHER_SCREAM в enum нет — визer's крик в ванилле называется
entity.wither.spawn, то есть ENTITY_WITHER_SPAWN.

Про «дальность 150» честно: это радиус доставки пакета на сервере, то есть кто
его вообще получил. Громкость 0.2 — громкость пакета, а клиент дальше
применяет своё позиционное затухание: у источника слышно, в сотне метров
почти нет. Чтобы звук был одинаково громким на всех 150 блоках, громкость
пришлось бы поднять до 9.4, и тогда заглушало бы всех рядом с источником.
Именно для такой задачи в Delta есть раздельная дальность звука, и флаг
decoupled-sound-range-enabled на проде включён: без него World#playSound с
радиусом молча уходит в стоковое поведение, где радиус выводится из
громкости, то есть 0.2 даёт 3.2 блока.

Проверено: плагин грузится без ошибок, флаг виден в /delta как true. Сама
смерть в пустоте не проверялась — нужен игрок.
2026-10-19 20:49:27 +00:00
SashegDev 285bcbc282 feat(voidexit): stab a sharp item from a jump to open a one-way exit
Десятый этап: выход из пустоты. Теперь пустоту можно посмотреть и покинуть без
рестарта.

Как работает: в пустоте берёшь любой острый предмет, прыгаешь и жмёшь ЛКМ по
воздуху. Пол ищется верхним твёрдым блоком в колонке, а не лучом от глаз: пол
пустоты плоский, а луч при быстром падении проходит мимо. Дыра работает в одну
сторону — из пустоты выкидывает, снаружи это только метка, войти нельзя.

Три решения, которые стоит зафиксировать:

1. Высота при выходе берётся с поверхности ОБЫЧНОГО мира, а не из пустоты.
   Пустота стоит на барьере внизу диапазона, и прямой перенос высоты уронил бы
   игрока в камень. X и Z пересчитываются по соотношению 1:2.

2. Коэффициент берётся из getCoordinateScale, а не константой 2.0. Если
   coordinate_scale поменяется в датапаке, пересчёт последует за ним сам.

3. Живёт дыра по факту использования, как в условии, но с потолком в 5 минут.
   Иначе дыра, о которой владелец забыл, висит до рестарта и продолжает
   выкидывать людей из пустоты. Это моё добавление, не из описания.

Войти может только владелец: дыра сделана в пустоте, а по условию такая пускает
лишь открывшего.

Острый предмет определяется по суффиксу в имени материала: _SWORD, _AXE,
_PICKAXE, _SHOVEL, _HOE. Проверяется по всему инвентарю, а не только по руке:
в пустоте игрок в приключениях, и предмет в руке держать не обязательно.

Мелочь с API: слушатель не может принимать лишний параметр, Bukkit создаёт
его сам, поэтому плагин берётся из инстанса.

Проверено: плагин грузится без ошибок, измерение находится. Сам перенос не
проверялся — нужен игрок.
2026-10-19 04:07:25 +00:00
SashegDev 5a1ea2343e feat(void): persisted void counter and solo entry into the void world
Девятый этап и вход в пустоту: счётчик в файле плюс возможность войти в одиночку.

Счётчик void:
- живёт в plugins/BlackKnife/void.yml, по условию в файле, а не в PDC
- пишется через временный файл и атомарную подмену: при убийстве сервера в
  момент записи остаётся предыдущая целая версия, а не обрезанный файл
- сохранение раз в минуту асинхронно и синхронно при выключении, потому что
  после cancelTasks асинхронная задача уже не выполнится
- порог 20, команда /blackknife void get|set|add [player] — нужна и для
  настройки баланса, и для проверки: двадцать нор ждать долго

Вход в пустоту:
- при счётчике от 20 дыра работает и без ответа: в неё можно войти и оказаться
  в пустоте
- пускает ТОЛЬКО открывший. Иначе счётчик превратился бы в телепорт для всех,
  и весь смысл накопления теряется
- точка входа запоминается: обратный выход пойдёт по соотношению 1:2, поэтому
  выход не равен входу по координатам
- приземление ищется через getHighestBlockYAt, а не вычисляется: высота, на
  которую лягут слои барьера, зависит от версии, а поиск верхнего блока
  находит пол всегда

Voidness копится раз в секунду (период задачи эмбиента ровно 60 тиков) и
обнуляется на выходе: счётчик сессионный, рестарт его тоже убирает.

Найденное имя измерения: world_delta_void_void_world. Сервер добавляет
префикс папки мира и санитизирует путь, так что угадать его в коде было бы
верным способом не заработать. Проверка по имени мира, а не по флагам уровня.

Проверено на сервере: команда отвечает, измерение находится, генератор кладёт
барьерные слои на нескольких высотах. Сам перенос игрока не проверялся —
нужен живой игрок.
2026-10-17 15:52:28 +00:00
SashegDev ecb741140b feat(voidworld): void world rules, adventure mode and basalt ambience
Восьмой этап: поведение мира пустоты. Заход в пустоту пока не сделан, но все
правила поведения готовы и включаются сразу, как только игрок туда попадёт.

Что сделано:
- режим приключений на входе и возврат прежнего на выходе. Слушается
  PlayerChangedWorldEvent, а не проверка каждый тик: состояние меняется только
  при смене мира, и событие об этом сообщает.
- кровать в пустоте взрывается. Тип измерения уже запрещает сон через
  bed_works, но игрок всё равно получает сообщение об успешном сне и ждёт
  пробуждения — доводим до взрыва.
- якорь возвражения взрывается.
- ломать блоки нельзя.
- эмбиент базальтовых дельт: AMBIENT_BASALT_DELTAS_LOOP и ADDITIONS, плюс
  пепельный дым и белые языки пламени. Одна задача на сервер, не на игрока.

Три ошибки поймал компилятор, а не я, и все три были про выдуманные имена:

1. PlayerRespawnAnchorEvent в этом API НЕ существует. Есть только блочные
   данные org.bukkit.block.data.type.RespawnAnchor. Якорь ловится обычным
   PlayerInteractEvent со сверкой типа данных блока.

2. У PlayerChangedWorldEvent в Paper есть только getFrom(), getTo() нет.
   Но к моменту события игрок уже в новом мире, поэтому цель берётся из
   p.getWorld() — это надёжнее, чем отсутствующий геттер.

3. PlayerBedEnterEvent#getBed() возвращает Block, а не Bed. Приводим с
   проверкой: при неудачном входе в кровать блок может оказаться чем угодно.

Имена enum проверял по jar, а не по памяти: звук называется
AMBIENT_BASALT_DELTAS_ADDITIONS, со строчной s в DELTAS. Варианта без неё
в enum нет.

Плюс в лог при старте печатается имя найденного измерения. Оно оказалось
world_delta_void_void_world, а не delta_void:void_world, как я бы угадал:
сервер добавляет префикс папки мира и санитизирует путь. Угадывать имя
измерения в коде было бы верным способом не заработать.
2026-10-17 15:47:30 +00:00
SashegDev 54dcd2078a feat(molehole): teleporting hole pairs, state machine and visuals
Первый этап из одиннадцати: дыры, парность, таймер, погружение, телепорт,
визуал и разбор координат. Мир пустоты ещё не трогаем — этого этапа ничего
не ломает в существующей игре.

Поток ровно как задумано:
  ПКМ            -> входная дыра, состояние WAITING_COORDS, таймера нет
  ввод "x, y, z" -> ответная дыра, PAIRED, -100% TP, кулдаун 30с, таймер 5с
  истёк 5с      -> обе дыры исчезают

Ключевые решения, которые стоит знать при доработке:

1. Повторный ввод координат в состоянии PAIRED ЗАБЛОКИРОВЕН. Иначе можно было
   бы переставлять ответную дыру и продлевать себе окно, то есть 5 секунд
   перестали бы быть 5 секундами. В WAITING_COORDS повторять можно сколько
   угодно: ошибся — передвинул.

2. TP и кулдаун списываются в момент создания ПАРЫ, а не прыжка. Значит пару
   можно создать и уйти не воспользовавшись — потрачено.

3. Порядок действий фиксированный: ПКМ, потом координаты. Раньше в описании
   было «порядок не важен», но раз координаты вводятся только после ПКМ,
   обратный порядок физически невозможен.

4. Парсер координат: три числа это x,y,z, два числа это x,z и Y берётся
   верхним блоком колонки. Формата «x, y» без Z не существует: двусмысленно,
   и читать его как две координаты безопаснее. Разбор ведётся только в
   WAITING_COORDS; всё остальное уходит в чат нетронутым — перехватчик не
   имеет права глотать переписку.

5. Проверка места дыры: блок точки обязан быть воздухом, над ним свободно,
   под ним опора. Иначе в дыру невозможно встать, а висящая в воздухе дыра
   бессмысленна.

6. Визуал идёт под одной задачей, как StarFx. Дыр может быть много одновременно
   у разных игроков, и задача на каждую сущность дала бы десятки записей в
   планировщике. Осколки: чёрное стекло, чёрный бетон и чёрный бетонный порошок,
   поднимаются и одновременно уменьшаются.

7. Погружение телепортацией на доли блока, а не скоростью. Скорость смешалась бы
   с сущностями в дыре, телепорт даёт ровное smoothstep-движение. По достижении
   -0.5 телепорт в ответную дыру с тем же смещением по Y.

Задел на будущее: Session уже несёт флаги fromVoid и owner под правило
«дыра из пустоты — только для открывшего», и поля entranceTo/exitTo под
механику острого предмета. Логика их пока не читает.
2026-10-15 21:06:48 +00:00
SashegDev f6d850b5a7 feat(blackknife): aim indicators use long-range particles, fix broken SWOON
Частицы прицелов уходили через spawnParticle, который ванильный сервер режет
на 32 блоках. Механика при этом работает на 120: OVERHEAD бьёт по точке
взгляда, DIVE туда же, SNIPE и TRI ищут цель в конусе до 120 блоков. То есть
вне 32 блоков прицела не было видно, а удар приходил — выглядит как поломка.

Теперь прицелы идут через Particles.farRing/farParticle, которые вызывают
World#spawnParticleLongDistance. Дистанция считается от игрока до точки
прицела и подрезается потолком 120. Боевые эффекты остались на обычных
spawnParticle: удар мгновенный, рисовать его за 100 блоков незачем.

Переведены четыре прицела: превью OVERHEAD, превью DIVE, кольцо TRI/SNIPE и
метки на цели. В OVERHEAD заодно кольцо из 8 отдельных SCULK_SOUL свернуто в
один дисковый вызов.

Попутно исправлен SWOON. DamageSource собирался только с withCausingEntity,
без withDirectEntity, и Bukkit такой вызов отвергал:
    IllegalArgumentException: Direct entity must be set if causing entity is set
То есть урон не наносился вообще, а в лог падало исключение на каждый удар
ножом. Причина со времён переписывания SWOON, не сегодняшняя.

build.gradle: подключён локальный paper-api из сборки Delta, потому что нового
метода нет в стоковом API. Файл идёт первым, оба jar содержат
org/bukkit/World.class, и на classpath побеждает тот, что раньше.
2026-10-05 17:31:08 +00:00
SashegDev 4b8989a28e 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
SashegDev 651454914d perf: hoist last two DustOptions out of particle loops
Сфера взрыва SWORD — 36 частиц за вызов, превью разрушения щита — 8, и обе
внутри циклов, каждые 4 тика на игрока. Осталось три аллокации DustOptions
на весь плагин: две здесь и grenadeDust(размер) у GRENADE, где размер
вычисляется и константой не обойтись.
2026-10-04 13:12:38 +00:00
SashegDev cf1ee08bb8 perf: cut per-tick allocation and collapse STARS into one scheduler task
Цель — не средний тик, а разброс: 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 вызывается один раз, при удалении.
2026-10-04 13:11:14 +00:00
SashegDev 4890f91991 perf: batch particles, drop LUNGE, fix target search, stop per-tick map copy
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.
2026-10-01 12:50:33 +00:00
SashegDev 81e86fdc9f rework: SWOON rewrite, tension economy, DIVE physics, predictive displays
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.
2026-09-30 12:41:28 +00:00
SashegDev 21d5e4df94 version strings 1.21.1 -> 1.21.8 2026-09-30 05:28:51 +00:00
SashegDev e5636a5b57 update to Paper 1.21.8 (Attribute.MAX_HEALTH rename) 2026-09-30 05:26:00 +00:00
SashegDev a86cc7689a remove orb soulbound (free transfer, no mint) + remove 10% knife drop on SWOON 2026-09-25 19:38:37 +00:00
SashegDev 53700b29ec fix DIVE aim: drop per-tick teleport (was locking camera), MoveEvent-only position lock; higher launch 2026-09-24 19:13:02 +00:00
SashegDev 3e26bb9ad5 fix DIVE: allow creative, feedback messages, segment hit sampling; tune GRENADE faster flight + 10t cd 2026-09-24 19:07:50 +00:00
SashegDev 014a823c1b feat: BlackKnife facade manager + tab completion + mode/tp/awaken subcommands 2026-09-24 18:54:36 +00:00
SashegDev 45c7250c08 fix: missing PersistentDataType import in ParryHandler 2026-09-24 18:38:09 +00:00
SashegDev fa81d144ba smooth display motion via teleport_duration=1 (no desync) 2026-09-17 09:18:53 +00:00
SashegDev 3e00c57fa7 tweak grenade: no flight cap, range 15, preview matches 2026-09-17 08:43:44 +00:00
SashegDev ee4eb7f897 feat: GRENADE parabolic nade (35/5.5, 25TP, 15cd) + DIVE sky strike (25dmg knockback, 87.5TP, 120cd) 2026-09-17 08:41:14 +00:00
SashegDev 39ffe477a5 fix multiplayer: remove KNOCKED, swoon softlock, combat checks, dodge TP gate, inv cache, killfeed, threads/leaks 2026-09-14 05:07:09 +00:00
SashegDev e227ad63bd feat: disable invuln for plugin damage + DamageUtil + DiamondTri/Barrage/Overhead nerf 2026-08-28 14:17:21 +03:00
SashegDev a8ce2375c0 feat: DeltaCraft awakening - TP only with magic/knife + soulbound MagicOrb + all physical triggers + Lang config 2026-08-28 04:08:41 +03:00
SashegDev 4ce654b838 feat: swoon VHP lock + effects + kill obfuscation Lang ru|en config + magic 4 2026-08-27 16:41:07 +03:00
SashegDev 10faf5679f feat: BlackKnife DeltaMine v1.0.0 - Paper 1.21.1 SWOON/Stars/Sword/Diamond/Lunge/Snipe/Tri/Overhead + Magic 4commons Parry chat 400words +12h updates 2026-08-27 01:55:05 +03:00