diff --git a/.claude/skills/phase-review/SKILL.md b/.claude/skills/phase-review/SKILL.md
new file mode 100644
index 0000000..cbb9283
--- /dev/null
+++ b/.claude/skills/phase-review/SKILL.md
@@ -0,0 +1,137 @@
+---
+name: phase-review
+description: Ревью срезов и фаз проекта h-school по их же критериям приёмки — с дописыванием недостающих тестов, исправлением найденного и журналом проверенного в docs/phases/reviewed.md. Обязательно используй этот скилл, когда просят «сделай ревью», «проведи ревью», «проверь срез», «проверь непроверенные срезы», «что ещё не проверено», «review the slice» — и вообще при любой просьбе проверить, действительно ли сделанная фаза сделана. Не для ревью одного диффа или пул-реквеста: там нужен /code-review.
+---
+
+# Ревью срезов
+
+Фазы в `docs/phases/` помечаются ✅, когда их **написали**. Это заявка автора, а не
+доказательство. Скилл превращает заявку в проверенный факт: берёт срез, сверяет каждое его
+обещание с кодом и тестами, дописывает недостающее, чинит найденное и записывает результат,
+чтобы одну и ту же работу не делать дважды.
+
+Главное, что делает ревью здесь дешёвым: **критерии приёмки уже написаны**. У каждой фазы есть
+«Задачи», «Тесты, без которых фаза не закрыта» и «Критерий готовности», а у проекта — список
+инвариантов в `AGENTS.md`. Ничего не надо выдумывать, надо проверить.
+
+## Журнал
+
+`docs/phases/reviewed.md` — единственный источник правды о том, что уже проверено. Если файла
+нет, создай его с заголовком и одной строкой о том, что это такое.
+
+Раздел на срез:
+
+```markdown
+## Срез 4. Расписание
+
+- **Фазы:** 14–17
+- **Проверен на:** `3c54f98`, 2026-08-19
+- **Пути:** `src/HSchool.Schedule`, `src/HSchool.Simulation/SchoolTimetables.cs`,
+ `src/HSchool.Server/Api/TimetableEndpoints.cs`, `tests/HSchool.Schedule.Tests`
+- **Итог:** дописано 3 теста, исправлено 2 расхождения с дизайном, одно замечание оставлено
+ открытым (см. ниже)
+```
+
+Коммит в строке «Проверен на» — не украшение. Он делает журнал самопроверяющимся: срез,
+проверенный на `3c54f98`, перестаёт быть проверенным, как только его пути тронули дальше. Поэтому
+«Пути» тоже обязательны — без них дрейф не поймать.
+
+## Как выбрать, что проверять
+
+1. Прочитай `docs/phases/README.md` — там срезы, их фазы и статусы.
+2. Прочитай журнал.
+3. Срез попадает в очередь, если:
+ - его нет в журнале; **или**
+ - `git log <коммит-из-журнала>..HEAD -- <пути среза>` непустой — код менялся после проверки;
+ это перепроверка, и в отчёте её надо называть именно так;
+ - в журнале записано «проверен частично».
+4. Порядок — **от раннего к позднему**. Верхние срезы стоят на нижних: искать причину странного
+ расписания, не проверив генерацию людей, значит искать не там.
+
+Срезы в работе (есть ⬜ или 🔄, код уже пишется) проверять можно и нужно, но в журнале это
+отмечается как «в работе на момент проверки» — иначе следующий проход решит, что там всё
+закрыто.
+
+**Один срез за проход.** Проверил → починил → записал в журнал → доложил → взялся за следующий.
+Если проходов будет несколько, пользователь увидит промежуточные результаты и сможет вмешаться,
+а не получит через час одну кучу правок в семи проектах.
+
+## Что именно проверять
+
+Идти сверху вниз, по каждому пункту фазы отдельно:
+
+**1. Обещания фазы.** Для каждой задачи и каждого пункта «Тесты, без которых фаза не закрыта»
+найди фактическое подтверждение: строчку кода, которая это делает, и тест, который это
+проверяет. Галочка `[x]` подтверждением не является. Чаще всего расхождение выглядит так: код
+написан, а теста из списка нет — либо тест есть, но проверяет соседнее.
+
+**2. Инварианты `AGENTS.md`,** относящиеся к затронутым проектам. Особенно те, что нельзя
+нарушить незаметно: протокол в трёх местах сразу, отсутствие HTTP и ASP.NET в `Simulation` и в
+чистых библиотеках, фиксированный шаг вместо `DateTime.Now`, одна школа — один поток,
+детерминированность от сида.
+
+**3. «Things that will bite you».** Этот раздел `AGENTS.md` — список уже случившихся регрессий.
+Проверить, что ни одна не вернулась, дешевле, чем поймать её второй раз.
+
+**4. Дизайн-док среза** (`docs/design/*.md`, ссылка есть в индексе фаз). Расхождение кода с
+принятым решением — находка, даже если тесты зелёные. Но сначала спроси себя, не устарел ли док:
+бывает, что решение сознательно поменяли, и тогда чинить надо документ.
+
+## Как проверять
+
+**Измеряй, а не рассуждай.** Когда вопрос звучит как «а хорошо ли раскладываются часы» или
+«а сколько получается неполных семей» — не рассуждай о коде, напиши временный тест, который
+печатает реальные числа, посмотри на них и удали его. Час размышлений о том, как поведёт себя
+алгоритм, стоит дороже и ошибается чаще, чем один прогон, который печатает распределение.
+
+**Проверяй подозрение до того, как о нём докладывать.** Половина находок «на глаз» рассыпается
+при первом же взгляде на соседний файл: параметр, который считался забытым, передаётся из
+вызывающего кода; поле, которого якобы нет в размере кадра, там есть. Проверенное подозрение —
+находка, непроверенное — шум, который пользователю придётся разбирать за тебя.
+
+## Что чинить
+
+- **Недостающие тесты дописывай** в проект, который назначен политикой из `AGENTS.md`
+ («Testing policy»). Тест генерации людей не место в тестах хоста.
+- **Чини причину, а не симптом.** Падающий тест не ослабляют и не удаляют ради зелёного прогона.
+ Если тест действительно неправ — так и напиши в отчёте, с обоснованием, и меняй его осознанно.
+- **Держись границ среза.** Найденное за его пределами — в отчёт строкой «замечено рядом», а не
+ в диф. Ревью, которое походя переписало соседний проект, невозможно посмотреть глазами.
+- **Молча не меняй** версию протокола, форму сейва и публичное поведение API. Это отдельное
+ решение пользователя, даже когда оно очевидно правильное.
+
+## Окружение
+
+```bash
+dotnet test
+```
+
+```bash
+npm --prefix src/HSchool.Client test
+```
+
+Чего ждать и что делать:
+
+- **`MSB3021` / `MSB3027`, «блокирует этот файл»** — у пользователя запущено приложение, оно
+ держит DLL. **Процесс не убивать.** Проверь то, что можно проверить без сборки .NET
+ (клиентские тесты, чтение кода, дизайн-доки), а в отчёте скажи прямо: .NET-часть не
+ прогонялась, потому что запущено приложение. В журнале такой срез — «проверен частично».
+- **Концы строк.** В репозитории есть файлы и с CRLF, и с LF. Правка не должна переворачивать
+ файл целиком: сверься с `git diff --stat` — внезапные «изменено 400 строк» в файле, где ты
+ правил три, это оно.
+- **Dev-сервер не запускать.** Если нужно посмотреть на UI, пользуйся уже запущенным
+ приложением пользователя; свой не поднимай и чужой не останавливай.
+
+## Отчёт
+
+Коротко и по делу, в конце каждого среза:
+
+- что проверено и чем это подтверждено;
+- что дописано (тесты — списком, по одной строке);
+- что исправлено и почему это была ошибка;
+- что осталось под вопросом — с формулировкой, по которой можно принять решение;
+- какой срез следующий в очереди.
+
+Пустой отчёт — тоже результат: «срез 2 проверен, все восемь тестов из фазы 6 на месте,
+расхождений с дизайном нет» стоит написать явно. Это ровно та информация, ради которой
+затевался журнал.
diff --git a/docs/phases/21-decisions.md b/docs/phases/21-decisions.md
index 29213b9..cb8b9b0 100644
--- a/docs/phases/21-decisions.md
+++ b/docs/phases/21-decisions.md
@@ -11,33 +11,33 @@
## Задачи
-- [ ] Цели, веса и планирование — целиком в `HSchool.Ai`: на вход значения, на выход решение.
+- [x] Цели, веса и планирование — целиком в `HSchool.Ai`: на вход значения, на выход решение.
В `HSchool.Simulation` — только сбор входов и запись ответа
-- [ ] Цели и их вес: обязанность — постоянный высокий, нужда — тем больше, чем ближе к нулю,
+- [x] Цели и их вес: обязанность — постоянный высокий, нужда — тем больше, чем ближе к нулю,
досуг — низкий и только при отсутствии обязанности
-- [ ] Побеждает наибольший вес. Уход с урока получается из сравнения, а не из отдельного правила
-- [ ] Планирование «дойти → сделать»: цель выбирает действие, планировщик достраивает дорогу до
+- [x] Побеждает наибольший вес. Уход с урока получается из сравнения, а не из отдельного правила
+- [x] Планирование «дойти → сделать»: цель выбирает действие, планировщик достраивает дорогу до
подходящей комнаты
-- [ ] Начатое доводится до конца: переключение только при заметном перевесе новой цели
-- [ ] Решения по событиям — звонок, конец действия, приход в назначенный узел, пересечение
+- [x] Начатое доводится до конца: переключение только при заметном перевесе новой цели
+- [x] Решения по событиям — звонок, конец действия, приход в назначенный узел, пересечение
нуждой порога, — а не каждый тик
-- [ ] Очередь решений с потолком за тик: отложенное принимается следующим тиком, а не пропадает
-- [ ] Возвращение к обязанности после закрытия нужды, если урок ещё идёт
-- [ ] Навык растёт на уроке: от предмета, с поправкой на черты и на состояние нужд
-- [ ] Опоздание и отсутствие видны: в панели локации на уроке не весь класс, в карточке написано,
+- [x] Очередь решений с потолком за тик: отложенное принимается следующим тиком, а не пропадает
+- [x] Возвращение к обязанности после закрытия нужды, если урок ещё идёт
+- [x] Навык растёт на уроке: от предмета, с поправкой на черты и на состояние нужд
+- [x] Опоздание и отсутствие видны: в панели локации на уроке не весь класс, в карточке написано,
где человек
-- [ ] Строки через `t(...)`, обе локали
+- [x] Строки через `t(...)`, обе локали
## Тесты, без которых фаза не закрыта
-- [ ] Ученик с нулевым «туалетом» уходит с урока, доходит до санузла и возвращается в кабинет
-- [ ] Нужда чуть ниже порога урок не срывает
-- [ ] Две почти равные нужды не заставляют метаться: действие доводится до конца
-- [ ] На перемене выбирается досуг, на уроке — нет
-- [ ] Навык после учебного дня вырос, у голодного — меньше
-- [ ] Потолок решений за тик не теряет решения, а сдвигает их
-- [ ] Тот же сид и те же действия игрока дают ту же школу через игровую неделю
-- [ ] Разбор решения таблицей входов и выходов, без мира и без хоста
+- [x] Ученик с нулевым «туалетом» уходит с урока, доходит до санузла и возвращается в кабинет
+- [x] Нужда чуть ниже порога урок не срывает
+- [x] Две почти равные нужды не заставляют метаться: действие доводится до конца
+- [x] На перемене выбирается досуг, на уроке — нет
+- [x] Навык после учебного дня вырос, у голодного — меньше
+- [x] Потолок решений за тик не теряет решения, а сдвигает их
+- [x] Тот же сид и те же действия игрока дают ту же школу через игровую неделю
+- [x] Разбор решения таблицей входов и выходов, без мира и без хоста
## Критерий готовности
diff --git a/docs/phases/README.md b/docs/phases/README.md
index 9cac064..e52d3c4 100644
--- a/docs/phases/README.md
+++ b/docs/phases/README.md
@@ -90,4 +90,4 @@
| Фаза | Статус | Зачем |
| --- | --- | --- |
| [20. Нужды и действия](20-needs-actions.md) | ✅ | `ActionDef` с полями, декей, восполнение |
-| [21. Выбор действия](21-decisions.md) | ⬜ | Цели и веса, «дойти → сделать», уход с урока, рост навыка |
+| [21. Выбор действия](21-decisions.md) | ✅ | Цели и веса, «дойти → сделать», уход с урока, рост навыка |
diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md
new file mode 100644
index 0000000..83f1360
--- /dev/null
+++ b/docs/phases/reviewed.md
@@ -0,0 +1,96 @@
+# Проверенные срезы
+
+Журнал ревью. Фаза с ✅ в [`README.md`](README.md) — заявка автора; строка здесь — проверенный
+факт: каждое обещание фазы сверено с кодом и тестом, найденное дописано или исправлено.
+
+Коммит в «Проверен на» делает журнал самопроверяющимся. Срез перестаёт считаться проверенным,
+как только `git log <коммит>..HEAD -- <пути>` перестаёт быть пустым — поэтому «Пути» обязательны.
+
+## Срез 1. Оболочка и карта
+
+- **Фазы:** 0–4
+- **Проверен на:** `b135a9c`, 2026-08-19
+- **Пути:** `src/HSchool.Client`, `src/HSchool.Protocol`, `src/HSchool.Content`,
+ `src/HSchool.Server/Game`, `src/HSchool.Server/Api/ModEndpoints.cs`,
+ `src/HSchool.Server/Api/SchoolEndpoints.cs`, `src/HSchool.Server/Net`,
+ `tests/HSchool.Content.Tests`, `tests/HSchool.Protocol.Tests`,
+ `tests/HSchool.AppHost.Tests/SchoolApiTests.cs`, `tests/HSchool.AppHost.Tests/GameSocketTests.cs`
+- **Итог:** дописано 2 теста (оба прогнаны на чистом `b135a9c`), расхождений кода с дизайном
+ не найдено кроме одного —
+ панель «Общие события» из фазы 1 снята с экрана в фазе 8 и решение нигде не записано
+ (оставлено открытым, см. ниже)
+
+Что подтверждено:
+
+- **Фаза 0.** `pixi.js` нет ни в `package.json`, ни в lockfile, ни в импортах; `npm run build`
+ проходит.
+- **Фаза 1.** Дерево карты, панель локации со всеми пятью секциями и пустыми состояниями, клик по
+ дереву фильтрует на клиенте, `localize()` перекрашивает подписи. Панели «Общие события» на
+ экране нет — единственное расхождение среза.
+- **Фаза 2.** `SchoolWorker` — `LongRunning`-поток, свой `PeriodicTimer`, свой `World`, аккумулятор
+ с потолком в 5 шагов. Супервизор держит таблицу и не трогает `World` (инвариант 3). Сейв пишется
+ на create, на shutdown и по `SaveIntervalSeconds`; настройки коалесцируются. Требуемый фазой тест
+ «create → reload → та же школа с тем же временем» — это
+ `GameSocketTests.ReloadFromDisk_RestoresAPausedClock` (точное равенство времени, паузы и
+ скорости), плюс `SchoolApiTests.ReloadFromDisk_RestoresCreatedSchools`. Независимость школ —
+ `GameSocketTests.PausingOneSchool_DoesNotStopAnother`.
+- **Фаза 3.** Все восемь тестов из списка фазы на месте в `HSchool.Content.Tests` (JSONC-фикстуры,
+ last-wins по `defName` и по ключу локали, parent/abstract и цикл, патч `add` в `actions`,
+ связность, пустая комната, одна комната без двора). Ванильная раскладка валидируется
+ (`VanillaCoreTests.CoreDefaultMap_PassesValidation`). Пропавшая папка мода не стартует школу и
+ не трогает файл: исключение летит до создания `School`, поэтому `finally` с `Persist()`
+ недостижим, а супервизор только снимает школу с таблицы.
+- **Фаза 4.** `GET /api/mods`, `GET /api/catalog?lang=`, `POST /api/schools` с картой и модами,
+ снимок карты при OpenSchool в языке Hello — всё покрыто тестами
+ (`OpeningASchool_SendsAMapSnapshot`, `LabelsTheSnapshotInTheHelloLocale`,
+ `WithACustomMap_ReturnsThatLayout`, `ReloadFromDisk_RestoresACustomMap`). Кодек, `protocol.ts` и
+ `docs/protocol.md` описывают один и тот же `MapSnapshot`, байтовые смещения проверены с обеих
+ сторон; версия 7 совпадает в `ProtocolConstants` и `PROTOCOL_VERSION`.
+- **Инварианты.** `Content`/`People`/`Schedule`/`Ai` не ссылаются на Arch и ASP.NET, `Simulation` —
+ только на Arch; `DateTime.Now`/`UtcNow` в этих проектах нет.
+- **«Things that will bite you».** Ни одна из относящихся к срезу регрессий не вернулась:
+ `modal.ts` резолвит промис явно, `.dialog--screen` держит `height: fit-content`,
+ `DefaultStartDate` форсирует UTC-kind, аккумулятор работника на месте, `MapSnapshotSize`
+ считается от сообщения, `GameClient` переносит оба `WaitToReadAsync` между итерациями, outbox
+ роняет старейший кадр, меню патчит карточки через `card.update`.
+
+Дописано:
+
+- `SchoolApiTests.Catalog_WithAnUnknownMod_IsRejected` — `GET /api/catalog?mods=` с неизвестной
+ пачкой описан в `docs/protocol.md` как `400 unknown-mod`, но не проверялся.
+- `SchoolApiTests.ModId_ThatEscapesTheModsFolder_IsRejectedOnBothEndpoints` — инвариант 6: id
+ пачки приходит из браузера и подставляется в путь. `ModContent.IsSafePackId` его чистит на обоих
+ входах (каталог и создание школы), тестом это закреплено не было.
+
+Открыто:
+
+- **Панель «Общие события» пропала.** Фаза 1 требует три области, `design/near-term.md` держит их
+ в «Что уже зафиксировано», а `design/people.md` описывает список людей как панель «рядом с картой
+ и событиями». Коммит `d8f8db6` (фаза 8) заменил панель вкладками Карта/Люди, не сказав об этом;
+ ключи `eventsTitle`/`eventsEmpty` остались в `i18n/strings.ts` мёртвыми. Решать надо одно из
+ двух: вернуть пустую панель (или третью вкладку) — или записать отказ от неё в дизайн и убрать
+ ключи. Молча менять экран ревью не стало.
+
+Замечено рядом (за границами среза):
+
+- `SchoolRegistry` после фазы 2 в бою не используется: сервер тикает школы работниками, а из
+ класса вызывается только `TryNormalizeName`. `SchoolRegistryTests.Tick_AdvancesOnlyRunningSchools`
+ проверяет путь, которым сервер больше не ходит.
+- **`PeopleApiTests.Card_IncludesFamilyAndLiveNeeds` зависит от порядка тестов** (фаза 8, срез 2).
+ Проверено на чистом checkout `b135a9c` в отдельном worktree: в одиночку класс проходит (8/8),
+ в полном прогоне класса падает на `Assert.Equal(2, card.Family.Parents.Count)` — ожидалось 2,
+ пришёл 1. Причина: тест берёт `People[0]` и требует у него двух родителей, а состав семьи
+ зависит от сида, сид — от id школы, id — от того, сколько школ создали тесты до него (и от
+ `saves/index.json` на машине). На машине с накопленными сейвами тест зелёный, на чистом
+ клоне — красный. Чинить надо тест: брать ученика, у которого два родителя, а не первого
+ попавшегося. Неполные семьи — сознательное решение `design/people.md`, не баг генерации.
+- **`GameSocketTests.OpeningASchoolDuringAMathLesson_...` не выдерживает полный прогон** (срез 4).
+ В одиночку проходит за 1 с, в полном прогоне падает с «No presence frame matched within 40
+ frames» через 22 с. Похоже на нехватку кадров под нагрузкой, а не на ошибку присутствия.
+- Полный `dotnet test` один раз упал: процесс `HSchool.Simulation.Tests` умер с `0xC0000005`,
+ успев отчитаться о 44 тестах из 63, и два `ActivityTests` (фаза 20) не прошли. Повторить не
+ удалось. Скорее всего дерево правилось прямо во время прогона (см. ниже).
+- На момент проверки в рабочем дереве шла правка фазы 21 (`Decision.cs`, `LessonLearning.cs`,
+ `LessonLearningSystem.cs`, `PresenceSystem.cs`, `gameScreen.ts`), и какое-то время решение не
+ компилировалось. Выводам по срезу 1 это не мешает: его файлы правка не трогает, а дописанные
+ тесты прогнаны и на чистом `b135a9c`, и на дереве после починки сборки.
diff --git a/src/HSchool.Ai/Decision.cs b/src/HSchool.Ai/Decision.cs
new file mode 100644
index 0000000..7e2f855
--- /dev/null
+++ b/src/HSchool.Ai/Decision.cs
@@ -0,0 +1,462 @@
+using HSchool.Content;
+
+namespace HSchool.Ai;
+
+public enum GoalKind
+{
+ None,
+ Duty,
+ Need,
+ Leisure,
+}
+
+/// What this person is currently pursuing. Compared against a new winner using switchMargin.
+public readonly record struct Intent(GoalKind Kind, string? Id, float Weight, string? ActionId)
+{
+ public static Intent None { get; } = new(GoalKind.None, null, 0f, null);
+
+ public bool IsSet => Kind != GoalKind.None;
+}
+
+/// Walk, start an action, or stay. Heading home is not a goal — the day plan handles it.
+public readonly record struct Decision(
+ string? WalkTo,
+ string? StartAction,
+ Intent Intent)
+{
+ public static Decision Stay(Intent intent) => new(null, null, intent);
+}
+
+public readonly record struct ActorState(
+ string? NodeId,
+ string? DestinationId,
+ bool IsWalking,
+ bool ActivityActive,
+ bool IsStudent,
+ bool IsStaff,
+ bool IsParent,
+ bool BoundToLesson,
+ string? DutyRoom,
+ IReadOnlyDictionary Needs,
+ Intent Intent);
+
+///
+/// Picks a goal by weight and plans walk-then-do. No world, no clock — a table of inputs to an
+/// output. Duty is a strong goal, not an order; a need at zero beats it, a need just under the
+/// threshold does not.
+///
+public static class DecisionPlanner
+{
+ /// Lesson or posted work. Beats leisure and a need that only just crossed the threshold.
+ public const float DutyLessonWeight = 10f;
+
+ /// Walk to the next room on a break. Beats chatting in the corridor you are standing in.
+ public const float DutyTravelWeight = 5f;
+
+ /// Need at zero. Beats a lesson so a desperate toilet trip leaves class.
+ public const float NeedWeightAtZero = 20f;
+
+ public static Decision Decide(
+ DefCatalog catalog,
+ MapLayout map,
+ WalkGraph walks,
+ ActorState state,
+ OccupiedCount occupied)
+ {
+ ArgumentNullException.ThrowIfNull(catalog);
+ ArgumentNullException.ThrowIfNull(map);
+ ArgumentNullException.ThrowIfNull(walks);
+ ArgumentNullException.ThrowIfNull(occupied);
+
+ var rules = catalog.BehaviorRules;
+ var threshold = rules?.NeedThreshold ?? 0.35f;
+ var margin = rules?.SwitchMargin ?? 0.15f;
+ var best = PickGoal(catalog, map, walks, state, occupied, threshold);
+ var held = HeldGoal(catalog, map, walks, state, occupied, threshold);
+ if (held.IsSet && best.Weight <= held.Weight + margin && SameGoal(state.Intent, held))
+ {
+ return Continue(state with { Intent = held });
+ }
+
+ return Plan(catalog, map, walks, state, occupied, best);
+ }
+
+ private static Intent PickGoal(
+ DefCatalog catalog,
+ MapLayout map,
+ WalkGraph walks,
+ ActorState state,
+ OccupiedCount occupied,
+ float threshold)
+ {
+ var best = Intent.None;
+ Consider(ref best, DutyGoal(state));
+ foreach (var need in catalog.Needs.Values.OrderBy(def => def.DefName, StringComparer.Ordinal))
+ {
+ Consider(ref best, NeedGoal(catalog, map, walks, state, occupied, need, threshold));
+ }
+
+ if (best.Kind != GoalKind.Duty || best.Weight < DutyLessonWeight)
+ {
+ foreach (var action in catalog.Actions.Values.OrderBy(def => def.DefName, StringComparer.Ordinal))
+ {
+ Consider(ref best, LeisureGoal(catalog, map, walks, state, occupied, action));
+ }
+ }
+
+ return best;
+ }
+
+ ///
+ /// The stored intent's weight under current inputs. A lesson that just ended is not still
+ /// worth 10 — otherwise leisure can never beat a stale duty.
+ ///
+ private static Intent HeldGoal(
+ DefCatalog catalog,
+ MapLayout map,
+ WalkGraph walks,
+ ActorState state,
+ OccupiedCount occupied,
+ float threshold)
+ {
+ if (!state.Intent.IsSet)
+ {
+ return Intent.None;
+ }
+
+ switch (state.Intent.Kind)
+ {
+ case GoalKind.Duty:
+ return DutyGoal(state);
+ case GoalKind.Need:
+ if (state.Intent.Id is null || !catalog.Needs.TryGetValue(state.Intent.Id, out var need))
+ {
+ return Intent.None;
+ }
+
+ return NeedGoal(catalog, map, walks, state, occupied, need, threshold);
+ case GoalKind.Leisure:
+ if (state.Intent.ActionId is null || !catalog.Actions.TryGetValue(state.Intent.ActionId, out var action))
+ {
+ return Intent.None;
+ }
+
+ return LeisureGoal(catalog, map, walks, state, occupied, action);
+ default:
+ return Intent.None;
+ }
+ }
+
+ private static Intent DutyGoal(ActorState state)
+ {
+ if (state.DutyRoom is null)
+ {
+ return Intent.None;
+ }
+
+ if (state.BoundToLesson)
+ {
+ return new Intent(GoalKind.Duty, state.DutyRoom, DutyLessonWeight, null);
+ }
+
+ if (state.Intent.Kind == GoalKind.Leisure)
+ {
+ return Intent.None;
+ }
+
+ if (state.NodeId is not null
+ && state.NodeId.Equals(state.DutyRoom, StringComparison.Ordinal)
+ && !state.IsWalking)
+ {
+ // Arrived this break (travel-weight duty). Stay put so a 3-weight chat does not
+ // pull them out of the gym they just walked to. A leftover lesson intent (weight
+ // 10) in the same room is the other case: the next lesson is here, leisure can win.
+ if (state.Intent.Kind == GoalKind.Duty
+ && string.Equals(state.Intent.Id, state.DutyRoom, StringComparison.Ordinal)
+ && state.Intent.Weight <= DutyTravelWeight)
+ {
+ return new Intent(GoalKind.Duty, state.DutyRoom, DutyTravelWeight, null);
+ }
+
+ return Intent.None;
+ }
+
+ return new Intent(GoalKind.Duty, state.DutyRoom, DutyTravelWeight, null);
+ }
+
+ private static Intent NeedGoal(
+ DefCatalog catalog,
+ MapLayout map,
+ WalkGraph walks,
+ ActorState state,
+ OccupiedCount occupied,
+ NeedDef need,
+ float threshold)
+ {
+ if (need.Abstract || !state.Needs.TryGetValue(need.DefName, out var value) || value >= threshold)
+ {
+ return Intent.None;
+ }
+
+ var action = ActionForNeed(catalog, state, need.DefName);
+ if (action is null || 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;
+ return new Intent(GoalKind.Need, need.DefName, weight, action.DefName);
+ }
+
+ private static Intent LeisureGoal(
+ DefCatalog catalog,
+ MapLayout map,
+ WalkGraph walks,
+ ActorState state,
+ OccupiedCount occupied,
+ ActionDef action)
+ {
+ if (action.Abstract || action.Weight <= 0 || !RoleFits(action, state))
+ {
+ return Intent.None;
+ }
+
+ if (RoomFor(catalog, map, walks, state, occupied, action) is null)
+ {
+ return Intent.None;
+ }
+
+ return new Intent(GoalKind.Leisure, action.DefName, action.Weight, action.DefName);
+ }
+
+ private static Decision Continue(ActorState state)
+ {
+ if (state.ActivityActive)
+ {
+ return Decision.Stay(state.Intent);
+ }
+
+ if (state.Intent.ActionId is not null
+ && !state.IsWalking
+ && state.NodeId is not null
+ && (state.DestinationId is null || state.NodeId.Equals(state.DestinationId, StringComparison.Ordinal)))
+ {
+ return new Decision(null, state.Intent.ActionId, state.Intent);
+ }
+
+ if (state.Intent.Kind == GoalKind.Duty
+ && state.NodeId is not null
+ && state.Intent.Id is not null
+ && state.NodeId.Equals(state.Intent.Id, StringComparison.Ordinal)
+ && !state.IsWalking)
+ {
+ return Decision.Stay(state.Intent);
+ }
+
+ return new Decision(state.DestinationId ?? state.Intent.Id, null, state.Intent);
+ }
+
+ private static Decision Plan(
+ DefCatalog catalog,
+ MapLayout map,
+ WalkGraph walks,
+ ActorState state,
+ OccupiedCount occupied,
+ Intent goal)
+ {
+ if (!goal.IsSet)
+ {
+ return Decision.Stay(Intent.None);
+ }
+
+ if (goal.Kind == GoalKind.Duty)
+ {
+ var room = goal.Id;
+ if (room is null || (state.NodeId is not null && state.NodeId.Equals(room, StringComparison.Ordinal) && !state.IsWalking))
+ {
+ return Decision.Stay(goal);
+ }
+
+ return new Decision(room, null, goal);
+ }
+
+ if (goal.ActionId is null || !catalog.Actions.TryGetValue(goal.ActionId, out var action))
+ {
+ return Decision.Stay(Intent.None);
+ }
+
+ var node = RoomFor(catalog, map, walks, state, occupied, action);
+ if (node is null)
+ {
+ return Decision.Stay(Intent.None);
+ }
+
+ if (state.NodeId is not null && state.NodeId.Equals(node, StringComparison.Ordinal) && !state.IsWalking)
+ {
+ return new Decision(null, action.DefName, goal);
+ }
+
+ return new Decision(node, null, goal);
+ }
+
+ private static ActionDef? ActionForNeed(DefCatalog catalog, ActorState state, string need)
+ {
+ ActionDef? best = null;
+ foreach (var action in catalog.Actions.Values.OrderBy(def => def.DefName, StringComparer.Ordinal))
+ {
+ if (action.Abstract
+ || !need.Equals(action.Need, StringComparison.Ordinal)
+ || !RoleFits(action, state))
+ {
+ continue;
+ }
+
+ if (best is null || action.NeedGain > best.NeedGain)
+ {
+ best = action;
+ }
+ }
+
+ return best;
+ }
+
+ private static string? RoomFor(
+ DefCatalog catalog,
+ MapLayout map,
+ WalkGraph walks,
+ ActorState state,
+ OccupiedCount occupied,
+ ActionDef action)
+ {
+ if (string.IsNullOrWhiteSpace(action.Room))
+ {
+ return null;
+ }
+
+ string? best = null;
+ var bestCost = float.PositiveInfinity;
+ var from = state.NodeId ?? walks.TerritoryId;
+ foreach (var room in map.Rooms)
+ {
+ if (!action.Room.Equals(room.Def, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (!HasSlot(catalog, map, occupied, room.Id, action.Thing))
+ {
+ continue;
+ }
+
+ var cost = walks.Minutes(from, room.Id);
+ if (float.IsInfinity(cost) || cost > bestCost)
+ {
+ continue;
+ }
+
+ if (cost < bestCost || best is null || string.CompareOrdinal(room.Id, best) < 0)
+ {
+ best = room.Id;
+ bestCost = cost;
+ }
+ }
+
+ if (map.Territory is { } territory
+ && action.Room.Equals(territory.Def, StringComparison.Ordinal)
+ && HasSlot(catalog, map, occupied, territory.Id, action.Thing))
+ {
+ var cost = walks.Minutes(from, territory.Id);
+ if (!float.IsInfinity(cost) && (best is null || cost < bestCost || (cost == bestCost && string.CompareOrdinal(territory.Id, best) < 0)))
+ {
+ best = territory.Id;
+ }
+ }
+
+ return best;
+ }
+
+ private static bool HasSlot(DefCatalog catalog, MapLayout map, OccupiedCount occupied, string nodeId, string? thing)
+ {
+ if (string.IsNullOrWhiteSpace(thing))
+ {
+ return true;
+ }
+
+ var available = RoomOccupancy.ThingCount(catalog, map, nodeId, thing);
+ return ActionStepper.CanOccupy(available, occupied(nodeId, thing));
+ }
+
+ private static bool RoleFits(ActionDef action, ActorState state)
+ {
+ if (action.Roles.Count == 0)
+ {
+ return true;
+ }
+
+ foreach (var role in action.Roles)
+ {
+ if (role.Equals(PersonRoles.Student, StringComparison.OrdinalIgnoreCase) && state.IsStudent)
+ {
+ return true;
+ }
+
+ if (role.Equals(PersonRoles.Staff, StringComparison.OrdinalIgnoreCase) && state.IsStaff)
+ {
+ return true;
+ }
+
+ if (role.Equals(PersonRoles.Parent, StringComparison.OrdinalIgnoreCase) && state.IsParent)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool SameGoal(Intent current, Intent held)
+ {
+ if (current.Kind != held.Kind)
+ {
+ return false;
+ }
+
+ // A new duty room (classroom → gym on the break) is not the same goal even at a similar
+ // weight; keeping the old id would walk back to the lesson they just left.
+ if (current.Kind == GoalKind.Duty)
+ {
+ return string.Equals(current.Id, held.Id, StringComparison.Ordinal);
+ }
+
+ return true;
+ }
+
+ private static void Consider(ref Intent best, Intent candidate)
+ {
+ if (!candidate.IsSet)
+ {
+ return;
+ }
+
+ if (!best.IsSet
+ || candidate.Weight > best.Weight
+ || (candidate.Weight == best.Weight && Order(candidate.Kind) < Order(best.Kind))
+ || (candidate.Weight == best.Weight
+ && candidate.Kind == best.Kind
+ && string.CompareOrdinal(candidate.Id, best.Id) < 0))
+ {
+ best = candidate;
+ }
+ }
+
+ private static int Order(GoalKind kind) => kind switch
+ {
+ GoalKind.Duty => 0,
+ GoalKind.Need => 1,
+ GoalKind.Leisure => 2,
+ _ => 3,
+ };
+}
+
+public delegate int OccupiedCount(string nodeId, string thing);
diff --git a/src/HSchool.Ai/LessonLearning.cs b/src/HSchool.Ai/LessonLearning.cs
new file mode 100644
index 0000000..35f0c07
--- /dev/null
+++ b/src/HSchool.Ai/LessonLearning.cs
@@ -0,0 +1,28 @@
+using HSchool.Content;
+
+namespace HSchool.Ai;
+
+///
+/// How much a lesson adds to one skill this step. Hungry learns worse; trait offsets scale the
+/// rate. The world stores the running total — this is just the number.
+///
+public static class LessonLearning
+{
+ public static float NeedFactor(float hunger) => Math.Clamp(0.25f + (0.75f * hunger), 0.25f, 1f);
+
+ public static float TraitFactor(int offset) => Math.Max(0.1f, 1f + (offset / 100f));
+
+ public static float Gain(
+ float current,
+ SkillDef skill,
+ float share,
+ float lessonSkillPerHour,
+ float hours,
+ float hunger,
+ int traitOffset)
+ {
+ ArgumentNullException.ThrowIfNull(skill);
+ var delta = share * lessonSkillPerHour * hours * NeedFactor(hunger) * TraitFactor(traitOffset);
+ return Math.Clamp(current + delta, skill.Range.Min, skill.Range.Max);
+ }
+}
diff --git a/src/HSchool.Client/src/i18n/strings.test.ts b/src/HSchool.Client/src/i18n/strings.test.ts
index bb5717b..f1bf935 100644
--- a/src/HSchool.Client/src/i18n/strings.test.ts
+++ b/src/HSchool.Client/src/i18n/strings.test.ts
@@ -29,6 +29,7 @@ describe('t', () => {
expect(t('mapHeadcount', { name: 'Коридор', count: 12 })).toBe('Коридор (12)');
expect(t('mapHeadcountActivity', { name: 'Класс 101', count: 18, activity: 'Математика · 5А' }))
.toBe('Класс 101 (18 · Математика · 5А)');
+ expect(t('locationWalking', { name: 'Иванов' })).toBe('Иванов (walking)');
});
});
diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts
index d56dccc..d9b4408 100644
--- a/src/HSchool.Client/src/i18n/strings.ts
+++ b/src/HSchool.Client/src/i18n/strings.ts
@@ -175,6 +175,7 @@ const ru = {
presenceAt: '{name}',
presenceWalking: 'в пути ({name})',
presenceAway: 'вне школы',
+ locationWalking: '{name} (в пути)',
timetableTitle: 'Расписание',
timetableClass: 'Класс',
timetableEmpty: 'Нет уроков.',
@@ -372,6 +373,7 @@ const en: Messages = {
presenceAt: '{name}',
presenceWalking: 'walking ({name})',
presenceAway: 'off campus',
+ locationWalking: '{name} (walking)',
timetableTitle: 'Timetable',
timetableClass: 'Class',
timetableEmpty: 'No lessons.',
diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts
index 5a874aa..5a0f5c2 100644
--- a/src/HSchool.Client/src/ui/gameScreen.ts
+++ b/src/HSchool.Client/src/ui/gameScreen.ts
@@ -405,7 +405,10 @@ export class GameScreen {
const names = this.presence.people
.filter((person) => person.nodeId === id)
- .map((person) => this.directory.get(person.id) ?? person.id);
+ .map((person) => {
+ const name = this.directory.get(person.id) ?? person.id;
+ return person.state === PresenceState.Walking ? t('locationWalking', { name }) : name;
+ });
names.sort((left, right) => left.localeCompare(right));
return names;
}
diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs
index 760b139..968ad73 100644
--- a/src/HSchool.Content/PeopleDefs.cs
+++ b/src/HSchool.Content/PeopleDefs.cs
@@ -211,7 +211,7 @@ public sealed class BehaviorDef : Def
/// A need at or below this value is urgent. Phase 21 turns that into a goal weight.
public float NeedThreshold { get; init; }
- /// Skill points a lesson adds per game hour, before traits and need state. Unused until phase 21.
+ /// Skill points a lesson adds per game hour, before traits and need state.
public float LessonSkillPerHour { get; init; }
/// Inclusive range of extra commute minutes rolled per person per day.
diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs
index 572c43b..856714e 100644
--- a/src/HSchool.Server/Game/PersonCardReader.cs
+++ b/src/HSchool.Server/Game/PersonCardReader.cs
@@ -14,6 +14,9 @@ internal static class PersonCardReader
private static readonly QueryDescription IdentityAndNeeds =
new QueryDescription().WithAll();
+ private static readonly QueryDescription IdentityAndSkills =
+ new QueryDescription().WithAll();
+
private static readonly QueryDescription IdentityAndActivity =
new QueryDescription().WithAll();
@@ -45,6 +48,7 @@ internal static class PersonCardReader
}
var needs = LiveNeeds(school.World, personId) ?? person.Needs;
+ var skills = LiveSkills(school.World, personId);
var activityId = LiveActivity(school.World, personId);
string? activityLabel = null;
if (activityId is not null && catalog is not null && catalog.Actions.TryGetValue(activityId, out var action))
@@ -72,7 +76,7 @@ internal static class PersonCardReader
person.Position,
PeopleListMapper.PositionLabel(catalog, locale, person.Position),
Body(person, catalog, locale),
- Skills(person, catalog, locale),
+ Skills(person, skills, catalog, locale),
Traits(person, catalog, locale),
Needs(needs, catalog, locale),
activityId,
@@ -93,6 +97,19 @@ internal static class PersonCardReader
return found;
}
+ private static IReadOnlyDictionary? LiveSkills(World world, string personId)
+ {
+ Dictionary? found = null;
+ world.Query(in IdentityAndSkills, (ref PersonIdentity identity, ref PersonSkills skills) =>
+ {
+ if (identity.Id.Equals(personId, StringComparison.Ordinal))
+ {
+ found = new Dictionary(skills.Values, StringComparer.Ordinal);
+ }
+ });
+ return found;
+ }
+
private static string? LiveActivity(World world, string personId)
{
string? found = null;
@@ -153,10 +170,21 @@ internal static class PersonCardReader
return rows;
}
- private static IReadOnlyList Skills(Person person, DefCatalog? catalog, string locale)
+ private static IReadOnlyList Skills(
+ Person person,
+ IReadOnlyDictionary? live,
+ DefCatalog? catalog,
+ string locale)
{
if (catalog is null)
{
+ if (live is not null)
+ {
+ return live
+ .Select(pair => new LabeledStatResponse(pair.Key, pair.Key, FormatSkill(pair.Value)))
+ .ToArray();
+ }
+
return person.Skills
.Select(pair => new LabeledStatResponse(pair.Key, pair.Key, pair.Value.ToString()))
.ToArray();
@@ -165,7 +193,23 @@ internal static class PersonCardReader
var rows = new List();
foreach (var def in catalog.Skills.Values)
{
- if (def.Abstract || !person.Skills.TryGetValue(def.DefName, out var value))
+ if (def.Abstract)
+ {
+ continue;
+ }
+
+ if (live is not null)
+ {
+ if (!live.TryGetValue(def.DefName, out var liveValue))
+ {
+ continue;
+ }
+
+ rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), FormatSkill(liveValue)));
+ continue;
+ }
+
+ if (!person.Skills.TryGetValue(def.DefName, out var value))
{
continue;
}
@@ -176,6 +220,8 @@ internal static class PersonCardReader
return rows;
}
+ private static string FormatSkill(float value) => Math.Round(value, 2).ToString("0.##");
+
private static IReadOnlyList Traits(Person person, DefCatalog? catalog, string locale)
{
var rows = new List(person.Traits.Count);
diff --git a/src/HSchool.Simulation/ActivitySystem.cs b/src/HSchool.Simulation/ActivitySystem.cs
index 041ca2e..b68b5f5 100644
--- a/src/HSchool.Simulation/ActivitySystem.cs
+++ b/src/HSchool.Simulation/ActivitySystem.cs
@@ -69,17 +69,18 @@ internal static class ActivitySystem
return started;
}
- public static void Apply(School school, double gameMinutes)
+ public static IReadOnlyList Apply(School school, double gameMinutes)
{
if (school.Catalog is null || gameMinutes <= 0)
{
- return;
+ return [];
}
var catalog = school.Catalog;
var minutes = (float)gameMinutes;
+ var completed = new List();
var world = school.World;
- world.Query(in People, (ref PersonNeeds needs, ref Presence presence, ref PersonActivity activity) =>
+ world.Query(in People, (ref PersonIdentity identity, ref PersonNeeds needs, ref Presence presence, ref PersonActivity activity) =>
{
if (!activity.IsActive)
{
@@ -92,11 +93,18 @@ internal static class ActivitySystem
return;
}
+ var location = school.Map?.NodeDef(presence.NodeId ?? "");
+ if (location is null || !location.Equals(action.Room, StringComparison.Ordinal))
+ {
+ activity = PersonActivity.Idle;
+ return;
+ }
+
var next = ActionStepper.Advance(
new ActivityProgress(activity.ActionId, activity.Thing, activity.RemainingMinutes),
minutes,
- out var completed);
- if (!completed)
+ out var finished);
+ if (!finished)
{
activity = new PersonActivity(next.ActionId, next.Thing, next.RemainingMinutes);
return;
@@ -110,7 +118,10 @@ internal static class ActivitySystem
}
activity = PersonActivity.Idle;
+ completed.Add(identity.Id);
});
+ completed.Sort(StringComparer.Ordinal);
+ return completed;
}
private static int Occupied(School school, string nodeId, string thing)
diff --git a/src/HSchool.Simulation/Components/PersonComponents.cs b/src/HSchool.Simulation/Components/PersonComponents.cs
index 58cceff..348e0d7 100644
--- a/src/HSchool.Simulation/Components/PersonComponents.cs
+++ b/src/HSchool.Simulation/Components/PersonComponents.cs
@@ -15,7 +15,7 @@ public readonly record struct PersonBody(
IReadOnlyDictionary Numbers,
IReadOnlyDictionary Choices);
-public readonly record struct PersonSkills(IReadOnlyDictionary Values);
+public readonly record struct PersonSkills(Dictionary Values);
public readonly record struct PersonTraits(IReadOnlyList Ids);
diff --git a/src/HSchool.Simulation/LessonLearningSystem.cs b/src/HSchool.Simulation/LessonLearningSystem.cs
new file mode 100644
index 0000000..38f9e75
--- /dev/null
+++ b/src/HSchool.Simulation/LessonLearningSystem.cs
@@ -0,0 +1,131 @@
+using Arch.Core;
+using HSchool.Ai;
+using HSchool.Content;
+using HSchool.People;
+using HSchool.Schedule;
+
+namespace HSchool.Simulation;
+
+///
+/// Grows skills for people who are actually in the lesson: at the room, not walking, not off
+/// doing something else. The formula lives in .
+///
+internal static class LessonLearningSystem
+{
+ private static readonly QueryDescription People =
+ new QueryDescription().WithAll();
+
+ public static void Apply(School school, double gameMinutes)
+ {
+ if (school.Catalog is null || school.Timetable is null || school.Roster is null || gameMinutes <= 0)
+ {
+ return;
+ }
+
+ var rules = school.Catalog.BehaviorRules;
+ if (rules is null || rules.LessonSkillPerHour <= 0)
+ {
+ return;
+ }
+
+ var slot = SchoolDay.At(school.Catalog, school.Clock.Time, school.SchoolWeekDays);
+ if (slot.Kind != DaySlotKind.Lesson)
+ {
+ return;
+ }
+
+ var hours = (float)(gameMinutes / 60d);
+ var weekday = SchoolDay.WeekdayIndex(school.Clock.Time);
+ var catalog = school.Catalog;
+ var world = school.World;
+ world.Query(
+ in People,
+ (ref PersonIdentity identity, ref PersonSkills skills, ref PersonTraits traits, ref PersonNeeds needs, ref PersonRoles roles, ref Presence presence, ref PersonActivity activity) =>
+ {
+ if (activity.IsActive || presence.NodeId is null || presence.Path.Length > 0 || presence.RemainingMinutes > 0)
+ {
+ return;
+ }
+
+ var personId = identity.Id;
+ var person = school.Roster.People.FirstOrDefault(row => row.Id.Equals(personId, StringComparison.Ordinal));
+ if (person is null)
+ {
+ return;
+ }
+
+ var lesson = CurrentLesson(school, person, weekday, slot.Index);
+ if (lesson is null
+ || !presence.NodeId.Equals(lesson.RoomId, StringComparison.Ordinal)
+ || !catalog.Subjects.TryGetValue(lesson.Subject, out var subject))
+ {
+ return;
+ }
+
+ var hunger = needs.Values.GetValueOrDefault("Hunger", 1f);
+ foreach (var share in subject.Skills)
+ {
+ if (!catalog.Skills.TryGetValue(share.Skill, out var skill)
+ || skill.Abstract
+ || !skills.Values.TryGetValue(share.Skill, out var current))
+ {
+ continue;
+ }
+
+ skills.Values[share.Skill] = LessonLearning.Gain(
+ current,
+ skill,
+ share.Share,
+ rules.LessonSkillPerHour,
+ hours,
+ hunger,
+ TraitOffset(catalog, traits, share.Skill));
+ }
+ });
+ }
+
+ private static LessonPlacement? CurrentLesson(School school, Person person, int weekday, int period)
+ {
+ foreach (var lesson in Duty.LessonsToday(person, ClassOf(school, person), school.Timetable, weekday))
+ {
+ if (lesson.Period == period)
+ {
+ return lesson;
+ }
+ }
+
+ return null;
+ }
+
+ private static SchoolClass? ClassOf(School school, Person person)
+ {
+ if (person.ClassId is null)
+ {
+ return null;
+ }
+
+ return school.Roster?.Classes.FirstOrDefault(row => row.Id.Equals(person.ClassId, StringComparison.Ordinal));
+ }
+
+ private static int TraitOffset(DefCatalog catalog, PersonTraits traits, string skill)
+ {
+ var offset = 0;
+ foreach (var name in traits.Ids)
+ {
+ if (!catalog.Traits.TryGetValue(name, out var trait))
+ {
+ continue;
+ }
+
+ foreach (var modifier in trait.SkillModifiers)
+ {
+ if (modifier.Skill.Equals(skill, StringComparison.Ordinal))
+ {
+ offset += modifier.Offset;
+ }
+ }
+ }
+
+ return offset;
+ }
+}
diff --git a/src/HSchool.Simulation/PresenceSystem.cs b/src/HSchool.Simulation/PresenceSystem.cs
index b91fa0e..f732db4 100644
--- a/src/HSchool.Simulation/PresenceSystem.cs
+++ b/src/HSchool.Simulation/PresenceSystem.cs
@@ -13,7 +13,7 @@ namespace HSchool.Simulation;
internal static class PresenceSystem
{
private static readonly QueryDescription People =
- new QueryDescription().WithAll();
+ new QueryDescription().WithAll();
public static void Apply(School school, double gameMinutes)
{
@@ -23,13 +23,58 @@ internal static class PresenceSystem
}
EnsurePlans(school);
+ school.DecisionBudget = school.MaxDecisionsPerTick;
EnqueueEvents(school);
EnqueueTimeEvents(school, (float)gameMinutes);
DrainDecisions(school);
Move(school, (float)gameMinutes);
FinishHome(school);
+ DrainDecisions(school);
}
+ public static IReadOnlySet BelowThreshold(School school)
+ {
+ var ids = new HashSet(StringComparer.Ordinal);
+ if (school.Catalog?.BehaviorRules is null)
+ {
+ return ids;
+ }
+
+ var threshold = school.Catalog.BehaviorRules.NeedThreshold;
+ var world = school.World;
+ var query = new QueryDescription().WithAll();
+ world.Query(in query, (ref PersonIdentity identity, ref PersonNeeds needs, ref Presence presence) =>
+ {
+ if (!presence.IsOnCampus)
+ {
+ return;
+ }
+
+ foreach (var def in school.Catalog.Needs.Values)
+ {
+ if (!def.Abstract && needs.Values.TryGetValue(def.DefName, out var value) && value < threshold)
+ {
+ ids.Add(identity.Id);
+ return;
+ }
+ }
+ });
+ return ids;
+ }
+
+ public static void EnqueueNewlyUrgent(School school, IReadOnlySet previouslyBelow)
+ {
+ foreach (var id in BelowThreshold(school))
+ {
+ if (!previouslyBelow.Contains(id))
+ {
+ school.DecisionQueue.Enqueue(id);
+ }
+ }
+ }
+
+ public static void Enqueue(School school, string personId) => school.DecisionQueue.Enqueue(personId);
+
public static bool IsEmpty(School school)
{
var empty = true;
@@ -48,7 +93,7 @@ internal static class PresenceSystem
{
var rows = new List();
var world = school.World;
- world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity) =>
+ world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
{
rows.Add(new PresenceSnapshot(
identity.Id,
@@ -59,7 +104,11 @@ internal static class PresenceSystem
presence.Path,
activity.ActionId,
activity.Thing,
- activity.RemainingMinutes));
+ activity.RemainingMinutes,
+ intent.Kind.ToString(),
+ intent.Id,
+ intent.Weight,
+ intent.ActionId));
});
rows.Sort((left, right) => StringComparer.Ordinal.Compare(left.PersonId, right.PersonId));
return rows;
@@ -77,12 +126,13 @@ internal static class PresenceSystem
var byId = saved
.Where(row => !string.IsNullOrWhiteSpace(row.PersonId))
.ToDictionary(row => row.PersonId, StringComparer.Ordinal);
- ForEachPerson(school, (person, _, ref presence, ref activity) =>
+ ForEachPerson(school, (person, _, ref presence, ref activity, ref intent) =>
{
if (!byId.TryGetValue(person.Id, out var row))
{
presence = PlaceByDuty(school, person);
activity = PersonActivity.Idle;
+ intent = Intent.None;
return;
}
@@ -90,6 +140,7 @@ internal static class PresenceSystem
{
presence = Presence.OffCampus;
activity = PersonActivity.Idle;
+ intent = Intent.None;
return;
}
@@ -102,21 +153,33 @@ internal static class PresenceSystem
activity = string.IsNullOrWhiteSpace(row.ActionId)
? PersonActivity.Idle
: new PersonActivity(row.ActionId, row.ActionThing, row.ActionRemaining);
+ intent = ParseIntent(row);
});
}
public static void PlaceMissingByDuty(School school)
{
- ForEachPerson(school, (person, _, ref presence, ref activity) =>
+ ForEachPerson(school, (person, _, ref presence, ref activity, ref intent) =>
{
if (!presence.IsOnCampus)
{
presence = PlaceByDuty(school, person);
activity = PersonActivity.Idle;
+ intent = Intent.None;
}
});
}
+ public static void DrainDecisions(School school)
+ {
+ while (school.DecisionBudget > 0 && school.DecisionQueue.Count > 0)
+ {
+ var id = school.DecisionQueue.Dequeue();
+ Decide(school, id);
+ school.DecisionBudget--;
+ }
+ }
+
private static Presence PlaceByDuty(School school, Person person)
{
var room = Duty.RoomAt(
@@ -207,17 +270,6 @@ internal static class PresenceSystem
}
}
- private static void DrainDecisions(School school)
- {
- var budget = school.MaxDecisionsPerTick;
- while (budget > 0 && school.DecisionQueue.Count > 0)
- {
- var id = school.DecisionQueue.Dequeue();
- Decide(school, id);
- budget--;
- }
- }
-
private static void Decide(School school, string personId)
{
var person = school.Roster!.People.FirstOrDefault(candidate => candidate.Id.Equals(personId, StringComparison.Ordinal));
@@ -226,26 +278,55 @@ internal static class PresenceSystem
return;
}
+ var occupied = SnapshotOccupied(school, personId);
+ string? startAction = null;
var world = school.World;
var found = false;
- world.Query(in People, (ref PersonIdentity identity, ref Presence presence) =>
- {
- if (found || !identity.Id.Equals(personId, StringComparison.Ordinal))
+ world.Query(
+ in People,
+ (ref PersonIdentity identity, ref PersonRoles roles, ref PersonNeeds needs, ref PersonTraits traits, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
{
- return;
- }
+ if (found || !identity.Id.Equals(personId, StringComparison.Ordinal))
+ {
+ return;
+ }
- found = true;
- presence = NextPresence(school, person, plan, presence);
- });
+ found = true;
+ _ = traits;
+ startAction = ApplyDecision(
+ school,
+ person,
+ plan,
+ occupied,
+ ref presence,
+ ref activity,
+ ref intent,
+ roles,
+ needs);
+ });
+
+ if (startAction is not null)
+ {
+ ActivitySystem.TryStart(school, person.Id, startAction);
+ }
}
- private static Presence NextPresence(School school, Person person, DayPlan plan, Presence presence)
+ private static string? ApplyDecision(
+ School school,
+ Person person,
+ DayPlan plan,
+ Dictionary<(string Node, string Thing), int> occupied,
+ ref Presence presence,
+ ref PersonActivity activity,
+ ref Intent intent,
+ PersonRoles roles,
+ PersonNeeds needs)
{
var now = school.Clock.Time;
var walks = school.Walks!;
if (!presence.IsOnCampus)
{
+ intent = Intent.None;
if (plan.AppearAt is { } appear && now >= appear && (plan.WalkHomeAt is null || now < plan.WalkHomeAt))
{
var dest = plan.FirstRoom ?? Duty.RoomAt(
@@ -255,15 +336,22 @@ internal static class PresenceSystem
school.Catalog!,
now,
school.SchoolWeekDays);
- return dest is null ? Presence.OffCampus : PresenceStepper.StartWalk(Presence.OffCampus, walks, dest, headingHome: false);
+ presence = dest is null
+ ? Presence.OffCampus
+ : PresenceStepper.StartWalk(Presence.OffCampus, walks, dest, headingHome: false);
+ return null;
}
- return Presence.OffCampus;
+ presence = Presence.OffCampus;
+ return null;
}
if (plan.WalkHomeAt is { } leave && now >= leave)
{
- return PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true);
+ activity = PersonActivity.Idle;
+ intent = Intent.None;
+ presence = PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true);
+ return null;
}
var duty = Duty.RoomAt(
@@ -275,15 +363,71 @@ internal static class PresenceSystem
school.SchoolWeekDays);
if (duty is null)
{
- return PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true);
+ activity = PersonActivity.Idle;
+ intent = Intent.None;
+ presence = PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true);
+ return null;
}
- if (presence.HeadingHome || !duty.Equals(presence.DestinationId, StringComparison.Ordinal))
+ var slot = SchoolDay.At(school.Catalog!, now, school.SchoolWeekDays);
+ var weekday = SchoolDay.WeekdayIndex(now);
+ var lessons = Duty.LessonsToday(person, ClassOf(school, person), school.Timetable, weekday);
+ var bound = Duty.IsOtherStaff(person)
+ ? slot.Kind != DaySlotKind.Outside
+ : slot.Kind == DaySlotKind.Lesson && lessons.Any(lesson => lesson.Period == slot.Index);
+ var state = new ActorState(
+ presence.NodeId,
+ presence.DestinationId,
+ presence.Path.Length > 0 || presence.RemainingMinutes > 0,
+ activity.IsActive,
+ roles.IsStudent,
+ roles.IsStaff,
+ roles.IsParent,
+ bound,
+ duty,
+ needs.Values,
+ intent);
+ var decision = DecisionPlanner.Decide(
+ school.Catalog!,
+ school.Map!,
+ walks,
+ state,
+ (node, thing) => occupied.GetValueOrDefault((node, thing)));
+
+ if (decision.WalkTo is not null
+ && !decision.WalkTo.Equals(presence.NodeId, StringComparison.Ordinal)
+ && activity.IsActive)
{
- return PresenceStepper.StartWalk(presence, walks, duty, headingHome: false);
+ activity = PersonActivity.Idle;
}
- return presence;
+ intent = decision.Intent;
+ if (decision.WalkTo is not null)
+ {
+ presence = PresenceStepper.StartWalk(presence, walks, decision.WalkTo, headingHome: false);
+ }
+
+ return decision.StartAction is not null && !activity.IsActive ? decision.StartAction : null;
+ }
+
+ private static Dictionary<(string Node, string Thing), int> SnapshotOccupied(School school, string exceptId)
+ {
+ var occupied = new Dictionary<(string Node, string Thing), int>();
+ var world = school.World;
+ world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity) =>
+ {
+ if (identity.Id.Equals(exceptId, StringComparison.Ordinal)
+ || !activity.IsActive
+ || presence.NodeId is null
+ || activity.Thing is null)
+ {
+ return;
+ }
+
+ var key = (presence.NodeId, activity.Thing);
+ occupied[key] = occupied.GetValueOrDefault(key) + 1;
+ });
+ return occupied;
}
private static void Move(School school, float minutes)
@@ -295,13 +439,15 @@ internal static class PresenceSystem
var walks = school.Walks;
var world = school.World;
- world.Query(in People, (ref Presence presence) =>
+ var arrived = new List();
+ world.Query(in People, (ref PersonIdentity identity, ref Presence presence) =>
{
if (!presence.IsOnCampus)
{
return;
}
+ var travelling = presence.Path.Length > 0 || presence.RemainingMinutes > 0;
var remaining = presence.RemainingMinutes - minutes;
var node = presence.NodeId!;
var path = presence.Path;
@@ -320,7 +466,16 @@ internal static class PresenceSystem
var leftover = index >= path.Length ? [] : path[index..];
presence = presence with { NodeId = node, RemainingMinutes = remaining, Path = leftover };
+ if (travelling && leftover.Length == 0 && remaining <= 0)
+ {
+ arrived.Add(identity.Id);
+ }
});
+
+ foreach (var id in arrived.OrderBy(value => value, StringComparer.Ordinal))
+ {
+ school.DecisionQueue.Enqueue(id);
+ }
}
private static void FinishHome(School school)
@@ -332,7 +487,7 @@ internal static class PresenceSystem
}
var world = school.World;
- world.Query(in People, (ref Presence presence, ref PersonActivity activity) =>
+ world.Query(in People, (ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
{
if (presence.HeadingHome
&& presence.NodeId is not null
@@ -342,10 +497,23 @@ internal static class PresenceSystem
{
presence = Presence.OffCampus;
activity = PersonActivity.Idle;
+ intent = Intent.None;
}
});
}
+ private static Intent ParseIntent(PresenceSnapshot row)
+ {
+ if (string.IsNullOrWhiteSpace(row.GoalKind)
+ || !Enum.TryParse(row.GoalKind, out var kind)
+ || kind == GoalKind.None)
+ {
+ return Intent.None;
+ }
+
+ return new Intent(kind, row.GoalId, row.GoalWeight, row.GoalAction);
+ }
+
private static SchoolClass? ClassOf(School school, Person person)
{
if (person.ClassId is null)
@@ -359,17 +527,22 @@ internal static class PresenceSystem
private static IReadOnlyList OrderedPeople(School school) =>
school.Roster!.People.OrderBy(person => person.Id, StringComparer.Ordinal).ToArray();
- private delegate void PersonAction(Person person, PersonIdentity identity, ref Presence presence, ref PersonActivity activity);
+ private delegate void PersonAction(
+ Person person,
+ PersonIdentity identity,
+ ref Presence presence,
+ ref PersonActivity activity,
+ ref Intent intent);
private static void ForEachPerson(School school, PersonAction action)
{
var roster = school.Roster!.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var world = school.World;
- world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity) =>
+ world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
{
if (roster.TryGetValue(identity.Id, out var person))
{
- action(person, identity, ref presence, ref activity);
+ action(person, identity, ref presence, ref activity, ref intent);
}
});
}
@@ -384,4 +557,8 @@ public sealed record PresenceSnapshot(
IReadOnlyList Path,
string? ActionId = null,
string? ActionThing = null,
- float ActionRemaining = 0f);
+ float ActionRemaining = 0f,
+ string? GoalKind = null,
+ string? GoalId = null,
+ float GoalWeight = 0f,
+ string? GoalAction = null);
diff --git a/src/HSchool.Simulation/RosterSpawner.cs b/src/HSchool.Simulation/RosterSpawner.cs
index 998bbae..0febeb6 100644
--- a/src/HSchool.Simulation/RosterSpawner.cs
+++ b/src/HSchool.Simulation/RosterSpawner.cs
@@ -28,7 +28,7 @@ public static class RosterSpawner
world.Create(
new PersonIdentity(person.Id, person.FamilyId, person.Female, person.BirthDate, person.Name),
new PersonBody(person.Numbers, person.Choices),
- new PersonSkills(person.Skills),
+ new PersonSkills(person.Skills.ToDictionary(pair => pair.Key, pair => (float)pair.Value, StringComparer.Ordinal)),
new PersonTraits(person.Traits),
new PersonNeeds(new Dictionary(person.Needs, StringComparer.Ordinal)),
new PersonRoles(
@@ -39,7 +39,8 @@ public static class RosterSpawner
person.Position,
person.WorkplaceRoomId),
Presence.OffCampus,
- PersonActivity.Idle);
+ PersonActivity.Idle,
+ Intent.None);
}
}
diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs
index a742d44..62180c3 100644
--- a/src/HSchool.Simulation/School.cs
+++ b/src/HSchool.Simulation/School.cs
@@ -101,6 +101,17 @@ public sealed class School : IDisposable
internal Queue DecisionQueue { get; } = new();
+ internal int DecisionBudget { get; set; }
+
+ public int PendingDecisionCount => DecisionQueue.Count;
+
+ public void QueueDecision(string personId)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentException.ThrowIfNullOrWhiteSpace(personId);
+ PresenceSystem.Enqueue(this, personId);
+ }
+
///
/// Installs a roster that already matches the map. Spawns entities; does not write to disk.
///
@@ -243,10 +254,18 @@ public sealed class School : IDisposable
}
PresenceSystem.Apply(this, gameMinutes);
- ActivitySystem.Apply(this, gameMinutes);
+ foreach (var id in ActivitySystem.Apply(this, gameMinutes))
+ {
+ PresenceSystem.Enqueue(this, id);
+ }
+
if (Catalog is not null)
{
+ var below = PresenceSystem.BelowThreshold(this);
NeedDecay.Apply(World, Catalog, gameMinutes);
+ PresenceSystem.EnqueueNewlyUrgent(this, below);
+ PresenceSystem.DrainDecisions(this);
+ LessonLearningSystem.Apply(this, gameMinutes);
}
}
diff --git a/tests/HSchool.Ai.Tests/DecisionPlannerTests.cs b/tests/HSchool.Ai.Tests/DecisionPlannerTests.cs
new file mode 100644
index 0000000..a36f574
--- /dev/null
+++ b/tests/HSchool.Ai.Tests/DecisionPlannerTests.cs
@@ -0,0 +1,238 @@
+using HSchool.Content;
+
+namespace HSchool.Ai.Tests;
+
+public class DecisionPlannerTests
+{
+ [Fact]
+ public void ZeroToilet_BeatsALesson()
+ {
+ var (catalog, map, walks) = World();
+ var duty = map.Rooms.First(room => room.Def == "Classroom").Id;
+ var decision = DecisionPlanner.Decide(
+ catalog,
+ map,
+ walks,
+ Actor(duty, boundToLesson: true, duty, new Dictionary(StringComparer.Ordinal)
+ {
+ ["Toilet"] = 0f,
+ ["Hunger"] = 1f,
+ ["Social"] = 1f,
+ ["Sleep"] = 1f,
+ }),
+ (_, _) => 0);
+
+ Assert.Equal(GoalKind.Need, decision.Intent.Kind);
+ Assert.Equal("Toilet", decision.Intent.Id);
+ Assert.Equal("UseToilet", decision.Intent.ActionId);
+ Assert.NotNull(decision.WalkTo);
+ Assert.Equal("Restroom", map.Rooms.First(room => room.Id == decision.WalkTo).Def);
+ }
+
+ [Fact]
+ public void NeedJustBelowThreshold_DoesNotLeaveClass()
+ {
+ var (catalog, map, walks) = World();
+ var duty = map.Rooms.First(room => room.Def == "Classroom").Id;
+ var decision = DecisionPlanner.Decide(
+ catalog,
+ map,
+ walks,
+ Actor(duty, boundToLesson: true, duty, new Dictionary(StringComparer.Ordinal)
+ {
+ ["Toilet"] = 0.34f,
+ ["Hunger"] = 1f,
+ ["Social"] = 1f,
+ ["Sleep"] = 1f,
+ }),
+ (_, _) => 0);
+
+ Assert.Equal(GoalKind.Duty, decision.Intent.Kind);
+ Assert.Null(decision.StartAction);
+ Assert.True(decision.WalkTo is null || decision.WalkTo == duty);
+ }
+
+ [Fact]
+ public void EqualNeeds_FinishTheCurrentAction()
+ {
+ var (catalog, map, walks) = World();
+ var restroom = map.Rooms.First(room => room.Def == "Restroom").Id;
+ var intent = new Intent(GoalKind.Need, "Toilet", DecisionPlanner.NeedWeightAtZero, "UseToilet");
+ var decision = DecisionPlanner.Decide(
+ catalog,
+ map,
+ walks,
+ Actor(
+ restroom,
+ boundToLesson: true,
+ dutyRoom: map.Rooms.First(room => room.Def == "Classroom").Id,
+ needs: new Dictionary(StringComparer.Ordinal)
+ {
+ ["Toilet"] = 0f,
+ ["Hunger"] = 0f,
+ ["Social"] = 1f,
+ ["Sleep"] = 1f,
+ },
+ intent: intent,
+ activityActive: true),
+ (_, _) => 0);
+
+ Assert.Equal("Toilet", decision.Intent.Id);
+ Assert.Null(decision.WalkTo);
+ Assert.Null(decision.StartAction);
+ }
+
+ [Fact]
+ public void BreakAtDutyRoom_PicksLeisureEvenWithAStaleLessonIntent()
+ {
+ var (catalog, map, walks) = World();
+ var duty = map.Rooms.First(room => room.Def == "Classroom").Id;
+ var decision = DecisionPlanner.Decide(
+ catalog,
+ map,
+ walks,
+ Actor(
+ duty,
+ boundToLesson: false,
+ duty,
+ FullNeeds(),
+ intent: new Intent(GoalKind.Duty, duty, DecisionPlanner.DutyLessonWeight, null)),
+ (_, _) => 0);
+
+ Assert.Equal(GoalKind.Leisure, decision.Intent.Kind);
+ }
+
+ [Fact]
+ public void LeisureOnABreak_IsNotYankedBackToTheNextHomeroom()
+ {
+ var (catalog, map, walks) = World();
+ var classroom = "classroom-101";
+ var corridor = "corridor-1";
+ var decision = DecisionPlanner.Decide(
+ catalog,
+ map,
+ walks,
+ Actor(
+ corridor,
+ boundToLesson: false,
+ classroom,
+ FullNeeds(),
+ intent: new Intent(GoalKind.Leisure, "Chat", 3f, "Chat")),
+ (_, _) => 0);
+
+ Assert.Equal(GoalKind.Leisure, decision.Intent.Kind);
+ Assert.NotEqual(classroom, decision.WalkTo);
+ }
+
+ [Fact]
+ public void BreakAtDutyRoom_PicksLeisure_LessonDoesNot()
+ {
+ var (catalog, map, walks) = World();
+ var duty = map.Rooms.First(room => room.Def == "Classroom").Id;
+ var onBreak = DecisionPlanner.Decide(
+ catalog,
+ map,
+ walks,
+ Actor(duty, boundToLesson: false, duty, FullNeeds()),
+ (_, _) => 0);
+ var inLesson = DecisionPlanner.Decide(
+ catalog,
+ map,
+ walks,
+ Actor(duty, boundToLesson: true, duty, FullNeeds()),
+ (_, _) => 0);
+
+ Assert.Equal(GoalKind.Leisure, onBreak.Intent.Kind);
+ Assert.NotNull(onBreak.Intent.ActionId);
+ Assert.True(catalog.Actions[onBreak.Intent.ActionId!].Weight > 0);
+ Assert.Equal(GoalKind.Duty, inLesson.Intent.Kind);
+ Assert.Null(inLesson.StartAction);
+ }
+
+ [Fact]
+ public void BreakAwayFromNextRoom_WalksThereInsteadOfChatting()
+ {
+ var (catalog, map, walks) = World();
+ var classroom = map.Rooms.First(room => room.Id == "classroom-101").Id;
+ var gym = "gym-hall";
+ var fromIdle = DecisionPlanner.Decide(
+ catalog,
+ map,
+ walks,
+ Actor(classroom, boundToLesson: false, gym, FullNeeds()),
+ (_, _) => 0);
+ var fromStaleLesson = DecisionPlanner.Decide(
+ catalog,
+ map,
+ walks,
+ Actor(
+ classroom,
+ boundToLesson: false,
+ gym,
+ FullNeeds(),
+ intent: new Intent(GoalKind.Duty, classroom, DecisionPlanner.DutyLessonWeight, null)),
+ (_, _) => 0);
+
+ Assert.Equal(GoalKind.Duty, fromIdle.Intent.Kind);
+ Assert.Equal(gym, fromIdle.WalkTo);
+ Assert.Equal(gym, fromStaleLesson.WalkTo);
+ Assert.Equal(gym, fromStaleLesson.Intent.Id);
+ }
+
+ [Fact]
+ public void ArrivedAtNextRoomThisBreak_StaysInsteadOfChatting()
+ {
+ var (catalog, map, walks) = World();
+ var gym = "gym-hall";
+ var decision = DecisionPlanner.Decide(
+ catalog,
+ map,
+ walks,
+ Actor(
+ gym,
+ boundToLesson: false,
+ gym,
+ FullNeeds(),
+ intent: new Intent(GoalKind.Duty, gym, DecisionPlanner.DutyTravelWeight, null)),
+ (_, _) => 0);
+
+ Assert.Equal(GoalKind.Duty, decision.Intent.Kind);
+ Assert.Equal(gym, decision.Intent.Id);
+ Assert.Null(decision.WalkTo);
+ Assert.Null(decision.StartAction);
+ }
+
+ private static ActorState Actor(
+ string node,
+ bool boundToLesson,
+ string dutyRoom,
+ IReadOnlyDictionary needs,
+ Intent? intent = null,
+ bool activityActive = false) =>
+ new(
+ node,
+ node,
+ IsWalking: false,
+ activityActive,
+ IsStudent: true,
+ IsStaff: false,
+ IsParent: false,
+ boundToLesson,
+ dutyRoom,
+ needs,
+ intent ?? Intent.None);
+
+ private static Dictionary FullNeeds() => new(StringComparer.Ordinal)
+ {
+ ["Toilet"] = 1f,
+ ["Hunger"] = 1f,
+ ["Social"] = 1f,
+ ["Sleep"] = 1f,
+ };
+
+ private static (DefCatalog Catalog, MapLayout Map, WalkGraph Walks) World()
+ {
+ var (catalog, map) = Fixtures.Vanilla();
+ return (catalog, map, WalkGraph.Build(catalog, map));
+ }
+}
diff --git a/tests/HSchool.Ai.Tests/LessonLearningTests.cs b/tests/HSchool.Ai.Tests/LessonLearningTests.cs
new file mode 100644
index 0000000..cef0d5f
--- /dev/null
+++ b/tests/HSchool.Ai.Tests/LessonLearningTests.cs
@@ -0,0 +1,32 @@
+using HSchool.Content;
+
+namespace HSchool.Ai.Tests;
+
+public class LessonLearningTests
+{
+ private static readonly SkillDef Math = new()
+ {
+ DefName = "Mathematics",
+ Range = new IntRange { Min = 0, Max = 100 },
+ };
+
+ [Fact]
+ public void HungryLearnsLessThanFull()
+ {
+ var full = LessonLearning.Gain(50, Math, share: 1, lessonSkillPerHour: 0.05f, hours: 0.75f, hunger: 1f, traitOffset: 0);
+ var hungry = LessonLearning.Gain(50, Math, share: 1, lessonSkillPerHour: 0.05f, hours: 0.75f, hunger: 0.1f, traitOffset: 0);
+
+ Assert.True(full > 50);
+ Assert.True(hungry > 50);
+ Assert.True(full - 50 > hungry - 50);
+ }
+
+ [Fact]
+ public void DiligentOffset_RaisesTheGain()
+ {
+ var plain = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 1f, 0);
+ var diligent = LessonLearning.Gain(50, Math, 1, 0.05f, 1f, 1f, 8);
+
+ Assert.True(diligent > plain);
+ }
+}
diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs
index 0b694bd..edce469 100644
--- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs
+++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs
@@ -180,6 +180,46 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.Contains(ru.Holidays, holiday => holiday.DefName == "SpringBreak");
}
+ [Fact]
+ public async Task Catalog_WithAnUnknownMod_IsRejected()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+
+ using var response = await client.GetAsync("/api/catalog?mods=no-such-mod", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ Assert.Equal("unknown-mod", await ProblemCodeAsync(response));
+ }
+
+ ///
+ /// A pack id is a folder name that came from a browser. Anything outside the safe alphabet is
+ /// refused as unknown before it can be joined onto a path — on both endpoints that take one.
+ ///
+ [Fact]
+ public async Task ModId_ThatEscapesTheModsFolder_IsRejectedOnBothEndpoints()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await ResetAsync(client);
+
+ using var catalog = await client.GetAsync("/api/catalog?mods=..%2F..%2Fsaves", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.BadRequest, catalog.StatusCode);
+ Assert.Equal("unknown-mod", await ProblemCodeAsync(catalog));
+
+ using var create = await client.PostAsJsonAsync(
+ "/api/schools",
+ new
+ {
+ name = "Побег из mods",
+ startDate = ExpectedDefaultStart,
+ modIds = new[] { "../../saves" },
+ },
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.BadRequest, create.StatusCode);
+ Assert.Equal("unknown-mod", await ProblemCodeAsync(create));
+ }
+
[Fact]
public async Task CreateSchool_WithABrokenMap_IsRejected()
{
diff --git a/tests/HSchool.Simulation.Tests/DecisionTests.cs b/tests/HSchool.Simulation.Tests/DecisionTests.cs
new file mode 100644
index 0000000..6747cbf
--- /dev/null
+++ b/tests/HSchool.Simulation.Tests/DecisionTests.cs
@@ -0,0 +1,343 @@
+using Arch.Core;
+using HSchool.Content;
+using HSchool.People;
+using HSchool.Schedule;
+
+namespace HSchool.Simulation.Tests;
+
+public class DecisionTests
+{
+ private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void ZeroToilet_LeavesClass_ReachesRestroom_AndReturns()
+ {
+ var (school, homeroom, pupilId) = StaffedFirstFloorClass();
+ using (school)
+ {
+ AdvanceTo(school, new DateTime(2012, 4, 3, 8, 30, 0, DateTimeKind.Utc));
+ SetNeed(school, pupilId, "Toilet", 0f);
+ Assert.Equal(0f, NeedOf(school, pupilId, "Toilet"));
+ school.QueueDecision(pupilId);
+
+ if (!WaitUntil(school, pupilId, row => school.Map!.NodeDef(row.NodeId ?? "") == "Restroom", 25))
+ {
+ var row = school.CapturePresence().Single(item => item.PersonId == pupilId);
+ Assert.Fail(
+ $"never reached a restroom: node={row.NodeId} dest={row.DestinationId} goal={row.GoalKind}/{row.GoalId}/{row.GoalAction} action={row.ActionId} toilet={NeedOf(school, pupilId, "Toilet")} path={string.Join(",", row.Path)}");
+ }
+ Assert.True(WaitUntil(school, pupilId, row => row.ActionId == "UseToilet", 8));
+ Assert.True(WaitUntil(
+ school,
+ pupilId,
+ row => row.NodeId == homeroom && row.Path.Count == 0 && row.RemainingMinutes <= 0 && row.ActionId is null,
+ 25));
+ }
+ }
+
+ [Fact]
+ public void NeedJustBelowThreshold_StaysInClass()
+ {
+ var (school, homeroom, pupilId) = StaffedFirstFloorClass();
+ using (school)
+ {
+ AdvanceTo(school, new DateTime(2012, 4, 3, 8, 30, 0, DateTimeKind.Utc));
+ SetNeed(school, pupilId, "Toilet", 0.34f);
+ school.QueueDecision(pupilId);
+ AdvanceTo(school, new DateTime(2012, 4, 3, 8, 38, 0, DateTimeKind.Utc));
+
+ var row = school.CapturePresence().Single(item => item.PersonId == pupilId);
+ Assert.Equal(homeroom, row.NodeId);
+ Assert.Empty(row.Path);
+ Assert.NotEqual("UseToilet", row.ActionId);
+ Assert.Equal("Duty", row.GoalKind);
+ }
+ }
+
+ [Fact]
+ public void TwoEqualNeeds_FinishTheToiletAction()
+ {
+ var (school, _, pupilId) = StaffedFirstFloorClass();
+ using (school)
+ {
+ AdvanceTo(school, new DateTime(2012, 4, 3, 8, 30, 0, DateTimeKind.Utc));
+ SetNeed(school, pupilId, "Toilet", 0f);
+ school.QueueDecision(pupilId);
+ Assert.True(WaitUntil(school, pupilId, row => row.ActionId == "UseToilet", 25));
+
+ SetNeed(school, pupilId, "Hunger", 0f);
+ school.QueueDecision(pupilId);
+ school.Tick(0.2d, 5d);
+
+ Assert.Equal("UseToilet", ActivityOf(school, pupilId));
+ }
+ }
+
+ [Fact]
+ public void BreakPicksLeisure_LessonDoesNot()
+ {
+ var (school, homeroom, pupilId) = TwoHomeroomLessons();
+ using (school)
+ {
+ AdvanceTo(school, new DateTime(2012, 4, 3, 8, 45, 0, DateTimeKind.Utc));
+ var inLesson = school.CapturePresence().Single(item => item.PersonId == pupilId);
+ Assert.Equal(homeroom, inLesson.NodeId);
+ Assert.Equal("Duty", inLesson.GoalKind);
+ Assert.Null(inLesson.ActionId);
+
+ AdvanceTo(school, new DateTime(2012, 4, 3, 9, 18, 0, DateTimeKind.Utc));
+ var onBreak = school.CapturePresence().Single(item => item.PersonId == pupilId);
+ Assert.Equal("Leisure", onBreak.GoalKind);
+ Assert.True(
+ school.Map!.NodeDef(onBreak.NodeId ?? "") is "Corridor"
+ || school.Map.NodeDef(onBreak.DestinationId ?? "") is "Corridor"
+ || onBreak.ActionId is "Chat" or "RecessRest" or "WalkCorridor");
+ Assert.True(
+ school.Map!.NodeDef(onBreak.NodeId ?? "") is "Corridor"
+ || school.Map.NodeDef(onBreak.DestinationId ?? "") is "Corridor"
+ || onBreak.ActionId is "Chat" or "RecessRest" or "WalkCorridor");
+ }
+ }
+
+ [Fact]
+ public void HungryPupil_GainsLessSkillDuringTheLesson()
+ {
+ var (school, _, firstId) = StaffedFirstFloorClass();
+ using (school)
+ {
+ var schoolClass = school.Roster!.Classes.First(row => row.PupilIds.Contains(firstId));
+ var secondId = schoolClass.PupilIds.First(id => id != firstId);
+ AdvanceTo(school, new DateTime(2012, 4, 3, 8, 30, 0, DateTimeKind.Utc));
+ SetNeed(school, firstId, "Hunger", 0.2f);
+ SetNeed(school, secondId, "Hunger", 1f);
+ var hungryBefore = SkillOf(school, firstId, "Mathematics");
+ var fullBefore = SkillOf(school, secondId, "Mathematics");
+
+ AdvanceTo(school, new DateTime(2012, 4, 3, 9, 15, 0, DateTimeKind.Utc));
+
+ var hungryGain = SkillOf(school, firstId, "Mathematics") - hungryBefore;
+ var fullGain = SkillOf(school, secondId, "Mathematics") - fullBefore;
+ Assert.True(fullGain > 0);
+ Assert.True(hungryGain > 0);
+ Assert.True(fullGain > hungryGain);
+ }
+ }
+
+ [Fact]
+ public void DecisionCap_DefersTheOverflow()
+ {
+ var (school, _, firstId) = StaffedFirstFloorClass();
+ using (school)
+ {
+ var secondId = school.Roster!.People.First(person => person.IsStudent && person.Id != firstId).Id;
+ AdvanceTo(school, new DateTime(2012, 4, 3, 8, 45, 0, DateTimeKind.Utc));
+ school.Tick(0.2d, 5d);
+ Assert.Equal(0, school.PendingDecisionCount);
+
+ school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 1);
+ school.QueueDecision(firstId);
+ school.QueueDecision(secondId);
+ Assert.Equal(2, school.PendingDecisionCount);
+
+ school.Tick(0.2d, 5d);
+ Assert.Equal(1, school.PendingDecisionCount);
+ }
+ }
+
+ [Fact]
+ public void SameSeedAndActions_MatchAfterAWeek()
+ {
+ var (catalog, map) = Vanilla();
+ using var a = OpenStaffed(catalog, map, seed: 9);
+ using var b = OpenStaffed(catalog, map, seed: 9);
+ var until = new DateTime(2012, 4, 10, 9, 20, 0, DateTimeKind.Utc);
+ PlayTo(a, until);
+ PlayTo(b, until);
+
+ Assert.Equal(StateFingerprint(a), StateFingerprint(b));
+ }
+
+ private static (School School, string Homeroom, string PupilId) StaffedFirstFloorClass()
+ {
+ var (catalog, map) = Vanilla();
+ var school = OpenStaffed(catalog, map, seed: 1);
+ var homeroomClass = school.Roster!.Classes.First(row =>
+ row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104");
+ var pupil = homeroomClass.PupilIds
+ .Select(id => school.Roster.People.First(person => person.Id == id))
+ .First(person => !person.Traits.Contains("Lazy"));
+ return (school, homeroomClass.RoomId, pupil.Id);
+ }
+
+ private static (School School, string Homeroom, string PupilId) TwoHomeroomLessons()
+ {
+ var (catalog, map) = Vanilla();
+ var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", TuesdayMorning);
+ var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", TuesdayMorning);
+ var schoolClass = roster.Classes.First(row =>
+ row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104");
+ var school = School.Create(1, "Два урока", TuesdayMorning, catalog, map);
+ school.InstallPeople(roster, seed: 1, "Slavic", pool);
+ school.SetTimetable(new Timetable(
+ [
+ new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1),
+ new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 2),
+ ],
+ []));
+ school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
+ var pupil = schoolClass.PupilIds
+ .Select(id => school.Roster!.People.First(person => person.Id == id))
+ .First(person => !person.Traits.Contains("Lazy"));
+ return (school, schoolClass.RoomId, pupil.Id);
+ }
+
+ private static School OpenStaffed(DefCatalog catalog, MapLayout map, int seed)
+ {
+ var roster = RosterGenerator.Generate(catalog, map, seed, "Slavic", TuesdayMorning);
+ var pool = ApplicantPool.Create(catalog, roster, seed, "Slavic", TuesdayMorning);
+ var schoolClass = roster.Classes.First(row =>
+ row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104");
+ var school = School.Create(seed, "Решения", TuesdayMorning, catalog, map);
+ school.InstallPeople(roster, seed, "Slavic", pool);
+ school.SetTimetable(new Timetable(
+ [
+ new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1),
+ new LessonPlacement(schoolClass.Id, "PhysicalEducation", "t2", "gym-hall", Day: 1, Period: 2),
+ ],
+ []));
+ school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
+ return school;
+ }
+
+ private static void AdvanceTo(School school, DateTime until)
+ {
+ while (school.Clock.Time < until)
+ {
+ school.Tick(0.2d, 5d);
+ }
+ }
+
+ private static void PlayTo(School school, DateTime until)
+ {
+ while (school.Clock.Time < until)
+ {
+ if (school.PeekSkipEmpty().Allowed)
+ {
+ school.TrySkipEmpty();
+ continue;
+ }
+
+ school.Tick(0.2d, 5d);
+ }
+ }
+
+ private static bool WaitUntil(School school, string personId, Func match, int minutes)
+ {
+ for (var i = 0; i < minutes; i++)
+ {
+ school.Tick(0.2d, 5d);
+ var row = school.CapturePresence().Single(item => item.PersonId == personId);
+ if (match(row))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static void SetNeed(School school, string personId, string need, float value)
+ {
+ var query = new QueryDescription().WithAll();
+ school.World.Query(in query, (ref PersonIdentity identity, ref PersonNeeds needs) =>
+ {
+ if (identity.Id.Equals(personId, StringComparison.Ordinal))
+ {
+ needs.Values[need] = value;
+ }
+ });
+ }
+
+ private static float NeedOf(School school, string personId, string need)
+ {
+ var value = float.NaN;
+ var query = new QueryDescription().WithAll();
+ school.World.Query(in query, (ref PersonIdentity identity, ref PersonNeeds needs) =>
+ {
+ if (identity.Id.Equals(personId, StringComparison.Ordinal))
+ {
+ value = needs.Values.GetValueOrDefault(need, float.NaN);
+ }
+ });
+ return value;
+ }
+
+ private static float SkillOf(School school, string personId, string skill)
+ {
+ var value = float.NaN;
+ var query = new QueryDescription().WithAll();
+ school.World.Query(in query, (ref PersonIdentity identity, ref PersonSkills skills) =>
+ {
+ if (identity.Id.Equals(personId, StringComparison.Ordinal))
+ {
+ value = skills.Values.GetValueOrDefault(skill);
+ }
+ });
+ return value;
+ }
+
+ private static string? ActivityOf(School school, string personId)
+ {
+ string? found = null;
+ var query = new QueryDescription().WithAll();
+ school.World.Query(in query, (ref PersonIdentity identity, ref PersonActivity activity) =>
+ {
+ if (identity.Id.Equals(personId, StringComparison.Ordinal))
+ {
+ found = activity.ActionId;
+ }
+ });
+ return found;
+ }
+
+ private static string StateFingerprint(School school)
+ {
+ var needs = new Dictionary(StringComparer.Ordinal);
+ var skills = new Dictionary(StringComparer.Ordinal);
+ var needQuery = new QueryDescription().WithAll();
+ school.World.Query(in needQuery, (ref PersonIdentity identity, ref PersonNeeds personNeeds, ref PersonSkills personSkills) =>
+ {
+ needs[identity.Id] = string.Join(",", personNeeds.Values.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}:{pair.Value:0.###}"));
+ skills[identity.Id] = string.Join(",", personSkills.Values.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}:{pair.Value:0.###}"));
+ });
+
+ return string.Join(
+ "|",
+ school.CapturePresence()
+ .OrderBy(row => row.PersonId, StringComparer.Ordinal)
+ .Select(row =>
+ $"{row.PersonId}:{row.NodeId ?? "-"}:{row.RemainingMinutes:0.###}:{row.DestinationId ?? "-"}:{(row.HeadingHome ? "1" : "0")}:{string.Join(",", row.Path)}:{row.ActionId ?? "-"}:{row.GoalKind ?? "-"}:{needs.GetValueOrDefault(row.PersonId)}:{skills.GetValueOrDefault(row.PersonId)}"));
+ }
+
+ private static (DefCatalog Catalog, MapLayout Map) Vanilla()
+ {
+ var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
+ 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('\\', '/');
+ documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path)));
+ }
+
+ var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
+ var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
+ Assert.NotNull(map);
+ return (catalog, map);
+ }
+}