Remove off-queue.md and add new design documentation files including README.md, defs.md, near-term.md, projects.md, runtime.md, people.md, staffing.md, and schedule.md to outline the structure and agreements for the game's design phases.

This commit is contained in:
Leonid Pershin
2026-08-20 12:26:13 +03:00
parent 12cc36b4dd
commit a37a5eb82d
101 changed files with 1015 additions and 921 deletions
+310
View File
@@ -0,0 +1,310 @@
# 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
romance/ # контентный пак: ориентация, симпатия, пары 18+, темы
defs/orientations/
defs/affinity/
defs/traits/
defs/topics/
patches/
localizations/
```
Порядок загрузки: сначала `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) | Не в этом срезе |
+79
View File
@@ -0,0 +1,79 @@
# Ближайший горизонт: что видит игрок
Это не текущее состояние кода, а договорённости на ближайшее планирование.
Как устроено сейчас — в [`architecture.md`](../../architecture.md).
## Решение: не PixiJS, а менеджер на DOM
Внутри школы нет 2D-сцены, камеры и спрайтов. Интерфейс ближе к менеджеру, чем к классическому
QSP: не «полотно текста + список действий», а панели.
Ориентир по экрану школы (после часов и паузы, которые уже есть):
- **Карта** — дерево локаций (tree view).
- **Выбранная локация** — что происходит именно там.
Отдельной панели общих событий нет: пока ленты событий не существует, постоянно пустая треть
экрана только мешала, и место отдано вкладкам «Карта» и «Люди». Появится лента — вернётся и
панель; решение принято в фазе 8, здесь оно записано.
Картинок локаций и портретов в этом горизонте нет; позже можно добавить, не ломая панели.
Анимация пока только переходы панелей/списков и живые цифры (часы, счётчики). Графики —
когда появятся числа, которые ими стоит показывать, не раньше.
Клиент остаётся plain DOM + CSS. WebGL-сцены нет.
Симуляция по-прежнему на сервере: ученики и комнаты могут существовать как данные в Arch ECS,
даже если клиент рисует строки списка, а не человечков.
Типы комнат, предметов и действий описываются **defs** (JSONC), конкретная школа — **картой-инстансом**.
Подробности: [`defs.md`](defs.md). Дерево в UI — территория (ещё и ходибелая локация) → здания → этажи → помещения.
## Первый срез (закрыто)
Это и реализуем. Нового геймплея сверх этого не закладываем.
**Экран школы.** Часы, пауза, скорость. Две панели:
- дерево карты (позже к нему вкладкой встанут «Люди»);
- выбранная локация (комната, этаж, корпус **или двор**): имя, предметы, персонажи (пусто),
действия на месте (пусто), должности. Секции те же; где данных нет — пустое состояние.
Клик по дереву — **фильтр на клиенте**. Сервер один раз отдаёт снимок. Язык подписей модов:
`?lang=` на HTTP-каталоге (диалог создания) и то же значение в **Hello** на сокете (снимок при
открытии). Не `Accept-Language`: его легко разъехать с переключателем RU/EN.
**Create.** Моды (`core` заблокирован) → каталог → редактор карты или стереть дефолт → имя, дата → школа на диске и в своём потоке.
**Не в этом срезе:** исполнение действий, живые персонажи, записи в ленте событий, смена модов у живой школы, Pixi, DLL-моды.
## Что уже зафиксировано
| Тема | Решение |
| --- | --- |
| Вид внутри школы | Менеджер: дерево (вкладкой с «Людьми») + содержимое локации |
| Рендер | DOM, не WebGL/Pixi |
| Картинки | Нет в этом горизонте |
| Анимация | Переходы и живые цифры |
| Часы, пауза, скорость | Остаются; это не отменяется панелями |
| Логика игры | Только сервер; клиент шлёт намерения и рисует то, что сказали |
| Данные игры | Defs (типы) + карта (инстанс), см. [`defs.md`](defs.md) |
| Дерево карты | Территория (= двор, узел графа) → здание → этаж → помещение |
| Редактор карты | Только при создании школы |
| Моды | `core` всегда; остальные — выбор при создании; поздний мод побеждает при споре имён |
| Поток школы | Основа среза: один поток и один ECS world на школу, см. [`runtime.md`](runtime.md) |
| Сохранения | На диск; часы не писать на каждом тике |
| События | Отдельной панели нет; место отдано вкладкам «Карта» и «Люди». Появится лента — вернётся и панель |
| Локация | Одни секции для двора, корпуса, этажа и комнаты |
| Двор | TerritoryDef, узел графа и корень дерева |
| Язык модов | HTTP `?lang=`, то же в Hello; не Accept-Language |
| Проекты | Каталог не в Server и не в Simulation, см. [`projects.md`](projects.md) |
| Выбор в дереве | Клиентский фильтр |
| Этаж | FloorDef (тонкий тип); узел карты ссылается на def |
## Заведомо не сейчас
- Портреты, арт локаций, схема этажа в пикселях.
- Управление персонажем от первого/третьего лица.
- Победа/кампания, живые персонажи, исполнение «сесть», записи событий.
- Смена модов у живой школы, код модов (DLL).
+61
View File
@@ -0,0 +1,61 @@
# Нарезка проектов (целевая)
Сейчас в коде: `Protocol ← Server → Simulation → Ai → People / Schedule → Content`. Каталог не в Simulation (рядом с Arch)
и не в Server (рядом с Kestrel) — парсер JSONC и проверка графа не знают, что такое tick.
## Зависимости
```
Protocol — ни на кого из игровых проектов (только байты)
Content — ни на Protocol, ни на Simulation, ни на ASP.NET
People → Content (defs, карта, склонения; без Arch и хоста)
Schedule → Content (раскладка уроков; без Arch и хоста)
Ai → Content, People, Schedule (маршруты и решения; без Arch и хоста)
Simulation → Ai, People, Content (ростер в World; без HTTP)
Server → Protocol, Simulation, People, Content
Client — своя сторона Protocol (TS) + HTTP
```
Клиент по-прежнему не ссылается на C#-проекты.
## Кто чем владеет
| Проект | Да | Нет |
| --- | --- | --- |
| **HSchool.Content** | Типы def, JSONC-загрузчик, наследование, патчи, слияние локалей, граф карты, валидация связности | Arch, часы, HTTP, потоки, пути `mods/` с диска хоста |
| **HSchool.People** | Генерация ростера: семьи, классы, тело/навыки/черты из каталога, сида и родного языка школы | Arch, ASP.NET, сокеты, `DateTime.Now`, файлы сейва |
| **HSchool.Schedule** | Раскладка учебного плана в таблицу уроков вокруг закреплённых правок | Arch, ASP.NET, сокеты, `DateTime.Now`, файлы сейва |
| **HSchool.Ai** | Маршруты по графу карты, план дня человека, выбор цели и действия, продвижение плана во времени | Arch, ASP.NET, сокеты, `DateTime.Now`, файлы сейва |
| **HSchool.Simulation** | `School`, `GameClock`, Arch `World`, тик, применение раскладки к миру, адаптер к `Ai` | Kestrel, сокеты, `Directory.Enumerate`, сейв-файлы |
| **HSchool.Server** | Хост, супервизор, работник-поток, `mods/` и `saves/` из конфига, HTTP/WS, DTO запросов | Правила «комната должна быть связана», схема Chair |
| **HSchool.Protocol** | Кадры сокета, в том числе снимок карты и locale в Hello | Имена комнат, JSONC |
| **HSchool.Client** | DOM, `t()` для хрома, рисует снимок | Файлы модов, симуляция |
Content принимает уже открытые потоки или список документов (сервер прочитал папки и отдал
байты/пути через узкий порт вроде «вот корневые каталоги этих id»). Тогда тесты Content кормят
фикстурами из `tests/`, без `appsettings`.
Сейв: **форма** (часы + id модов + раскладка) собирается из типов Simulation/Content; **запись
файла** — Server, и только с потока работника.
## Зачем не сваливать в Simulation
Arch и фиксированный тик — про живую школу. Парсер JSONC, `defName` и проверка графа не знают,
что такое tick. Их гоняют сотни раз в юните без мира. Если загрузчик сидит в Simulation, любой
тест каталога тащит ECS и наоборот.
## Зачем не сваливать в Server
Иначе связность карты и last-wins нельзя проверить без Aspire. Хост должен быть тонким:
найти папки, сериализовать HTTP, крутить потоки.
## Тесты
- `tests/HSchool.Content.Tests` — фикстуры JSONC, коллизии имён, дырявый граф, двор обязателен.
- `tests/HSchool.People.Tests` — тот же сид даёт тот же ростер; места и должности заполнены.
- `tests/HSchool.Schedule.Tests` — тот же штат и карта дают ту же таблицу; четыре запрета.
- `tests/HSchool.Ai.Tests` — путь между комнатами и его цена; те же входы дают то же решение.
- `HSchool.Simulation.Tests` — часы, тик, «школа с таким каталогом живёт».
- `HSchool.AppHost.Tests` — create с картой, рестарт, сейв на диске.
Ориентир в `AGENTS.md` обновить в фазе, которая заводит проект (сейчас это фаза 3).
+65
View File
@@ -0,0 +1,65 @@
# Рантайм школы: поток и ECS
Договорённость, которую код фазы 2 уже выполняет: каждая школа — свой работник, свой Arch
`World`, свой файл. Инвариант в `AGENTS.md` совпадает с этим текстом.
Экран и defs: [`near-term.md`](near-term.md), [`defs.md`](defs.md).
Куда класть код: [`projects.md`](projects.md).
Работник (поток, очередь, сейв-файл) живёт в **Server**. То, что он тикает — `School` в
**Simulation**. Каталог, с которым школа создана — **Content**, замороженный у работника.
## Что уже почти есть
У каждой `School` уже свой Arch `World`. Это оставляем и делаем жёстким правилом: мир не
разделяется между школами и не отдаётся чужому потоку. Arch не потокобезопасен.
## Целевая модель — в этом срезе
Каждая школа — **актор**: свой поток (`TaskCreationOptions.LongRunning` / выделенный поток, не
пул тиков), свой `World`, свои часы, свой **замороженный** каталог def, свой файл на диске.
Над ними — тонкий супервизор (вместо сегодняшнего общего цикла):
- создать / удалить школу, знать лимит;
- держать ящики: `id →` очередь команд этой школы;
- собирать **снимки** для `GET /api/schools` (работник публикует неизменяемое состояние, как
сейчас цикл публикует `SchoolsState`);
- маршрутизировать сокет: `OpenSchool` / `SetRunning` / кадры часов — только в ящик той школы;
- при старте процесса поднять школы с диска, при остановке — дождаться записи работников.
Супервизор **не** вызывает `World`, не тикает часы, не читает defs инстанса. Работник **не**
трогает чужой мир и не ходит в ASP.NET. Файл школы пишет **работник** (create/delete через
команду супервизору, снимок часов — редкий, shutdown — обязательный).
Тик по-прежнему фиксированный (`SimulationOptions.FixedDeltaTime`), у каждого работника свой
таймер. Школы не синхронизируют календарь друг с другом — так и задумано.
Пока школ максимум шесть, шесть потоков — нормально. Не ставить тик симуляции на `ThreadPool`:
под нагрузкой его заберут запросы и часы начнут плыть.
## Зачем ломать текущий цикл
Один поток на все школы дешевле и проще (нет гонок между мирами). Отдельный поток нужен, когда
школы перестанут быть «только часы»: моды, карта, ECS. Тогда одна тяжёлая школа не должна
останавливать остальные, и набор модов у школы A не должен быть глобальным синглтоном процесса.
Каталог def **на школу**, не на процесс: при создании выбирают моды, работник грузит папки и
больше их не перечитывает. Иначе правка JSON на диске внезапно меняет уже идущую школу.
## Как это стыкуется с сокетом и меню
Как сейчас по смыслу, другое только «кто трогает School»:
- входящее с HTTP/WS → команда в ящик школы (или супервизору, если это create/delete);
- меню читает опубликованный снимок, без блокировки работника;
- исходящие кадры часов — из работника в outbox соединения, не наоборот.
Инвариант вместо «только поток цикла трогает registry» становится: **только работник школы
трогает её `School` / `World` / каталог; супервизор трогает только таблицу ящиков.**
## Пока не делаем
- Потоки на системы внутри одной школы (job system как у RimWorld).
- Миграция живой школы на другой набор модов.
- Запись сейва на каждом тике.