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