From 5eabc90d531b786e2311297b902f60f6d341a7cb Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 19 Aug 2026 23:17:16 +0300 Subject: [PATCH] Enhance school day structure and decision-making for lunch breaks - Updated `ai.md` to clarify the mechanics of hunger restoration and the importance of lunch breaks in the school schedule. - Revised `schedule.md` to detail the new lunch break structure, allowing for separate sittings for different grade levels. - Enhanced `Decision.cs` and `DecisionPlanner.cs` to incorporate logic for lunch breaks, ensuring that students only leave lessons during their designated lunch windows. - Updated `DayFrameDef` and related classes to support multiple lunch breaks and validate their configurations. - Adjusted tests to validate the new decision-making logic regarding lunch breaks and hunger management, ensuring robust functionality. - Improved localization strings to reflect changes in the school day structure and lunch functionalities. --- docs/design/ai.md | 14 +- docs/design/near-term.md | 12 +- docs/design/people.md | 12 +- docs/design/schedule.md | 3 +- docs/phases/01-manager-shell.md | 4 + docs/phases/08-people-browser.md | 5 +- docs/phases/reviewed.md | 116 ++++++++++- src/HSchool.Ai/Decision.cs | 39 +++- src/HSchool.Client/src/i18n/strings.ts | 4 - src/HSchool.Content/CalendarDefs.cs | 20 ++ src/HSchool.Content/Defs.cs | 7 + src/HSchool.Content/PeopleDefValidator.cs | 25 +++ src/HSchool.Content/SchoolDay.cs | 77 ++++++- src/HSchool.Server/Game/GameLoopService.cs | 2 +- .../mods/core/defs/actions/living.jsonc | 2 + .../mods/core/defs/dayframe/standard.jsonc | 7 + .../mods/core/defs/needs/needs.jsonc | 2 +- .../mods/core/maps/default.jsonc | 2 +- src/HSchool.Simulation/PresenceSystem.cs | 6 +- src/HSchool.Simulation/SchoolCreationError.cs | 18 ++ src/HSchool.Simulation/SchoolNames.cs | 28 +++ src/HSchool.Simulation/SchoolRegistry.cs | 147 ------------- .../HSchool.Ai.Tests/DecisionPlannerTests.cs | 69 ++++++- .../HSchool.AppHost.Tests/GameSocketTests.cs | 13 +- .../HSchool.AppHost.Tests/StaffingApiTests.cs | 23 +-- .../HSchool.Content.Tests/LunchBreakTests.cs | 99 +++++++++ tests/HSchool.Content.Tests/PeopleDefTests.cs | 2 +- .../HSchool.Content.Tests/VanillaCoreTests.cs | 6 +- tests/HSchool.People.Tests/SkillGrantTests.cs | 31 +++ tests/HSchool.Simulation.Tests/LunchTests.cs | 160 +++++++++++++++ .../NeedDecayTests.cs | 5 +- .../SchoolNamesTests.cs | 81 ++++++++ .../SchoolRegistryTests.cs | 194 ------------------ 33 files changed, 831 insertions(+), 404 deletions(-) create mode 100644 src/HSchool.Simulation/SchoolCreationError.cs create mode 100644 src/HSchool.Simulation/SchoolNames.cs delete mode 100644 src/HSchool.Simulation/SchoolRegistry.cs create mode 100644 tests/HSchool.Content.Tests/LunchBreakTests.cs create mode 100644 tests/HSchool.Simulation.Tests/LunchTests.cs create mode 100644 tests/HSchool.Simulation.Tests/SchoolNamesTests.cs delete mode 100644 tests/HSchool.Simulation.Tests/SchoolRegistryTests.cs diff --git a/docs/design/ai.md b/docs/design/ai.md index 31fb111..42f0a50 100644 --- a/docs/design/ai.md +++ b/docs/design/ai.md @@ -177,7 +177,16 @@ Декей включается. Голод — столовая, туалет — санузел, общение — перемена рядом с одноклассниками. Сон **за кадром**: человек вне школы отдыхает, и утром приходит выспавшимся. -Ночной школы у нас нет, а изображать её ради одной полоски — лишняя работа. +Ночной школы у нас нет, а изображать её ради одной полоски — лишняя работа. **Голод восстанавливается +там же**: дома завтракают и ужинают, иначе дефицит копится из дня в день и вся школа через неделю +сидит на нуле — так и было, пока это не измерили. + +**Обед — расписание, а не порыв.** Действие с `lunch: true` предлагается только в свою смену +(`DayFrameDef.lunchBreaks`), зато в неё идут и те, кто ещё не проголодался до порога. Иначе +получается одно из двух: либо класс уходит с урока в столовую, когда приспичило, либо — если +ждать порога — вся параллель проскакивает свою смену и голодает до вечера. Смены разводят +параллели: в `core` младшие едят после третьего урока, старшие после четвёртого, и каждая такая +перемена становится длинной. Прогресс-бары нужд в карточке уже нарисованы. В этом срезе они впервые начнут шевелиться. @@ -302,7 +311,8 @@ | Планирование | Вырожденный GOAP: «дойти → сделать», два предусловия | | Переключение | Начатое доводится до конца, если новая цель не сильно тяжелее | | Частота решений | По событиям: звонок, конец действия, приход, порог нужды | -| Нужды | Декей включается; сон вне школы | +| Нужды | Декей включается; сон и голод восстанавливаются вне школы | +| Обед | Смены по параллелям в каркасе дня; в свою смену едят и не проголодавшиеся | | Последствия | Навык на уроке, видимые опоздание и прогул. Оценок нет | | Игрок | Наблюдает; приказов нет | | Поток присутствия | Своё сообщение, ~2 Гц, по всей школе, фильтр на клиенте | diff --git a/docs/design/near-term.md b/docs/design/near-term.md index 3ac36c0..af38cde 100644 --- a/docs/design/near-term.md +++ b/docs/design/near-term.md @@ -11,9 +11,12 @@ QSP: не «полотно текста + список действий», а п Ориентир по экрану школы (после часов и паузы, которые уже есть): - **Карта** — дерево локаций (tree view). -- **Общие события** — то, что происходит в школе целиком, не привязано к выбранному узлу. - **Выбранная локация** — что происходит именно там. +Отдельной панели общих событий нет: пока ленты событий не существует, постоянно пустая треть +экрана только мешала, и место отдано вкладкам «Карта» и «Люди». Появится лента — вернётся и +панель; решение принято в фазе 8, здесь оно записано. + Картинок локаций и портретов в этом горизонте нет; позже можно добавить, не ломая панели. Анимация пока только переходы панелей/списков и живые цифры (часы, счётчики). Графики — когда появятся числа, которые ими стоит показывать, не раньше. @@ -30,10 +33,9 @@ QSP: не «полотно текста + список действий», а п Это и реализуем. Нового геймплея сверх этого не закладываем. -**Экран школы.** Часы, пауза, скорость. Три панели: +**Экран школы.** Часы, пауза, скорость. Две панели: -- дерево карты; -- общие события — **пусто** (заголовок и пустое состояние); +- дерево карты (позже к нему вкладкой встанут «Люди»); - выбранная локация (комната, этаж, корпус **или двор**): имя, предметы, персонажи (пусто), действия на месте (пусто), должности. Секции те же; где данных нет — пустое состояние. @@ -49,7 +51,7 @@ QSP: не «полотно текста + список действий», а п | Тема | Решение | | --- | --- | -| Вид внутри школы | Менеджер: дерево + общие события + содержимое локации | +| Вид внутри школы | Менеджер: дерево (вкладкой с «Людьми») + содержимое локации | | Рендер | DOM, не WebGL/Pixi | | Картинки | Нет в этом горизонте | | Анимация | Переходы и живые цифры | diff --git a/docs/design/people.md b/docs/design/people.md index ef1e818..3f3a55f 100644 --- a/docs/design/people.md +++ b/docs/design/people.md @@ -290,13 +290,15 @@ Protocol ← Server → Simulation → People → Content ## Просмотр -Не отдельный экран. Список людей — **панель в оболочке менеджера**, рядом с картой и событиями: -уходя в отдельный экран, игрок теряет из виду часы, локацию и события, а смысл этой оболочки +Не отдельный экран. Список людей — **панель в оболочке менеджера**, рядом с картой: +уходя в отдельный экран, игрок теряет из виду часы и локацию, а смысл этой оболочки ровно в том, чтобы всё было на одном экране. Ориентир — прогрессивный QSP: текст, списки и ссылки, без сцены. -Панель «Люди» занимает нижний ряд во всю ширину — фильтрам, колонкам и пейджеру нужна ширина, -которой нет в колонке шириной в треть экрана. +Панель «Люди» — вторая вкладка той же панели, что и карта: фильтрам, колонкам и пейджеру нужна +ширина, а держать список третьим рядом под картой значило бы отдать ему высоту, которой на экране +школы нет. Вкладка переключается рядом с деревом, выбранная строка открывает карточку справа — +там же, где содержимое локации. - **Фильтры**: роль (ученики / работники / родители), параллель и литера, должность, пол, возрастной диапазон. @@ -388,7 +390,7 @@ Protocol ← Server → Simulation → People → Content | Имена и язык UI | Независимы | | Библиотека | `HSchool.People`: зависит от Content, не знает Arch и ASP.NET | | Детерминизм | Сид в сейве, свой сид на семью; родной язык — часть входа | -| Просмотр | Панель в оболочке менеджера, нижний ряд; не отдельный экран | +| Просмотр | Панель в оболочке менеджера, вкладкой рядом с картой; не отдельный экран | | Список | Фильтры, сортировки, пейджинг; в строке — только лёгкие поля | | Транспорт списка | HTTP по опубликованному снимку ростера | | Транспорт карточки | Запрос в воркер, потому что нужды живые | diff --git a/docs/design/schedule.md b/docs/design/schedule.md index f74f03b..a8f199e 100644 --- a/docs/design/schedule.md +++ b/docs/design/schedule.md @@ -131,7 +131,8 @@ | --- | --- | | Кто строит | Сервер, детерминированно, из плана и назначений | | Правки игрока | Можно; исправленное закрепляется и переживает перестроение | -| Каркас дня | `core`: 8:30, до 7 уроков, 45 минут, перемены 10 и одна 20; одна смена | +| Каркас дня | `core`: 8:30, до 7 уроков, 45 минут, перемены 10; длинные — те, в которые обедают | +| Обеденные смены | Список в каркасе дня: какая параллель ест после какого урока | | Длина недели | В настройках, пять дней по умолчанию | | Каникулы | Дефами, диапазонами дат; в каникулы расписания нет | | Учебный план | Часы из `SubjectDef`, расписание их только расставляет | diff --git a/docs/phases/01-manager-shell.md b/docs/phases/01-manager-shell.md index 63f6ddc..382a624 100644 --- a/docs/phases/01-manager-shell.md +++ b/docs/phases/01-manager-shell.md @@ -17,6 +17,10 @@ - [x] Клик по дереву переключает панель локации на клиенте (пока узлы-заглушки или пустое дерево) - [x] Переключение языка обновляет подписи панелей и пустых состояний (`t(...)`) +> **Отменено в фазе 8.** Панель общих событий убрана с экрана: пока ленты событий нет, постоянно +> пустая треть экрана мешала, и место отдано вкладкам «Карта» и «Люди». Решение записано в +> [`../design/near-term.md`](../design/near-term.md); появится лента — вернётся и панель. + ## Критерий готовности - Школу можно открыть, часы и пауза работают как раньше diff --git a/docs/phases/08-people-browser.md b/docs/phases/08-people-browser.md index feba2cf..f4bfa5b 100644 --- a/docs/phases/08-people-browser.md +++ b/docs/phases/08-people-browser.md @@ -16,8 +16,9 @@ воркер — инвариант «только поток школы касается школы» остаётся целым - [x] `GET /api/schools/{id}/people/{personId}`: карточка через мейлбокс воркера с `TaskCompletionSource`, потому что нужды живые -- [x] Панель «Люди» нижним рядом оболочки менеджера, во всю ширину: фильтры, сортируемые - колонки, пейджер +- [x] Панель «Люди» в оболочке менеджера: фильтры, сортируемые колонки, пейджер. Сделана + **вкладкой рядом с картой**, а не нижним рядом — заодно с экрана ушла пустая панель + событий из фазы 1; см. [`../design/people.md`](../design/people.md) - [x] Строка списка — только лёгкие поля: ФИО, роль, класс или должность, возраст, пол - [x] Карточка: тело, навыки, черты, нужды, семья со ссылками на родных — по ссылке открывается карточка родственника diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md index 6959573..3dade4b 100644 --- a/docs/phases/reviewed.md +++ b/docs/phases/reviewed.md @@ -9,7 +9,7 @@ ## Срез 1. Оболочка и карта - **Фазы:** 0–4 -- **Проверен на:** `b135a9c`, 2026-08-19 +- **Проверен на:** `5a00ad7`, 2026-08-19 (первичная проверка — `b135a9c`) - **Пути:** `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`, @@ -95,10 +95,29 @@ компилировалось. Выводам по срезу 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`. ## Срез 2. Люди - **Фазы:** 5–9 -- **Проверен на:** `5cd5a6d`, 2026-08-19 +- **Проверен на:** `5a00ad7`, 2026-08-19 (первичная проверка — `5cd5a6d`) - **Пути:** `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`, @@ -176,10 +195,24 @@ идущего человека). По правилу журнала он больше не «проверен на `b135a9c`» — правка маленькая и относится к срезу 5, но следующий проход это увидит. +### Перепроверка на `5a00ad7`, 2026-08-19 + +`5a00ad7` переписал половину генератора: `PersonSampler` (+266), `FamilyFactory` (+52), +`YearlyIntake` (+61), новый `NativeLanguages`, новые навыки в `core` (языки, социальные, +рабочие). Проверено по изменению: + +- Решение записано заранее — `design/people.md` описывает родные языки и порядок выдачи навыков + ещё с `a441ed9`, код догнал документ. Дрейфа нет, это тот редкий случай, когда дизайн шёл первым. +- Восемь новых тестов в `SkillGrantTests` закрывают выдачу навыков по параллелям, отсутствие + рабочих навыков у учеников и набор без переброса уже выданного. Старые тесты детерминизма, + согласованности семьи и годового набора зелёные — генератор переписан, а обещания среза целы. +- Исправления среза 2 (карточка семьи, сортировка по фамилии, состав после набора и перезапуска) + проходят на новом коде. + ## Срез 3. Штат - **Фазы:** 10–13 -- **Проверен на:** `21d79cb`, 2026-08-19 +- **Проверен на:** `5a00ad7`, 2026-08-19 (первичная проверка — `21d79cb`) - **Пути:** `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}`, @@ -249,10 +282,38 @@ - В `AssignAndUnassign_HonourTheCap_AndReloadKeepsAssignments` осталась ветка `if/else` вокруг ожидаемого отказа. Теперь, когда отказ проверяется отдельным тестом, эта ветка просто не может упасть — её стоит однажды упростить, но трогать работающий тест ради этого ревью не стало. +### Перепроверка на `5a00ad7`, 2026-08-19 — **вывод про бюджет исправлен** + +`5a00ad7` поднял `poolSize` с 12 до 32. Это не косметика: предел фонда начал работать, и запись +выше про «предел почти не ограничивает игрока» больше не описывает игру. Перемерено на шести +сидах: + +| что делаем | сколько людей | фонд из 100 000 | непокрыто | +| --- | --- | --- | --- | +| нанимать подряд, не назначая предметов | 20–21 из 32 | 95 000–99 400, дальше отказ | всё | +| по учителю на предмет | 12 | 86 800–94 700 | `PrimarySchool` | +| закрыть и его | 14 | 87 100–95 100 | ничего | + +То есть полностью укомплектованная школа теперь **влезает, но впритык** — запас 5–13%, и +`PrimarySchool` (1–4 классы, 80 часов в неделю против потолка в 36 на человека) требует трёх +учителей, о чём игрок узнаёт только из списка непокрытых. + +Прежняя цифра «около 70 000 из 100 000» получена на вырожденной раскладке — когда каждому из +двенадцати назначены все двенадцать предметов. Так часы предмета делятся на всех и почти каждый +садится на пол одной ставки; это самая **дешёвая** конфигурация, а не «полностью +укомплектованная школа». Ошибка была в замере, а не в коде. + +Ставки соискателей при этом не изменились (в среднем 57.9–59.3 против прежних 58.5–62.5), хотя +навыков у человека стало больше — цена часа считается от среднего по навыкам, и среднее устояло. + +`Hire_IdlePool_FitsUnderTheCap` в том же коммите переписан так, чтобы принимать отказ по пределу +как штатный исход. Это правильно: с пулом в 32 человека нанять всех и остаться в пределах суммы +уже нельзя. + ## Срез 4. Расписание - **Фазы:** 14–17 -- **Проверен на:** `21d79cb`, 2026-08-19 +- **Проверен на:** `5a00ad7`, 2026-08-19 (первичная проверка — `21d79cb`) - **Пути:** `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` (пин, перестроение), @@ -319,10 +380,18 @@ прогона старых бинарников против новых данных, а не находка. Поэтому весь срез проверялся в отдельном worktree на `21d79cb`: AppHost 63/63 (три прогона подряд), клиент 33/33 и сборка. +### Перепроверка на `5a00ad7`, 2026-08-19 + +Из путей среза коммит тронул только каталог навыков: пять строк ушли из `skills/academic.jsonc`, +появились `languages`, `social` и `work`. Предметы ссылаются на навыки долями, и обе защиты на +месте и зелёные — `Subject_UnknownSkill_FailsTheCatalog` и `EveryVanillaLabel_ExistsInBothLocales`. +Планировщик, каркас дня и API расписания не менялись; дописанный в прошлый проход тест на коды +отказа пина проходит. + ## Срез 5. Жизнь школы - **Фазы:** 18–21 -- **Проверен на:** `21d79cb`, 2026-08-19. Фаза 21 закрыта за час до проверки, `core` правился +- **Проверен на:** `5a00ad7`, 2026-08-19 (первичная проверка — `21d79cb`). Фаза 21 закрыта за час до проверки, `core` правился параллельно — проверка велась в отдельном worktree на коммите, не на рабочем дереве - **Пути:** `src/HSchool.Ai`, `src/HSchool.Simulation/{PresenceSystem,ActivitySystem,LessonLearningSystem,School}.cs`, `src/HSchool.Protocol` (кадр `Presence`, пропуск в кадре часов), @@ -402,3 +471,40 @@ случаях чинить надо тест, а не присутствие: занятость узла берётся из расписания, а явка — из состояния, и совпадать они не обязаны — фаза 21 прямо обещает, что «опоздание и отсутствие видны». + +### Перепроверка на `5a00ad7`, 2026-08-19 + +`School.cs` изменился на десять строк — родной язык прокинут в `InstallPeople`. Присутствие, +ходьба, действия и решения не тронуты, `decayPerHour` и `needGain` тоже, поэтому измеренная выше +картина с голодом остаётся в силе: вопрос «чем закрывать голод» по-прежнему открыт. + +## Что из журнала закрыто, 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 клиентских, сборка клиента зелёная. diff --git a/src/HSchool.Ai/Decision.cs b/src/HSchool.Ai/Decision.cs index 7e2f855..9901309 100644 --- a/src/HSchool.Ai/Decision.cs +++ b/src/HSchool.Ai/Decision.cs @@ -38,7 +38,8 @@ public readonly record struct ActorState( bool BoundToLesson, string? DutyRoom, IReadOnlyDictionary Needs, - Intent Intent); + Intent Intent, + bool LunchWindowOpen = false); /// /// Picks a goal by weight and plans walk-then-do. No world, no clock — a table of inputs to an @@ -56,6 +57,14 @@ public static class DecisionPlanner /// Need at zero. Beats a lesson so a desperate toilet trip leaves class. public const float NeedWeightAtZero = 20f; + /// + /// A sitting during this parallel's own lunch break. Above so + /// lunch beats walking on to the next room, below so it never + /// pulls anybody out of a lesson. Lunch is a timetable, not an urge: waiting for hunger to + /// cross the threshold made the juniors miss their sitting and starve all afternoon. + /// + public const float LunchWeight = 6f; + public static Decision Decide( DefCatalog catalog, MapLayout map, @@ -193,19 +202,33 @@ public static class DecisionPlanner NeedDef need, float threshold) { - if (need.Abstract || !state.Needs.TryGetValue(need.DefName, out var value) || value >= threshold) + if (need.Abstract || !state.Needs.TryGetValue(need.DefName, out var value)) { return Intent.None; } + var urgent = value < threshold; + + // ActionForNeed already refuses a sitting outside its window, so an action that comes back + // Lunch means this person's own break is open right now. var action = ActionForNeed(catalog, state, need.DefName); - if (action is null || RoomFor(catalog, map, walks, state, occupied, action) is null) + if (action is null || (!urgent && !action.Lunch)) + { + return Intent.None; + } + + if (RoomFor(catalog, map, walks, state, occupied, action) is null) { return Intent.None; } var span = Math.Max(threshold, 0.0001f); - var weight = (threshold - value) / span * NeedWeightAtZero; + var weight = urgent ? (threshold - value) / span * NeedWeightAtZero : 0f; + if (action.Lunch) + { + weight = Math.Max(weight, LunchWeight); + } + return new Intent(GoalKind.Need, need.DefName, weight, action.DefName); } @@ -312,6 +335,14 @@ public static class DecisionPlanner continue; } + // A sitting is only on offer during this person's own lunch break. Outside it hunger + // keeps building instead of pulling somebody out of a lesson — that is what keeps the + // canteen from filling with the whole school at once. + if (action.Lunch && !state.LunchWindowOpen) + { + continue; + } + if (best is null || action.NeedGain > best.NeedGain) { best = action; diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 0e893c0..6182788 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -76,8 +76,6 @@ const ru = { resume: 'Продолжить', mapTitle: 'Карта', - eventsTitle: 'События', - eventsEmpty: 'Пока ничего не происходит.', locationName: 'Локация', personTitle: 'Человек', locationItems: 'Предметы', @@ -288,8 +286,6 @@ const en: Messages = { resume: 'Resume', mapTitle: 'Map', - eventsTitle: 'Events', - eventsEmpty: 'Nothing is happening yet.', locationName: 'Location', personTitle: 'Person', locationItems: 'Items', diff --git a/src/HSchool.Content/CalendarDefs.cs b/src/HSchool.Content/CalendarDefs.cs index bdd290d..06f097b 100644 --- a/src/HSchool.Content/CalendarDefs.cs +++ b/src/HSchool.Content/CalendarDefs.cs @@ -16,6 +16,26 @@ public sealed class DayFrameDef : Def public int LongBreakAfter { get; init; } public int LongBreakMinutes { get; init; } + + /// + /// Which break each parallel eats at. Empty means one sitting for the whole school, at + /// . Every listed break is a long one, so a school that feeds its + /// juniors and seniors separately gets two wide breaks rather than one crowded canteen. + /// + public IReadOnlyList LunchBreaks { get; init; } = []; +} + +/// One lunch sitting: the break it happens at and the parallels it feeds. +public sealed class LunchBreakDef +{ + /// The sitting fills the break after this 1-based lesson. + public int AfterLesson { get; init; } + + public int GradeMin { get; init; } + + public int GradeMax { get; init; } + + public bool Covers(int year) => year >= GradeMin && year <= GradeMax; } public sealed class MonthDay diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index 42dd83d..d2905d7 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -54,6 +54,13 @@ public sealed class ActionDef : Def /// Leisure weight. Zero means phase 21 will not pick this for fun — only for a need. public float Weight { get; init; } + + /// + /// A sitting rather than something done whenever the need bites: only offered during the + /// eater's own lunch break (). Without it a hungry class + /// would walk out of a lesson to the canteen, and the whole school would arrive at once. + /// + public bool Lunch { get; init; } } public sealed class ThingDef : Def diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 5999ff7..e5f2844 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -391,6 +391,31 @@ internal static class PeopleDefValidator { throw new ContentLoadException($"DayFrameDef '{frame.DefName}' longBreakAfter must be 0 or a lesson before the last."); } + + var fed = new HashSet(); + foreach (var sitting in frame.LunchBreaks) + { + if (sitting.AfterLesson < 1 || sitting.AfterLesson >= frame.LessonCount) + { + throw new ContentLoadException( + $"DayFrameDef '{frame.DefName}' has a lunch break after lesson {sitting.AfterLesson}; it must be a lesson before the last."); + } + + if (sitting.GradeMin < 1 || sitting.GradeMax < sitting.GradeMin) + { + throw new ContentLoadException( + $"DayFrameDef '{frame.DefName}' has a lunch break with grades {sitting.GradeMin}–{sitting.GradeMax}."); + } + + for (var year = sitting.GradeMin; year <= sitting.GradeMax; year++) + { + if (!fed.Add(year)) + { + throw new ContentLoadException( + $"DayFrameDef '{frame.DefName}' feeds grade {year} at two lunch breaks."); + } + } + } } private static void ValidateAction(ActionDef action, DefCatalog catalog) diff --git a/src/HSchool.Content/SchoolDay.cs b/src/HSchool.Content/SchoolDay.cs index 9b4ee6b..140a8ea 100644 --- a/src/HSchool.Content/SchoolDay.cs +++ b/src/HSchool.Content/SchoolDay.cs @@ -65,7 +65,7 @@ public static class SchoolDay break; } - var gap = TimeSpan.FromMinutes(i == frame.LongBreakAfter ? frame.LongBreakMinutes : frame.BreakMinutes); + var gap = TimeSpan.FromMinutes(IsLongBreakAfter(frame, i) ? frame.LongBreakMinutes : frame.BreakMinutes); var breakEnd = cursor + gap; if (clock >= cursor && clock < breakEnd) { @@ -152,7 +152,7 @@ public static class SchoolDay for (var i = 1; i < period; i++) { cursor += lesson; - cursor += TimeSpan.FromMinutes(i == frame.LongBreakAfter ? frame.LongBreakMinutes : frame.BreakMinutes); + cursor += TimeSpan.FromMinutes(IsLongBreakAfter(frame, i) ? frame.LongBreakMinutes : frame.BreakMinutes); } return TimeOnly.FromTimeSpan(cursor); @@ -225,4 +225,77 @@ public static class SchoolDay return false; } + + /// + /// A break is long when it is the school-wide one or when somebody eats in it. Sittings are + /// what make a second break wide: fifteen minutes of lunch do not fit into ten. + /// + public static bool IsLongBreakAfter(DayFrameDef frame, int lesson) + { + if (lesson == frame.LongBreakAfter) + { + return true; + } + + foreach (var sitting in frame.LunchBreaks) + { + if (sitting.AfterLesson == lesson) + { + return true; + } + } + + return false; + } + + /// + /// The break this parallel eats in. Falls back to the school-wide long break when no sitting + /// lists the year — a staff member ( null) eats at any of them. + /// + public static int LunchBreakAfter(DayFrameDef frame, int? year) + { + if (frame.LunchBreaks.Count == 0 || year is null) + { + return frame.LongBreakAfter; + } + + foreach (var sitting in frame.LunchBreaks) + { + if (sitting.Covers(year.Value)) + { + return sitting.AfterLesson; + } + } + + return frame.LongBreakAfter; + } + + /// True when is the sitting that feeds this parallel. + public static bool IsLunchWindow(DayFrameDef frame, DaySlot slot, int? year) + { + if (slot.Kind != DaySlotKind.Break) + { + return false; + } + + if (frame.LunchBreaks.Count == 0) + { + return slot.Index == frame.LongBreakAfter; + } + + if (year is null) + { + foreach (var sitting in frame.LunchBreaks) + { + if (sitting.AfterLesson == slot.Index) + { + return true; + } + } + + return false; + } + + return slot.Index == LunchBreakAfter(frame, year); + } } diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index 9ae24ab..a5e41bb 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -281,7 +281,7 @@ internal sealed class GameLoopService( return; } - if (!SchoolRegistry.TryNormalizeName(command.Name, out var normalized)) + if (!SchoolNames.TryNormalize(command.Name, out var normalized)) { command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidName)); return; diff --git a/src/HSchool.Server/mods/core/defs/actions/living.jsonc b/src/HSchool.Server/mods/core/defs/actions/living.jsonc index f69ab35..a7c67b3 100644 --- a/src/HSchool.Server/mods/core/defs/actions/living.jsonc +++ b/src/HSchool.Server/mods/core/defs/actions/living.jsonc @@ -8,6 +8,8 @@ "needGain": 0.5, "roles": ["student", "staff"], "weight": 0, + // Only during this parallel's sitting, see the day frame. + "lunch": true, }, { "defName": "UseToilet", diff --git a/src/HSchool.Server/mods/core/defs/dayframe/standard.jsonc b/src/HSchool.Server/mods/core/defs/dayframe/standard.jsonc index f395514..4b4385b 100644 --- a/src/HSchool.Server/mods/core/defs/dayframe/standard.jsonc +++ b/src/HSchool.Server/mods/core/defs/dayframe/standard.jsonc @@ -6,4 +6,11 @@ "breakMinutes": 10, "longBreakAfter": 3, "longBreakMinutes": 20, + // Two sittings so the canteen is not the whole school at once: juniors eat after the + // third lesson, seniors after the fourth. Both breaks are long because somebody eats in + // them. A pack that wants one sitting just drops this list. + "lunchBreaks": [ + { "afterLesson": 3, "gradeMin": 1, "gradeMax": 5 }, + { "afterLesson": 4, "gradeMin": 6, "gradeMax": 11 }, + ], } diff --git a/src/HSchool.Server/mods/core/defs/needs/needs.jsonc b/src/HSchool.Server/mods/core/defs/needs/needs.jsonc index 05f80be..4a35cd6 100644 --- a/src/HSchool.Server/mods/core/defs/needs/needs.jsonc +++ b/src/HSchool.Server/mods/core/defs/needs/needs.jsonc @@ -1,6 +1,6 @@ [ { "defName": "Sleep", "initial": 1, "decayPerHour": 0.05, "min": 0, "max": 1, "restoredOffCampus": true }, - { "defName": "Hunger", "initial": 1, "decayPerHour": 0.1, "min": 0, "max": 1 }, + { "defName": "Hunger", "initial": 1, "decayPerHour": 0.2, "min": 0, "max": 1, "restoredOffCampus": true }, { "defName": "Toilet", "initial": 1, "decayPerHour": 0.15, "min": 0, "max": 1 }, { "defName": "Social", "initial": 1, "decayPerHour": 0.08, "min": 0, "max": 1 }, ] diff --git a/src/HSchool.Server/mods/core/maps/default.jsonc b/src/HSchool.Server/mods/core/maps/default.jsonc index 2926cc0..4d98cbe 100644 --- a/src/HSchool.Server/mods/core/maps/default.jsonc +++ b/src/HSchool.Server/mods/core/maps/default.jsonc @@ -85,7 +85,7 @@ "floor": "floor-1", "slots": [ { "key": "counter", "thing": "DiningTable" }, - { "key": "seats", "thing": "Chair", "count": 8 }, + { "key": "seats", "thing": "Chair", "count": 112 }, ], }, { "id": "restroom-1", "def": "Restroom", "building": "main", "floor": "floor-1", "label": "1" }, diff --git a/src/HSchool.Simulation/PresenceSystem.cs b/src/HSchool.Simulation/PresenceSystem.cs index f732db4..c9108a1 100644 --- a/src/HSchool.Simulation/PresenceSystem.cs +++ b/src/HSchool.Simulation/PresenceSystem.cs @@ -375,6 +375,9 @@ internal static class PresenceSystem var bound = Duty.IsOtherStaff(person) ? slot.Kind != DaySlotKind.Outside : slot.Kind == DaySlotKind.Lesson && lessons.Any(lesson => lesson.Period == slot.Index); + var frame = school.Catalog!.DayFrame; + var lunchOpen = frame is not null + && SchoolDay.IsLunchWindow(frame, slot, ClassOf(school, person)?.Year); var state = new ActorState( presence.NodeId, presence.DestinationId, @@ -386,7 +389,8 @@ internal static class PresenceSystem bound, duty, needs.Values, - intent); + intent, + lunchOpen); var decision = DecisionPlanner.Decide( school.Catalog!, school.Map!, diff --git a/src/HSchool.Simulation/SchoolCreationError.cs b/src/HSchool.Simulation/SchoolCreationError.cs new file mode 100644 index 0000000..c743d57 --- /dev/null +++ b/src/HSchool.Simulation/SchoolCreationError.cs @@ -0,0 +1,18 @@ +namespace HSchool.Simulation; + +/// +/// Why a school could not be created. Lives here rather than in the server because the checks +/// that produce it — the name rules, the clock range — belong to the simulation. +/// +public enum SchoolCreationError +{ + None = 0, + LimitReached, + InvalidName, + InvalidStartDate, + InvalidMap, + UnknownMod, + InvalidCatalog, + UnknownNameSet, + UnknownNativeLanguage, +} diff --git a/src/HSchool.Simulation/SchoolNames.cs b/src/HSchool.Simulation/SchoolNames.cs new file mode 100644 index 0000000..966d7df --- /dev/null +++ b/src/HSchool.Simulation/SchoolNames.cs @@ -0,0 +1,28 @@ +namespace HSchool.Simulation; + +/// +/// The rules a school name has to pass. A name arrives from a browser, so it is trimmed and +/// stripped before anything stores it. +/// +public static class SchoolNames +{ + /// Trims, strips control characters and enforces the length limit. + public static bool TryNormalize(string? name, out string normalized) + { + normalized = string.Empty; + + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + var cleaned = new string(name.Where(character => !char.IsControl(character)).ToArray()).Trim(); + if (cleaned.Length == 0 || cleaned.Length > School.MaxNameLength) + { + return false; + } + + normalized = cleaned; + return true; + } +} diff --git a/src/HSchool.Simulation/SchoolRegistry.cs b/src/HSchool.Simulation/SchoolRegistry.cs deleted file mode 100644 index a5ad734..0000000 --- a/src/HSchool.Simulation/SchoolRegistry.cs +++ /dev/null @@ -1,147 +0,0 @@ -namespace HSchool.Simulation; - -/// Why a school could not be created. -public enum SchoolCreationError -{ - None = 0, - LimitReached, - InvalidName, - InvalidStartDate, - InvalidMap, - UnknownMod, - InvalidCatalog, - UnknownNameSet, - UnknownNativeLanguage, -} - -/// Outcome of : either the school or the reason there is none. -public readonly record struct SchoolCreationResult(School? School, SchoolCreationError Error) -{ - public bool Succeeded => Error == SchoolCreationError.None && School is not null; - - public static SchoolCreationResult Failed(SchoolCreationError error) => new(null, error); -} - -/// -/// In-memory set of schools plus the cap from configuration. Not thread-safe — unit tests and -/// name/limit checks use it; the live server gives each school its own worker instead. -/// -public sealed class SchoolRegistry : IDisposable -{ - private readonly SimulationOptions _options; - private readonly List _schools = []; - - private int _nextId = 1; - private bool _disposed; - - public SchoolRegistry(SimulationOptions options) - { - _options = options; - NameGenerator = new SchoolNameGenerator(); - } - - public SchoolNameGenerator NameGenerator { get; } - - public int MaxSchools => _options.MaxSchools; - - public int Count => _schools.Count; - - public bool IsFull => _schools.Count >= _options.MaxSchools; - - /// Schools in creation order — the order the menu lists them in. - public IReadOnlyList Schools => _schools; - - public School? Find(int id) => _schools.Find(school => school.Id == id); - - public SchoolCreationResult Create(string name, DateTime startDate) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (IsFull) - { - return SchoolCreationResult.Failed(SchoolCreationError.LimitReached); - } - - if (!TryNormalizeName(name, out var normalized)) - { - return SchoolCreationResult.Failed(SchoolCreationError.InvalidName); - } - - if (!GameClock.IsValidStartDate(startDate)) - { - return SchoolCreationResult.Failed(SchoolCreationError.InvalidStartDate); - } - - var school = School.Create(_nextId++, normalized, startDate); - _schools.Add(school); - - return new SchoolCreationResult(school, SchoolCreationError.None); - } - - public bool Delete(int id) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - var school = Find(id); - if (school is null) - { - return false; - } - - _schools.Remove(school); - school.Dispose(); - return true; - } - - /// Advances every running school by one fixed step. - public void Tick() - { - ObjectDisposedException.ThrowIf(_disposed, this); - - foreach (var school in _schools) - { - school.Tick(_options.FixedDeltaTime, _options.GameMinutesPerRealSecond); - } - } - - /// A name the player has not used yet, for the "random" button in the creation form. - public string SuggestName(SchoolNameLanguage language = SchoolNameLanguage.Russian) => - NameGenerator.Next(_schools.Select(school => school.Name), language); - - /// Trims, strips control characters and enforces the length limit. - public static bool TryNormalizeName(string? name, out string normalized) - { - normalized = string.Empty; - - if (string.IsNullOrWhiteSpace(name)) - { - return false; - } - - var cleaned = new string(name.Where(character => !char.IsControl(character)).ToArray()).Trim(); - if (cleaned.Length == 0 || cleaned.Length > School.MaxNameLength) - { - return false; - } - - normalized = cleaned; - return true; - } - - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - - foreach (var school in _schools) - { - school.Dispose(); - } - - _schools.Clear(); - } -} diff --git a/tests/HSchool.Ai.Tests/DecisionPlannerTests.cs b/tests/HSchool.Ai.Tests/DecisionPlannerTests.cs index a36f574..0a6f4d2 100644 --- a/tests/HSchool.Ai.Tests/DecisionPlannerTests.cs +++ b/tests/HSchool.Ai.Tests/DecisionPlannerTests.cs @@ -202,13 +202,77 @@ public class DecisionPlannerTests Assert.Null(decision.StartAction); } + /// + /// Lunch is a timetable, not an urge. Inside the sitting a pupil who is merely peckish still + /// goes; outside it a hungry one does not walk out of the corridor to the canteen, and a + /// hungry one in class does not walk out of the lesson either. + /// + [Fact] + public void OutsideTheSitting_HungerFindsNoAction() + { + var (catalog, map, walks) = World(); + var corridor = map.Rooms.First(room => room.Def == "Corridor").Id; + var needs = FullNeeds(); + needs["Hunger"] = 0.05f; + + var closed = DecisionPlanner.Decide( + catalog, map, walks, + Actor(corridor, boundToLesson: false, corridor, needs, lunchWindowOpen: false), + (_, _) => 0); + + Assert.NotEqual("EatLunch", closed.Intent.ActionId); + + var open = DecisionPlanner.Decide( + catalog, map, walks, + Actor(corridor, boundToLesson: false, corridor, needs, lunchWindowOpen: true), + (_, _) => 0); + + Assert.Equal(GoalKind.Need, open.Intent.Kind); + Assert.Equal("EatLunch", open.Intent.ActionId); + Assert.Equal("Cafeteria", map.Rooms.First(room => room.Id == open.WalkTo).Def); + } + + [Fact] + public void InsideTheSitting_EvenAPeckishPupilGoesToEat() + { + var (catalog, map, walks) = World(); + var corridor = map.Rooms.First(room => room.Def == "Corridor").Id; + var needs = FullNeeds(); + needs["Hunger"] = 0.6f; + + var decision = DecisionPlanner.Decide( + catalog, map, walks, + Actor(corridor, boundToLesson: false, corridor, needs, lunchWindowOpen: true), + (_, _) => 0); + + Assert.Equal("EatLunch", decision.Intent.ActionId); + Assert.Equal(DecisionPlanner.LunchWeight, decision.Intent.Weight); + } + + [Fact] + public void ALessonOutweighsTheSitting() + { + var (catalog, map, walks) = World(); + var classroom = map.Rooms.First(room => room.Def == "Classroom").Id; + var needs = FullNeeds(); + needs["Hunger"] = 0.6f; + + var decision = DecisionPlanner.Decide( + catalog, map, walks, + Actor(classroom, boundToLesson: true, classroom, needs, lunchWindowOpen: true), + (_, _) => 0); + + Assert.Equal(GoalKind.Duty, decision.Intent.Kind); + } + private static ActorState Actor( string node, bool boundToLesson, string dutyRoom, IReadOnlyDictionary needs, Intent? intent = null, - bool activityActive = false) => + bool activityActive = false, + bool lunchWindowOpen = false) => new( node, node, @@ -220,7 +284,8 @@ public class DecisionPlannerTests boundToLesson, dutyRoom, needs, - intent ?? Intent.None); + intent ?? Intent.None, + lunchWindowOpen); private static Dictionary FullNeeds() => new(StringComparer.Ordinal) { diff --git a/tests/HSchool.AppHost.Tests/GameSocketTests.cs b/tests/HSchool.AppHost.Tests/GameSocketTests.cs index a8bea35..2dcedbc 100644 --- a/tests/HSchool.AppHost.Tests/GameSocketTests.cs +++ b/tests/HSchool.AppHost.Tests/GameSocketTests.cs @@ -204,12 +204,17 @@ public class GameSocketTests(AppHostFixture fixture) Assert.NotNull(directory); Assert.Contains(directory.People, person => person.Id == applicant.Id && person.FullName == applicant.FullName); + // Resume and check that people ride the frame at all — that is what "occupancy is on + // presence, not the snapshot" means. Do not wait for the freshly hired teacher in + // particular: whether somebody hired mid-day comes in today depends on their day plan, + // and the frozen moment drifts with however long this test's own HTTP calls took (the + // school runs at five game minutes per real second until it is paused). Waiting for that + // one person made this test fail under load. await SendAsync(socket, buffer => ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true))); - presence = await ReceivePresenceWhereAsync( - socket, - frame => frame.People.Any(person => person.Id == applicant.Id)); - Assert.Contains(presence.People, person => person.Id == applicant.Id); + presence = await ReceivePresenceWhereAsync(socket, frame => frame.People.Count > 0); + Assert.All(presence.People, person => Assert.False(string.IsNullOrWhiteSpace(person.NodeId))); + Assert.Contains(presence.People, person => directory.People.Any(row => row.Id == person.Id)); } [Fact] diff --git a/tests/HSchool.AppHost.Tests/StaffingApiTests.cs b/tests/HSchool.AppHost.Tests/StaffingApiTests.cs index f33bd07..c2b0c0d 100644 --- a/tests/HSchool.AppHost.Tests/StaffingApiTests.cs +++ b/tests/HSchool.AppHost.Tests/StaffingApiTests.cs @@ -179,25 +179,10 @@ public class StaffingApiTests(AppHostFixture fixture) Assert.True(assigned.Payroll > hired.Payroll); Assert.Equal(2, assigned.Staff.Single().Subjects.Count); - using var over = await client.PostAsJsonAsync( - $"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher.Id)}/subjects", - new { subject = "Literature" }, - TestContext.Current.CancellationToken); - // Cheap asks may still fit; only the numbers matter when the cap actually bites. - if (over.StatusCode == HttpStatusCode.Conflict) - { - var problem = await over.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.Equal("payroll-exceeded", problem?.Code); - Assert.True(problem?.Attempted > problem?.Allocated); - Assert.Equal(assigned.Payroll, problem?.Payroll); - } - else - { - over.EnsureSuccessStatusCode(); - assigned = await over.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.NotNull(assigned); - } - + // A third subject used to be tried here with an if/else around the cap, which meant the + // test passed whichever way it went. The refusal has its own test now + // (AssigningPastTheCap_IsRejectedWithTheNumbers); this one is about assign, unassign and + // what survives a restart. var payrollWithSubjects = assigned.Payroll; using var unassign = await client.DeleteAsync( $"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher.Id)}/subjects/RussianLanguage", diff --git a/tests/HSchool.Content.Tests/LunchBreakTests.cs b/tests/HSchool.Content.Tests/LunchBreakTests.cs new file mode 100644 index 0000000..c8b0b24 --- /dev/null +++ b/tests/HSchool.Content.Tests/LunchBreakTests.cs @@ -0,0 +1,99 @@ +namespace HSchool.Content.Tests; + +/// +/// Two sittings instead of one crowded canteen: juniors eat after the third lesson, seniors after +/// the fourth. The list also decides which breaks are long, because fifteen minutes of lunch do +/// not fit into ten. +/// +public class LunchBreakTests +{ + private readonly CatalogLoader _loader = new(); + + [Fact] + public void VanillaCore_FeedsJuniorsAndSeniorsAtDifferentBreaks() + { + var frame = LoadVanilla().DayFrame!; + + Assert.Equal(2, frame.LunchBreaks.Count); + Assert.Equal(3, SchoolDay.LunchBreakAfter(frame, year: 1)); + Assert.Equal(3, SchoolDay.LunchBreakAfter(frame, year: 5)); + Assert.Equal(4, SchoolDay.LunchBreakAfter(frame, year: 6)); + Assert.Equal(4, SchoolDay.LunchBreakAfter(frame, year: 11)); + } + + [Fact] + public void EveryBreakSomebodyEatsIn_IsALongOne() + { + var frame = LoadVanilla().DayFrame!; + + Assert.True(SchoolDay.IsLongBreakAfter(frame, 3)); + Assert.True(SchoolDay.IsLongBreakAfter(frame, 4)); + Assert.False(SchoolDay.IsLongBreakAfter(frame, 2)); + + // The fourth break is twenty minutes now, so the fifth lesson starts at half past noon + // rather than twenty past: the second sitting has to fit into the day. + Assert.Equal(new TimeOnly(12, 30), SchoolDay.PeriodStart(frame, 5)); + } + + [Fact] + public void LunchWindow_IsOpenOnlyAtTheSittingOfThatParallel() + { + var frame = LoadVanilla().DayFrame!; + + Assert.True(SchoolDay.IsLunchWindow(frame, DaySlot.BreakAfter(3), year: 4)); + Assert.False(SchoolDay.IsLunchWindow(frame, DaySlot.BreakAfter(4), year: 4)); + Assert.False(SchoolDay.IsLunchWindow(frame, DaySlot.BreakAfter(3), year: 9)); + Assert.True(SchoolDay.IsLunchWindow(frame, DaySlot.BreakAfter(4), year: 9)); + + // Staff belong to no parallel and can eat at either sitting; a lesson is never a window. + Assert.True(SchoolDay.IsLunchWindow(frame, DaySlot.BreakAfter(3), year: null)); + Assert.True(SchoolDay.IsLunchWindow(frame, DaySlot.BreakAfter(4), year: null)); + Assert.False(SchoolDay.IsLunchWindow(frame, DaySlot.Lesson(3), year: 4)); + } + + [Fact] + public void ASittingAfterTheLastLesson_FailsTheCatalog() + { + var error = Assert.Throws(() => LoadWithSittings( + """{ "afterLesson": 7, "gradeMin": 1, "gradeMax": 11 }""")); + + Assert.Contains("lunch break", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TwoSittingsForTheSameParallel_FailTheCatalog() + { + var error = Assert.Throws(() => LoadWithSittings( + """{ "afterLesson": 3, "gradeMin": 1, "gradeMax": 5 }, { "afterLesson": 4, "gradeMin": 5, "gradeMax": 11 }""")); + + Assert.Contains("two lunch breaks", error.Message, StringComparison.OrdinalIgnoreCase); + } + + private DefCatalog LoadWithSittings(string sittings) => + _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "dayframe", + "day", + $$""" + { + "defName": "Day", + "firstLesson": "08:30", + "lessonCount": 7, + "lessonMinutes": 45, + "breakMinutes": 10, + "longBreakAfter": 3, + "longBreakMinutes": 20, + "lunchBreaks": [{{sittings}}] + } + """), + ]); + + private DefCatalog LoadVanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + return _loader.Load([CatalogLoader.CorePackId], PackDocuments.FromDirectory(CatalogLoader.CorePackId, root)); + } +} diff --git a/tests/HSchool.Content.Tests/PeopleDefTests.cs b/tests/HSchool.Content.Tests/PeopleDefTests.cs index 74e60a7..606c0a4 100644 --- a/tests/HSchool.Content.Tests/PeopleDefTests.cs +++ b/tests/HSchool.Content.Tests/PeopleDefTests.cs @@ -18,7 +18,7 @@ public class PeopleDefTests Assert.Equal(BodyAttributeKind.Choice, catalog.BodyAttributes["HairColor"].Kind); Assert.All(catalog.Needs.Values, need => Assert.True(need.DecayPerHour > 0)); Assert.True(catalog.Needs["Sleep"].RestoredOffCampus); - Assert.Equal(0.1f, catalog.Needs["Hunger"].DecayPerHour); + Assert.Equal(0.2f, catalog.Needs["Hunger"].DecayPerHour); Assert.True(catalog.Skills["Communication"].Always); Assert.True(catalog.Skills["Agility"].Always); Assert.True(catalog.Skills["Pedagogy"].Work); diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index 959fa1e..d798a65 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -71,7 +71,11 @@ public class VanillaCoreTests Assert.Equal("Hunger", catalog.Actions["EatLunch"].Need); Assert.Equal(0.5f, catalog.Actions["EatLunch"].NeedGain); Assert.Equal("SchoolYard", catalog.Actions["WalkYard"].Room); - Assert.Equal(0.1f, catalog.Needs["Hunger"].DecayPerHour); + // Hunger falls fast enough to be felt inside one school day and, like sleep, comes back + // off campus — people eat at home. Before that pair of numbers the school went hungry and + // never recovered: half of it sat below the urgency threshold permanently. + Assert.Equal(0.2f, catalog.Needs["Hunger"].DecayPerHour); + Assert.True(catalog.Needs["Hunger"].RestoredOffCampus); Assert.True(catalog.Needs["Sleep"].RestoredOffCampus); Assert.NotNull(catalog.BehaviorRules); Assert.Equal(0, catalog.BehaviorRules.CommuteSlackMin); diff --git a/tests/HSchool.People.Tests/SkillGrantTests.cs b/tests/HSchool.People.Tests/SkillGrantTests.cs index 89ea77e..13cf1c5 100644 --- a/tests/HSchool.People.Tests/SkillGrantTests.cs +++ b/tests/HSchool.People.Tests/SkillGrantTests.cs @@ -129,6 +129,37 @@ public class SkillGrantTests }); } + /// + /// A school saved before native languages carries no pick, and the reload path asks for one + /// with rollIfOmitted: false. The contract is that it takes the first listed language + /// instead of rolling — a roll would change an existing school's tongue on the next start, + /// and the intake after it would draw different people. + /// + [Fact] + public void ReloadWithoutASavedPick_TakesTheFirstLanguageInsteadOfRolling() + { + var names = Fixtures.Catalog().NameSets["Slavic"]; + Assert.True(names.Spoken.Count > 1, "this pins behaviour that only matters for a multi-language set"); + + foreach (var seed in new[] { 1, 2, 7, 20260818 }) + { + Assert.Equal(names.Spoken[0], NativeLanguages.Pick(names, seed, requested: null, rollIfOmitted: false)); + } + + // A new school rolls instead, and the roll is stable for one seed. + var rolled = NativeLanguages.Pick(names, 7, requested: null, rollIfOmitted: true); + Assert.Contains(rolled, names.Spoken); + Assert.Equal(rolled, NativeLanguages.Pick(names, 7, requested: null, rollIfOmitted: true)); + + // A saved pick is honoured whichever way it is asked for. + Assert.Equal( + names.Spoken[^1], + NativeLanguages.Pick(names, 7, names.Spoken[^1], rollIfOmitted: false)); + + // Anything outside the set comes back null so the create path can answer 400. + Assert.Null(NativeLanguages.Pick(names, 7, "Klingon", rollIfOmitted: true)); + } + [Fact] public void OmittedNativeLanguage_IsStableForTheSameSeed() { diff --git a/tests/HSchool.Simulation.Tests/LunchTests.cs b/tests/HSchool.Simulation.Tests/LunchTests.cs new file mode 100644 index 0000000..9470335 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/LunchTests.cs @@ -0,0 +1,160 @@ +using HSchool.Content; +using HSchool.People; +using HSchool.Schedule; + +namespace HSchool.Simulation.Tests; + +/// +/// The canteen is fed in two sittings so it is not the whole school at once: juniors eat in the +/// break after the third lesson, seniors after the fourth. Before that, everybody crossed the +/// hunger threshold at roughly the same minute and the room could not hold them. +/// +public class LunchTests +{ + private static readonly DateTime Tuesday6 = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + + [Fact] + public void JuniorsEatAtTheFirstSitting_SeniorsAtTheSecond() + { + using var school = StaffedSchool(); + var (junior, senior) = Parallels(school); + + // Staff belong to no parallel and may eat at either sitting, so the claim is about pupils: + // the two halves of the school never meet in the canteen. + AdvanceTo(school, new DateTime(2012, 4, 3, 11, 15, 0, DateTimeKind.Utc)); + var first = InCafeteria(school); + Assert.Contains(first, junior.Contains); + Assert.DoesNotContain(first, senior.Contains); + + AdvanceTo(school, new DateTime(2012, 4, 3, 12, 20, 0, DateTimeKind.Utc)); + var second = InCafeteria(school); + Assert.Contains(second, senior.Contains); + Assert.DoesNotContain(second, junior.Contains); + } + + /// + /// Hunger is restored off campus — people eat at home — so a school week does not leave the + /// roster starving. It used to: nothing refilled hunger except eight chairs, and by the end of + /// the week a sixth of the school sat at zero. + /// + [Fact] + public void AfterASchoolDay_NobodyIsLeftStarving() + { + using var school = StaffedSchool(); + var pupils = school.Roster!.People.Where(person => person.IsStudent).Select(person => person.Id) + .ToHashSet(StringComparer.Ordinal); + + AdvanceTo(school, new DateTime(2012, 4, 4, 8, 0, 0, DateTimeKind.Utc)); + + var hunger = Hunger(school, pupils); + Assert.NotEmpty(hunger); + Assert.All(hunger.Values, value => Assert.True(value > 0.5f, $"somebody came back to school at {value:F2}")); + } + + private static School StaffedSchool() + { + var (catalog, map) = Vanilla(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", Tuesday6); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", Tuesday6); + + foreach (var subject in catalog.Subjects.Values.Where(def => !def.Abstract).Select(def => def.DefName)) + { + if (pool.Applicants.Count == 0) + { + break; + } + + var candidate = pool.Applicants[0].Person.Id; + var hired = Staffing.Hire(catalog, map, roster, pool, candidate, Staffing.TeacherPosition, 1_000_000f); + if (hired.Error != StaffingError.None) + { + break; + } + + roster = hired.Roster; + pool = hired.Pool; + var assigned = Staffing.AssignSubject(catalog, roster, pool, candidate, subject, 1_000_000f); + if (assigned.Error == StaffingError.None) + { + roster = assigned.Roster; + } + } + + var school = School.Create(1, "Столовая", Tuesday6, catalog, map); + school.InstallPeople(roster, seed: 1, "Slavic", pool); + school.SetTimetable(SchoolTimetables.Build(catalog, map, roster, null, weekDays: 5)); + school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 64); + return school; + } + + private static (HashSet Junior, HashSet Senior) Parallels(School school) + { + var junior = new HashSet(StringComparer.Ordinal); + var senior = new HashSet(StringComparer.Ordinal); + foreach (var row in school.Roster!.Classes) + { + foreach (var id in row.PupilIds) + { + (row.Year <= 5 ? junior : senior).Add(id); + } + } + + return (junior, senior); + } + + private static string[] InCafeteria(School school) => + school.CapturePresence() + .Where(row => row.NodeId == "cafeteria") + .Select(row => row.PersonId) + .ToArray(); + + private static Dictionary Hunger(School school, HashSet pupils) + { + var values = new Dictionary(StringComparer.Ordinal); + var query = new Arch.Core.QueryDescription().WithAll(); + school.World.Query(in query, (ref PersonIdentity identity, ref PersonNeeds needs) => + { + if (pupils.Contains(identity.Id) && needs.Values.TryGetValue("Hunger", out var value)) + { + values[identity.Id] = value; + } + }); + return values; + } + + private static void AdvanceTo(School school, DateTime until) + { + while (school.Clock.Time < until) + { + school.Tick(0.2d, 5d); + } + } + + private static (DefCatalog Catalog, MapLayout Map) Vanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + var documents = PackDocumentsFrom(root); + var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); + var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); + Assert.NotNull(map); + return (catalog, map); + } + + private static IReadOnlyList PackDocumentsFrom(string root) + { + var documents = new List(); + foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories)) + { + if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase) + && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var relative = Path.GetRelativePath(root, path).Replace(Path.DirectorySeparatorChar, (char)47); + documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path))); + } + + return documents; + } +} diff --git a/tests/HSchool.Simulation.Tests/NeedDecayTests.cs b/tests/HSchool.Simulation.Tests/NeedDecayTests.cs index 10653b2..8600353 100644 --- a/tests/HSchool.Simulation.Tests/NeedDecayTests.cs +++ b/tests/HSchool.Simulation.Tests/NeedDecayTests.cs @@ -32,7 +32,7 @@ public class NeedDecayTests } [Fact] - public void Sleep_ReturnsToMaxOffCampus() + public void SleepAndHunger_ReturnToMaxOffCampus() { var catalog = VanillaCatalog(); var world = World.Create(); @@ -50,8 +50,9 @@ public class NeedDecayTests var query = new QueryDescription().WithAll(); world.Query(in query, (ref PersonNeeds needs) => { + // Both are restored за кадром: the day at home covers a night and meals alike. Assert.Equal(catalog.Needs["Sleep"].Max, needs.Values["Sleep"]); - Assert.Equal(0.4f, needs.Values["Hunger"]); + Assert.Equal(catalog.Needs["Hunger"].Max, needs.Values["Hunger"]); }); } finally diff --git a/tests/HSchool.Simulation.Tests/SchoolNamesTests.cs b/tests/HSchool.Simulation.Tests/SchoolNamesTests.cs new file mode 100644 index 0000000..7e34cd9 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/SchoolNamesTests.cs @@ -0,0 +1,81 @@ +namespace HSchool.Simulation.Tests; + +/// +/// What used to be checked through SchoolRegistry. The registry is gone — the server gives +/// every school its own worker — but the name and start-date rules it guarded are still the ones +/// the create endpoint applies. +/// +public class SchoolNamesTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\u0009\u000A")] + public void BlankNames_AreRejected(string? name) + { + Assert.False(SchoolNames.TryNormalize(name, out _)); + } + + [Fact] + public void OverlongNames_AreRejected() + { + Assert.False(SchoolNames.TryNormalize(new string('ш', School.MaxNameLength + 1), out _)); + Assert.True(SchoolNames.TryNormalize(new string('ш', School.MaxNameLength), out _)); + } + + [Fact] + public void SurroundingSpaceAndControlCharacters_AreStripped() + { + Assert.True(SchoolNames.TryNormalize(" Лицей ", out var normalized)); + Assert.Equal("Лицей", normalized); + } + + [Fact] + public void StartDatesOutsideTheSupportedRange_AreRejected() + { + Assert.False(GameClock.IsValidStartDate(new DateTime(1500, 1, 1, 0, 0, 0, DateTimeKind.Utc))); + Assert.False(GameClock.IsValidStartDate(new DateTime(3200, 1, 1, 0, 0, 0, DateTimeKind.Utc))); + Assert.True(GameClock.IsValidStartDate(new DateTime(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc))); + } + + [Fact] + public void SuggestedNames_AreUsableAndNeverRepeatATakenOne() + { + var generator = new SchoolNameGenerator(new Random(1234)); + var taken = new List(); + + for (var i = 0; i < 20; i++) + { + var suggestion = generator.Next(taken, SchoolNameLanguage.Russian); + Assert.True(SchoolNames.TryNormalize(suggestion, out _), $"\"{suggestion}\" is not a usable name."); + Assert.DoesNotContain(suggestion, taken, StringComparer.OrdinalIgnoreCase); + taken.Add(suggestion); + } + } + + [Fact] + public void SuggestedNames_FitTheNameLimitInBothLanguages() + { + var generator = new SchoolNameGenerator(new Random(1234)); + + foreach (var language in new[] { SchoolNameLanguage.Russian, SchoolNameLanguage.English }) + { + for (var i = 0; i < 200; i++) + { + Assert.InRange(generator.Next([], language).Length, 1, School.MaxNameLength); + } + } + } + + [Fact] + public void SuggestedEnglishNames_AreAscii() + { + var generator = new SchoolNameGenerator(new Random(1234)); + + for (var i = 0; i < 50; i++) + { + Assert.Matches("^[A-Za-z0-9 .]+$", generator.Next([], SchoolNameLanguage.English)); + } + } +} diff --git a/tests/HSchool.Simulation.Tests/SchoolRegistryTests.cs b/tests/HSchool.Simulation.Tests/SchoolRegistryTests.cs deleted file mode 100644 index 5f838d0..0000000 --- a/tests/HSchool.Simulation.Tests/SchoolRegistryTests.cs +++ /dev/null @@ -1,194 +0,0 @@ -namespace HSchool.Simulation.Tests; - -public class SchoolRegistryTests -{ - private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); - - private static SchoolRegistry NewRegistry(int maxSchools = 6) => - new(new SimulationOptions { MaxSchools = maxSchools, TickRate = 20, GameMinutesPerRealSecond = 5 }); - - [Fact] - public void NewRegistry_IsEmpty() - { - using var registry = NewRegistry(); - - Assert.Equal(0, registry.Count); - Assert.Equal(6, registry.MaxSchools); - Assert.False(registry.IsFull); - } - - [Fact] - public void Create_AddsASchoolAtTheGivenStartDate() - { - using var registry = NewRegistry(); - - var result = registry.Create("Гимназия №1", Start); - - Assert.True(result.Succeeded); - Assert.Equal("Гимназия №1", result.School!.Name); - Assert.Equal(Start, result.School.Clock.Time); - - // A new school starts living straight away; only the pause button stops it. - Assert.True(result.School.Clock.IsRunning); - Assert.Equal(1, registry.Count); - } - - [Fact] - public void Create_BeyondTheLimit_Fails() - { - using var registry = NewRegistry(maxSchools: 2); - registry.Create("Первая", Start); - registry.Create("Вторая", Start); - - var result = registry.Create("Третья", Start); - - Assert.False(result.Succeeded); - Assert.Equal(SchoolCreationError.LimitReached, result.Error); - Assert.True(registry.IsFull); - Assert.Equal(2, registry.Count); - } - - [Fact] - public void Delete_FreesASlot() - { - using var registry = NewRegistry(maxSchools: 1); - var first = registry.Create("Первая", Start).School!; - - Assert.False(registry.Create("Вторая", Start).Succeeded); - Assert.True(registry.Delete(first.Id)); - - Assert.True(registry.Create("Вторая", Start).Succeeded); - } - - [Fact] - public void Delete_UnknownId_ReportsFailure() - { - using var registry = NewRegistry(); - - Assert.False(registry.Delete(42)); - } - - [Fact] - public void Ids_AreNotReusedAfterDeletion() - { - using var registry = NewRegistry(); - var first = registry.Create("Первая", Start).School!; - registry.Delete(first.Id); - - var second = registry.Create("Вторая", Start).School!; - - Assert.NotEqual(first.Id, second.Id); - Assert.Null(registry.Find(first.Id)); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("\t\n")] - public void Create_RejectsBlankNames(string name) - { - using var registry = NewRegistry(); - - Assert.Equal(SchoolCreationError.InvalidName, registry.Create(name, Start).Error); - } - - [Fact] - public void Create_RejectsOverlongNames() - { - using var registry = NewRegistry(); - - var result = registry.Create(new string('ш', School.MaxNameLength + 1), Start); - - Assert.Equal(SchoolCreationError.InvalidName, result.Error); - } - - [Fact] - public void Create_TrimsAndStripsControlCharacters() - { - using var registry = NewRegistry(); - - var result = registry.Create(" Лицей ", Start); - - Assert.Equal("Лицей", result.School!.Name); - } - - [Fact] - public void Create_RejectsStartDatesOutsideTheSupportedRange() - { - using var registry = NewRegistry(); - - var result = registry.Create("Школа", new DateTime(1500, 1, 1, 0, 0, 0, DateTimeKind.Utc)); - - Assert.Equal(SchoolCreationError.InvalidStartDate, result.Error); - } - - [Fact] - public void Tick_AdvancesOnlyRunningSchools() - { - using var registry = NewRegistry(); - var running = registry.Create("Идёт", Start).School!; - var paused = registry.Create("Стоит", Start).School!; - paused.Clock.IsRunning = false; - - for (var i = 0; i < 20; i++) - { - registry.Tick(); - } - - Assert.Equal(Start.AddMinutes(5), running.Clock.Time); - Assert.Equal(Start, paused.Clock.Time); - } - - [Fact] - public void SuggestName_NeverRepeatsAnExistingName() - { - using var registry = NewRegistry(maxSchools: 20); - - for (var i = 0; i < 20; i++) - { - var suggestion = registry.SuggestName(); - Assert.True(registry.Create(suggestion, Start).Succeeded, $"\"{suggestion}\" was rejected."); - } - - var names = registry.Schools.Select(school => school.Name).ToArray(); - Assert.Equal(names.Length, names.Distinct(StringComparer.OrdinalIgnoreCase).Count()); - } - - [Fact] - public void SuggestedNames_FitTheNameLimit() - { - var generator = new SchoolNameGenerator(new Random(1234)); - - foreach (var language in new[] { SchoolNameLanguage.Russian, SchoolNameLanguage.English }) - { - for (var i = 0; i < 200; i++) - { - var name = generator.Next([], language); - Assert.InRange(name.Length, 1, School.MaxNameLength); - } - } - } - - [Fact] - public void SuggestedEnglishNames_AreAscii() - { - var generator = new SchoolNameGenerator(new Random(1234)); - - for (var i = 0; i < 50; i++) - { - var name = generator.Next([], SchoolNameLanguage.English); - Assert.Matches("^[A-Za-z0-9 .]+$", name); - } - } - - [Fact] - public void Dispose_DropsEverySchool() - { - var registry = NewRegistry(); - registry.Create("Школа", Start); - - registry.Dispose(); - - Assert.Equal(0, registry.Count); - } -}