Skip to content

Latest commit

 

History

History
225 lines (149 loc) · 21.4 KB

modulesystem.md

File metadata and controls

225 lines (149 loc) · 21.4 KB

Модульная архитектура

Как устроена гибкость ядра: микроядро + подключаемые модули. Спутник architecture.md (там — общая философия data-driven; здесь — как именно она реализована). Рабочий дизайн-документ. Дополняется по мере разработки.


Терминология (чтобы искать и обсуждать правильно)

Термин Что значит Нам подходит?
Микроядерная (microkernel) / плагинная архитектура Маленькое неизменяемое ядро + подключаемые модули в одном процессе Да — это наш подход
Модульный монолит Одно приложение, внутри организованное как ядро + модули. Без сети между модулями Да — так держим всю систему
Микросервисы (microservices) Отдельные развёрнутые сервисы, общаются по сети (HTTP/очереди) Нет — оверинжиниринг для 5–10 игроков
Микроархитектура (microarchitecture) Устройство процессора (железо) Не про нас вообще

Правильные поисковые запросы: «microkernel architecture», «plugin architecture», «modular monolith». НЕ «microarchitecture» (это про CPU).

Браузерный и мобильный клиент — это просто два клиента к одному и тому же TS-ядру. Микроядерность описывает устройство симуляции, а не платформу рендера. Выбор архитектуры от платформы не зависит.


Почему этот подход подходит проекту

Задача №1 — расширяемость. Боль Bytro: хрупкое ядро, механики не добавляются годами. Плагинная архитектура — учебный ответ на «хотим добавлять фичи годами, не ломая ядро». Игра правил-насыщенная, с механиками которые приходят и уходят (юниты, фракции, события, способности) — идеальный кейс для микроядра + data-driven.

Важно: грех Bytro был не «мало плагинов», а монолит без границ. Лекарство — чёткие границы модулей, а не максимальная гранулярность.


Три уровня системы

┌─────────────────────────────────────────────┐
│  Ядро (kernel) — неизменяемое                 │
│  состояние · время · диспетчер · RNG · шина   │
└─────────────────────────────────────────────┘
        ↕ всё общение через шину событий
  Базовые модули (сплошные — игра без них скучна,
  но ядро грузится):
    Территория · Экономика · Движение · Бой

  Опциональные модули (пунктир — отключаемы):
    Дипломатия · Способности · Тёмные события ·
    Механики фракций

Ядро никогда не знает о конкретных модулях. Содержит только механизм: контейнер состояния, время, диспетчер действий, seeded RNG, шину событий, реестр модулей, движок трейтов/эффектов. Неизменяемо — в него не добавляют игровые правила.

Базовые модули — территория, экономика, движение, бой. Игра без них не игра, но ядро технически грузится и работает.

Опциональные модули — дипломатия, способности («магия»), тёмные события, механики фракций. Полностью отключаемы с изящной деградацией.

Ключевое свойство схемы: каждый модуль соединён только с шиной, никогда напрямую с другим модулём. Прямых вызовов между модулями (import { combat } from '../combat') нет вообще. Это и есть причина, по которой ядро работает без любого модуля.


Три механизма общения

Шина — это не одна штука, а три дополняющих паттерна.

1. События (pub/sub) — для реакций

«Произошло X, отреагируй если хочешь». Юнит погиб, планета захвачена, начался ход. Модули подписываются на интересное. Нет подписчиков → событие безвредно гаснет.

2. Хуки/конвейеры — для вычисления значений

«Посчитай итоговую X, дав модулям вклад». Цепочка преобразований:

computeSpeed(флот):
  базовый модуль → 10
  модуль «варп-двигатели» (если есть) → ×1.5 → 15
  модуль «проклятие» (если есть) → ×0.8 → 12

Отключили оба → остаётся базовый → 10. Без поломки.

Ключевое: у каждой точки расширения есть базовое значение по умолчанию. Нет ни одного хука → получаешь базу. Никогда не падение.

3. Реестр возможностей (capability registry) — для опциональных связей

Если модулю нужно что-то от другого, он спрашивает «есть ли возможность X?» и имеет запасной путь. Дипломатия отключена → getRelation(a, b) возвращает дефолт (например, «все враждебны»). Боевой модуль берёт дефолт, а не падает.


Изящная деградация (как ядро работает без модуля)

Конкретный пример. Способность некроманта «поднять павших» — обработчик, подписанный на unit.died. Боевой модуль публикует unit.died после сражения.

Модуль фракций включён:
  бой → публикует "unit.died" → подписчик поднимает юнита

Модуль фракций отключён:
  бой → публикует "unit.died" → никто не слушает → событие гаснет
  → бой работает как ни в чём не бывало

Боевой модуль не знает и не должен знать, слушает ли его кто-то. Он просто объявляет, что произошло. Это развязка (decoupling) в чистом виде.


Бюджет глубины: паттерны ≠ машинерия

Соблазн — сделать «каждый плагин тоже микроядром» рекурсивно. Здесь критично разделить два смысла.

Машинерия микроядра — реестр модулей, манифест, жизненный цикл, загрузчик плагинов, включение/выключение. Тяжёлая инфраструктура. Оправдана там, где части реально отключаемы — на верхнем уровне (модули собирают разные режимы игры).

Паттерны микроядра — хуки, события, маленькие композируемые функции. Лёгкие приёмы. Переиспользуются на любой глубине.

Боевой модуль внутри себя считает урон через хук-конвейер (базовый → броня → мораль → проклятие) — это микроядерный паттерн внутри модуля. Но боевому модулю не нужен свой загрузчик плагинов и манифест — нужны внутренние точки расширения (хуки), а это легче на порядок.

Где что

Уровень Что нужно Чего не нужно
Ядро (0) Реестр, шина, манифест, версионирование —
Модули (1) Вкл/выкл, регистрация в ядре, граница Свой под-загрузчик
Под-части (2) Хуки и события внутри модуля Свой реестр, свой манифест
Глубже (3+) Обычные функции Вся церемония

Правило: полное микроядро — там, где части честно отключаемы во время игры. Лёгкие хуки — там, где части цельные, но расширяемые. Бой — цельная система (его не отключают в середине матча), но внутри расширяемая. Значит: граница + внутренние хуки, а не вложенное микроядро.

Иногда внутри модуля оправдан один уровень сменной стратегии (у боя — взаимозаменяемые алгоритмы: быстрый vs тактический). Это легально, но через тот же хук-механизм, а не отдельное микроядро.

Цена глубокой рекурсии

Бьёт не по процессору (темп «часами/днями», бой считается редко), а по человеку:

  • Прослеживаемость. Чем глубже вложены шины, тем труднее ответить «что происходит при атаке?». Логика размазывается по подписчикам. Это та же боль, что у Bytro, но в другой форме: не «монолит без границ», а «границы без числа».
  • Детерминизм. Порядок модулей обязан быть фиксированным (для replay и античита). Каждый вложенный уровень добавляет свою задачу упорядочивания.
  • Когнитивная нагрузка. Для маленькой команды держать 4 уровня абстракций дороже выгоды, которая часто гипотетическая (принцип YAGNI — не строй то, что пока не нужно).

«По кирпичику» — да

Кирпичи — это композируемые функции и хуки. Сборка — через данные и манифест на верхнем уровне, а не через бесконечную вложенность ядер. Бой собирается из маленьких функций-модификаторов (каждая чистая, тестируемая отдельно), игра целиком — из модулей через манифест.

Практическое правило на старте: два уровня машинерии (ядро + модули), внутри модулей — хуки и функции. Третий уровень церемонии — только если конкретный модуль реально потребует, и пусть потребность придёт из боли, а не из симметрии.


Детерминизм и манифест модулей

Ядро — чистая функция (для replay и античита), значит порядок срабатывания модулей обязан быть фиксированным. Pub/sub с произвольным порядком сломал бы воспроизводимость.

Решение: порядок модулей — их порядок в одном списке GAME_MODULES (packages/shared-core/src/gameModules.ts), и он версионируется вместе с матчем (как игровые данные в architecture.md). Матч помнит, какие модули, в каком порядке и какой версии были активны — это манифест модулей партии (kernel.manifest, версия — MODULE_MANIFEST_VERSION в packages/server/src/scenario.ts).

Режимы сейчас различаются данными (data/modes.json, поля карты), а не составом модулей: оба хоста собирают один список. Модуль, которому в режиме нечего делать, инертен — например, pve в PvP-партии.


Граница безопасности vs игровое правило (туман войны)

Тонкость, которую нельзя путать:

  • Фильтрация видимости — граница безопасности на сетевом уровне (сервер не шлёт невидимое). Всегда включена, живёт в ядре, не опциональный модуль.
  • Правила видимости — «что считать видимым» (радиус сканера, запоминается ли последнее увиденное) — это пушенный модуль.

То есть: защиту отключать нельзя (она в ядре), а игровые правила видимости — модуль. Не путать «можно ли упростить правила» с «можно ли отключить защиту».

События и фог: конвенция имён ключей payload

События тоже проходят фог (MatchRoom.eventVisibleTo): сервер решает, кому показать событие, по именам ключей его payload. Действующая конвенция (проверено по коду):

  • адресат — owner, playerId, a, b, from, to, buyer, seller: названный участник видит своё событие;
  • место — location, planetId, at: событие видно тому, кто идентифицирует этот узел;
  • владение флотом — fleetId: видно владельцу флота;
  • бой — battleId: видно тому, кто видит этот бой в своей проекции (участник видит свой бой всегда — engagementOf), в том числе кадром раньше: battle.resolved приходит, когда боя в состоянии уже нет;
  • time.advanced, match.* и публичные новости (PUBLIC_EVENTS в matchRoom.ts: сейчас только espionage.exposed, раскрытый шпион) — глобальные (видны всем); hero.* — строго owner-only; разведданные (OWNER_ONLY_EVENTS в matchRoom.ts) — тоже только владельцу, даже на опознанном мире. Это стройка, ремонт и производство (building.constructed, building.upgraded, building.repair.started, station.repair.started, unit.built, army.load.cancelled, army.unload.cancelled): результат наблюдатель видит в состоянии. И это события, пересказывающие то, что проекция режет у соперника даже на опознанном мире: приказы стройки (construction.*), capital.designated, steward.holdpoint, mines.laid, salvage.paid, fleet.merge.pending, refuge.found, fleet.halted, а также шпионаж и украденные сведения (espionage.*, кроме публичного espionage.exposed, и intel.*). У каждого записан ключ, которым событие называет хозяина (owner или playerId), и эмиттер обязан нести именно его.

Заводя событие, спроси: не пересказывает ли оно то, что visibleState прячет у соперника? Если да, это разведданные: внеси событие в OWNER_ONLY_EVENTS. Иначе ключ места (planetId/at/location) отдаст его каждому, кто опознаёт мир.

Новый модуль обязан называть ключи payload по этой конвенции. Ключ вне списка (например target или recipient) фильтр не читает — событие будет молча скрыто от адресата (fail-closed: утечки не случится, но игрок не увидит своего события), пока ключ не добавят в eventVisibleTo вместе с тестом в matchRoom.test.ts.


Что это значит для разработки

  • Модуль разрабатывается изолированно — общается только с шиной, которую легко имитировать в тестах
  • Модуль чинится без касания остальных — нет прямых связей, нет каскадных поломок
  • Ядро тестируется с выключенными модулями — проверка, что фундамент честно работает один
  • Новая механика = новый модуль (подписан на события + регистрирует хуки). Ядро не трогаешь

Связь с роадмапом: самый первый кирпич этапа 1 — kernel + шина + реестр модулей. Это каркас, в который потом всё втыкается; его надо собрать до первого модуля.


Сводка принципов

  1. Микроядро для симуляции (бой, экономика, события — то, что меняется). Не для рендера/сети/аутентификации.
  2. Модули общаются только через шину — никаких прямых вызовов между модулями.
  3. Три механизма: события (реакции), хуки (значения), реестр возможностей (опциональные связи).
  4. У каждой точки расширения — базовый дефолт. Нет модуля → дефолт, не падение.
  5. Паттерны (хуки, события) переиспользуются на любой глубине; машинерия (загрузчик, манифест) — только на верхнем уровне.
  6. Порядок модулей фиксирован и версионирован (манифест матча) — ради детерминизма.
  7. Граница безопасности (видимость) в ядре, всегда включена; игровые правила видимости — модуль.
  8. Вся система — модульный монолит, не микросервисы.

Документ отражает архитектурные решения на этапе проектирования. Дополняется по мере разработки.