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

173 lines
16 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.
# Фундамент: паки, числа, сид, версия и золотые файлы
Договорённость на срез после жизни школы. Срез закрыт фазами 22–28; таблица внизу — решения.
Абзацы «почему сейчас» ниже описывают дыры, которые срез закрыл, а не текущий код.
## Почему сейчас
**Моды существуют на бумаге — остался проверяемый путь.** Удостоверение пака уже есть:
`pack.jsonc` (версия и `requires`), название в локалях по id, `GET /api/mods?lang=` отдаёт
подпись, создание отказывает во внятном коде, если зависимости нет или они замкнуты в цикл.
Порядок загрузки сервер выстраивает сам и кладёт в сейв. Рядом с `core` лежит `mods/example`
маленький скучный пак на диске, чтобы last-wins, патчи и дорога «игрок выбрал мод» проверялись
настоящей папкой, а не документами в памяти.
**Числа поведения уехали в `BehaviorDef`.** Порог нужды, обучение, разброс на дорогу и веса целей
(урок, переход, нужда на нуле, обед) — данные. Каталог без дефа падает на константы кода, а не
ломается. Пак может поднять `lunchWeight` выше урока без форка.
**Золотые файлы ловят то, что раньше ловили глазами.** Отпечаток ростера — файл в фикстурах.
Сейв текущего формата и сейв без `nativeLanguage` грузятся тестом хоста. Формат новее своего
школу не стартует и файл не трогает.
## Удостоверение пака
`mods/<id>/pack.jsonc` — один объект, не список:
```jsonc
{
"version": "1.0",
"requires": ["core"],
}
```
**Название — не в этом файле.** Оно живёт там же, где все остальные подписи: в
`localizations/<lang>.jsonc` того же пака, ключом по id пака. Заводить второй способ называть вещи
ради одной строки не стоит, а так мод-автор пишет название рядом с названиями своих комнат.
**Файла может не быть.** Папка без `pack.jsonc` — по-прежнему валидный пак: id вместо названия,
версия пустая, зависимостей нет. Быстрый мод «две черты и набор имён» не должен требовать церемоний.
**`requires` — присутствие и порядок.** Пак, который патчит чужие def, обязан грузиться после
того, кого патчит. Поэтому сервер не просто проверяет, что зависимость выбрана, а **переставляет**
выбранные паки так, чтобы зависимости шли раньше (устойчивая топологическая сортировка: порядок
игрока сохраняется всюду, где он не противоречит зависимостям). Тихой перестановка не будет —
разрешённый порядок возвращается в ответе создания и пишется в лог.
Отсутствующая зависимость — отказ создания с внятным кодом, а не сломанный каталог. Цикл
зависимостей — тоже отказ: сортировать нечего.
**`core` не особенный.** У него такой же `pack.jsonc` и такое же название в локалях; особенность у
него ровно одна и прежняя — его нельзя снять.
## Пример пака в репозитории
Один настоящий пак рядом с `core`, маленький и скучный: пара черт, набор имён, патч чужого def и
своя строка локализации. Он нужен не как контент, а как **проверяемый путь**: каталог с модом,
создание школы с модом, патч поверх `core`, конфликт `defName` и last-wins, зависимость. Сегодня
всё это живёт только в тестах с документами в памяти, а дорога от папки на диске до школы не
проверена ни разу.
Пак должен быть виден из тестов хоста, то есть лежать рядом с `core` в `mods/` и ехать в вывод
сборки теми же правилами. Живая папка — `src/HSchool.Server/mods/example/`.
## Числа поведения — в `BehaviorDef`
Веса целей переезжают в деф правил: обязанность-урок, обязанность-переход, нужда на нуле, обед.
Значения по умолчанию — сегодняшние, чтобы поведение не поменялось ни на минуту. Константы в коде
остаются как запасной вариант на случай пака без `BehaviorDef` — каталог без правил поведения
должен работать, а не падать.
Это не рефакторинг ради красоты: после переезда мод, меняющий одно число, перестаёт быть форком.
## Золотые файлы
**Ростер.** Отпечаток генерации для зафиксированного сида, карты, набора имён и родного языка —
файлом в фикстурах, а не строкой в тесте. Тест сравнивает и, когда расходится, печатает первую
несовпавшую строку.
Правило, без которого это превратится в раздражение: **отпечаток обновляется в том же коммите,
что и изменение генерации, и коммит объясняет, что именно поменялось**. Красный золотой тест — это
вопрос «ты правда хотел?», а не приказ.
**Сейв.** Файл школы текущего формата кладётся в фикстуры и живёт там навсегда. Тест грузит его и
проверяет, что школа поднялась. Каждое следующее поле сейва обязано оставить этот файл рабочим —
именно так проверяется обещание «лишние поля игнорируются, отсутствующие имеют разумное значение».
Меняется формат по-настоящему — рядом кладётся новый файл, старый остаётся.
## Мелкие долги, которые дешевле закрыть здесь
**Сколько учителей не хватает.** Сейчас `uncovered` говорит «предмет непокрыт». Замер показал, что
`PrimarySchool` требует троих: восемьдесят часов в неделю против потолка в тридцать шесть. Игрок
нанимает второго, ничего не меняется, и понять почему неоткуда. В строку непокрытого предмета
добавляется, сколько человек его не вытягивают.
**Def без подписи.** Загрузчик пишет предупреждение в лог, когда у конкретного неабстрактного def
нет ключа ни в `ru`, ни в `en`. Для `core` это ловит тест на полноту; для мода автор видит дыру
сразу, а не по кривой подписи в дереве. Каталог при этом собирается — подставляется `defName`.
## Сид школы — свой, а не производный
Сегодня сид генерации это `school.Id`. Отсюда три неприятности сразу: у двух игроков школа №1
населена одинаково; состав школы зависит от того, сколько школ создали до неё; воспроизвести чужой
баг нельзя, не повторив последовательность идентификаторов. Ревью споткнулось об это дважды — тест,
зелёный на машине разработчика и красный на чистом клоне, был ровно про это.
Сид становится собственным полем: случайный при создании, необязательно передаваемый в запросе,
видимый игроку. Файл людей его уже хранит, поэтому существующие школы не меняются — они просто
продолжают жить со своим прежним числом.
Побочная выгода важнее исходной причины: сидом можно поделиться. «Вот моя школа» становится одной
строкой, а не архивом сейвов.
## Версия сейва читается
`Format` штампуется при записи, копируется при чтении и **никогда не сравнивается**. Файл из
будущей версии загрузится молча и будет понят неправильно, а места для миграции не предусмотрено.
Правило то же, что с пропавшей папкой мода: формат новее своего — школу не стартовать, файл не
трогать, в лог. Формат старее — явный шов для апгрейда, сегодня пустой, но названный. Пустой
именованный шов дешевле, чем попытка вспомнить через полгода, куда его вставлять.
## Взгляд внутрь школы
Всё ревью замеры делались временными тестами: узнать, кто где стоит и с какими нуждами, иначе
нечем. Дев-ручка отдаёт ростер, присутствие, расписание и нужды одним JSON — за тем же
переключателем, что и перезагрузка сейвов, и никогда включённой по умолчанию.
Ручка обязана читать опубликованные снимки и ходить через мейлбокс, а не лезть в `World` мимо
работника: инструмент диагностики, нарушающий инвариант, сам становится источником загадок.
## Экраны под тестом
`AGENTS.md` записывал: у экранов тестов нет, потому что DOM-окружение стоило бы зависимости,
которой у проекта нет. На первой фазе это было верно — экран состоял из часов. Сейчас в клиенте
несколько тысяч строк, а vitest уже стоит: окружение — одна девзависимость.
Проверяются не экраны целиком, а места, где на клиенте есть **логика**: сборка запроса из фильтров,
превращение кода ошибки в текст, блокировки в диалоге создания. Вёрстка и стили остаются на глаза —
их тест всё равно не удержит.
## Что этот срез не делает
- Не грузит DLL-моды и не исполняет чужой код. Паки остаются данными.
- Не даёт менять набор модов у живой школы. Каталог по-прежнему замерзает на работнике.
- Не кладёт `defName` комнаты в снимок карты. Клиент смог бы стилизовать типы помещений от модов,
но это версия протокола и правка в трёх местах — не «дёшево», значит не сюда.
- Не заводит UI для порядка модов. Порядок — выбор игрока плюс устойчивая топологическая
сортировка по `requires`; ручная перестановка ждёт того дня, когда паков станет больше трёх.
## Зафиксировано этим разговором
| Тема | Решение |
| --- | --- |
| Удостоверение пака | `pack.jsonc`: версия и `requires`; название — в локалях по id пака |
| Пак без файла | Валиден: id вместо названия, без зависимостей |
| Зависимости | Проверяются на присутствие; порядок сервер выстраивает сам |
| Порядок загрузки | Устойчивая топологическая сортировка поверх порядка игрока; результат виден в ответе |
| Отсутствующая зависимость | Отказ создания с кодом ошибки, не сломанный каталог |
| Цикл зависимостей | Отказ |
| `core` | Такой же пак; особенность одна — нельзя снять |
| Пример мода | Настоящая папка в репозитории, ради проверяемого пути, а не ради контента |
| Веса целей | В `BehaviorDef`, значения по умолчанию — сегодняшние |
| Каталог без `BehaviorDef` | Работает на константах кода |
| Золотой ростер | Файл-отпечаток; обновляется в том же коммите, что и генерация |
| Золотой сейв | Файл живёт вечно; новое поле обязано его не сломать |
| Непокрытый предмет | Говорит, скольких учителей не хватает |
| Def без подписи | Предупреждение в лог при загрузке |
| Сид школы | Своё поле: случайный при создании, можно передать, видно игроку |
| Старые школы | Продолжают жить со своим прежним сидом из файла людей |
| Версия сейва | Читается: новее — не стартуем, старее — именованный шов для апгрейда |
| Дев-дамп | За переключателем, через снимки и мейлбокс, не в обход работника |
| Тесты экранов | Одна девзависимость; проверяется логика, не вёрстка |