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.
This commit is contained in:
+12
-2
@@ -177,7 +177,16 @@
|
|||||||
|
|
||||||
Декей включается. Голод — столовая, туалет — санузел, общение — перемена рядом с
|
Декей включается. Голод — столовая, туалет — санузел, общение — перемена рядом с
|
||||||
одноклассниками. Сон **за кадром**: человек вне школы отдыхает, и утром приходит выспавшимся.
|
одноклассниками. Сон **за кадром**: человек вне школы отдыхает, и утром приходит выспавшимся.
|
||||||
Ночной школы у нас нет, а изображать её ради одной полоски — лишняя работа.
|
Ночной школы у нас нет, а изображать её ради одной полоски — лишняя работа. **Голод восстанавливается
|
||||||
|
там же**: дома завтракают и ужинают, иначе дефицит копится из дня в день и вся школа через неделю
|
||||||
|
сидит на нуле — так и было, пока это не измерили.
|
||||||
|
|
||||||
|
**Обед — расписание, а не порыв.** Действие с `lunch: true` предлагается только в свою смену
|
||||||
|
(`DayFrameDef.lunchBreaks`), зато в неё идут и те, кто ещё не проголодался до порога. Иначе
|
||||||
|
получается одно из двух: либо класс уходит с урока в столовую, когда приспичило, либо — если
|
||||||
|
ждать порога — вся параллель проскакивает свою смену и голодает до вечера. Смены разводят
|
||||||
|
параллели: в `core` младшие едят после третьего урока, старшие после четвёртого, и каждая такая
|
||||||
|
перемена становится длинной.
|
||||||
|
|
||||||
Прогресс-бары нужд в карточке уже нарисованы. В этом срезе они впервые начнут шевелиться.
|
Прогресс-бары нужд в карточке уже нарисованы. В этом срезе они впервые начнут шевелиться.
|
||||||
|
|
||||||
@@ -302,7 +311,8 @@
|
|||||||
| Планирование | Вырожденный GOAP: «дойти → сделать», два предусловия |
|
| Планирование | Вырожденный GOAP: «дойти → сделать», два предусловия |
|
||||||
| Переключение | Начатое доводится до конца, если новая цель не сильно тяжелее |
|
| Переключение | Начатое доводится до конца, если новая цель не сильно тяжелее |
|
||||||
| Частота решений | По событиям: звонок, конец действия, приход, порог нужды |
|
| Частота решений | По событиям: звонок, конец действия, приход, порог нужды |
|
||||||
| Нужды | Декей включается; сон вне школы |
|
| Нужды | Декей включается; сон и голод восстанавливаются вне школы |
|
||||||
|
| Обед | Смены по параллелям в каркасе дня; в свою смену едят и не проголодавшиеся |
|
||||||
| Последствия | Навык на уроке, видимые опоздание и прогул. Оценок нет |
|
| Последствия | Навык на уроке, видимые опоздание и прогул. Оценок нет |
|
||||||
| Игрок | Наблюдает; приказов нет |
|
| Игрок | Наблюдает; приказов нет |
|
||||||
| Поток присутствия | Своё сообщение, ~2 Гц, по всей школе, фильтр на клиенте |
|
| Поток присутствия | Своё сообщение, ~2 Гц, по всей школе, фильтр на клиенте |
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ QSP: не «полотно текста + список действий», а п
|
|||||||
Ориентир по экрану школы (после часов и паузы, которые уже есть):
|
Ориентир по экрану школы (после часов и паузы, которые уже есть):
|
||||||
|
|
||||||
- **Карта** — дерево локаций (tree view).
|
- **Карта** — дерево локаций (tree view).
|
||||||
- **Общие события** — то, что происходит в школе целиком, не привязано к выбранному узлу.
|
|
||||||
- **Выбранная локация** — что происходит именно там.
|
- **Выбранная локация** — что происходит именно там.
|
||||||
|
|
||||||
|
Отдельной панели общих событий нет: пока ленты событий не существует, постоянно пустая треть
|
||||||
|
экрана только мешала, и место отдано вкладкам «Карта» и «Люди». Появится лента — вернётся и
|
||||||
|
панель; решение принято в фазе 8, здесь оно записано.
|
||||||
|
|
||||||
Картинок локаций и портретов в этом горизонте нет; позже можно добавить, не ломая панели.
|
Картинок локаций и портретов в этом горизонте нет; позже можно добавить, не ломая панели.
|
||||||
Анимация пока только переходы панелей/списков и живые цифры (часы, счётчики). Графики —
|
Анимация пока только переходы панелей/списков и живые цифры (часы, счётчики). Графики —
|
||||||
когда появятся числа, которые ими стоит показывать, не раньше.
|
когда появятся числа, которые ими стоит показывать, не раньше.
|
||||||
@@ -30,10 +33,9 @@ QSP: не «полотно текста + список действий», а п
|
|||||||
|
|
||||||
Это и реализуем. Нового геймплея сверх этого не закладываем.
|
Это и реализуем. Нового геймплея сверх этого не закладываем.
|
||||||
|
|
||||||
**Экран школы.** Часы, пауза, скорость. Три панели:
|
**Экран школы.** Часы, пауза, скорость. Две панели:
|
||||||
|
|
||||||
- дерево карты;
|
- дерево карты (позже к нему вкладкой встанут «Люди»);
|
||||||
- общие события — **пусто** (заголовок и пустое состояние);
|
|
||||||
- выбранная локация (комната, этаж, корпус **или двор**): имя, предметы, персонажи (пусто),
|
- выбранная локация (комната, этаж, корпус **или двор**): имя, предметы, персонажи (пусто),
|
||||||
действия на месте (пусто), должности. Секции те же; где данных нет — пустое состояние.
|
действия на месте (пусто), должности. Секции те же; где данных нет — пустое состояние.
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@ QSP: не «полотно текста + список действий», а п
|
|||||||
|
|
||||||
| Тема | Решение |
|
| Тема | Решение |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Вид внутри школы | Менеджер: дерево + общие события + содержимое локации |
|
| Вид внутри школы | Менеджер: дерево (вкладкой с «Людьми») + содержимое локации |
|
||||||
| Рендер | DOM, не WebGL/Pixi |
|
| Рендер | DOM, не WebGL/Pixi |
|
||||||
| Картинки | Нет в этом горизонте |
|
| Картинки | Нет в этом горизонте |
|
||||||
| Анимация | Переходы и живые цифры |
|
| Анимация | Переходы и живые цифры |
|
||||||
|
|||||||
@@ -290,13 +290,15 @@ Protocol ← Server → Simulation → People → Content
|
|||||||
|
|
||||||
## Просмотр
|
## Просмотр
|
||||||
|
|
||||||
Не отдельный экран. Список людей — **панель в оболочке менеджера**, рядом с картой и событиями:
|
Не отдельный экран. Список людей — **панель в оболочке менеджера**, рядом с картой:
|
||||||
уходя в отдельный экран, игрок теряет из виду часы, локацию и события, а смысл этой оболочки
|
уходя в отдельный экран, игрок теряет из виду часы и локацию, а смысл этой оболочки
|
||||||
ровно в том, чтобы всё было на одном экране. Ориентир — прогрессивный QSP: текст, списки и ссылки,
|
ровно в том, чтобы всё было на одном экране. Ориентир — прогрессивный QSP: текст, списки и ссылки,
|
||||||
без сцены.
|
без сцены.
|
||||||
|
|
||||||
Панель «Люди» занимает нижний ряд во всю ширину — фильтрам, колонкам и пейджеру нужна ширина,
|
Панель «Люди» — вторая вкладка той же панели, что и карта: фильтрам, колонкам и пейджеру нужна
|
||||||
которой нет в колонке шириной в треть экрана.
|
ширина, а держать список третьим рядом под картой значило бы отдать ему высоту, которой на экране
|
||||||
|
школы нет. Вкладка переключается рядом с деревом, выбранная строка открывает карточку справа —
|
||||||
|
там же, где содержимое локации.
|
||||||
|
|
||||||
- **Фильтры**: роль (ученики / работники / родители), параллель и литера, должность, пол,
|
- **Фильтры**: роль (ученики / работники / родители), параллель и литера, должность, пол,
|
||||||
возрастной диапазон.
|
возрастной диапазон.
|
||||||
@@ -388,7 +390,7 @@ Protocol ← Server → Simulation → People → Content
|
|||||||
| Имена и язык UI | Независимы |
|
| Имена и язык UI | Независимы |
|
||||||
| Библиотека | `HSchool.People`: зависит от Content, не знает Arch и ASP.NET |
|
| Библиотека | `HSchool.People`: зависит от Content, не знает Arch и ASP.NET |
|
||||||
| Детерминизм | Сид в сейве, свой сид на семью; родной язык — часть входа |
|
| Детерминизм | Сид в сейве, свой сид на семью; родной язык — часть входа |
|
||||||
| Просмотр | Панель в оболочке менеджера, нижний ряд; не отдельный экран |
|
| Просмотр | Панель в оболочке менеджера, вкладкой рядом с картой; не отдельный экран |
|
||||||
| Список | Фильтры, сортировки, пейджинг; в строке — только лёгкие поля |
|
| Список | Фильтры, сортировки, пейджинг; в строке — только лёгкие поля |
|
||||||
| Транспорт списка | HTTP по опубликованному снимку ростера |
|
| Транспорт списка | HTTP по опубликованному снимку ростера |
|
||||||
| Транспорт карточки | Запрос в воркер, потому что нужды живые |
|
| Транспорт карточки | Запрос в воркер, потому что нужды живые |
|
||||||
|
|||||||
@@ -131,7 +131,8 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Кто строит | Сервер, детерминированно, из плана и назначений |
|
| Кто строит | Сервер, детерминированно, из плана и назначений |
|
||||||
| Правки игрока | Можно; исправленное закрепляется и переживает перестроение |
|
| Правки игрока | Можно; исправленное закрепляется и переживает перестроение |
|
||||||
| Каркас дня | `core`: 8:30, до 7 уроков, 45 минут, перемены 10 и одна 20; одна смена |
|
| Каркас дня | `core`: 8:30, до 7 уроков, 45 минут, перемены 10; длинные — те, в которые обедают |
|
||||||
|
| Обеденные смены | Список в каркасе дня: какая параллель ест после какого урока |
|
||||||
| Длина недели | В настройках, пять дней по умолчанию |
|
| Длина недели | В настройках, пять дней по умолчанию |
|
||||||
| Каникулы | Дефами, диапазонами дат; в каникулы расписания нет |
|
| Каникулы | Дефами, диапазонами дат; в каникулы расписания нет |
|
||||||
| Учебный план | Часы из `SubjectDef`, расписание их только расставляет |
|
| Учебный план | Часы из `SubjectDef`, расписание их только расставляет |
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
- [x] Клик по дереву переключает панель локации на клиенте (пока узлы-заглушки или пустое дерево)
|
- [x] Клик по дереву переключает панель локации на клиенте (пока узлы-заглушки или пустое дерево)
|
||||||
- [x] Переключение языка обновляет подписи панелей и пустых состояний (`t(...)`)
|
- [x] Переключение языка обновляет подписи панелей и пустых состояний (`t(...)`)
|
||||||
|
|
||||||
|
> **Отменено в фазе 8.** Панель общих событий убрана с экрана: пока ленты событий нет, постоянно
|
||||||
|
> пустая треть экрана мешала, и место отдано вкладкам «Карта» и «Люди». Решение записано в
|
||||||
|
> [`../design/near-term.md`](../design/near-term.md); появится лента — вернётся и панель.
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
- Школу можно открыть, часы и пауза работают как раньше
|
- Школу можно открыть, часы и пауза работают как раньше
|
||||||
|
|||||||
@@ -16,8 +16,9 @@
|
|||||||
воркер — инвариант «только поток школы касается школы» остаётся целым
|
воркер — инвариант «только поток школы касается школы» остаётся целым
|
||||||
- [x] `GET /api/schools/{id}/people/{personId}`: карточка через мейлбокс воркера с
|
- [x] `GET /api/schools/{id}/people/{personId}`: карточка через мейлбокс воркера с
|
||||||
`TaskCompletionSource`, потому что нужды живые
|
`TaskCompletionSource`, потому что нужды живые
|
||||||
- [x] Панель «Люди» нижним рядом оболочки менеджера, во всю ширину: фильтры, сортируемые
|
- [x] Панель «Люди» в оболочке менеджера: фильтры, сортируемые колонки, пейджер. Сделана
|
||||||
колонки, пейджер
|
**вкладкой рядом с картой**, а не нижним рядом — заодно с экрана ушла пустая панель
|
||||||
|
событий из фазы 1; см. [`../design/people.md`](../design/people.md)
|
||||||
- [x] Строка списка — только лёгкие поля: ФИО, роль, класс или должность, возраст, пол
|
- [x] Строка списка — только лёгкие поля: ФИО, роль, класс или должность, возраст, пол
|
||||||
- [x] Карточка: тело, навыки, черты, нужды, семья со ссылками на родных — по ссылке открывается
|
- [x] Карточка: тело, навыки, черты, нужды, семья со ссылками на родных — по ссылке открывается
|
||||||
карточка родственника
|
карточка родственника
|
||||||
|
|||||||
+111
-5
@@ -9,7 +9,7 @@
|
|||||||
## Срез 1. Оболочка и карта
|
## Срез 1. Оболочка и карта
|
||||||
|
|
||||||
- **Фазы:** 0–4
|
- **Фазы:** 0–4
|
||||||
- **Проверен на:** `b135a9c`, 2026-08-19
|
- **Проверен на:** `5a00ad7`, 2026-08-19 (первичная проверка — `b135a9c`)
|
||||||
- **Пути:** `src/HSchool.Client`, `src/HSchool.Protocol`, `src/HSchool.Content`,
|
- **Пути:** `src/HSchool.Client`, `src/HSchool.Protocol`, `src/HSchool.Content`,
|
||||||
`src/HSchool.Server/Game`, `src/HSchool.Server/Api/ModEndpoints.cs`,
|
`src/HSchool.Server/Game`, `src/HSchool.Server/Api/ModEndpoints.cs`,
|
||||||
`src/HSchool.Server/Api/SchoolEndpoints.cs`, `src/HSchool.Server/Net`,
|
`src/HSchool.Server/Api/SchoolEndpoints.cs`, `src/HSchool.Server/Net`,
|
||||||
@@ -95,10 +95,29 @@
|
|||||||
компилировалось. Выводам по срезу 1 это не мешает: его файлы правка не трогает, а дописанные
|
компилировалось. Выводам по срезу 1 это не мешает: его файлы правка не трогает, а дописанные
|
||||||
тесты прогнаны и на чистом `b135a9c`, и на дереве после починки сборки.
|
тесты прогнаны и на чистом `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. Люди
|
## Срез 2. Люди
|
||||||
|
|
||||||
- **Фазы:** 5–9
|
- **Фазы:** 5–9
|
||||||
- **Проверен на:** `5cd5a6d`, 2026-08-19
|
- **Проверен на:** `5a00ad7`, 2026-08-19 (первичная проверка — `5cd5a6d`)
|
||||||
- **Пути:** `src/HSchool.People`, `src/HSchool.Content/PeopleDefs.cs`,
|
- **Пути:** `src/HSchool.People`, `src/HSchool.Content/PeopleDefs.cs`,
|
||||||
`src/HSchool.Simulation/RosterSpawner.cs`, `src/HSchool.Simulation/School.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/Api/SchoolEndpoints.cs`, `src/HSchool.Server/Game/PersonCardReader.cs`,
|
||||||
@@ -176,10 +195,24 @@
|
|||||||
идущего человека). По правилу журнала он больше не «проверен на `b135a9c`» — правка маленькая и
|
идущего человека). По правилу журнала он больше не «проверен на `b135a9c`» — правка маленькая и
|
||||||
относится к срезу 5, но следующий проход это увидит.
|
относится к срезу 5, но следующий проход это увидит.
|
||||||
|
|
||||||
|
### Перепроверка на `5a00ad7`, 2026-08-19
|
||||||
|
|
||||||
|
`5a00ad7` переписал половину генератора: `PersonSampler` (+266), `FamilyFactory` (+52),
|
||||||
|
`YearlyIntake` (+61), новый `NativeLanguages`, новые навыки в `core` (языки, социальные,
|
||||||
|
рабочие). Проверено по изменению:
|
||||||
|
|
||||||
|
- Решение записано заранее — `design/people.md` описывает родные языки и порядок выдачи навыков
|
||||||
|
ещё с `a441ed9`, код догнал документ. Дрейфа нет, это тот редкий случай, когда дизайн шёл первым.
|
||||||
|
- Восемь новых тестов в `SkillGrantTests` закрывают выдачу навыков по параллелям, отсутствие
|
||||||
|
рабочих навыков у учеников и набор без переброса уже выданного. Старые тесты детерминизма,
|
||||||
|
согласованности семьи и годового набора зелёные — генератор переписан, а обещания среза целы.
|
||||||
|
- Исправления среза 2 (карточка семьи, сортировка по фамилии, состав после набора и перезапуска)
|
||||||
|
проходят на новом коде.
|
||||||
|
|
||||||
## Срез 3. Штат
|
## Срез 3. Штат
|
||||||
|
|
||||||
- **Фазы:** 10–13
|
- **Фазы:** 10–13
|
||||||
- **Проверен на:** `21d79cb`, 2026-08-19
|
- **Проверен на:** `5a00ad7`, 2026-08-19 (первичная проверка — `21d79cb`)
|
||||||
- **Пути:** `src/HSchool.People/Staffing.cs`, `src/HSchool.People/ApplicantPool.cs`,
|
- **Пути:** `src/HSchool.People/Staffing.cs`, `src/HSchool.People/ApplicantPool.cs`,
|
||||||
`src/HSchool.Content/SubjectDef` и `defs/staffing`, `src/HSchool.Server/Api/SchoolEndpoints.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}`,
|
(staffing/hire/subjects), `src/HSchool.Server/mods/core/defs/{subjects,positions,rooms,staffing,traits}`,
|
||||||
@@ -249,10 +282,38 @@
|
|||||||
- В `AssignAndUnassign_HonourTheCap_AndReloadKeepsAssignments` осталась ветка `if/else` вокруг
|
- В `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. Расписание
|
## Срез 4. Расписание
|
||||||
|
|
||||||
- **Фазы:** 14–17
|
- **Фазы:** 14–17
|
||||||
- **Проверен на:** `21d79cb`, 2026-08-19
|
- **Проверен на:** `5a00ad7`, 2026-08-19 (первичная проверка — `21d79cb`)
|
||||||
- **Пути:** `src/HSchool.Schedule`, `src/HSchool.Content` (каркас дня, каникулы, `SubjectDef.room`),
|
- **Пути:** `src/HSchool.Schedule`, `src/HSchool.Content` (каркас дня, каникулы, `SubjectDef.room`),
|
||||||
`src/HSchool.Simulation/SchoolTimetables.cs`, `src/HSchool.Server/Api/TimetableEndpoints.cs`,
|
`src/HSchool.Simulation/SchoolTimetables.cs`, `src/HSchool.Server/Api/TimetableEndpoints.cs`,
|
||||||
`src/HSchool.Server/Game/SchoolWorker.cs` (пин, перестроение),
|
`src/HSchool.Server/Game/SchoolWorker.cs` (пин, перестроение),
|
||||||
@@ -319,10 +380,18 @@
|
|||||||
прогона старых бинарников против новых данных, а не находка. Поэтому весь срез проверялся в
|
прогона старых бинарников против новых данных, а не находка. Поэтому весь срез проверялся в
|
||||||
отдельном worktree на `21d79cb`: AppHost 63/63 (три прогона подряд), клиент 33/33 и сборка.
|
отдельном worktree на `21d79cb`: AppHost 63/63 (три прогона подряд), клиент 33/33 и сборка.
|
||||||
|
|
||||||
|
### Перепроверка на `5a00ad7`, 2026-08-19
|
||||||
|
|
||||||
|
Из путей среза коммит тронул только каталог навыков: пять строк ушли из `skills/academic.jsonc`,
|
||||||
|
появились `languages`, `social` и `work`. Предметы ссылаются на навыки долями, и обе защиты на
|
||||||
|
месте и зелёные — `Subject_UnknownSkill_FailsTheCatalog` и `EveryVanillaLabel_ExistsInBothLocales`.
|
||||||
|
Планировщик, каркас дня и API расписания не менялись; дописанный в прошлый проход тест на коды
|
||||||
|
отказа пина проходит.
|
||||||
|
|
||||||
## Срез 5. Жизнь школы
|
## Срез 5. Жизнь школы
|
||||||
|
|
||||||
- **Фазы:** 18–21
|
- **Фазы:** 18–21
|
||||||
- **Проверен на:** `21d79cb`, 2026-08-19. Фаза 21 закрыта за час до проверки, `core` правился
|
- **Проверен на:** `5a00ad7`, 2026-08-19 (первичная проверка — `21d79cb`). Фаза 21 закрыта за час до проверки, `core` правился
|
||||||
параллельно — проверка велась в отдельном worktree на коммите, не на рабочем дереве
|
параллельно — проверка велась в отдельном worktree на коммите, не на рабочем дереве
|
||||||
- **Пути:** `src/HSchool.Ai`, `src/HSchool.Simulation/{PresenceSystem,ActivitySystem,LessonLearningSystem,School}.cs`,
|
- **Пути:** `src/HSchool.Ai`, `src/HSchool.Simulation/{PresenceSystem,ActivitySystem,LessonLearningSystem,School}.cs`,
|
||||||
`src/HSchool.Protocol` (кадр `Presence`, пропуск в кадре часов),
|
`src/HSchool.Protocol` (кадр `Presence`, пропуск в кадре часов),
|
||||||
@@ -402,3 +471,40 @@
|
|||||||
случаях чинить надо тест, а не присутствие: занятость узла берётся из расписания, а явка — из
|
случаях чинить надо тест, а не присутствие: занятость узла берётся из расписания, а явка — из
|
||||||
состояния, и совпадать они не обязаны — фаза 21 прямо обещает, что «опоздание и отсутствие
|
состояния, и совпадать они не обязаны — фаза 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 клиентских, сборка клиента зелёная.
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ public readonly record struct ActorState(
|
|||||||
bool BoundToLesson,
|
bool BoundToLesson,
|
||||||
string? DutyRoom,
|
string? DutyRoom,
|
||||||
IReadOnlyDictionary<string, float> Needs,
|
IReadOnlyDictionary<string, float> Needs,
|
||||||
Intent Intent);
|
Intent Intent,
|
||||||
|
bool LunchWindowOpen = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Picks a goal by weight and plans walk-then-do. No world, no clock — a table of inputs to an
|
/// 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
|
|||||||
/// <summary>Need at zero. Beats a lesson so a desperate toilet trip leaves class.</summary>
|
/// <summary>Need at zero. Beats a lesson so a desperate toilet trip leaves class.</summary>
|
||||||
public const float NeedWeightAtZero = 20f;
|
public const float NeedWeightAtZero = 20f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A sitting during this parallel's own lunch break. Above <see cref="DutyTravelWeight"/> so
|
||||||
|
/// lunch beats walking on to the next room, below <see cref="DutyLessonWeight"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public const float LunchWeight = 6f;
|
||||||
|
|
||||||
public static Decision Decide(
|
public static Decision Decide(
|
||||||
DefCatalog catalog,
|
DefCatalog catalog,
|
||||||
MapLayout map,
|
MapLayout map,
|
||||||
@@ -193,19 +202,33 @@ public static class DecisionPlanner
|
|||||||
NeedDef need,
|
NeedDef need,
|
||||||
float threshold)
|
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;
|
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);
|
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;
|
return Intent.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
var span = Math.Max(threshold, 0.0001f);
|
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);
|
return new Intent(GoalKind.Need, need.DefName, weight, action.DefName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +335,14 @@ public static class DecisionPlanner
|
|||||||
continue;
|
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)
|
if (best is null || action.NeedGain > best.NeedGain)
|
||||||
{
|
{
|
||||||
best = action;
|
best = action;
|
||||||
|
|||||||
@@ -76,8 +76,6 @@ const ru = {
|
|||||||
resume: 'Продолжить',
|
resume: 'Продолжить',
|
||||||
|
|
||||||
mapTitle: 'Карта',
|
mapTitle: 'Карта',
|
||||||
eventsTitle: 'События',
|
|
||||||
eventsEmpty: 'Пока ничего не происходит.',
|
|
||||||
locationName: 'Локация',
|
locationName: 'Локация',
|
||||||
personTitle: 'Человек',
|
personTitle: 'Человек',
|
||||||
locationItems: 'Предметы',
|
locationItems: 'Предметы',
|
||||||
@@ -288,8 +286,6 @@ const en: Messages = {
|
|||||||
resume: 'Resume',
|
resume: 'Resume',
|
||||||
|
|
||||||
mapTitle: 'Map',
|
mapTitle: 'Map',
|
||||||
eventsTitle: 'Events',
|
|
||||||
eventsEmpty: 'Nothing is happening yet.',
|
|
||||||
locationName: 'Location',
|
locationName: 'Location',
|
||||||
personTitle: 'Person',
|
personTitle: 'Person',
|
||||||
locationItems: 'Items',
|
locationItems: 'Items',
|
||||||
|
|||||||
@@ -16,6 +16,26 @@ public sealed class DayFrameDef : Def
|
|||||||
public int LongBreakAfter { get; init; }
|
public int LongBreakAfter { get; init; }
|
||||||
|
|
||||||
public int LongBreakMinutes { get; init; }
|
public int LongBreakMinutes { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which break each parallel eats at. Empty means one sitting for the whole school, at
|
||||||
|
/// <see cref="LongBreakAfter"/>. 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.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<LunchBreakDef> LunchBreaks { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One lunch sitting: the break it happens at and the parallels it feeds.</summary>
|
||||||
|
public sealed class LunchBreakDef
|
||||||
|
{
|
||||||
|
/// <summary>The sitting fills the break after this 1-based lesson.</summary>
|
||||||
|
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
|
public sealed class MonthDay
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ public sealed class ActionDef : Def
|
|||||||
|
|
||||||
/// <summary>Leisure weight. Zero means phase 21 will not pick this for fun — only for a need.</summary>
|
/// <summary>Leisure weight. Zero means phase 21 will not pick this for fun — only for a need.</summary>
|
||||||
public float Weight { get; init; }
|
public float Weight { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A sitting rather than something done whenever the need bites: only offered during the
|
||||||
|
/// eater's own lunch break (<see cref="DayFrameDef.LunchBreaks"/>). Without it a hungry class
|
||||||
|
/// would walk out of a lesson to the canteen, and the whole school would arrive at once.
|
||||||
|
/// </summary>
|
||||||
|
public bool Lunch { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ThingDef : Def
|
public sealed class ThingDef : Def
|
||||||
|
|||||||
@@ -391,6 +391,31 @@ internal static class PeopleDefValidator
|
|||||||
{
|
{
|
||||||
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' longBreakAfter must be 0 or a lesson before the last.");
|
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' longBreakAfter must be 0 or a lesson before the last.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fed = new HashSet<int>();
|
||||||
|
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)
|
private static void ValidateAction(ActionDef action, DefCatalog catalog)
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ public static class SchoolDay
|
|||||||
break;
|
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;
|
var breakEnd = cursor + gap;
|
||||||
if (clock >= cursor && clock < breakEnd)
|
if (clock >= cursor && clock < breakEnd)
|
||||||
{
|
{
|
||||||
@@ -152,7 +152,7 @@ public static class SchoolDay
|
|||||||
for (var i = 1; i < period; i++)
|
for (var i = 1; i < period; i++)
|
||||||
{
|
{
|
||||||
cursor += lesson;
|
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);
|
return TimeOnly.FromTimeSpan(cursor);
|
||||||
@@ -225,4 +225,77 @@ public static class SchoolDay
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The break this parallel eats in. Falls back to the school-wide long break when no sitting
|
||||||
|
/// lists the year — a staff member (<paramref name="year"/> null) eats at any of them.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when <paramref name="slot"/> is the sitting that feeds this parallel.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ internal sealed class GameLoopService(
|
|||||||
return;
|
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));
|
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidName));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
"needGain": 0.5,
|
"needGain": 0.5,
|
||||||
"roles": ["student", "staff"],
|
"roles": ["student", "staff"],
|
||||||
"weight": 0,
|
"weight": 0,
|
||||||
|
// Only during this parallel's sitting, see the day frame.
|
||||||
|
"lunch": true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"defName": "UseToilet",
|
"defName": "UseToilet",
|
||||||
|
|||||||
@@ -6,4 +6,11 @@
|
|||||||
"breakMinutes": 10,
|
"breakMinutes": 10,
|
||||||
"longBreakAfter": 3,
|
"longBreakAfter": 3,
|
||||||
"longBreakMinutes": 20,
|
"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 },
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[
|
[
|
||||||
{ "defName": "Sleep", "initial": 1, "decayPerHour": 0.05, "min": 0, "max": 1, "restoredOffCampus": true },
|
{ "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": "Toilet", "initial": 1, "decayPerHour": 0.15, "min": 0, "max": 1 },
|
||||||
{ "defName": "Social", "initial": 1, "decayPerHour": 0.08, "min": 0, "max": 1 },
|
{ "defName": "Social", "initial": 1, "decayPerHour": 0.08, "min": 0, "max": 1 },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
"floor": "floor-1",
|
"floor": "floor-1",
|
||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "counter", "thing": "DiningTable" },
|
{ "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" },
|
{ "id": "restroom-1", "def": "Restroom", "building": "main", "floor": "floor-1", "label": "1" },
|
||||||
|
|||||||
@@ -375,6 +375,9 @@ internal static class PresenceSystem
|
|||||||
var bound = Duty.IsOtherStaff(person)
|
var bound = Duty.IsOtherStaff(person)
|
||||||
? slot.Kind != DaySlotKind.Outside
|
? slot.Kind != DaySlotKind.Outside
|
||||||
: slot.Kind == DaySlotKind.Lesson && lessons.Any(lesson => lesson.Period == slot.Index);
|
: 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(
|
var state = new ActorState(
|
||||||
presence.NodeId,
|
presence.NodeId,
|
||||||
presence.DestinationId,
|
presence.DestinationId,
|
||||||
@@ -386,7 +389,8 @@ internal static class PresenceSystem
|
|||||||
bound,
|
bound,
|
||||||
duty,
|
duty,
|
||||||
needs.Values,
|
needs.Values,
|
||||||
intent);
|
intent,
|
||||||
|
lunchOpen);
|
||||||
var decision = DecisionPlanner.Decide(
|
var decision = DecisionPlanner.Decide(
|
||||||
school.Catalog!,
|
school.Catalog!,
|
||||||
school.Map!,
|
school.Map!,
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public enum SchoolCreationError
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
LimitReached,
|
||||||
|
InvalidName,
|
||||||
|
InvalidStartDate,
|
||||||
|
InvalidMap,
|
||||||
|
UnknownMod,
|
||||||
|
InvalidCatalog,
|
||||||
|
UnknownNameSet,
|
||||||
|
UnknownNativeLanguage,
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The rules a school name has to pass. A name arrives from a browser, so it is trimmed and
|
||||||
|
/// stripped before anything stores it.
|
||||||
|
/// </summary>
|
||||||
|
public static class SchoolNames
|
||||||
|
{
|
||||||
|
/// <summary>Trims, strips control characters and enforces the length limit.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
namespace HSchool.Simulation;
|
|
||||||
|
|
||||||
/// <summary>Why a school could not be created.</summary>
|
|
||||||
public enum SchoolCreationError
|
|
||||||
{
|
|
||||||
None = 0,
|
|
||||||
LimitReached,
|
|
||||||
InvalidName,
|
|
||||||
InvalidStartDate,
|
|
||||||
InvalidMap,
|
|
||||||
UnknownMod,
|
|
||||||
InvalidCatalog,
|
|
||||||
UnknownNameSet,
|
|
||||||
UnknownNativeLanguage,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Outcome of <see cref="SchoolRegistry.Create"/>: either the school or the reason there is none.</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class SchoolRegistry : IDisposable
|
|
||||||
{
|
|
||||||
private readonly SimulationOptions _options;
|
|
||||||
private readonly List<School> _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;
|
|
||||||
|
|
||||||
/// <summary>Schools in creation order — the order the menu lists them in.</summary>
|
|
||||||
public IReadOnlyList<School> 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Advances every running school by one fixed step.</summary>
|
|
||||||
public void Tick()
|
|
||||||
{
|
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
||||||
|
|
||||||
foreach (var school in _schools)
|
|
||||||
{
|
|
||||||
school.Tick(_options.FixedDeltaTime, _options.GameMinutesPerRealSecond);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>A name the player has not used yet, for the "random" button in the creation form.</summary>
|
|
||||||
public string SuggestName(SchoolNameLanguage language = SchoolNameLanguage.Russian) =>
|
|
||||||
NameGenerator.Next(_schools.Select(school => school.Name), language);
|
|
||||||
|
|
||||||
/// <summary>Trims, strips control characters and enforces the length limit.</summary>
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -202,13 +202,77 @@ public class DecisionPlannerTests
|
|||||||
Assert.Null(decision.StartAction);
|
Assert.Null(decision.StartAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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(
|
private static ActorState Actor(
|
||||||
string node,
|
string node,
|
||||||
bool boundToLesson,
|
bool boundToLesson,
|
||||||
string dutyRoom,
|
string dutyRoom,
|
||||||
IReadOnlyDictionary<string, float> needs,
|
IReadOnlyDictionary<string, float> needs,
|
||||||
Intent? intent = null,
|
Intent? intent = null,
|
||||||
bool activityActive = false) =>
|
bool activityActive = false,
|
||||||
|
bool lunchWindowOpen = false) =>
|
||||||
new(
|
new(
|
||||||
node,
|
node,
|
||||||
node,
|
node,
|
||||||
@@ -220,7 +284,8 @@ public class DecisionPlannerTests
|
|||||||
boundToLesson,
|
boundToLesson,
|
||||||
dutyRoom,
|
dutyRoom,
|
||||||
needs,
|
needs,
|
||||||
intent ?? Intent.None);
|
intent ?? Intent.None,
|
||||||
|
lunchWindowOpen);
|
||||||
|
|
||||||
private static Dictionary<string, float> FullNeeds() => new(StringComparer.Ordinal)
|
private static Dictionary<string, float> FullNeeds() => new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -204,12 +204,17 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
Assert.NotNull(directory);
|
Assert.NotNull(directory);
|
||||||
Assert.Contains(directory.People, person => person.Id == applicant.Id && person.FullName == applicant.FullName);
|
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 =>
|
await SendAsync(socket, buffer =>
|
||||||
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true)));
|
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true)));
|
||||||
presence = await ReceivePresenceWhereAsync(
|
presence = await ReceivePresenceWhereAsync(socket, frame => frame.People.Count > 0);
|
||||||
socket,
|
Assert.All(presence.People, person => Assert.False(string.IsNullOrWhiteSpace(person.NodeId)));
|
||||||
frame => frame.People.Any(person => person.Id == applicant.Id));
|
Assert.Contains(presence.People, person => directory.People.Any(row => row.Id == person.Id));
|
||||||
Assert.Contains(presence.People, person => person.Id == applicant.Id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -179,25 +179,10 @@ public class StaffingApiTests(AppHostFixture fixture)
|
|||||||
Assert.True(assigned.Payroll > hired.Payroll);
|
Assert.True(assigned.Payroll > hired.Payroll);
|
||||||
Assert.Equal(2, assigned.Staff.Single().Subjects.Count);
|
Assert.Equal(2, assigned.Staff.Single().Subjects.Count);
|
||||||
|
|
||||||
using var over = await client.PostAsJsonAsync(
|
// A third subject used to be tried here with an if/else around the cap, which meant the
|
||||||
$"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher.Id)}/subjects",
|
// test passed whichever way it went. The refusal has its own test now
|
||||||
new { subject = "Literature" },
|
// (AssigningPastTheCap_IsRejectedWithTheNumbers); this one is about assign, unassign and
|
||||||
TestContext.Current.CancellationToken);
|
// what survives a restart.
|
||||||
// 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<ProblemResponse>(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<StaffingResponse>(TestContext.Current.CancellationToken);
|
|
||||||
Assert.NotNull(assigned);
|
|
||||||
}
|
|
||||||
|
|
||||||
var payrollWithSubjects = assigned.Payroll;
|
var payrollWithSubjects = assigned.Payroll;
|
||||||
using var unassign = await client.DeleteAsync(
|
using var unassign = await client.DeleteAsync(
|
||||||
$"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher.Id)}/subjects/RussianLanguage",
|
$"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher.Id)}/subjects/RussianLanguage",
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
namespace HSchool.Content.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<ContentLoadException>(() => LoadWithSittings(
|
||||||
|
"""{ "afterLesson": 7, "gradeMin": 1, "gradeMax": 11 }"""));
|
||||||
|
|
||||||
|
Assert.Contains("lunch break", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TwoSittingsForTheSameParallel_FailTheCatalog()
|
||||||
|
{
|
||||||
|
var error = Assert.Throws<ContentLoadException>(() => 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ public class PeopleDefTests
|
|||||||
Assert.Equal(BodyAttributeKind.Choice, catalog.BodyAttributes["HairColor"].Kind);
|
Assert.Equal(BodyAttributeKind.Choice, catalog.BodyAttributes["HairColor"].Kind);
|
||||||
Assert.All(catalog.Needs.Values, need => Assert.True(need.DecayPerHour > 0));
|
Assert.All(catalog.Needs.Values, need => Assert.True(need.DecayPerHour > 0));
|
||||||
Assert.True(catalog.Needs["Sleep"].RestoredOffCampus);
|
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["Communication"].Always);
|
||||||
Assert.True(catalog.Skills["Agility"].Always);
|
Assert.True(catalog.Skills["Agility"].Always);
|
||||||
Assert.True(catalog.Skills["Pedagogy"].Work);
|
Assert.True(catalog.Skills["Pedagogy"].Work);
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ public class VanillaCoreTests
|
|||||||
Assert.Equal("Hunger", catalog.Actions["EatLunch"].Need);
|
Assert.Equal("Hunger", catalog.Actions["EatLunch"].Need);
|
||||||
Assert.Equal(0.5f, catalog.Actions["EatLunch"].NeedGain);
|
Assert.Equal(0.5f, catalog.Actions["EatLunch"].NeedGain);
|
||||||
Assert.Equal("SchoolYard", catalog.Actions["WalkYard"].Room);
|
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.True(catalog.Needs["Sleep"].RestoredOffCampus);
|
||||||
Assert.NotNull(catalog.BehaviorRules);
|
Assert.NotNull(catalog.BehaviorRules);
|
||||||
Assert.Equal(0, catalog.BehaviorRules.CommuteSlackMin);
|
Assert.Equal(0, catalog.BehaviorRules.CommuteSlackMin);
|
||||||
|
|||||||
@@ -129,6 +129,37 @@ public class SkillGrantTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A school saved before native languages carries no pick, and the reload path asks for one
|
||||||
|
/// with <c>rollIfOmitted: false</c>. 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.
|
||||||
|
/// </summary>
|
||||||
|
[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]
|
[Fact]
|
||||||
public void OmittedNativeLanguage_IsStableForTheSameSeed()
|
public void OmittedNativeLanguage_IsStableForTheSameSeed()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
|
using HSchool.Schedule;
|
||||||
|
|
||||||
|
namespace HSchool.Simulation.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<string> Junior, HashSet<string> Senior) Parallels(School school)
|
||||||
|
{
|
||||||
|
var junior = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var senior = new HashSet<string>(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<string, float> Hunger(School school, HashSet<string> pupils)
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, float>(StringComparer.Ordinal);
|
||||||
|
var query = new Arch.Core.QueryDescription().WithAll<PersonIdentity, PersonNeeds>();
|
||||||
|
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<ContentDocument> PackDocumentsFrom(string root)
|
||||||
|
{
|
||||||
|
var documents = new List<ContentDocument>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ public class NeedDecayTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Sleep_ReturnsToMaxOffCampus()
|
public void SleepAndHunger_ReturnToMaxOffCampus()
|
||||||
{
|
{
|
||||||
var catalog = VanillaCatalog();
|
var catalog = VanillaCatalog();
|
||||||
var world = World.Create();
|
var world = World.Create();
|
||||||
@@ -50,8 +50,9 @@ public class NeedDecayTests
|
|||||||
var query = new QueryDescription().WithAll<PersonNeeds>();
|
var query = new QueryDescription().WithAll<PersonNeeds>();
|
||||||
world.Query(in query, (ref PersonNeeds needs) =>
|
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(catalog.Needs["Sleep"].Max, needs.Values["Sleep"]);
|
||||||
Assert.Equal(0.4f, needs.Values["Hunger"]);
|
Assert.Equal(catalog.Needs["Hunger"].Max, needs.Values["Hunger"]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
namespace HSchool.Simulation.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What used to be checked through <c>SchoolRegistry</c>. 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user