181 lines
17 KiB
Markdown
181 lines
17 KiB
Markdown
# Фундамент: паки, числа, сид, версия и золотые файлы
|
||
|
||
Договорённость на срез после жизни школы. Не текущий код — то, что решено сделать.
|
||
|
||
Срез не добавляет игроку ни одной новой кнопки. Он берёт то, что ревью нашло дешёвым и заметным,
|
||
и доводит до конца: мод становится настоящим объектом, числа поведения уезжают из кода в данные,
|
||
сид перестаёт быть идентификатором школы, у сейва появляется читаемая версия, а регрессии, которые
|
||
сейчас ловятся только глазами, начинают ловиться тестом.
|
||
|
||
## Почему сейчас
|
||
|
||
**Моды существуют на бумаге.** В `mods/` лежит один `core`. Last-wins, патчи и порядок загрузки
|
||
проверяются синтетическими документами в памяти, а путь «игрок выбрал мод» не проверяется вообще —
|
||
подставить нечего. Пак не имеет удостоверения: `GET /api/mods` отдаёт `{id, required}`, и в диалоге
|
||
создания игрок видит имя папки. Зависимостей между паками нет, поэтому мебельный набор, который
|
||
патчит чужой def, может быть выбран без того, кого он патчит, — и школа не соберётся с невнятной
|
||
ошибкой каталога.
|
||
|
||
**Числа поведения живут в коде вопреки [`ai.md`](ai.md).** Там записано: «Числа поведения —
|
||
отдельный деф правил, как `StaffingDef` у штата». В `BehaviorDef` уехали порог нужды, скорость
|
||
обучения и разброс на дорогу, а веса целей остались константами в `Decision.cs`. Пока они там, мод
|
||
не может перебалансировать поведение, не написав кода, — а это ровно то обещание, ради которого
|
||
заводились дефы.
|
||
|
||
**Две регрессии ловятся руками.** Генератор людей переписывали дважды за одну сессию ревью, а
|
||
тест на детерминизм сравнивает два прогона *в одном процессе*: он не заметит, если поменяется сам
|
||
порядок бросков. [`people.md`](people.md) называет отпечаток «единственным способом поймать
|
||
регрессию в генераторе». Так же с сейвом: поле `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/` и ехать в вывод
|
||
сборки теми же правилами.
|
||
|
||
## Числа поведения — в `BehaviorDef`
|
||
|
||
Веса целей переезжают в деф правил: обязанность-урок, обязанность-переход, нужда на нуле, обед.
|
||
Значения по умолчанию — сегодняшние, чтобы поведение не поменялось ни на минуту. Константы в коде
|
||
остаются как запасной вариант на случай пака без `BehaviorDef` — каталог без правил поведения
|
||
должен работать, а не падать.
|
||
|
||
Это не рефакторинг ради красоты: после переезда мод, меняющий одно число, перестаёт быть форком.
|
||
|
||
## Золотые файлы
|
||
|
||
**Ростер.** Отпечаток генерации для зафиксированного сида, карты, набора имён и родного языка —
|
||
файлом в фикстурах, а не строкой в тесте. Тест сравнивает и, когда расходится, печатает первую
|
||
несовпавшую строку.
|
||
|
||
Правило, без которого это превратится в раздражение: **отпечаток обновляется в том же коммите,
|
||
что и изменение генерации, и коммит объясняет, что именно поменялось**. Красный золотой тест — это
|
||
вопрос «ты правда хотел?», а не приказ.
|
||
|
||
**Сейв.** Файл школы текущего формата кладётся в фикстуры и живёт там навсегда. Тест грузит его и
|
||
проверяет, что школа поднялась. Каждое следующее поле сейва обязано оставить этот файл рабочим —
|
||
именно так проверяется обещание «лишние поля игнорируются, отсутствующие имеют разумное значение».
|
||
Меняется формат по-настоящему — рядом кладётся новый файл, старый остаётся.
|
||
|
||
## Мелкие долги, которые дешевле закрыть здесь
|
||
|
||
**Сколько учителей не хватает.** Сейчас `uncovered` говорит «предмет непокрыт». Замер показал, что
|
||
`PrimarySchool` требует троих: восемьдесят часов в неделю против потолка в тридцать шесть. Игрок
|
||
нанимает второго, ничего не меняется, и понять почему неоткуда. В строку непокрытого предмета
|
||
добавляется, сколько человек его не вытягивают.
|
||
|
||
**Def без подписи.** Загрузчик молча подставляет `defName`, когда ключа локали нет. Для `core` это
|
||
ловит тест на полноту, для мода — ничего. Загрузчик начинает писать предупреждение в лог: мод-автор
|
||
видит дыру сразу, а не по кривой подписи в дереве.
|
||
|
||
## Сид школы — свой, а не производный
|
||
|
||
Сегодня сид генерации это `school.Id`. Отсюда три неприятности сразу: у двух игроков школа №1
|
||
населена одинаково; состав школы зависит от того, сколько школ создали до неё; воспроизвести чужой
|
||
баг нельзя, не повторив последовательность идентификаторов. Ревью споткнулось об это дважды — тест,
|
||
зелёный на машине разработчика и красный на чистом клоне, был ровно про это.
|
||
|
||
Сид становится собственным полем: случайный при создании, необязательно передаваемый в запросе,
|
||
видимый игроку. Файл людей его уже хранит, поэтому существующие школы не меняются — они просто
|
||
продолжают жить со своим прежним числом.
|
||
|
||
Побочная выгода важнее исходной причины: сидом можно поделиться. «Вот моя школа» становится одной
|
||
строкой, а не архивом сейвов.
|
||
|
||
## Версия сейва читается
|
||
|
||
`Format` штампуется при записи, копируется при чтении и **никогда не сравнивается**. Файл из
|
||
будущей версии загрузится молча и будет понят неправильно, а места для миграции не предусмотрено.
|
||
|
||
Правило то же, что с пропавшей папкой мода: формат новее своего — школу не стартовать, файл не
|
||
трогать, в лог. Формат старее — явный шов для апгрейда, сегодня пустой, но названный. Пустой
|
||
именованный шов дешевле, чем попытка вспомнить через полгода, куда его вставлять.
|
||
|
||
## Взгляд внутрь школы
|
||
|
||
Всё ревью замеры делались временными тестами: узнать, кто где стоит и с какими нуждами, иначе
|
||
нечем. Дев-ручка отдаёт ростер, присутствие, расписание и нужды одним JSON — за тем же
|
||
переключателем, что и перезагрузка сейвов, и никогда включённой по умолчанию.
|
||
|
||
Ручка обязана читать опубликованные снимки и ходить через мейлбокс, а не лезть в `World` мимо
|
||
работника: инструмент диагностики, нарушающий инвариант, сам становится источником загадок.
|
||
|
||
## Экраны под тестом
|
||
|
||
`AGENTS.md` записывал: у экранов тестов нет, потому что DOM-окружение стоило бы зависимости,
|
||
которой у проекта нет. На первой фазе это было верно — экран состоял из часов. Сейчас в клиенте
|
||
несколько тысяч строк, а vitest уже стоит: окружение — одна девзависимость.
|
||
|
||
Проверяются не экраны целиком, а места, где на клиенте есть **логика**: сборка запроса из фильтров,
|
||
превращение кода ошибки в текст, блокировки в диалоге создания. Вёрстка и стили остаются на глаза —
|
||
их тест всё равно не удержит.
|
||
|
||
## Что этот срез не делает
|
||
|
||
- Не грузит DLL-моды и не исполняет чужой код. Паки остаются данными.
|
||
- Не даёт менять набор модов у живой школы. Каталог по-прежнему замерзает на работнике.
|
||
- Не кладёт `defName` комнаты в снимок карты. Клиент смог бы стилизовать типы помещений от модов,
|
||
но это версия протокола и правка в трёх местах — не «дёшево», значит не сюда.
|
||
- Не заводит UI для порядка модов. Порядок — алфавит плюс зависимости; ручная перестановка ждёт
|
||
того дня, когда паков станет больше трёх.
|
||
|
||
## Зафиксировано этим разговором
|
||
|
||
| Тема | Решение |
|
||
| --- | --- |
|
||
| Удостоверение пака | `pack.jsonc`: версия и `requires`; название — в локалях по id пака |
|
||
| Пак без файла | Валиден: id вместо названия, без зависимостей |
|
||
| Зависимости | Проверяются на присутствие; порядок сервер выстраивает сам |
|
||
| Порядок загрузки | Устойчивая топологическая сортировка поверх порядка игрока; результат виден в ответе |
|
||
| Отсутствующая зависимость | Отказ создания с кодом ошибки, не сломанный каталог |
|
||
| Цикл зависимостей | Отказ |
|
||
| `core` | Такой же пак; особенность одна — нельзя снять |
|
||
| Пример мода | Настоящая папка в репозитории, ради проверяемого пути, а не ради контента |
|
||
| Веса целей | В `BehaviorDef`, значения по умолчанию — сегодняшние |
|
||
| Каталог без `BehaviorDef` | Работает на константах кода |
|
||
| Золотой ростер | Файл-отпечаток; обновляется в том же коммите, что и генерация |
|
||
| Золотой сейв | Файл живёт вечно; новое поле обязано его не сломать |
|
||
| Непокрытый предмет | Говорит, скольких учителей не хватает |
|
||
| Def без подписи | Предупреждение в лог при загрузке |
|
||
| Сид школы | Своё поле: случайный при создании, можно передать, видно игроку |
|
||
| Старые школы | Продолжают жить со своим прежним сидом из файла людей |
|
||
| Версия сейва | Читается: новее — не стартуем, старее — именованный шов для апгрейда |
|
||
| Дев-дамп | За переключателем, через снимки и мейлбокс, не в обход работника |
|
||
| Тесты экранов | Одна девзависимость; проверяется логика, не вёрстка |
|