Skip to content

Latest commit

 

History

History
244 lines (172 loc) · 37.3 KB

DEVLOG.md

File metadata and controls

244 lines (172 loc) · 37.3 KB

Dev Log — KotlinScriptLoader 1.2.0

Два движка по ТЗ: частицы-эффекты и кастомные сущности. Оба ТЗ сами по себе указывали на правильную архитектуру — оставалось не отступить от неё в реализации.

Эффекты — почему не через onEvent/Thread.sleep

ТЗ сразу называло главную опасность: 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-логика продолжает вызываться, ссылаясь на код давно выгруженного скрипта.

Два независимых решения работают вместе:

  1. onTick переиспользует plugin.trackTask — точно так же, как таски эффектов. KSLEntityBehaviorBuilder.onTick регистрирует runTaskTimer и сразу трекает его на scriptName, так что unloadScript отменяет его автоматически, без знания о сущностях вообще.
  2. 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() — не два отдельных парсера с разной логикой.

Второй проход: не только API, но и логика

Первый проход перепроверял вызовы 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?) с явной проверкой: если каст не удался, ошибочно заспавненная сущность немедленно удаляется, и наружу летит понятное сообщение с именем типа вместо голого исключения.

Attribute.GENERIC_MAX_HEALTH — движущаяся цель, а не разовая опечатка

Первая версия 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-метод оказался куда надёжнее "актуальной" замены.


Dev Log — KotlinScriptLoader 1.1.0

Технический дневник разработки. В отличие от CHANGELOG.md, здесь не только "что", но и "почему" и "как было найдено" — для тех, кто хочет понимать внутренности или контрибьютить.


Addon API

Первая по-настоящему новая подсистема в 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

Ключевое архитектурное решение здесь — производительность при масштабе, а не просто "работающий 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, table, export/library

Три разных механизма для разных сценариев:

  • 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 на одном потоке.


YAML: @placeholder@

Началось с простого 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 файлам и явная сверка сигнатур конструкторов между местом объявления и местом вызова. Это не заменяет реальную компиляцию, но ловит целый класс ошибок "файл разъехался с тем, что его вызывает" до релиза.