Как устроена гибкость ядра: микроядро + подключаемые модули. Спутник
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') нет вообще. Это и есть причина, по которой ядро работает без любого модуля.
Шина — это не одна штука, а три дополняющих паттерна.
«Произошло X, отреагируй если хочешь». Юнит погиб, планета захвачена, начался ход. Модули подписываются на интересное. Нет подписчиков → событие безвредно гаснет.
«Посчитай итоговую X, дав модулям вклад». Цепочка преобразований:
computeSpeed(флот):
базовый модуль → 10
модуль «варп-двигатели» (если есть) → ×1.5 → 15
модуль «проклятие» (если есть) → ×0.8 → 12
Отключили оба → остаётся базовый → 10. Без поломки.
Ключевое: у каждой точки расширения есть базовое значение по умолчанию. Нет ни одного хука → получаешь базу. Никогда не падение.
Если модулю нужно что-то от другого, он спрашивает «есть ли возможность 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-партии.
Тонкость, которую нельзя путать:
- Фильтрация видимости — граница безопасности на сетевом уровне (сервер не шлёт невидимое). Всегда включена, живёт в ядре, не опциональный модуль.
- Правила видимости — «что считать видимым» (радиус сканера, запоминается ли последнее увиденное) — это пушенный модуль.
То есть: защиту отключать нельзя (она в ядре), а игровые правила видимости — модуль. Не путать «можно ли упростить правила» с «можно ли отключить защиту».
События тоже проходят фог (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 + шина + реестр модулей. Это каркас, в который потом всё втыкается; его надо собрать до первого модуля.
- Микроядро для симуляции (бой, экономика, события — то, что меняется). Не для рендера/сети/аутентификации.
- Модули общаются только через шину — никаких прямых вызовов между модулями.
- Три механизма: события (реакции), хуки (значения), реестр возможностей (опциональные связи).
- У каждой точки расширения — базовый дефолт. Нет модуля → дефолт, не падение.
- Паттерны (хуки, события) переиспользуются на любой глубине; машинерия (загрузчик, манифест) — только на верхнем уровне.
- Порядок модулей фиксирован и версионирован (манифест матча) — ради детерминизма.
- Граница безопасности (видимость) в ядре, всегда включена; игровые правила видимости — модуль.
- Вся система — модульный монолит, не микросервисы.
Документ отражает архитектурные решения на этапе проектирования. Дополняется по мере разработки.