Обычная история: пишешь плагин для своего Paper-сервера, он обрастает фичами, и в какой-то момент понимаешь, что половина логики нужна ещё и на Velocity-прокси. Приветствие игроков, общий конфиг, пара команд. И ты садишься писать всё второй раз, только под другое API. Через полгода чинишь один и тот же баг в двух местах и думаешь: а можно было не разводить две кодовые базы?

Можно. Подход называется multiloader: общая логика живёт в одном модуле, а под каждую платформу лежит тонкий адаптер. Разберём, как это устроено внутри, чем такой проект питается в 2026 году и на каких граблях я станцевал, пока собирал рабочий шаблон.

Сначала про термин, чтобы не было путаницы

Слово multiloader в майнкрафт-тусовке значит два разных веща. Первое — моды под Forge/Fabric/NeoForge из одного кода, классика — MultiLoader-Template от Jared. Второе — то, о чём статья: плагины, где один код работает и на Paper-сервере, и на Velocity-прокси.

Это разные миры. У модов общий код упирается в маппинги и лоадер-специфичный запуск игры. У плагинов Paper и Velocity — два разных API поверх одной игры, и склеить их проще: обе платформы говорят на Adventure, обе умеют в Brigadier-команды, обе живут на Java. Статья про второй случай.

Две платформы крупным планом

Кто не писал под обе — коротко, чем они отличаются с точки зрения разработчика. Оба проекта, кстати, живут под крылом PaperMC:

PaperMC
PaperMC

Paper — это сервер. Игроки, миры, тики, инвентари. Точка входа — класс-наследник JavaPlugin с onEnable/onDisable, плюс отдельные фазы: PluginBootstrap (ранняя инициализация, зарегистрированные там команды видны даже функциям датапаков) и PluginLoader (догрузка библиотек в classpath до старта). Метаданные — paper-plugin.yml. Актуальная линейка — 26.x, и ей нужен Java 25.

Velocity — это прокси. Серверов за ним может быть пять, игроков он видит как подключения, миров у него нет. Класса-точки-входа нет — есть @Plugin-аннотация и Guice-инъекция: ProxyServer, логгер и папка данных прилетают в конструктор сами. Жизнь — это события: ProxyInitializeEvent, PostLoginEvent, ProxyShutdownEvent. velocity-plugin.json генерируется из аннотации процессором, руками его писать не надо. По версиям сейчас переходный момент: линейка 3.5.x ещё живёт на Java 21 и у неё шире поддержка плагинов, а четвёрка уже требует Java 25 минимум (так и написано в официальной доке).

Общего у платформ немного, но оно ключевое: Adventure-тексты, Brigadier под капотом команд и Java. Этого хватает, чтобы вынести вверх почти всё, кроме самих адаптеров.

Анатомия: четыре модуля и одно правило

Рабочая раскладка, к которой всё сводится:

  • common — вся логика: конфиг, сообщения, команды, фичи. Ноль импортов Paper и Velocity API.

  • api — публичный SPI для сторонних плагинов. Тоже без платформ.

  • paper — адаптер: Bootstrap, Loader, JavaPlugin, листенеры, paper-plugin.yml.

  • velocity — адаптер: @Plugin-класс, листенеры, генерация velocity-plugin.json.

Зависимости текут в одну сторону, и это видно даже на схеме:

paper/ ──\
           +--> common/ --> api/
velocity/ ──/

Ничего не знает о платформах, платформы знают всё. api вообще ни от кого не зависит, кроме Adventure.

И одно правило, которое держит всю конструкцию: в common не должно быть ни одного импорта платформ. Как только туда просочился org.bukkit или com.velocitypowered — multiloader кончился, у тебя снова две кодовые базы, просто в одной папке. Проверяется поиском по импортам за десять секунд, и эту проверку стоит держать в голове при каждом коммите.

Связь платформ с ядром — через маленькие порты. Пример из моего шаблона: отправитель команды. Cloud на Paper отдаёт CommandSourceStack, на Velocity — CommandSource, а логике команд оба не нужны. Поэтому общий интерфейс:

public interface TemplateSender {
    String name();
    boolean hasPermission(String node);
    void sendMessage(Component message);
}

Адаптер под Paper заворачивает stack.getSender() (три строчки — CommandSender из коробки и Audience, и holder прав), адаптер под Velocity — почти identity-обёртка над CommandSource. Дальше команда /template ping пишется один раз:

public static <C> void register(
        CommandManager<C> manager,
        Function<C, TemplateSender> senders,
        TemplateCore core) {
    var root = manager.commandBuilder("template");
    manager.command(root.literal("ping")
            .permission("template.command.ping")
            .handler(ctx -> {
                var sender = senders.apply(ctx.sender());
                // ...
            }));
}

Дженерик C — это и есть нативный тип отправителя каждой платформы. Paper передаёт PaperSender::new, Velocity — VelocitySender::new, общий код их не различает.

Чем питается multiloader в 2026

Стек ниже — тот, что стоит у меня в сборке и зелёный в CI. Версии называю точные, потому что «поставь последнее» для multiloader — плохой совет: всё, что даёт платформа, обновляется в её темпе, а не в темпе Central.

Gradle + version catalog. Четыре модуля без каталога версий превращаются в рассинхрон за месяц: paper тянет одно, velocity другое. Единый libs.versions.toml — не красота, а необходимость. Там же одно место, куда смотрит dependabot.

Adventure + MiniMessage. Тексты в 2026 параграфами вручную не красят. MiniMessage-строки в конфиге, Placeholder.unparsed("player", name) для подстановок. Отдельно про безопасность, потому что это реальная дыра, а не теория: ник игрока, склеенный конкатенацией в MiniMessage-строку, — это инъекция форматирования. Игрок с ником <red>админ ломает чужие сообщения. Только плейсхолдеры, только unparsed для пользовательского ввода. У меня на это есть отдельный тест, который скармливает приветствию враждебный ник и проверяет, что теги остались текстом.

Про сам Adventure стоит знать, что он переехал под крыло PaperMC, а весной вышла мажорная пятёрка: поднята Java, вычищены все deprecated, убран Examination, аннотации переведены на JSpecify. Для кода без deprecated — бесшовно, для остального есть официальный гайд миграции. Я пока сижу на четвёрке: её даёт рантайм платформ, а мажор в provided-зависимости тащить нельзя (почему — ниже, в разборе dependabot).

Configurate. Стандарт конфигов для Paper-плагинов, configurate-yaml 4.2.0 — свежее в 2026 году ничего нет, либа зрелая. Два принципа, которые я вшил в менеджер конфига: плагин никогда не падает из-за конфига (битый файл — warn в лог и работа на прошлом конфиге) и конфиг сам себя чинит (недостающие ключи дописываются значениями по умолчанию). Старые config.yml пользователей переживают обновления без вайпа.

Incendo Cloud v2. Общий фреймворк команд: ядро cloud-core в common, cloud-paper и cloud-velocity в адаптерах. Деталь, о которую я споткнулся: ядро и платформы версионируются отдельно. У меня cloud-core 2.1.0, платформы 2.0.1 — и записи «одна версия на всё» в каталоге быть не должно, такого артефакта просто нет на Central. На Paper используется modern-менеджер через PaperCommandManager.builder()...buildBootstrapped(context) — команды, зарегистрированные в bootstrap, видны датапакам, Brigadier включается сам. На Velocity менеджер собирается руками с SenderMapper.identity() поверх нативного CommandSource, Guice-модуль для этого не нужен.

Java: 21 внизу, 25 наверху. Общая логика — records для конфигов и контекстов, Optional вместо null-возвратов, pattern matching, виртуальные нити для коротких фоновых задач (newThreadPerTaskExecutor — дешевле платформенных пулов для мелочёвки, а долгое и периодическое всё равно уходит в шедулер платформы). А paper-модуль компилируется тулчейном 25, и это не прихоть: paper-api 26.x собран под 25-й классфайл, javac младшей версии его классы даже прочитать не может. Так что «Java 21 везде» — красивый лозунг, который не пережил встречу с реальностью: common и velocity на 21, paper на 25.

Сборка: что шейдится, что даётся платформой

Правило раздела на две кучи простое. Дают платформы — не кладём в jar: Adventure и slf4j есть и на Paper, и на Velocity, в shadowJar они вырезаются явно. Остальное бандлим: Configurate и Cloud платформы не предоставляют, поэтому они едут внутрь каждого jar с релокацией под свой пакет, чтобы не конфликтовать с другими плагинами, у которых свой Cloud внутри. Плюс mergeServiceFiles(), иначе склейка META-INF/services потеряет SPI-провайдеры либ. Каждый пункт тут стоит потому, что без него jar либо толстеет чужим, либо падает в рантайме.

Версии в дескрипторы подставляются сборкой, руками их не пишут: в paper-plugin.yml — через expand() (только версия, и это важно — ниже объясню почему), на Velocity — через генерацию BuildConstants из шаблона до компиляции, чтобы @Plugin видел константу.

Кому multiloader не нужен

Честно: не всем. Маленький плагин только под Paper — кидай всё в один модуль и не устраивай церемонию из четырёх грейдл-модулей. Multiloader окупается, когда логики хватает на две платформы: общие команды, общий конфиг, общие тексты, синхронизация чего-то между прокси и серверами. Если второй платформы в планах нет — это оверхед. Если есть или маячит — дешевле заложить раскладку сразу, чем распиливать потом.

Где посмотреть целиком

Всё выше — выжимка из живого шаблона: multiloader-template на GitHub.