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

16 KiB
Raw Blame History

Фундамент: паки, числа, сид, версия и золотые файлы

Договорённость на срез после жизни школы. Срез закрыт фазами 22–28; таблица внизу — решения. Абзацы «почему сейчас» ниже описывают дыры, которые срез закрыл, а не текущий код.

Почему сейчас

Моды существуют на бумаге — остался проверяемый путь. Удостоверение пака уже есть: pack.jsonc (версия и requires), название в локалях по id, GET /api/mods?lang= отдаёт подпись, создание отказывает во внятном коде, если зависимости нет или они замкнуты в цикл. Порядок загрузки сервер выстраивает сам и кладёт в сейв. Рядом с core лежит mods/example — маленький скучный пак на диске, чтобы last-wins, патчи и дорога «игрок выбрал мод» проверялись настоящей папкой, а не документами в памяти.

Числа поведения уехали в BehaviorDef. Порог нужды, обучение, разброс на дорогу и веса целей (урок, переход, нужда на нуле, обед) — данные. Каталог без дефа падает на константы кода, а не ломается. Пак может поднять lunchWeight выше урока без форка.

Золотые файлы ловят то, что раньше ловили глазами. Отпечаток ростера — файл в фикстурах. Сейв текущего формата и сейв без 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/ и ехать в вывод сборки теми же правилами. Живая папка — 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 без подписи Предупреждение в лог при загрузке
Сид школы Своё поле: случайный при создании, можно передать, видно игроку
Старые школы Продолжают жить со своим прежним сидом из файла людей
Версия сейва Читается: новее — не стартуем, старее — именованный шов для апгрейда
Дев-дамп За переключателем, через снимки и мейлбокс, не в обход работника
Тесты экранов Одна девзависимость; проверяется логика, не вёрстка