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

304 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Defs, карта и моды
Договорённость на ближайшее планирование, не текущий код.
Экран игрока: [`near-term.md`](near-term.md). Потоки: [`runtime.md`](runtime.md).
Проекты: [`projects.md`](projects.md). Как устроен хост сейчас: [`../architecture.md`](../architecture.md).
Идея как у RimWorld: **ядро — код систем**, содержание игры — **данные (defs)**. Новый стул, кабинет
или действие, которое уже умеет код, появляются без пересборки `HSchool.Simulation`. Сборка нужна,
только когда появляется *новый вид поведения*, которого нет ни в одной системе.
Это не локальная RimWorld: сервер авторитетен, клиент — браузер. Папка мода живёт **у сервера**.
Клиент не грузит defs и не считает ходы; он рисует снимки, которые сервер собрал из defs + инстанса
карты.
## Два слоя, не один
| Слой | Что это | Пример |
| --- | --- | --- |
| **Def** | Тип, каталог | «Стул», «Сесть», «Кабинет директора» |
| **Инстанс** | Конкретная школа / карта | *этот* стул директора в *этом* кабинете, дверь в *этот* коридор |
Дерево в UI — вид на инстанс. Граф проходов (из кабинета в коридор и обратно) тоже **инстанс**:
в def кабинета нельзя честно написать «ведёт в коридор №3», этого коридора ещё нет. В def —
какие предметы и работы *характерны* для типа помещения; на карте — какие узлы реально стоят
и какими рёбрами связаны.
Должность и вакансия рождаются из инстанса: поставили кабинет директора → в школе появилась
должность и незакрытая вакансия. Снесли кабинет — вакансия уходит. Def помещения говорит
*какую* должность этот тип создаёт, карта говорит *что уже построено*.
## Виды def (первая нарезка)
Имена предварительные, смысл — ваш.
1. **ActionDef** — глагол: сесть, перенести. Код системы знает, как исполнить известный `defName`.
2. **ThingDef** — предмет. Ссылается на действия, которые с ним допустимы (`Chair``Sit`).
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:
```jsonc
{ "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.
```jsonc
{
"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:
```jsonc
// 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/`](../../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`](projects.md).
`HSchool.Simulation` только пользуется уже собранным каталогом. Пути `mods/` и `saves/` — Server.
- Инстанс карты (граф) — тип из Content, лежит в `School` (Simulation) вместе с часами и ECS.
Поток и файл сейва — Server, см. [`runtime.md`](runtime.md) и [`projects.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`](runtime.md) |
| Код модов (DLL) | Не в этом срезе |