Files
h-school/docs/design/defs.md
T

22 KiB
Raw Blame History

Defs, карта и моды

Договорённость на ближайшее планирование, не текущий код. Экран игрока: near-term.md. Потоки: runtime.md. Проекты: projects.md. Как устроен хост сейчас: ../architecture.md.

Идея как у RimWorld: ядро — код систем, содержание игры — данные (defs). Новый стул, кабинет или действие, которое уже умеет код, появляются без пересборки HSchool.Simulation. Сборка нужна, только когда появляется новый вид поведения, которого нет ни в одной системе.

Это не локальная RimWorld: сервер авторитетен, клиент — браузер. Папка мода живёт у сервера. Клиент не грузит defs и не считает ходы; он рисует снимки, которые сервер собрал из defs + инстанса карты.

Два слоя, не один

Слой Что это Пример
Def Тип, каталог «Стул», «Сесть», «Кабинет директора»
Инстанс Конкретная школа / карта этот стул директора в этом кабинете, дверь в этот коридор

Дерево в UI — вид на инстанс. Граф проходов (из кабинета в коридор и обратно) тоже инстанс: в def кабинета нельзя честно написать «ведёт в коридор №3», этого коридора ещё нет. В def — какие предметы и работы характерны для типа помещения; на карте — какие узлы реально стоят и какими рёбрами связаны.

Должность и вакансия рождаются из инстанса: поставили кабинет директора → в школе появилась должность и незакрытая вакансия. Снесли кабинет — вакансия уходит. Def помещения говорит какую должность этот тип создаёт, карта говорит что уже построено.

Виды def (первая нарезка)

Имена предварительные, смысл — ваш.

  1. ActionDef — глагол: сесть, перенести. Код системы знает, как исполнить известный defName.
  2. ThingDef — предмет. Ссылается на действия, которые с ним допустимы (ChairSit).
  3. PositionDef — должность (директор). Вакансия — не def, а состояние школы.
  4. WorkDef — работа, которую можно вести в помещении (работа директора, урок, обход школы).
  5. RoomDef — тип помещения. Слоты предметов, должности, которые он открывает, список WorkDef.
  6. BuildingDef — тип корпуса на территории (учебный, спортзал). Тонкий: редактор ставит тип здания, моды добавляют новые корпуса.
  7. FloorDef — тип этажа (типовой, подвал, чердак). Сейчас тонкий: defName и локаль, без правил. Узел карты: id, def, родитель-здание, при необходимости номер/подпись («2»). Задел: позже на def повесятся свойства этажа, не меняя форму ссылки на карте.
  8. TerritoryDef — тип двора. Корень дерева и узел графа, такой же тонкий задел. В core хотя бы один. Клик по двору — те же секции панели, что у комнаты.

Связи «из A можно попасть в B» не поле RoomDef, а рёбра карты. Иначе дефолтная и пользовательская карта не смогут расставить одни и те же типы комнат по-разному.

Как выглядит дефолтная карта

Это файл раскладки, не каталог типов. Он ссылается на defName и задаёт id узлов.

Иерархия дерева (она же закрывает вопрос «из чего дерево»):

территория школы     ← TerritoryDef, корень дерева и узел графа
  └── здание
        └── этаж     ← FloorDef + id узла
              └── помещение

Граф локаций — территория, помещения и любые другие ходибелые узлы плюс рёбра в обе стороны (кабинет ↔ коридор, крыльцо ↔ двор). Дерево — группировка для UI; граф — куда можно перейти. Два здания связываются через территорию (или явное ребро), не «висят» рядом в дереве без пути.

В процессе разработки кладём одну ванильную раскладку в Core. При создании школы игрок может изменить её в редакторе или стереть и собрать свою. Редактор оперирует инстансом и выбирает типы из каталога def, а не рисует свободную геометрию (геометрии в этом горизонте нет).

Формат: JSONC, не новый язык

Рекомендация: JSON с комментариями и хвостовыми запятыми (JSONC). System.Text.Json это уже умеет (ReadCommentHandling.Allow, AllowTrailingCommas). Отдельный YAML/TOML/XML не даёт выигрыша, а RimWorld-XML здесь ни к чему — патчи можно сделать проще.

Почему не «чистый JSON»: defs пишут люди, комментарии обязательны. Почему не YAML: пробелы и два парсера в голове. Почему не свой формат: его придётся документировать сильнее, чем игру.

Один файл — один def или небольшая пачка одного вида (things/chair.jsonc). Ссылки — строки defName. Загрузчик Content идёт так:

  1. Прочитать defs всех пакетов по порядку (core, затем моды). Повтор defName — последний победил, warning.
  2. Разрешить наследование.
  3. Применить патчи пакетов в том же порядке.
  4. Резолв ссылок (actions, слоты). Abstract def на карту ставить нельзя.

Это база: новые поля у def и новые op у патча добавляются в Content, папки не меняются.

Наследование

Как ParentName у RimWorld:

{ "defName": "FurnitureBase", "abstract": true }
{ "defName": "Chair", "parent": "FurnitureBase", "actions": ["Sit"] }
{ "defName": "OfficeChair", "parent": "Chair" }
  • Нет поля у ребёнка — берётся у родителя (цепочка до корня).
  • Поле есть — заменяет целиком, в том числе массивы. Дописать элемент в массив родителя — не наследованием, а патчем.
  • abstract: true — только база, на карте и в редакторе не выбирается.
  • Родитель другого вида (Thing → Room) или цикл — ошибка загрузки каталога.
  • Локаль: ключ defName, если нет — вверх по parent.

Патчи

Чтобы не копировать ванильный стул ради одного действия. Лежат в patches/, не в defs/. Цель — defName уже после наследования. Указатель поля — JSON Pointer.

{
  "target": "Chair",
  "ops": [
    { "op": "add", "path": "/actions/-", "value": "Inspect" },
    { "op": "replace", "path": "/slots/0/thing", "value": "DirectorsChair" },
    { "op": "remove", "path": "/works/1" },
  ],
}

В этом срезе три операции: add (в массив - = в конец), replace, remove. Неизвестный op, битый pointer или нет target — каталог не грузится (школу с этим набором модов не поднимать). Новые операции потом просто появляются в enum, файлы модов те же.

Патч не меняет раскладку карты: дефолтная карта по-прежнему файл maps/ (last-wins на файл, если мод положит свой default.jsonc). Патчи — про типы.

Подписи содержания живут не в def, а в localizations/ мода. В def остаётся defName (и при необходимости ключ, если он не совпадает с именем). Черновик без вшитых ru/en:

// ActionDef — заготовка: каталог знает глагол, исполнения в этом горизонте нет
{ "defName": "Sit" }

// ThingDef
{ "defName": "Chair", "actions": ["Sit"] }

// RoomDef: тип, не конкретный кабинет на карте
{
  "defName": "PrincipalsOffice",
  "slots": [
    { "key": "directorChair", "thing": "DirectorsChair" },
    { "key": "desk", "thing": "Desk" },
    { "key": "guestChair", "thing": "Chair", "count": 2 },
  ],
  "positions": ["Principal"],
  "works": ["PrincipalOfficeWork", "TeachLesson", "WalkSchool"],
}

// Кусок карты (инстанс)
{
  "rooms": [
    {
      "id": "principals-office",
      "def": "PrincipalsOffice",
      "building": "main",
      "floor": "1",
      "links": ["corridor-1"],
    },
  ],
}

Что остаётся кодом

Def говорит «у стула есть Sit». Система Sit в C# знает правила: кто может сесть, сколько это длится, что происходит с персонажем. Новый предмет с тем же Sit — только JSON. Новый глагол «взломать замок», которого нет в коде — либо обобщённый исполнитель (долго и скользко), либо сборка с новой системой.

Пока держим правило RimWorld: данные компонуют известные глаголы; неизвестный глагол = код. Скриптовый язык внутри JSON в этом горизонте не заводим.

В первой живой версии ActionDef и ссылки с предметов — заготовки: они есть в каталоге и на карте, системы их ещё не исполняют, игрок не отдаёт приказ «сесть». В панели локации показываем имена действий у стоящих предметов. Персонажи и «А говорит с Б» — секции панели, списки пустые. Должности берём из RoomDef поставленного помещения (вакансии без людей).

Снимок карты на клиент

При OpenSchool сервер один раз отдаёт дерево и состав всех узлов (двор, корпуса, этажи, помещения). Подписи — язык из Hello (ru | en). Каталог для редактора — тот же код языка в ?lang= у HTTP. Accept-Language не используем: переключатель RU/EN в подвале с ним разъедется.

Клиент сам выбирает узел; секции панели одни и те же. Часы по-прежнему 20 Гц. Карта после create в этом срезе не меняется, повторно слать не нужно. Hello с locale — смена раскладки кадра, в том же коммите bump версии протокола.

Карта: пустые комнаты и связность

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

Изолированный узел запрещён. Практический минимум: двор + хотя бы одно помещение с ребром во двор (или цепочка помещений, которая в итоге выходит во двор). Школа «одна комната без двора» в этой модели не собирается — двор не выкидывается. Два здания без пути через двор (или другое ребро) — тоже нет.

Сервер отвергает раскладку с изолированной комнатой, ребром в несуществующий id и defName, которого нет в каталоге выбранных для этой школы модов.

Редактор — только при создании

В этом горизонте карту меняют до того, как школа пошла жить: в диалоге создания. После create раскладка замораживается вместе с набором модов. Построить/снести в уже идущей школе — отдельный разговор (это уже игровые приказы, не редактор).

Порядок в UI: выбрать моды → получить каталог (типы комнат/предметов) → править или стереть дефолтную карту → имя, дата, создать. Редактор без списка модов не знает, какие RoomDef существуют.

Папка мода и локализации

Моды — подпапки на диске сервера, например mods/<id>/. Ядро — мод core: тот же формат папки, но всегда включён, в UI создания его нельзя снять. Остальные папки — чекбоксы.

Живой образец — ../../src/HSchool.Server/mods/example/: pack.jsonc, две черты, набор имён, патч, last-wins и одна комната. Пока его не выбрали, ваниль не меняется. README внутри папки — как добавить своё.

mods/
  core/
    defs/              # JSONC типов
    localizations/
      ru.jsonc
      en.jsonc
    maps/
      default.jsonc    # ванильная раскладка
  example/             # образец, не контент; выключается в create
    defs/
    patches/           # правки чужих def, не копии
    localizations/
      ru.jsonc
      en.jsonc

Порядок загрузки: сначала core, потом выбранные моды в том порядке, в каком они стоят в запросе create. Одинаковый defName или ключ локали — побеждает последний. Имеет смысл писать предупреждение в лог, не молча, чтобы конфликт модов было видно.

Ключи локализации — по defName (и суффиксам вроде .label, если понадобятся несколько строк на тип). Нет ключа — в UI показывается defName, это сразу видно в тесте мода.

Хром клиента (меню, кнопки) по-прежнему в src/HSchool.Client/src/i18n/. Строки содержания сервер резолвит из localizations/ включённых модов под язык клиента и кладёт в снимок / в ответ каталога для редактора. Браузер файлы модов не читает.

В диалоге создания показывают core как включённый и заблокированный плюс список остальных папок. Тело create несёт только дополнительные id (сервер всё равно подставит core первым). Работник школы грузит этот набор и замораживает каталог. Новые файлы на диске влияют только на следующие школы; список модов для диалога можно сканировать при GET, без рестарта процесса.

Где это живёт в решении

  • Каталог def, раскладка и валидация — HSchool.Content, см. projects.md. HSchool.Simulation только пользуется уже собранным каталогом. Пути mods/ и saves/ — Server.
  • Инстанс карты (граф) — тип из Content, лежит в School (Simulation) вместе с часами и ECS. Поток и файл сейва — Server, см. runtime.md и projects.md. Сейв: раскладка, id модов, часы, имя; каталог снова с диска при загрузке. Не писать каждый тик.
  • Валидация раскладки — Content; Server только вызывает её на теле create.
  • Клиент редактора — DOM в диалоге создания; тело create = имя, дата, список модов, раскладка.

Сканирование mods/ — при запросе списка и при создании школы, не каждый тик. Горячая подмена каталога у уже живой школы в этом горизонте не нужна.

Советы, которые стоит принять сразу

  • Не класть граф проходов в RoomDef — только в карту.
  • Не слать весь каталог и всю карту 20 раз в секунду: дерево — один снимок при открытии.
  • Редактор только в create: шлёт раскладку и список id модов, не пути на диск и не C#.
  • Слоты в RoomDef — чертёж; на карте слот можно не заполнять (пустая комната).
  • Локали не смешивать с defs: иначе мод на третьем языке правит типы.

Зафиксировано этим разговором

Тема Решение
Содержание игры Максимально defs, ядро — системы под известные глаголы
Формат (предложение) JSONC, ссылки строками defName
Карта Инстанс: территория → здания → этажи → помещения + граф связей
Дефолтная карта Одна ванильная раскладка Core; при создании можно править или стереть
Редактор Только в диалоге создания школы
Пустая комната Можно; граф связный, двор всегда в графе
Действия Заготовки в def, без исполнения и без приказов
Моды mods/<id>/; core всегда первый и не снимается; остальные — UI create
Образец пака mods/example/: настоящая папка рядом с core, не контент
Коллизии имён Последний пакет победил (defs и локали), warning в лог
Наследование parent, abstract; поле ребёнка заменяет, не мержит массивы
Патчи patches/ после наследования; ops: add / replace / remove; неизвестный op — ошибка
Территория TerritoryDef: корень дерева и ходибелый двор
Панель узла Одни секции у двора, корпуса, этажа, комнаты
Язык ?lang= и Hello; не Accept-Language
Локализация модов mods/<id>/localizations/{ru,en}.jsonc, не внутри def
Этаж FloorDef, тонкий; правила этажа — поля def позже
Здание BuildingDef — тип корпуса
Сохранения Файл школы: раскладка, id модов, часы, имя, пауза/скорость; каталог с диска при загрузке
Поток / ECS Основа среза: один поток и один World на школу, см. runtime.md
Код модов (DLL) Не в этом срезе