From 5cd5a6d258b406e9e71849fe65183c70779490d6 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 19 Aug 2026 21:00:12 +0300 Subject: [PATCH] Update decision-making phase and enhance localization for presence tracking - Marked tasks as complete in the decision-making phase documentation, indicating readiness for implementation. - Updated the README to reflect the completion status of the decision-making phase. - Enhanced localization strings to include new presence tracking features, improving user experience. - Revised the game screen logic to display real-time presence status, including walking states for individuals. - Added tests to validate the new localization strings and presence functionalities, ensuring robust performance. --- .claude/skills/phase-review/SKILL.md | 137 ++++++ docs/phases/21-decisions.md | 38 +- docs/phases/README.md | 2 +- docs/phases/reviewed.md | 96 ++++ src/HSchool.Ai/Decision.cs | 462 ++++++++++++++++++ src/HSchool.Ai/LessonLearning.cs | 28 ++ src/HSchool.Client/src/i18n/strings.test.ts | 1 + src/HSchool.Client/src/i18n/strings.ts | 2 + src/HSchool.Client/src/ui/gameScreen.ts | 5 +- src/HSchool.Content/PeopleDefs.cs | 2 +- src/HSchool.Server/Game/PersonCardReader.cs | 52 +- src/HSchool.Simulation/ActivitySystem.cs | 21 +- .../Components/PersonComponents.cs | 2 +- .../LessonLearningSystem.cs | 131 +++++ src/HSchool.Simulation/PresenceSystem.cs | 253 ++++++++-- src/HSchool.Simulation/RosterSpawner.cs | 5 +- src/HSchool.Simulation/School.cs | 21 +- .../HSchool.Ai.Tests/DecisionPlannerTests.cs | 238 +++++++++ tests/HSchool.Ai.Tests/LessonLearningTests.cs | 32 ++ tests/HSchool.AppHost.Tests/SchoolApiTests.cs | 40 ++ .../HSchool.Simulation.Tests/DecisionTests.cs | 343 +++++++++++++ 21 files changed, 1839 insertions(+), 72 deletions(-) create mode 100644 .claude/skills/phase-review/SKILL.md create mode 100644 docs/phases/reviewed.md create mode 100644 src/HSchool.Ai/Decision.cs create mode 100644 src/HSchool.Ai/LessonLearning.cs create mode 100644 src/HSchool.Simulation/LessonLearningSystem.cs create mode 100644 tests/HSchool.Ai.Tests/DecisionPlannerTests.cs create mode 100644 tests/HSchool.Ai.Tests/LessonLearningTests.cs create mode 100644 tests/HSchool.Simulation.Tests/DecisionTests.cs 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); + } +}