Два движка по ТЗ: частицы-эффекты и кастомные сущности. Оба ТЗ сами по себе указывали на правильную архитектуру — оставалось не отступить от неё в реализации.
ТЗ сразу называло главную опасность: Thread.sleep() внутри скрипта повесит весь сервер, а десятки sin/cos на тик от нескольких активных эффектов одновременно просядут по TPS, если считать их неэффективно.
Решение: KSLEffectManager.play() — один runTaskTimer на активный эффект, вся геометрия вынесена в KSLEffectShapes (чистые функции points(tick, center): List<Vector>, без побочных эффектов, без обращения к Bukkit-состоянию кроме Location/Vector). Скрипт вызывает circle(radius, stepDegrees) — и всё, ни одного Math.cos в .kts.
Ловушка с порядком вызовов в DSL-блоке, пойманная при проектировании, не после: helix() должен строить форму, зависящую от durationTicks (чтобы спираль поднялась на всю высоту ровно за время жизни эффекта) — но duration = ... в блоке конфигурации может быть написан ПОСЛЕ вызова helix(...), и на момент вызова helix() durationTicks ещё держит значение по умолчанию. Решение: circle/helix/lineTo не строят KSLEffectShape сразу, а сохраняют билдер-лямбду ((Long) -> KSLEffectShape), которая материализуется только один раз — в KSLEffectManager.play(), уже после того как весь DSL-блок полностью выполнился и durationTicks принял финальное значение. Тем самым порядок вызовов внутри { } перестал быть важен.
follow = true и смерть цели. Если эффект следует за мобом (Entity.playEffect(follow = true)), а моб умирает раньше, чем истекут durationTicks, наивная реализация продолжила бы красиво крутить частицы вокруг трупа до конца таймера — не крашится, но выглядит странно. Добавлено KSLEffectSpec.stopCondition: (() -> Boolean)? — общий, не привязанный к сущностям механизм: Entity.playEffect при follow = true сама подставляет { !entity.isValid }, KSLEffectManager проверяет это условие каждый тик наравне с истечением durationTicks.
Очистка при reload — досталась бесплатно. Задачи эффектов регистрируются через уже существующий plugin.trackTask(scriptName, taskId) — тот же механизм, что и every()/delay() с 1.1.0. unloadScript(scriptName) уже отменяет все таски скрипта в первую очередь — никакого нового кода для очистки эффектов при точечном reload писать не пришлось, только переиспользование.
ТЗ прямо назвал проблему, на которой ломаются подобные плагины: моб, заспавненный скриптом, переживает /ksl reload этого скрипта, а его onTick-логика продолжает вызываться, ссылаясь на код давно выгруженного скрипта.
Два независимых решения работают вместе:
onTickпереиспользуетplugin.trackTask— точно так же, как таски эффектов.KSLEntityBehaviorBuilder.onTickрегистрируетrunTaskTimerи сразу трекает его наscriptName, так чтоunloadScriptотменяет его автоматически, без знания о сущностях вообще.onDeath— не черезonEvent. Если быonDeathбыл реализован какonEvent<EntityDeathEvent> { if (entity == moboUuid) ... }, каждый заспавненный моб добавлял бы отдельный глобальный обработчик — ровно та же ошибка масштабирования, что чуть не была допущена с GUI в 1.1.0 (см. dev log 1.1.0, раздел про GUI). Вместо этого —KSLEntityManager, одинEntityDeathEvent-listener на весь плагин, диспетчеризация поUUIDсущности черезConcurrentHashMap— O(1) независимо от количества заспавненных мобов.
PDC-тег владельца — ровно как предложено в ТЗ: entity.persistentDataContainer.set(ownerKey, PersistentDataType.STRING, scriptName) в момент спавна. KSLEntityManager дополнительно держит entitiesByScript: ConcurrentHashMap<String, MutableSet<UUID>> в памяти (PDC — источник истины на случай рестарта/внешнего осмотра моба, in-memory индекс — для быстрой очистки без сканирования всех сущностей во всех мирах при каждом reload). cleanupForScript(scriptName) снимает onDeath-колбэк и, если removeOnReload не выставлен в false явно при спавне, убирает моба из мира.
Парсер шаблонов — не наивный split(" "). "NAME:<red>Big Boss</red>" не проблема (тег без пробелов внутри), но "NAME:<red>Big Boss with spaces</red>" — уже проблема для простого разбиения по пробелу. KSLTemplateParser — единая regex-based реализация (([A-Za-z_]+):("[^"]*"|\S+)), поддерживающая значения в кавычках для текста с пробелами, переиспользуемая и для spawnCustomEntity, и для equip() — не два отдельных парсера с разной логикой.
Первый проход перепроверял вызовы Bukkit API против документации. Второй — перечитывание собственной логики построчно, без доверия к тому, что "раз скомпилировалось и один раз сработало на примере — значит корректно". Три находки, от самой опасной до самой безобидной:
circle(radius, stepDegrees = 0.0) мог заморозить/уронить сервер по OOM.
pointCount = maxOf(4, (360.0 / stepDegrees).toInt()) — при stepDegrees = 0.0
360.0 / 0.0 в Kotlin/Java не кидает исключение, а даёт Double.POSITIVE_INFINITY,
.toInt() от бесконечности возвращает Int.MAX_VALUE (2 147 483 647).
maxOf(4, 2147483647) = 2147483647. Дальше на каждом тике эффекта код
пытался бы аллоцировать List на 2+ миллиарда элементов — гарантированный
OutOfMemoryError или зависание сервера на первом же тике. Это не гипотетическая
опечатка: circle(radius = 2.0, stepDegrees = 0), если кто-то интерпретирует
0 как "без вращения", выглядит вполне правдоподобным намерением автора
скрипта. То же самое отсутствие верхней границы было и в lineTo(target, points)
— steps = maxOf(1, points) защищал только от нуля/отрицательных, но не от
случайно огромного points. Обе формы теперь ограничены через coerceIn(4, 360)
/ coerceIn(1, 360) — разумный потолок для количества частиц на форму,
независимо от того, что передал скрипт.
durationTicks < 0 делал эффект бессмертным. Условие остановки было
spec.durationTicks in 0..tick — если durationTicks отрицательный, оно
никогда не попадает в диапазон [0, tick] ни при каком tick >= 0, и эффект
крутится вечно (до явного stopEffect() или рестарта сервера), а не завершается
за отведённое время. Переписано на прямое tick >= spec.durationTicks — корректно
для любого знака: отрицательная длительность останавливает эффект сразу же,
не запуская ни одного тика.
intervalTicks/period без валидации перед планировщиком. И
KSLEffectManager.play, и KSLEntityBehaviorBuilder.onTick, и уже существовавшие
с 1.1.0 every()/everyAsync() передавали значение периода в
Bukkit.getScheduler().runTaskTimer(...) без проверки. Bukkit требует период
>= 1 и кидает IllegalArgumentException немедленно при вызове — что важно,
до входа в try-catch, который оборачивает только тело самого тика, не сам
вызов планирования. Сервер это не роняло (ошибка в итоге ловится где-то выше
по стеку — при загрузке скрипта, в обработчике команды или события), но
сообщение об ошибке было бы совершенно не про дело: голое
IllegalArgumentException: Period must be >= 1 вместо "ты передал 0 в
every()". Добавлен общий KSLErrors.validPeriod(...) — валидирует и
подставляет 1 с понятным warning в лог, переиспользуется во всех четырёх
местах вместо четырёх копий одной и той же проверки.
Утечка кулдаунов. KSLCooldownStore.lastUse не чистился вообще никогда —
ни при /ksl reload, ни при выгрузке отдельного скрипта. Помимо утечки
памяти (небольшой — просто строковые ключи, растёт медленно) это ещё и
логическая проблема: если скрипт с изменившейся логикой перезагружается,
старые метки времени кулдауна от предыдущей версии скрипта продолжают
действовать для тех же игроков. Добавлен clearScript(scriptName) по тому же
паттерну, что уже был у KSLPersistStore/KSLLibraryRegistry — очистка по
префиксу ключа, подключена в unloadScript.
Пользователь прямо попросил перепроверить рискованные места, а не полагаться на то, что код скомпилировался и один раз отработал в примере. Два реальных находки, не связанные с компиляцией:
Утечка памяти в entitiesByScript. cleanupForScript(scriptName) снимал
UUID сущности из внутреннего индекса только при выгрузке скрипта — но если
моб умирал естественно (убит игроком, самый частый случай), его UUID
оставался в entitiesByScript[scriptName] навсегда. Для сервера, где мобы
скрипта спавнятся и убиваются сотнями за недели работы, это неограниченный
рост памяти. Добавлен отдельный listener на org.bukkit.event.entity.EntityRemoveEvent
(современная, кросс-версийная замена устаревшего EntityRemoveFromWorldEvent
из старого Paper-пакета com.destroystokyo.paper.event.entity) — ловит любую
причину удаления сущности (смерть, /kill, деспавн, снятие другим плагином),
не только выгрузку скрипта. Официальная документация Paper и открытый issue
на GitHub явно предупреждают: вызов .remove() на сущности изнутри этого
события может уронить сервер через ConcurrentModificationException, если
удаление происходит прямо во время итерации по сущностям мира — поэтому
новый listener только чистит внутренние ConcurrentHashMap, ни разу не
трогая саму сущность.
Непроверенный каст в spawnCustomEntity. world.spawnEntity(location, type) as LivingEntity
падал бы с сырым ClassCastException без единого полезного слова, если
скрипт укажет TYPE:ARROW/TYPE:ITEM_FRAME или любую другую не-живую
сущность — а сама сущность при этом уже успевала бы заспавниться в мир и
остаться никем не отслеживаемой (KSLEntityManager рассчитан только на
LivingEntity). Заменено на safe-cast (as?) с явной проверкой: если каст не
удался, ошибочно заспавненная сущность немедленно удаляется, и наружу летит
понятное сообщение с именем типа вместо голого исключения.
Первая версия HP в шаблоне ставила максимальное здоровье через
entity.getAttribute(Attribute.GENERIC_MAX_HEALTH)?.baseValue = hp. Сборка
упала на Unresolved reference 'GENERIC_MAX_HEALTH' — оказалось, Paper
переименовал константу в MAX_HEALTH начиная примерно с API 1.21.3 (проект
пинует 1.21.1 в build.gradle.kts, но фактически резолвится более новый
снапшот). Исправлено на MAX_HEALTH — и тут же поймана вторая волна: на
рантайме (уже не компиляция, а NoSuchFieldError при вызове /summonboss на
реальном сервере) выяснилось, что и MAX_HEALTH не существует на ещё более
новой сборке Paper, которую использует пользователь — Attribute там мигрирует
с enum-констант на Registry-based доступ (Registry.get(NamespacedKey))
целиком, и старые статические поля просто отсутствуют.
Гоняться за очередным именем константы под конкретную минорную версию Paper —
дорога в никуда, пока эта часть API находится в активной миграции. Решение —
уйти от Attribute для этой конкретной задачи и использовать
Damageable.setMaxHealth(Double): помечен @Deprecated в доках Paper, но
физически присутствует с очень старых версий Bukkit и не участвует в текущей
чехарде с переименованием enum-констант. Deprecated ≠ нестабильный — в этом
случае deprecated-метод оказался куда надёжнее "актуальной" замены.
Технический дневник разработки. В отличие от CHANGELOG.md, здесь не только "что", но и "почему" и "как было найдено" — для тех, кто хочет понимать внутренности или контрибьютить.
Первая по-настоящему новая подсистема в 1.1.0. Три интерфейса: KSLAddon (реализует сторонний плагин), KSLAPI (то, что доступно внутри onLoad), KSLContextExtension (хук на создание/уничтожение контекста скрипта). Плюс KSL — статический object-синглтон как точка входа (KSL.api, KSL.isAvailable).
Два конкретных решения стоит объяснить:
dependsOn + onAddonReady. Bukkit сам решает порядок загрузки плагинов, и depend/softdepend в plugin.yml не всегда дают то, что ожидаешь — особенно если два аддона софт-зависят друг от друга. dependsOn в KSLAddon — не блокирующая декларация, просто warning в лог, если зависимость ещё не загружена. onAddonReady(id) { addon -> ... } — настоящее решение: если аддон уже загружен, колбэк выполняется немедленно; если ещё нет — откладывается и срабатывает автоматически в момент его регистрации, в любом порядке. Реализовано через очередь колбэков плюс ThreadLocal<String?> для отслеживания "какой аддон сейчас грузится" — используется и для владения (см. ниже).
Автоматическая очистка при выгрузке. Изначально registerService/addDefaultImports/registerContextExtension ничего не знали о том, какой аддон их вызвал — просто складывали в общие коллекции. Если автор аддона забывал вручную вызвать unregisterService в своём onUnload(), объект оставался в памяти плагина навсегда, указывая на давно выгруженный аддон. Исправлено: KSLAddonManager выставляет currentlyLoading (ThreadLocal) перед вызовом addon.onLoad(this) и убирает после — все регистрации внутри onLoad автоматически помечаются владельцем. При unregisterAddon всё, что принадлежало этому аддону, снимается само.
Ключевое архитектурное решение здесь — производительность при масштабе, а не просто "работающий GUI".
Наивная реализация через уже существующий onEvent<InventoryClickEvent> была бы багом производительности: каждый вызов onEvent регистрирует новый обработчик в Bukkit. GUI с 20-30 кнопками, каждая как отдельный onEvent<InventoryClickEvent> { if (slot == X) ... }, означает, что любой клик в любом инвентаре на сервере вызывает все 20-30 обработчиков подряд — O(N) на каждый клик каждого игрока, независимо от того, к какому меню он относится.
Решение: KSLGuiManager — один listener на весь плагин, регистрируется один раз при onEnable. KSLGuiHolder хранит прямую ссылку на конкретный KSLGuiInstance, обработчики слотов лежат в обычном arrayOfNulls (максимум 54 элемента), не в HashMap. Диспетчеризация клика — O(1) независимо от количества открытых меню, скриптов и игроков.
Побочные решения, добавленные для устойчивости:
- Анти-дюп по умолчанию: любой клик по слоту меню отменяется (
event.isCancelled = true), если не вызванallowItemMovement(slot). - При
/ksl reload(или точечной перезагрузке) все открытые GUI выгружаемого скрипта принудительно закрываются (guiManager.closeGuisForScript) — иначе клик мог бы попасть в closure, держащую ссылку на уже выгруженное состояние скрипта. - Main-thread guard:
gui(), вызванный не из основного потока (например, по ошибке внутриrunAsync { }), кидает понятныйIllegalStateExceptionвместо того, чтобы тихо портить внутренности Bukkit Inventory (созданиеInventoryне потокобезопасно).
Три разных механизма для разных сценариев:
persist(key, persistent)— состояние одного скрипта. Безpersistent=trueживёт только в памяти плагина (переживает/ksl reload, не переживает рестарт). Сpersistent=true— SQLite, таблицаksl_persist, переживает рестарт.table(name)— общая key-value таблица, доступная любому скрипту, если он обратился к тому же имени. Таблицаksl_table, схема(table_name, row_key, column_key, value)— намеренно универсальная, а не конкретная (динамический DDL для произвольных схем в SQLite — боль).export/library— в памяти, между скриптами, не переживает вообще ничего (ни reload, ни рестарт) — про то, чтобы один скрипт мог отдать другому функцию/значение прямо сейчас.
Реальное архитектурное ограничение, найденное в процессе и не спрятанное: каждый .kts компилируется полностью независимо (host.eval по одному вызову на файл). Класс, объявленный прямо внутри одного скрипта, не существует как именуемый тип для другого — второй скрипт физически не может написать library<KdApi>(...), если KdApi объявлен внутри первого скрипта, а не в реальном jar. Первая версия примера (kd.kts/pvp.kts) была написана именно так и не работала бы. Переделано на функции через ::functionRef (тип Function1<...> из stdlib, виден отовсюду) и Map<String, Any?> как контейнер.
Порядок загрузки — раньше loadAllScripts() шёл по listFiles() в порядке файловой системы, не гарантированном. KSLScriptOrder сканирует первые 20 строк каждого .kts на // @requires: name1, name2 и топологически сортирует (алгоритм Кана, с сохранением исходного порядка как tie-break). Цикл или отсутствующая зависимость не роняют загрузку — warning в лог, откат к исходному порядку.
Кодек переиспользуется, не дублируется. KSLPersistCodec (encode/decode типизированных значений) изначально был приватным внутри KSLPersistStore, вынесен в отдельный объект — KSLQuickTable использует тот же код, а не копию. Это же вынесение сделало кодек тестируемым отдельно от Bukkit.
Гонки при работе с БД. dbExecute/dbQuery изначально шли через обычный runAsync (Bukkit async scheduler) — а он крутит асинхронные таски на пуле потоков без гарантии порядка. dbExecute("INSERT ..."), сразу за которым dbQuery("SELECT COUNT..."), мог иногда увидеть базу до применения INSERT. Аналогично persist(..., persistent=true) блокировал вызывающий (часто главный) поток синхронной записью. Оба случая исправлены выделенным однопоточным dbExecutor — все операции одного скрипта строго последовательны. KSLQuickTable.increment() — особый случай: чтобы вернуть новое значение сразу, синхронно дожидается результата через тот же dbExecutor — конкурентные инкременты не гонятся, весь read-modify-write на одном потоке.
Началось с простого config.getString(path)!!.replace(...) в каждом месте, закончилось продуманной системой:
message/messageList/richMessage— единая логика подстановки вынесена в приватныйsubstitute(), используемый всеми шестью перегрузками (3 вида × с/безPlayer).- Перегрузка с
Playerавто-заполняет@player@,@world@,@online@,@maxplayers@,@health@,@ping@. - PlaceholderAPI резолвится в той же строке через
KSLPapiResolver— маленький изолированный объект, ссылающийся на классы PAPI, вызывается только еслиpapiEnabled == true(тот же паттерн, что и остальные хуки). Doubleформатируется по-человечески (15, не15.0;15.5, не15.500000000000002).- Незаменённые
@..@-паттерны после всех подстановок сканируются регуляркой и логируются один раз на комбинацию (скрипт, путь, плейсхолдер) — ловит типичную опечатку в конфиге.
Баг, пойманный до релиза, не после. getOrSetDefault(path, default) в черновике безусловно вызывал saveConfig() — а это всегда пишет в config.yml конкретного скрипта. Если бы кто-то вызвал этот метод на YamlConfiguration из loadYaml("other.yml"), значение выставилось бы в памяти правильно, но при сохранении ушло бы не в тот файл. Исправлено проверкой if (this === config) saveConfig().
Vault, SkinsRestorer, WorldEdit, WorldGuard добавлены по уже устоявшемуся паттерну (LuckPerms/EssentialsX/PlaceholderAPI из 1.0.0): софт-зависимость в plugin.yml, хук создаётся только если плагин реально установлен, каждый метод хука обёрнут в KSLErrors.hookSafe(hookName, default) { ... } — сбой стороннего плагина в рантайме даёт дефолтное значение, а не падение скрипта.
Одно осознанное дизайн-решение: wgFlag не хардкодит список флагов. Вместо отдельных методов под BUILD/PVP/GREETING — один метод, ищущий флаг по строковому имени через WorldGuard.getInstance().flagRegistry. Работает и со стандартными флагами, и с любыми кастомными от сторонних плагинов защиты.
Сборочные грабли, наступленные один раз и учтённые заранее в следующий: VaultAPI в своём POM тянет транзитивную зависимость на устаревший org.bukkit:bukkit:1.13.1, который конфликтует с современным Paper API по одной и той же Gradle capability. Когда дошло до worldedit-bukkit/worldguard-bukkit — та же проблема была предугадана и exclude(group = "org.bukkit", module = "bukkit") поставлен сразу.
/ksl validate— dry-run компиляция всех.ktsчерезJvmScriptCompilerнапрямую, безeval(без выполнения top-level кода — безопасно проверить перед реальным/ksl reload)./ksl doctor— реально прогоняет тривиальный скрипт (6 * 7) через тот жеScriptRunner, которым грузятся настоящие скрипты, прямо в момент вызова команды. Плюс проверка живого соединения с БД, целостности таблиц, доступностиscripts/на запись, отсутствия команд, перехваченных другими плагинами./ksl errors— счётчик и последняя ошибка через общийKSLErrors— единая точка, куда стекаются все runtime-сбои./ksl reload <script>— точечная перезагрузка одного файла. Потребовала рефакторинга:unloadAllScripts()была батчем сразу по всем скриптам; вынесена в переиспользуемыйunloadScript(scriptName), теперь и полный, и точечный reload используют один и тот же код.registerCommand(..., permission, tabComplete)— оба параметра опциональны с дефолтомnull, обратная совместимость полная.
Три модуля (KSLPersistCodec, KSLCooldownStore, KSLScriptOrder) не имеют зависимостей на Bukkit/Paper. Это позволило поставить Kotlin-компилятор прямо в среде разработки и написать 28 реальных unit-тестов (src/test/kotlin/.../PureLogicSelfTest.kt): кодирование/декодирование всех поддерживаемых типов, кулдауны, топологическая сортировка (простая цепочка, независимые скрипты, цикл, отсутствующая зависимость, порядок в списке файлов). Все 28 — зелёные.
В процессе пойман и исправлен один реальный риск: .flatMap { it.split(",") } в KSLScriptOrder зависел от резолюции перегрузки Kotlin stdlib, неоднозначно ведущей себя на разных версиях компилятора — заменено на однозначный .map { }.flatten().
Cannot inline bytecode built with JVM target 21 into bytecode built with JVM target 1.8— embeddable-компилятор по умолчанию собирает скрипты под JVM target 1.8, аonEvent/registerCommand(inline-функции) скомпилированы под 21 вместе с плагином. Исправлено явнымcompilerOptions("-jvm-target", "21").Platform declaration clashмеждуfun service(key: String): Any?иinline fun <reified T> service(key: String): T?— после JVM-erasure оба компилируются в одинаковую сигнатуру. Убран нетипизированный overload.Unresolved reference 'c7Загружено'— кириллица валидна для идентификаторов в Kotlin,"$c7Загружено"парсится как один идентификатор целиком. Все такие переменные обёрнуты в${...}.Cannot infer type for this parameterвhookSafe("WorldGuard", emptyList()) { ... }—emptyList()без типового параметра давалоList<Nothing>, конфликтующий сList<String>из блока. Исправлено наemptyList<String>().ConcurrentHashMap+in/!in(KT-18053) —ConcurrentHashMap, в отличие отHashMap, унаследовал отHashtableметодcontains(value)(проверяет значения, не ключи).it !in addonsкомпилятор не может однозначно разрешить между этим legacy-методом и ожидаемымcontainsKey. Исправлено явным!addons.containsKey(it); прогнан grep по всемConcurrentHashMap-переменным проекта на предмет той же ошибки в других местах.Returns are prohibited for functions with an expression body—fun message(...): String = substitute(... ?: return path ...)не компилируется:returnзапрещён внутри expression-body (= выражение), только в block-body ({ }). Переведено на явный{ }сreturn.
В процессе работы над 1.1.0 дважды случалось так, что часть уже написанного кода не сохранялась между итерациями разработки. Один раз это привело к тому, что KSLErrors.kt и вся связанная с ним "надёжность"-итерация (защита onEvent/registerCommand/GUI-кликов, hookSafe во всех хуках, /ksl errors) пропала из финальной сборки, хотя в процессе разработки выглядело так, будто всё сделано.
Практический вывод, применённый в оставшейся части разработки: перед каждой правкой файл читается заново с диска, а не редактируется по памяти о том, что там должно быть. Плюс в конце каждой итерации — проверка баланса фигурных скобок по всем .kt файлам и явная сверка сигнатур конструкторов между местом объявления и местом вызова. Это не заменяет реальную компиляцию, но ловит целый класс ошибок "файл разъехался с тем, что его вызывает" до релиза.