Files
h-school/docs/phases/reviewed.md
T

67 KiB
Raw Blame History

Проверенные срезы

Журнал ревью. Фаза с в README.md — заявка автора; строка здесь — проверенный факт: каждое обещание фазы сверено с кодом и тестом, найденное дописано или исправлено.

Коммит в «Проверен на» делает журнал самопроверяющимся. Срез перестаёт считаться проверенным, как только git log <коммит>..HEAD -- <пути> перестаёт быть пустым — поэтому «Пути» обязательны.

Срез 1. Оболочка и карта

  • Фазы: 04
  • Проверен на: 418a4ea, 2026-08-20 (первичная проверка — b135a9c; предыдущая перепроверка — 5a00ad7)
  • Пути: src/HSchool.Client, src/HSchool.Protocol, src/HSchool.Content, src/HSchool.Server/Game, src/HSchool.Server/Api/ModEndpoints.cs, src/HSchool.Server/Api/SchoolEndpoints.cs, src/HSchool.Server/Net, tests/HSchool.Content.Tests, tests/HSchool.Protocol.Tests, tests/HSchool.AppHost.Tests/SchoolApiTests.cs, tests/HSchool.AppHost.Tests/GameSocketTests.cs
  • Итог: дописано 2 теста (оба прогнаны на чистом b135a9c), расхождений кода с дизайном не найдено кроме одного — панель «Общие события» из фазы 1 снята с экрана в фазе 8 и решение нигде не записано (оставлено открытым, см. ниже)

Что подтверждено:

  • Фаза 0. pixi.js нет ни в package.json, ни в lockfile, ни в импортах; npm run build проходит.
  • Фаза 1. Дерево карты, панель локации со всеми пятью секциями и пустыми состояниями, клик по дереву фильтрует на клиенте, localize() перекрашивает подписи. Панели «Общие события» на экране нет — единственное расхождение среза.
  • Фаза 2. SchoolWorkerLongRunning-поток, свой PeriodicTimer, свой World, аккумулятор с потолком в 5 шагов. Супервизор держит таблицу и не трогает World (инвариант 3). Сейв пишется на create, на shutdown и по SaveIntervalSeconds; настройки коалесцируются. Требуемый фазой тест «create → reload → та же школа с тем же временем» — это GameSocketTests.ReloadFromDisk_RestoresAPausedClock (точное равенство времени, паузы и скорости), плюс SchoolApiTests.ReloadFromDisk_RestoresCreatedSchools. Независимость школ — GameSocketTests.PausingOneSchool_DoesNotStopAnother.
  • Фаза 3. Все восемь тестов из списка фазы на месте в HSchool.Content.Tests (JSONC-фикстуры, last-wins по defName и по ключу локали, parent/abstract и цикл, патч add в actions, связность, пустая комната, одна комната без двора). Ванильная раскладка валидируется (VanillaCoreTests.CoreDefaultMap_PassesValidation). Пропавшая папка мода не стартует школу и не трогает файл: исключение летит до создания School, поэтому finally с Persist() недостижим, а супервизор только снимает школу с таблицы.
  • Фаза 4. GET /api/mods, GET /api/catalog?lang=, POST /api/schools с картой и модами, снимок карты при OpenSchool в языке Hello — всё покрыто тестами (OpeningASchool_SendsAMapSnapshot, LabelsTheSnapshotInTheHelloLocale, WithACustomMap_ReturnsThatLayout, ReloadFromDisk_RestoresACustomMap). Кодек, protocol.ts и docs/protocol.md описывают один и тот же MapSnapshot, байтовые смещения проверены с обеих сторон; версия 7 совпадает в ProtocolConstants и PROTOCOL_VERSION.
  • Инварианты. Content/People/Schedule/Ai не ссылаются на Arch и ASP.NET, Simulation — только на Arch; DateTime.Now/UtcNow в этих проектах нет.
  • «Things that will bite you». Ни одна из относящихся к срезу регрессий не вернулась: modal.ts резолвит промис явно, .dialog--screen держит height: fit-content, DefaultStartDate форсирует UTC-kind, аккумулятор работника на месте, MapSnapshotSize считается от сообщения, GameClient переносит оба WaitToReadAsync между итерациями, outbox роняет старейший кадр, меню патчит карточки через card.update.

Дописано:

  • SchoolApiTests.Catalog_WithAnUnknownMod_IsRejectedGET /api/catalog?mods= с неизвестной пачкой описан в docs/protocol.md как 400 unknown-mod, но не проверялся.
  • SchoolApiTests.ModId_ThatEscapesTheModsFolder_IsRejectedOnBothEndpoints — инвариант 6: id пачки приходит из браузера и подставляется в путь. ModContent.IsSafePackId его чистит на обоих входах (каталог и создание школы), тестом это закреплено не было.

Открыто:

  • Панель «Общие события» пропала. Фаза 1 требует три области, design/near-term.md держит их в «Что уже зафиксировано», а design/people.md описывает список людей как панель «рядом с картой и событиями». Коммит d8f8db6 (фаза 8) заменил панель вкладками Карта/Люди, не сказав об этом; ключи eventsTitle/eventsEmpty остались в i18n/strings.ts мёртвыми. Решать надо одно из двух: вернуть пустую панель (или третью вкладку) — или записать отказ от неё в дизайн и убрать ключи. Молча менять экран ревью не стало.

Замечено рядом (за границами среза):

  • SchoolRegistry после фазы 2 в бою не используется: сервер тикает школы работниками, а из класса вызывается только TryNormalizeName. SchoolRegistryTests.Tick_AdvancesOnlyRunningSchools проверяет путь, которым сервер больше не ходит.
  • PeopleApiTests.Card_IncludesFamilyAndLiveNeeds зависит от порядка тестов (фаза 8, срез 2). Проверено на чистом checkout b135a9c в отдельном worktree: в одиночку класс проходит (8/8), в полном прогоне класса падает на Assert.Equal(2, card.Family.Parents.Count) — ожидалось 2, пришёл 1. Причина: тест берёт People[0] и требует у него двух родителей, а состав семьи зависит от сида, сид — от id школы, id — от того, сколько школ создали тесты до него (и от saves/index.json на машине). На машине с накопленными сейвами тест зелёный, на чистом клоне — красный. Чинить надо тест: брать ученика, у которого два родителя, а не первого попавшегося. Неполные семьи — сознательное решение design/people.md, не баг генерации.
  • GameSocketTests.OpeningASchoolDuringAMathLesson_... не выдерживает полный прогон (срез 4). В одиночку проходит за 1 с, в полном прогоне падает с «No presence frame matched within 40 frames» через 22 с. Похоже на нехватку кадров под нагрузкой, а не на ошибку присутствия.
  • Полный dotnet test один раз упал: процесс HSchool.Simulation.Tests умер с 0xC0000005, успев отчитаться о 44 тестах из 63, и два ActivityTests (фаза 20) не прошли. Повторить не удалось. Скорее всего дерево правилось прямо во время прогона (см. ниже).
  • На момент проверки в рабочем дереве шла правка фазы 21 (Decision.cs, LessonLearning.cs, LessonLearningSystem.cs, PresenceSystem.cs, gameScreen.ts), и какое-то время решение не компилировалось. Выводам по срезу 1 это не мешает: его файлы правка не трогает, а дописанные тесты прогнаны и на чистом b135a9c, и на дереве после починки сборки.

Перепроверка на 5a00ad7, 2026-08-19

Коммит добавил родные языки: nativeLanguages в каталоге, nativeLanguage в создании школы, поле в сейве. Проверено по изменению:

  • Инвариант протокола цел. Версия осталась 7 в обоих местах, ProtocolCodec.cs и protocol.ts не тронуты — и правильно: изменение целиком в HTTP/JSON, который документ объявляет расширяемым. docs/protocol.md пополнен тем же коммитом, включая новый код ошибки unknown-native-language, и этот код проверяется тестом.
  • Формат сейва вырос на nullable-поле без бампа CurrentFormat. Старый сейв без поля загружается сознательно: NativeLanguages.Pick(..., rollIfOmitted: false) берёт первый язык набора, а не бросает кубик, — иначе существующая школа сменила бы язык при первом же старте сервера и следующий набор пришёл бы другим. Ветка была описана комментарием, но не проверялась.
  • Дописанные в первый проход тесты (unknown-mod и побег из папки mods) проходят на новом коде.

Дописано: SkillGrantTests.ReloadWithoutASavedPick_TakesTheFirstLanguageInsteadOfRolling — закрывает всю таблицу Pick: старый сейв берёт первый язык, новая школа бросает кубик и бросок стабилен по сиду, сохранённый выбор уважается, чужой id даёт null, из которого создание школы делает 400.

Перепроверка на 418a4ea, 2026-08-20

Дрейф после 5a00ad7 — это срез 6 (пакеты, сид, формат сейва, тесты экранов) плюс раздельные обеды. Обещания фаз 0–4 целы; протокол остался v7 в трёх местах, ProtocolCodec / protocol.ts не трогались.

Что подтверждено повторно:

  • Фаза 0. pixi.js нет; npm run build проходит (после правки ниже).
  • Фаза 1. Дерево, пять секций локации, клиентский фильтр, localize(). Отказ от панели событий записан в фазе 1 и в нарративе design/near-term.md; в таблице «Что уже зафиксировано» строка всё ещё говорила «панель есть» — поправлена.
  • Фаза 2. LongRunning, аккумулятор с потолком 5, супервизор не трогает World, WorkerFailed снимает школу с таблицы и не удаляет файл. ReloadFromDisk_RestoresAPausedClock и PausingOneSchool_DoesNotStopAnother зелёные.
  • Фаза 3. Все восемь тестов списка на месте в HSchool.Content.Tests (86/86). Ванильная карта валидна.
  • Фаза 4. GET /api/mods (core обязателен), каталог, create с картой и отказ на дырявый граф, снимок при OpenSchool, reload раскладки — зелёные. core в диалоге создания нельзя снять — теперь ещё и клиентским тестом (keeps core checked and disabled, фаза 28).
  • Инварианты и «Things that will bite you». Content без ASP.NET; DateTime.Now в симуляции нет; modal.ts резолвит явно; .dialog--screenheight: fit-content; DefaultStartDate UTC; MapSnapshotSize от сообщения; outbox DropOldest; WaitToReadAsync переносится; меню патчит card.update.

Исправлено:

  • School в src/HSchool.Client/src/net/api.ts содержал два поля speedIndex подряд — артефакт мержа фазы 26 (edd3d35). npm test (vitest) это не ловит, npm run build падал с TS2300. Дубликат убран; поле как было одно.
  • В таблице design/near-term.md «События | Панель есть, лента пустая» расходилась с решением фазы 8, которое журнал уже считал закрытым. Строка приведена к вкладочной оболочке.

Замечено рядом (за границами среза):

  • GameSocketTests.OpeningASchoolDuringAMathLesson_... теперь падает и в полном прогоне, и в одиночку (40 кадров присутствия после снятия паузы, People.Count == 0). Раньше это был флак под нагрузкой, и тест ослабили до «люди вообще едут». После среза 6 никто не появляется: планы дня собраны для пустого штата, найм в 10:20 на паузе пересобирает таблицу, но явка завязана на AppearAt утреннего прихода. Чинить в срезе 5, не здесь.
  • npm test в клиенте не гоняет tsc. Дубликат поля и любой другой TS2300 зелёный в vitest и красный на build. Это фаза 28.

Срез 2. Люди

  • Фазы: 59
  • Проверен на: bdb1253, 2026-08-20 (первичная проверка — 5cd5a6d; предыдущая перепроверка — 5a00ad7)
  • Пути: src/HSchool.People, src/HSchool.Content/PeopleDefs.cs, src/HSchool.Simulation/RosterSpawner.cs, src/HSchool.Simulation/School.cs, src/HSchool.Server/Api/SchoolEndpoints.cs, src/HSchool.Server/Game/PersonCardReader.cs, src/HSchool.Server/Game/SchoolWorker.cs, src/HSchool.Client/src/ui/peoplePanel.ts, src/HSchool.Client/src/ui/personCard.ts, tests/HSchool.People.Tests, tests/HSchool.Content.Tests/PeopleDefTests.cs, tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs, tests/HSchool.AppHost.Tests/PeopleApiTests.cs, tests/HSchool.AppHost.Tests/SchoolSeedTests.cs
  • Итог: исправлен один тест, который падал на чистом клоне, укреплён второй, дописан один; расхождение с дизайном одно и то же, что в срезе 1 (раскладка оболочки менеджера)

Что подтверждено:

  • Фаза 5. Все шесть пунктов списка тестов на месте в PeopleDefTests (разбор каждого вида, наследование навыка, патч черты, неизвестная ссылка — ошибка каталога, склонение по модели и перекрытие явной таблицей), плюс ExtraPack_AddsANameSetAndTrait — критерий «мод со своим набором имён виден в каталоге». Наборы имён в GET /api/catalog проверены в SchoolApiTests.Catalog_LabelsDefsInTheRequestedLanguage.
  • Фаза 6. Все шесть тестов из списка фазы существуют и проходят. Критерий «в проекте нет сокетов, ECS и DateTime.Now» закреплён самими тестами — Assembly_DoesNotReferenceArchAspNetOrSockets и Sources_DoNotUseWallClock.
  • Фаза 7. Одиннадцать кабинетов (VanillaMap_HasElevenHomeroomsNotTheComputerLab), нужды тикают со школой, ростер публикуется снимком, PersistPeople зовётся только когда состав изменился — файл не переписывается по таймеру. Ростер, не сходящийся с картой, оставляет школу незапущенной (RosterFit.MatchesSchoolContentUnavailableException).
  • Фаза 8. Список читает опубликованный снимок (loop.FindPeople), карточка идёт через очередь команд с TaskCompletionSource — инвариант 3 цел. Все пять требуемых фазой тестов API на месте. Клиент: фильтры, сортируемые колонки, пейджер, семейные ссылки, всё через t(...); в строке списка только лёгкие поля.
  • Фаза 9. Выпуск старшей имеющейся параллели, сохранение вместимости, семьи через поколения и уход родителей выпускников покрыты в YearlyIntakeTests и PeopleInSchoolTests.
  • Дизайн. design/people.md сверен построчно: неполные семьи ≈8% (тест требует 5–10%), фамилия и отчество отцовские даже у одинокой матери, идентификатор ребёнка — счётчик, места раздаются вперемешку. Расхождений нет.

Исправлено:

  • PeopleApiTests.Card_IncludesFamilyAndLiveNeeds падал на чистом клоне. Тест брал первого ученика страницы и требовал у него ровно двух родителей. Состав семьи выводится из сида, а сид — это id школы (SchoolWorker.InstallPeople: seed = school.Id), то есть зависит от того, сколько школ создали тесты до него и что лежит в saves/index.json. Замер по сидам 1–30: у 28 первый пятиклассник с двумя родителями, у 2 — с одним, и полный прогон на свежем checkout b135a9c в отдельном worktree ловил как раз такой сид. Одинокие родители — сознательное решение дизайна, поэтому неправ был тест: теперь он ищет ученика, у которого есть мать, а количество родителей проверяет диапазоном. Проверено тем же способом, каким ловилось: полный прогон на чистом b135a9c трижды, 60/60.
  • PeopleApiTests.List_SortsBySurname мерил не тем. Тест требовал ordinal-порядка, а сервер ставит Ё на место Е (RosterBrowser.NameOrder, тест SurnameSort_FollowsTheAlphabetNotCodePoints). Совпадали они только потому, что в core нет фамилий на Ё — мод с такой фамилией уронил бы тест, а не код. Теперь тест складывает Ё в Е так же, как сервер.

Дописано:

  • PeopleApiTests.ReloadAfterTheYearlyIntake_RestoresTheNewComposition — критерии фаз 7 и 9 «перезапуск поднимает тех же людей» и «перезапуск после перехода поднимает новый состав» не проверялись сквозь сервер. Просто сравнить людей до и после reload недостаточно: генерация детерминирована от id школы, поэтому забывший файл сервер собрал бы тот же ростер и тест бы ничего не поймал. Тест поэтому ждёт 1 сентября (школа создаётся за 15 игровых минут до полуночи, это около трёх секунд реального времени), а после reload требует состав после набора и явно отличный от исходного.

Открыто:

  • Раскладка оболочки менеджера разошлась с дизайном — то же расхождение, что записано в срезе
    1. Фаза 8 обещает панель «Люди» нижним рядом во всю ширину, design/people.md фиксирует «панель в оболочке менеджера, нижний ряд; не отдельный экран», а на экране она вкладкой рядом с картой. Тот же коммит d8f8db6, что убрал панель событий. Решается одним решением на оба пункта: либо вернуть нижний ряд, либо переписать обе строки дизайна.

Замечено рядом (за границами среза):

  • design/people.md и задача фазы 5 говорят «скорость убывания нужд в core — ноль», а в defs/needs теперь decayPerHour от 0.05 до 0.15. Это сознательная работа фазы 20, не регрессия; строка дизайна просто устарела и относится к срезу 5.
  • Срез 1 успел сдвинуться: коммит 5cd5a6d тронул gameScreen.ts и strings.ts (подпись идущего человека). По правилу журнала он больше не «проверен на b135a9c» — правка маленькая и относится к срезу 5, но следующий проход это увидит.

Перепроверка на 5a00ad7, 2026-08-19

5a00ad7 переписал половину генератора: PersonSampler (+266), FamilyFactory (+52), YearlyIntake (+61), новый NativeLanguages, новые навыки в core (языки, социальные, рабочие). Проверено по изменению:

  • Решение записано заранее — design/people.md описывает родные языки и порядок выдачи навыков ещё с a441ed9, код догнал документ. Дрейфа нет, это тот редкий случай, когда дизайн шёл первым.
  • Восемь новых тестов в SkillGrantTests закрывают выдачу навыков по параллелям, отсутствие рабочих навыков у учеников и набор без переброса уже выданного. Старые тесты детерминизма, согласованности семьи и годового набора зелёные — генератор переписан, а обещания среза целы.
  • Исправления среза 2 (карточка семьи, сортировка по фамилии, состав после набора и перезапуска) проходят на новом коде.

Перепроверка на bdb1253, 2026-08-20

Дрейф после 5a00ad7 — свой сид школы (фаза 26), золотые ростеры (25), тесты панели (28) и данные поведения. Обещания фаз 5–9 целы.

Что подтверждено повторно:

  • Фаза 5. Все пункты списка в PeopleDefTests (10/10): разбор, наследование, патч черты, неизвестная ссылка, склонение и таблица, мод со своим набором.
  • Фаза 6. Все шесть тестов генератора на месте, плюс NewSchool_HasNoStaff (фаза 11 не отменяет детерминизм и заполнение мест). Assembly_DoesNotReferenceArchAspNetOrSockets и Sources_DoNotUseWallClock зелёные. Неполные семьи 5–10% — ReviewFixTests.
  • Фаза 7. Одиннадцать кабинетов, сущности в World, PersistPeople только при изменении состава. Штат при создании пуст — это фаза 11, тест обновлён (LeaveJobsEmpty).
  • Фаза 8. Список с опубликованного снимка (FindPeople), карточка через TaskCompletionSource. Пять API-тестов плюс клиентские фильтры/пейджер (peoplePanel.test.ts 3/3). Вкладка, не нижний ряд.
  • Фаза 9. Выпуск старшей имеющейся, вместимость, семьи через поколения, reload после набора (ReloadAfterTheYearlyIntake_RestoresTheNewComposition).
  • Сид. Больше не school.Id. Новая школа берёт _createSeed, сейв — loaded.Seed; запасной school.Id только если people.json нет. Card_IncludesFamilyAndLiveNeeds передаёт seed: 1. SchoolSeedTests: тот же сид — те же люди, два без сида — разные, reload хранит выпавший.

Исправлено:

  • В design/people.md скорость нужд всё ещё была «ноль» (нарратив, совет и таблица), хотя срез 5 это отменил. Помечено как отменённое со ссылкой на ai.md, по тому же шаблону, что расписание. Код не трогался.

Закрыто из прошлого прохода:

  • Раскладка оболочки. «Открыто» выше больше не действует: design/people.md и фаза 8 уже записаны под вкладку рядом с картой. Решение принято в том же ходе, что отказ от панели событий.

Замечено рядом:

  • «Заведомо не сейчас» в people.md всё ещё говорит «должности закрываются генератором» — отменено срезом 3. Не переписывал исторический список стопов.

People 34, PeopleDef 10, PeopleInSchool 7, PeopleApi+SchoolSeed 13, клиентская панель 3/3.

Срез 3. Штат

  • Фазы: 1013
  • Проверен на: bdb1253, 2026-08-20 (первичная проверка — 21d79cb; предыдущая перепроверка — 5a00ad7)
  • Пути: src/HSchool.People/Staffing.cs, src/HSchool.People/ApplicantPool.cs, src/HSchool.Content/SubjectDef и defs/staffing, src/HSchool.Server/Api/SchoolEndpoints.cs (staffing/hire/subjects), src/HSchool.Server/mods/core/defs/{subjects,positions,rooms,staffing,traits}, src/HSchool.Client/src/ui/managementPanel.ts, src/HSchool.Client/src/net/api.ts, tests/HSchool.People.Tests/{StaffingTests,ApplicantPoolTests}.cs, tests/HSchool.AppHost.Tests/StaffingApiTests.cs, src/HSchool.Client/src/ui/managementPanel.test.ts
  • Итог: дописано 3 теста (один из них — прямо требуемый фазой 12 и отсутствовавший), дополнен docs/protocol.md; расхождений кода с дизайном не найдено

Что подтверждено:

  • Фаза 10. PETeacher в core нет, Classroom, ComputerLab и GymHall не открывают должностей — должности остались у библиотеки, приёмной, столовой, медкабинета и кабинета директора, ровно как обещано. Кабинет описан числом мест (seatThing/defaultSeats, пустой список слотов — проверено в Catalog_LabelsDefsInTheRequestedLanguage), редактор карты показывает у него одно поле «мест». Неизвестный навык в предмете роняет каталог (Subject_UnknownSkill_FailsTheCatalog), полнота локалей — EveryVanillaLabel_ExistsInBothLocales, стартовая дата 31 марта 2012 зашита в тестах API.
  • Фаза 11. Все шесть тестов из списка фазы на месте и проходят: детерминизм пула по сиду и неделе, частичное обновление с сохранением навыков и запроса, устойчивый размер за пятьдесят недель, «сильный просит больше», пустой штат при полной школе, соискатель вне ростера до найма.
  • Фаза 12. Фонд оплаты, деление часов между учителями предмета, пол одной ставки и покрытие предметов покрыты в StaffingTests. GET /staffing читает опубликованный снимок (loop.FindPeople), наём и назначение идут в воркер через GameCommand с TaskCompletionSource — инвариант 3 цел. Перезапуск с тем же штатом и назначениями — AssignAndUnassign_HonourTheCap_AndReloadKeepsAssignments.
  • Фаза 13. Строка денег, непокрытые предметы, список соискателей со ставкой и сильными навыками, кнопка найма с текстом ошибки, список штата с предметами и стоимостью, назначение и снятие с карточки — всё есть и всё через t(...).
  • Дизайн. design/staffing.md сверен построчно. Особо проверено: MaxWeeklyHours не запрещает назначение, а делает предмет непокрытым (в коде нет такой ошибки, и замер это подтверждает); цена часа считается от навыков и от черт.

Дописано:

  • StaffingApiTests.AssigningPastTheCap_IsRejectedWithTheNumbers — фаза 12 требует теста API на «отклонение по пределу», а его не было: единственное место, где он мог сработать, было написано через if (over.StatusCode == Conflict) … else … и проходило при любом исходе. Пределом занялся замер: при выделенных 100 000 полностью загруженная школа стоит всего 68–72 тысячи, то есть «нанять всех» предела не достигает. Но пик фонда приходится на середину раздачи предметов — пока часы предмета несут один-два учителя, а не двенадцать, — и там отказ случается: по сидам 1–30 отказов от 4 до 24 на прогон, ни одного сида без отказа. Тест раздаёт предметы штату, ловит первый 409, сверяет payroll-exceeded, allocated, attempted > allocated, remaining и то, что отклонённое назначение действительно не легло.
  • ApplicantPoolTests.ConfidentTraits_AskForMoreAndQuietOnesForLess — дизайн фиксирует «цена часа: от навыков и от черт (самоуверенный просит больше)», код это делает через TraitDef.WageAskcore Leader +10, HotTempered +6, Quiet 8), а тестом закреплена была только половина про навыки.
  • Проверка в AssignAndUnassign_HonourTheCap_AndReloadKeepsAssignments, что после перезапуска возвращается тот же пул с теми же ставками — критерий готовности фазы 11, который никто не проверял. Тест кусается: пул на диске уже без нанятого, а сгенерированный заново вернул бы его обратно.

Исправлено:

  • docs/protocol.md не упоминал ?lang=ru|en у POST staff/hire, POST …/subjects и DELETE …/subjects/{subject}, хотя все три его принимают и клиент его шлёт. Поведение не трогалось — дописана документация.

Замечено рядом (за границами среза):

  • Предел фонда при ванильных данных почти не ограничивает игрока: нанять всех двенадцать и раздать им все предметы стоит около 70 000 из 100 000. Отказ достижим только по дороге, на пике неравномерной нагрузки. Это игровой баланс, а не ошибка (см. коммит 207e7ec, которым предел поднимали намеренно), но если предел задумывался как настоящее ограничение — числа сейчас говорят обратное.
  • В AssignAndUnassign_HonourTheCap_AndReloadKeepsAssignments осталась ветка if/else вокруг ожидаемого отказа. Теперь, когда отказ проверяется отдельным тестом, эта ветка просто не может упасть — её стоит однажды упростить, но трогать работающий тест ради этого ревью не стало.

Перепроверка на 5a00ad7, 2026-08-19 — вывод про бюджет исправлен

5a00ad7 поднял poolSize с 12 до 32. Это не косметика: предел фонда начал работать, и запись выше про «предел почти не ограничивает игрока» больше не описывает игру. Перемерено на шести сидах:

что делаем сколько людей фонд из 100 000 непокрыто
нанимать подряд, не назначая предметов 2021 из 32 95 000–99 400, дальше отказ всё
по учителю на предмет 12 86 80094 700 PrimarySchool
закрыть и его 14 87 10095 100 ничего

То есть полностью укомплектованная школа теперь влезает, но впритык — запас 513%, и PrimarySchool (1–4 классы, 80 часов в неделю против потолка в 36 на человека) требует трёх учителей, о чём игрок узнаёт только из списка непокрытых.

Прежняя цифра «около 70 000 из 100 000» получена на вырожденной раскладке — когда каждому из двенадцати назначены все двенадцать предметов. Так часы предмета делятся на всех и почти каждый садится на пол одной ставки; это самая дешёвая конфигурация, а не «полностью укомплектованная школа». Ошибка была в замере, а не в коде.

Ставки соискателей при этом не изменились (в среднем 57.9–59.3 против прежних 58.5–62.5), хотя навыков у человека стало больше — цена часа считается от среднего по навыкам, и среднее устояло.

Hire_IdlePool_FitsUnderTheCap в том же коммите переписан так, чтобы принимать отказ по пределу как штатный исход. Это правильно: с пулом в 32 человека нанять всех и остаться в пределах суммы уже нельзя.

Перепроверка на bdb1253, 2026-08-20

Дрейф после 5a00ad7teachersShort (фаза 25), свой сид, удостоверение пака. Обещания фаз 10–13 целы; новых тестов не дописывал, расхождений с design/staffing.md нет.

Что подтверждено повторно:

  • Фаза 10. PETeacher нет, кабинеты без учительской должности, места через defaultSeats, редактор показывает у homeroom одно поле «мест». Subject_UnknownSkill_FailsTheCatalog, стартовая дата 31 марта 2012.
  • Фаза 11. Все шесть тестов пула на месте (SameSeedAndWeek, частичное обновление, 50 недель, сильный просит больше, пустой штат, соискатель вне ростера). Пул 32, reload хранит ставки.
  • Фаза 12. Hire_TwoPeopleAtThreeAndSevenThousand_RejectsTheThird на фикстуре 10 000. AssigningPastTheCap_IsRejectedWithTheNumbers на API. GET /staffing читает снимок, наём/назначение — TaskCompletionSource. MaxWeeklyHours не запрещает назначение (SubjectNeedingMoreHoursThanOnePersonCarries_StaysUncovered). Ветка if/else вокруг предела убрана.
  • Фаза 13. Деньги, непокрытые, пул, штат, назначение с карточки, всё через t(...). Отказ по пределу текстом — managementPanel.test.ts.
  • Покрытие. PrimarySchool на ванили требует трёх учителей (TeachersShort == 3 в API и в PrimarySchool_OnVanillaMap_NeedsThreeTeachers_AndASecondDoesNotCloseIt). Это фаза 25, не ломает фазу 12: список непокрытых был и раньше, появилось число.

Замечено рядом:

  • people.md «Заведомо не сейчас» всё ещё говорит, что должности закрывает генератор — то же, что в срезе 2. Исторический стоп среза 2, не трогал.

Staffing+ApplicantPool 18, Content 3, StaffingApi 9.

Срез 4. Расписание

  • Фазы: 1417
  • Проверен на: bdb1253, 2026-08-20 (первичная проверка — 21d79cb; предыдущая перепроверка — 5a00ad7)
  • Пути: src/HSchool.Schedule, src/HSchool.Content (каркас дня, каникулы, SubjectDef.room), src/HSchool.Simulation/SchoolTimetables.cs, src/HSchool.Server/Api/TimetableEndpoints.cs, src/HSchool.Server/Game/SchoolWorker.cs (пин, перестроение), src/HSchool.Client/src/ui/timetableGrid.ts, src/HSchool.Client/src/ui/gameScreen.ts, tests/HSchool.Schedule.Tests, tests/HSchool.Content.Tests/CalendarTests.cs, tests/HSchool.Content.Tests/LunchBreakTests.cs, tests/HSchool.AppHost.Tests/TimetableApiTests.cs, src/HSchool.Client/src/ui/timetableGrid.test.ts
  • Итог: дописан один тест на пять непокрытых кодов отказа, поправлены две устаревшие строки дизайна; расхождений кода с принятыми решениями нет

Что подтверждено:

  • Фаза 14. Все пункты критерия закрыты в CalendarTests: суббота, каникулы и ночь дают «вне учебного дня», 10:20 буднего дня — третий урок, шестидневка делает субботу рабочей, зимние каникулы переносятся через 1 января. Требование помещения у предмета есть (физкультура — спортзал), неизвестная комната роняет каталог.
  • Фаза 15. Все шесть тестов из списка фазы на месте. Четыре запрета проверяются одним утверждением в TeacherWithTwoSubjects_IsNeverInTwoRoomsAtOnce (уникальность по учителю, классу и кабинету в слоте) плюс ClassOfSixteen_DoesNotEnterALabOfTwelve. Отсутствие сокетов, ECS и часов реального времени — самопроверкой сборки, как в HSchool.People.
  • Фаза 16. Расписание пересобирается только когда изменился состав: TimetableDirty ставится в InstallPeople/Replace и в годовом наборе, а воркер перестраивает таблицу под if (result.PeopleChanged) — по тику этого не происходит. GET /timetable читает опубликованный снимок, пин и снятие идут через GameCommand с TaskCompletionSource. Перезапуск с закреплениями — Reload_RestoresLockedLessons.
  • Фаза 17. Скобки в дереве (Математика · 5Б) собираются из присутствия, сетка «день × урок» с переносом, сменой кабинета и отметкой «закреплено» есть, отказ планировщика показывается текстом (timetableErrorPin, timetableErrorNoTeacher, timetableErrorLesson), личное расписание — в карточке. Всё через t(...). Клиентские тесты и сборка на 21d79cb: 33/33 и зелёная сборка.

Дописано:

  • TimetableApiTests.PinAndUnpinRefusals_CarryTheDocumentedCodes — из шести кодов отказа пина в docs/protocol.md тестом был закрыт только pin-rejected (хвост PinThenHireAnother_KeepsTheLockedSlot, попытка провести урок в спортзале). Остальные пять — no-teacher, unknown-class, unknown-subject, unknown-room, unknown-lesson на повторном снятии, плюс invalid-query на неполном запросе — не проверялись, хотя клиент по ним переключает текст ошибки.

Исправлено:

  • design/schedule.md держал в «Зафиксировано» две строки, которые срез 5 сознательно отменил: «Панель локации заполняется из расписания» и «Люди находятся по расписанию; ходьбы нет». Отмена записана в design/ai.md («присутствие — состояние человека, а не функция от расписания»), а старый документ об этом не знал. Помечены как отменённые со ссылкой; код не трогался — он давно живёт по новому решению.

Замечено рядом (за границами среза):

  • Задача фазы 16 «снимок карты отдаёт по узлу, что там сейчас идёт и у кого» в протоколе v7 выглядит иначе: снимок карты стал статическим, а занятость уехала в кадр Presence. Это сделано фазой 19 намеренно и описано в docs/protocol.md; строка фазы 16 просто историческая.
  • GameSocketTests.OpeningASchoolDuringAMathLesson_..., который в срезе 1 падал дважды под нагрузкой, за этот проход не воспроизвёлся ни разу: шесть полных прогонов подряд на чистом worktree и два одиночных под искусственной нагрузкой из шести жгущих процессов — зелено. Оставляю как «наблюдалось, не воспроизводится»; если вернётся, смотреть на выдачу кадров присутствия под нехваткой процессора, а не на логику.

Про окружение:

  • Во время проверки параллельная сессия правила core и клиент (размер пула соискателей 12 → 32, новые навыки, диалог соискателей). Два прогона на рабочем дереве успели показать «падения» Hire_IdlePool_FitsUnderTheCap и GetStaffing_EmptySchool_HasPoolAndNoPayroll — это артефакт прогона старых бинарников против новых данных, а не находка. Поэтому весь срез проверялся в отдельном worktree на 21d79cb: AppHost 63/63 (три прогона подряд), клиент 33/33 и сборка.

Перепроверка на 5a00ad7, 2026-08-19

Из путей среза коммит тронул только каталог навыков: пять строк ушли из skills/academic.jsonc, появились languages, social и work. Предметы ссылаются на навыки долями, и обе защиты на месте и зелёные — Subject_UnknownSkill_FailsTheCatalog и EveryVanillaLabel_ExistsInBothLocales. Планировщик, каркас дня и API расписания не менялись; дописанный в прошлый проход тест на коды отказа пина проходит.

Перепроверка на bdb1253, 2026-08-20

Дрейф после 5a00ad7 — обеденные смены в каркасе дня (SchoolDay / CalendarDefs) и сид/дамп в воркере. Планировщик и API расписания не менялись. Обещания фаз 14–17 целы; тестов не дописывал.

Что подтверждено повторно:

  • Фаза 14. Суббота, каникулы и ночь — «вне учебного дня»; 10:20 вторника — третий урок; шестидневка делает субботу рабочей; зима через 1 января. Физкультура в спортзале. Обеденные смены уже стоят в design/schedule.md: младшие после 3-го, старшие после 4-го, обе перемены длинные (LunchBreakTests).
  • Фаза 15. Все шесть тестов планировщика на месте, плюс самопроверка сборки без Arch/ASP.NET и DateTime.Now.
  • Фаза 16. Перестроение только при PeopleChanged + TimetableDirty, не по тику. GET /timetable читает снимок, пин — TaskCompletionSource. Reload_RestoresLockedLessons, PinAndUnpinRefusals_CarryTheDocumentedCodes.
  • Фаза 17. Сетка, закрепление, тексты отказа — timetableGrid.test.ts (pin-rejected / no-teacher / unknown-lesson). Всё через t(...).

Замечено рядом:

  • GET /api/catalog по-прежнему отдаёт каркас без lunchBreaks (только longBreakAfter). Сетке расписания смены не нужны — они для решений. Молча расширять HTTP ревью не стало.
  • OpeningASchoolDuringAMathLesson_... с этой перепроверки падает и в одиночку — см. срез 1; это явка, не раскладка часов.

Calendar+обеды 20, планировщик 9, TimetableApi 6.

Срез 5. Жизнь школы

  • Фазы: 1821
  • Проверен на: 450495f, 2026-08-20 (первичная проверка — 21d79cb; предыдущая перепроверка — 5a00ad7)
  • Пути: src/HSchool.Ai, src/HSchool.Simulation/{PresenceSystem,ActivitySystem,LessonLearningSystem,School}.cs, src/HSchool.Protocol (кадр Presence, пропуск в кадре часов), src/HSchool.Server/Game/PresenceFrame.cs, src/HSchool.Client/src/ui/gameScreen.ts, src/HSchool.Server/mods/core/defs/{actions,behavior,needs}, tests/HSchool.Ai.Tests, tests/HSchool.Simulation.Tests/{PresenceTests,DecisionTests,ActivityTests,NeedDecayTests,LunchTests}.cs, tests/HSchool.AppHost.Tests/GameSocketTests.cs
  • Итог: все 32 теста из списков четырёх фаз на месте; дописаны 2 теста явки после смены таблицы, SetTimetable сбрасывает планы дня, хостовый тест ждёт Clock.Running после снятия паузы. Голод из первого прохода уже закрыт данными. AppHost не гонялся — Aspire держит DLL.

Что подтверждено:

  • Фаза 18. Все 13 требуемых тестов есть: маршрут 201 → санузел через лестницу, совпадение суммы travelMinutes с реально пройденным временем, пустая школа в воскресенье и в каникулы, класс без уроков не приходит, ученик в кабинете к звонку, спортзал впритык к перемене, детерминизм по сиду, сохранение посреди перемены, четыре сценария пропуска пустого времени, школа без учителей, лето с первым сентября, промотанная неделя против прожитой.
  • Фаза 19. Все пять тестов: байтовая раскладка присутствия с обеих сторон, круговой прогон, отсутствие людей и урока в снимке карты, разрешение на пропуск в кадре часов, отброшенный пропуск в учебное время.
  • Фаза 20. Все шесть: декей голода за час, обед на заявленные минуты и величину, нехватка стульев, ночь вне школы, действие с неизвестной нуждой роняет каталог, детерминизм нужд.
  • Фаза 21. Все восемь, включая сквозной ZeroToilet_LeavesClass_ReachesRestroom_AndReturns, DecisionCap_DefersTheOverflow и SameSeedAndActions_MatchAfterAWeek, плюс табличный разбор решений в DecisionPlannerTests без мира.
  • Инварианты. HSchool.Ai не знает Arch, ASP.NET и часов реального времени, HSchool.Simulation не знает сокетов — обе проверки в самих тестах. Протокол v7 сходится в трёх местах.

Измерено (временный тест, удалён после):

Полностью укомплектованная ванильная школа (12 учителей, 225 уроков, 176 учеников), прожитая тиками с вторника по пятницу:

день 1 день 2 день 3 день 4
побывали в столовой 0 144 133 141
завершённых обедов 0 75 132 111
минимальный голод 0.32 0.00 0.00 0.00

Картина дня в остальном осмысленная: в 8:25 двор, вестибюль и лестницы полны, к 8:45 все 144 пришедших в кабинетах и спортзале, на перемене коридор — самый людный узел школы (пик 112), после уроков остаются только те, у кого урок ещё идёт.

  • Столовая пустует весь первый день. Голод стартует с 1.0 и падает на 0.1 в игровой час только на территории школы, так что порог 0.35 достигается лишь к концу первого дня. Критерий готовности фазы 21 обещает «перемены с коридором и столовой» — со второго дня так и есть, но прогон одного дня, как в критерии написано, столовой не покажет. Это не поломка, это то, чего стоит ожидать при проверке руками.
  • Со второго дня голод упирается в ноль и оттуда не выходит. К концу четырёх учебных дней 27 учеников из 176 держат голод ≤0.05, 98 из 176 — ниже порога срочности, средний по школе ровно 0.35. Причина арифметическая: вне школы голод не восстанавливается (в отличие от сна), дома никто не ест, а единственный источник еды — восемь стульев в столовой на 176 человек при обеде в 15 минут. Обедов в день выходит 75–132, то есть до половины школы ежедневно остаётся без еды, и дефицит переносится на завтра. Поскольку рост навыка на уроке зависит от состояния нужд, больше половины школы учится со штрафом постоянно. Это баланс, а не логика: числа лежат в core (decayPerHour, needGain, число стульев на карте) и правятся данными. Молча их менять ревью не стало — см. «Открыто».

Открыто:

  • Чем закрывать голод. Вариантов три, и это решение автора, а не ревью: восстанавливать голод вне школы, как сон (restoredOffCampus на Hunger); поставить в ванильную столовую больше стульев; или снизить decayPerHour голода. Первое честнее всего описывает «поел дома», третье дешевле всего, второе меняет карту. Числа выше дают точку отсчёта для любого из них.

Замечено рядом (за границами среза):

  • GameSocketTests.OpeningASchoolDuringAMathLesson_... подтверждённо нестабилен. За сессию он упал четырежды — и все четыре раза в полном dotnet test по решению, когда параллельно шла сборка; в одиночку он проходит всегда, в том числе шесть прогонов подряд и два под нагрузкой из шести жгущих процессов. Симптом всегда один: последнее ожидание после снятия паузы — «нанятый учитель появился среди людей в кадре присутствия» — не выполняется за 40 кадров (~20 секунд). Механизм не пойман: инструментированный прогон, печатающий замороженное время и уроки этого учителя, три раза подряд прошёл зелёным. Две правдоподобные версии, обе про то, что тест зависит от игрового времени, набежавшего за время его собственных HTTP-вызовов (5 игровых минут на реальную секунду): либо замороженный момент уезжает за последний урок учителя и он по плану дня уже не приходит, либо за 20 секунд ожидания школьный день успевает кончиться. В обоих случаях чинить надо тест, а не присутствие: занятость узла берётся из расписания, а явка — из состояния, и совпадать они не обязаны — фаза 21 прямо обещает, что «опоздание и отсутствие видны».

Перепроверка на 5a00ad7, 2026-08-19

School.cs изменился на десять строк — родной язык прокинут в InstallPeople. Присутствие, ходьба, действия и решения не тронуты, decayPerHour и needGain тоже, поэтому измеренная выше картина с голодом остаётся в силе: вопрос «чем закрывать голод» по-прежнему открыт.

Перепроверка на 450495f, 2026-08-20

Дрейф после 5a00ad7 — обеденные смены, веса в BehaviorDef (фаза 24), Decision.cs, PresenceSystem (очередь после действий). Обещания фаз 18–21 целы; протокол остался v7.

Что подтверждено повторно:

  • Фаза 18. Все 13 тестов списка на месте в WalkingTests / PresenceTests. Пустая школа без учителей в рабочее окно не пропускается. EnsurePlans пересобирал планы только при смене дня или численности — после пина или назначения предмета без найма AppearAt оставался null.
  • Фаза 19. Раскладка присутствия с обеих сторон (Presence_RoundTrips, protocol.test.ts), снимок карты без людей, пропуск в учебное время отбрасывается. Кадр часов несёт разрешение на пропуск; клиент его не считает.
  • Фаза 20. Декей голода, обед, стулья, ночь вне школы, неизвестная нужда, детерминизм. SleepAndHunger_ReturnToMaxOffCampus и AfterASchoolDay_NobodyIsLeftStarving закрывают открытый в первом проходе голод.
  • Фаза 21. Восемь тестов списка плюс обеденные веса в DecisionPlannerTests. HSchool.Ai без Arch/ASP.NET/DateTime.Now, Simulation без сокетов.

Исправлено:

  • School.SetTimetable обнуляет PlanDay. Иначе EnsurePlans считает планы актуальными, пока не сменятся день или численность, и после назначения математики в 10:20 никто не приходит: вчерашний план собран по пустой таблице, AppearAt == null. Найм это маскировал (численность растёт), пин и назначение — нет.
  • OpeningASchoolDuringAMathLesson_... после снятия паузы сразу ждал людей в кадре. Присутствие уходит и на паузе (пустые люди, подписи урока из таблицы), и 40 кадров бюджета уходили в эти кадры до обработки SetRunning. Теперь тест ждёт clock.Running, потом людей. Сам хост с Aspire в этой сессии не гонялся (MSB3021).

Дописано:

  • HireAndAssignMath_DuringLessonThree_PutsSomeoneOnCampus — найм на паузе в 10:20, потолок 64 решения, шаг 20 Гц, как у работника.
  • AssigningMath_AfterDayPlansWereBuilt_PutsSomeoneOnCampus — назначение без смены численности; без сброса PlanDay кампус остаётся пустым.

Закрыто из прошлого прохода:

  • Голод. Уже закрыто 2026-08-19 (restoredOffCampus, стулья 112, смены). Повторно зелёные NeedDecayTests.SleepAndHunger_ReturnToMaxOffCampus и LunchTests.AfterASchoolDay_NobodyIsLeftStarving.
  • OpeningASchoolDuringAMathLesson_.... Это была явка плюс чтение паузных кадров, не флак нагрузки. Симуляция после правки зелёная; хостовый прогон — когда Aspire отпустят DLL.

Ai 29, Presence+Decision+Activity+NeedDecay+Lunch 31, Presence_RoundTrips 1.

Что из журнала закрыто, 2026-08-19

Ревью нашло семь пунктов; шесть закрыты в этот же день, седьмой снят как несуществующий.

  • Панель «Общие события». Решено отказаться. design/near-term.md и design/people.md переписаны под вкладочную оболочку, фазы 1 и 8 помечены сноской, мёртвые ключи eventsTitle/eventsEmpty убраны из i18n. Экран не менялся — он и был правдой.
  • Голод упирался в ноль. Закрыто тремя правками данных и одной правкой поведения: Hunger восстанавливается вне школы (дома едят), decayPerHour 0.1 → 0.2, стульев в столовой 8 → 112, и обед стал сменой по параллелям. Замер после: приходят с 0.97, к своей смене 0.43, уходят с 0.27, к утру снова 1.0; за неделю голодающих нет ни одного (было 27 из 176 на нуле и 98 ниже порога).
  • Раздельные обеды. Новая механика: DayFrameDef.lunchBreaks — какая параллель ест после какого урока, ActionDef.lunch — действие-смена. В свою смену едят и не проголодавшиеся (DecisionPlanner.LunchWeight), вне смены столовая не предлагается вовсе. В core младшие (1–5) едят после третьего урока, старшие (6–11) после четвёртого; обе перемены стали длинными. Замер: 11:10–11:30 в столовой только младшие, 12:15–12:30 только старшие, поели все 176.
  • SchoolRegistry. Удалён вместе со своими тестами. TryNormalizeName переехал в SchoolNames, SchoolCreationError — в свой файл; правила имени, диапазона дат и генератора названий закреплены в SchoolNamesTests.
  • Нестабильный OpeningASchoolDuringAMathLesson_.... Последнее ожидание требовало, чтобы нанятый в середине дня учитель появился на территории, — а это не гарантировано: явка идёт из плана дня, и замороженный момент уезжает тем дальше, чем дольше сам тест ходил по HTTP. Теперь тест проверяет то, ради чего написан: что люди вообще едут в кадре присутствия.
  • Ветка if/else вокруг предела в AssignAndUnassign_HonourTheCap_... убрана: отказ по пределу проверяется отдельным тестом, а этот про назначение, снятие и перезапуск.
  • Предел фонда «почти не ограничивает игрока» — снято при перепроверке на 5a00ad7: это была ошибка замера, а не код. Подробности в разделе среза 3.

Тесты после всех правок: 315 .NET и 33 клиентских, сборка клиента зелёная.