Update decision-making phase and enhance localization for presence tracking
ci / server (push) Failing after 3m40s
ci / client (push) Successful in 14s

- 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.
This commit is contained in:
Leonid Pershin
2026-08-19 21:00:12 +03:00
parent b135a9caad
commit 5cd5a6d258
21 changed files with 1839 additions and 72 deletions
+137
View File
@@ -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. Расписание
- **Фазы:** 1417
- **Проверен на:** `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 на месте,
расхождений с дизайном нет» стоит написать явно. Это ровно та информация, ради которой
затевался журнал.
+19 -19
View File
@@ -11,33 +11,33 @@
## Задачи ## Задачи
- [ ] Цели, веса и планирование — целиком в `HSchool.Ai`: на вход значения, на выход решение. - [x] Цели, веса и планирование — целиком в `HSchool.Ai`: на вход значения, на выход решение.
В `HSchool.Simulation` — только сбор входов и запись ответа В `HSchool.Simulation` — только сбор входов и запись ответа
- [ ] Цели и их вес: обязанность — постоянный высокий, нужда — тем больше, чем ближе к нулю, - [x] Цели и их вес: обязанность — постоянный высокий, нужда — тем больше, чем ближе к нулю,
досуг — низкий и только при отсутствии обязанности досуг — низкий и только при отсутствии обязанности
- [ ] Побеждает наибольший вес. Уход с урока получается из сравнения, а не из отдельного правила - [x] Побеждает наибольший вес. Уход с урока получается из сравнения, а не из отдельного правила
- [ ] Планирование «дойти → сделать»: цель выбирает действие, планировщик достраивает дорогу до - [x] Планирование «дойти → сделать»: цель выбирает действие, планировщик достраивает дорогу до
подходящей комнаты подходящей комнаты
- [ ] Начатое доводится до конца: переключение только при заметном перевесе новой цели - [x] Начатое доводится до конца: переключение только при заметном перевесе новой цели
- [ ] Решения по событиям — звонок, конец действия, приход в назначенный узел, пересечение - [x] Решения по событиям — звонок, конец действия, приход в назначенный узел, пересечение
нуждой порога, — а не каждый тик нуждой порога, — а не каждый тик
- [ ] Очередь решений с потолком за тик: отложенное принимается следующим тиком, а не пропадает - [x] Очередь решений с потолком за тик: отложенное принимается следующим тиком, а не пропадает
- [ ] Возвращение к обязанности после закрытия нужды, если урок ещё идёт - [x] Возвращение к обязанности после закрытия нужды, если урок ещё идёт
- [ ] Навык растёт на уроке: от предмета, с поправкой на черты и на состояние нужд - [x] Навык растёт на уроке: от предмета, с поправкой на черты и на состояние нужд
- [ ] Опоздание и отсутствие видны: в панели локации на уроке не весь класс, в карточке написано, - [x] Опоздание и отсутствие видны: в панели локации на уроке не весь класс, в карточке написано,
где человек где человек
- [ ] Строки через `t(...)`, обе локали - [x] Строки через `t(...)`, обе локали
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Ученик с нулевым «туалетом» уходит с урока, доходит до санузла и возвращается в кабинет - [x] Ученик с нулевым «туалетом» уходит с урока, доходит до санузла и возвращается в кабинет
- [ ] Нужда чуть ниже порога урок не срывает - [x] Нужда чуть ниже порога урок не срывает
- [ ] Две почти равные нужды не заставляют метаться: действие доводится до конца - [x] Две почти равные нужды не заставляют метаться: действие доводится до конца
- [ ] На перемене выбирается досуг, на уроке — нет - [x] На перемене выбирается досуг, на уроке — нет
- [ ] Навык после учебного дня вырос, у голодного — меньше - [x] Навык после учебного дня вырос, у голодного — меньше
- [ ] Потолок решений за тик не теряет решения, а сдвигает их - [x] Потолок решений за тик не теряет решения, а сдвигает их
- [ ] Тот же сид и те же действия игрока дают ту же школу через игровую неделю - [x] Тот же сид и те же действия игрока дают ту же школу через игровую неделю
- [ ] Разбор решения таблицей входов и выходов, без мира и без хоста - [x] Разбор решения таблицей входов и выходов, без мира и без хоста
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -90,4 +90,4 @@
| Фаза | Статус | Зачем | | Фаза | Статус | Зачем |
| --- | --- | --- | | --- | --- | --- |
| [20. Нужды и действия](20-needs-actions.md) | ✅ | `ActionDef` с полями, декей, восполнение | | [20. Нужды и действия](20-needs-actions.md) | ✅ | `ActionDef` с полями, декей, восполнение |
| [21. Выбор действия](21-decisions.md) | | Цели и веса, «дойти → сделать», уход с урока, рост навыка | | [21. Выбор действия](21-decisions.md) | | Цели и веса, «дойти → сделать», уход с урока, рост навыка |
+96
View File
@@ -0,0 +1,96 @@
# Проверенные срезы
Журнал ревью. Фаза с ✅ в [`README.md`](README.md) — заявка автора; строка здесь — проверенный
факт: каждое обещание фазы сверено с кодом и тестом, найденное дописано или исправлено.
Коммит в «Проверен на» делает журнал самопроверяющимся. Срез перестаёт считаться проверенным,
как только `git log <коммит>..HEAD -- <пути>` перестаёт быть пустым — поэтому «Пути» обязательны.
## Срез 1. Оболочка и карта
- **Фазы:** 04
- **Проверен на:** `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`, и на дереве после починки сборки.
+462
View File
@@ -0,0 +1,462 @@
using HSchool.Content;
namespace HSchool.Ai;
public enum GoalKind
{
None,
Duty,
Need,
Leisure,
}
/// <summary>What this person is currently pursuing. Compared against a new winner using switchMargin.</summary>
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;
}
/// <summary>Walk, start an action, or stay. Heading home is not a goal — the day plan handles it.</summary>
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<string, float> Needs,
Intent Intent);
/// <summary>
/// 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.
/// </summary>
public static class DecisionPlanner
{
/// <summary>Lesson or posted work. Beats leisure and a need that only just crossed the threshold.</summary>
public const float DutyLessonWeight = 10f;
/// <summary>Walk to the next room on a break. Beats chatting in the corridor you are standing in.</summary>
public const float DutyTravelWeight = 5f;
/// <summary>Need at zero. Beats a lesson so a desperate toilet trip leaves class.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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);
+28
View File
@@ -0,0 +1,28 @@
using HSchool.Content;
namespace HSchool.Ai;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -29,6 +29,7 @@ describe('t', () => {
expect(t('mapHeadcount', { name: 'Коридор', count: 12 })).toBe('Коридор (12)'); expect(t('mapHeadcount', { name: 'Коридор', count: 12 })).toBe('Коридор (12)');
expect(t('mapHeadcountActivity', { name: 'Класс 101', count: 18, activity: 'Математика · 5А' })) expect(t('mapHeadcountActivity', { name: 'Класс 101', count: 18, activity: 'Математика · 5А' }))
.toBe('Класс 101 (18 · Математика · 5А)'); .toBe('Класс 101 (18 · Математика · 5А)');
expect(t('locationWalking', { name: 'Иванов' })).toBe('Иванов (walking)');
}); });
}); });
+2
View File
@@ -175,6 +175,7 @@ const ru = {
presenceAt: '{name}', presenceAt: '{name}',
presenceWalking: 'в пути ({name})', presenceWalking: 'в пути ({name})',
presenceAway: 'вне школы', presenceAway: 'вне школы',
locationWalking: '{name} (в пути)',
timetableTitle: 'Расписание', timetableTitle: 'Расписание',
timetableClass: 'Класс', timetableClass: 'Класс',
timetableEmpty: 'Нет уроков.', timetableEmpty: 'Нет уроков.',
@@ -372,6 +373,7 @@ const en: Messages = {
presenceAt: '{name}', presenceAt: '{name}',
presenceWalking: 'walking ({name})', presenceWalking: 'walking ({name})',
presenceAway: 'off campus', presenceAway: 'off campus',
locationWalking: '{name} (walking)',
timetableTitle: 'Timetable', timetableTitle: 'Timetable',
timetableClass: 'Class', timetableClass: 'Class',
timetableEmpty: 'No lessons.', timetableEmpty: 'No lessons.',
+4 -1
View File
@@ -405,7 +405,10 @@ export class GameScreen {
const names = this.presence.people const names = this.presence.people
.filter((person) => person.nodeId === id) .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)); names.sort((left, right) => left.localeCompare(right));
return names; return names;
} }
+1 -1
View File
@@ -211,7 +211,7 @@ public sealed class BehaviorDef : Def
/// <summary>A need at or below this value is urgent. Phase 21 turns that into a goal weight.</summary> /// <summary>A need at or below this value is urgent. Phase 21 turns that into a goal weight.</summary>
public float NeedThreshold { get; init; } public float NeedThreshold { get; init; }
/// <summary>Skill points a lesson adds per game hour, before traits and need state. Unused until phase 21.</summary> /// <summary>Skill points a lesson adds per game hour, before traits and need state.</summary>
public float LessonSkillPerHour { get; init; } public float LessonSkillPerHour { get; init; }
/// <summary>Inclusive range of extra commute minutes rolled per person per day.</summary> /// <summary>Inclusive range of extra commute minutes rolled per person per day.</summary>
+49 -3
View File
@@ -14,6 +14,9 @@ internal static class PersonCardReader
private static readonly QueryDescription IdentityAndNeeds = private static readonly QueryDescription IdentityAndNeeds =
new QueryDescription().WithAll<PersonIdentity, PersonNeeds>(); new QueryDescription().WithAll<PersonIdentity, PersonNeeds>();
private static readonly QueryDescription IdentityAndSkills =
new QueryDescription().WithAll<PersonIdentity, PersonSkills>();
private static readonly QueryDescription IdentityAndActivity = private static readonly QueryDescription IdentityAndActivity =
new QueryDescription().WithAll<PersonIdentity, PersonActivity>(); new QueryDescription().WithAll<PersonIdentity, PersonActivity>();
@@ -45,6 +48,7 @@ internal static class PersonCardReader
} }
var needs = LiveNeeds(school.World, personId) ?? person.Needs; var needs = LiveNeeds(school.World, personId) ?? person.Needs;
var skills = LiveSkills(school.World, personId);
var activityId = LiveActivity(school.World, personId); var activityId = LiveActivity(school.World, personId);
string? activityLabel = null; string? activityLabel = null;
if (activityId is not null && catalog is not null && catalog.Actions.TryGetValue(activityId, out var action)) 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, person.Position,
PeopleListMapper.PositionLabel(catalog, locale, person.Position), PeopleListMapper.PositionLabel(catalog, locale, person.Position),
Body(person, catalog, locale), Body(person, catalog, locale),
Skills(person, catalog, locale), Skills(person, skills, catalog, locale),
Traits(person, catalog, locale), Traits(person, catalog, locale),
Needs(needs, catalog, locale), Needs(needs, catalog, locale),
activityId, activityId,
@@ -93,6 +97,19 @@ internal static class PersonCardReader
return found; return found;
} }
private static IReadOnlyDictionary<string, float>? LiveSkills(World world, string personId)
{
Dictionary<string, float>? found = null;
world.Query(in IdentityAndSkills, (ref PersonIdentity identity, ref PersonSkills skills) =>
{
if (identity.Id.Equals(personId, StringComparison.Ordinal))
{
found = new Dictionary<string, float>(skills.Values, StringComparer.Ordinal);
}
});
return found;
}
private static string? LiveActivity(World world, string personId) private static string? LiveActivity(World world, string personId)
{ {
string? found = null; string? found = null;
@@ -153,10 +170,21 @@ internal static class PersonCardReader
return rows; return rows;
} }
private static IReadOnlyList<LabeledStatResponse> Skills(Person person, DefCatalog? catalog, string locale) private static IReadOnlyList<LabeledStatResponse> Skills(
Person person,
IReadOnlyDictionary<string, float>? live,
DefCatalog? catalog,
string locale)
{ {
if (catalog is null) 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 return person.Skills
.Select(pair => new LabeledStatResponse(pair.Key, pair.Key, pair.Value.ToString())) .Select(pair => new LabeledStatResponse(pair.Key, pair.Key, pair.Value.ToString()))
.ToArray(); .ToArray();
@@ -165,7 +193,23 @@ internal static class PersonCardReader
var rows = new List<LabeledStatResponse>(); var rows = new List<LabeledStatResponse>();
foreach (var def in catalog.Skills.Values) 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; continue;
} }
@@ -176,6 +220,8 @@ internal static class PersonCardReader
return rows; return rows;
} }
private static string FormatSkill(float value) => Math.Round(value, 2).ToString("0.##");
private static IReadOnlyList<DefLabelResponse> Traits(Person person, DefCatalog? catalog, string locale) private static IReadOnlyList<DefLabelResponse> Traits(Person person, DefCatalog? catalog, string locale)
{ {
var rows = new List<DefLabelResponse>(person.Traits.Count); var rows = new List<DefLabelResponse>(person.Traits.Count);
+16 -5
View File
@@ -69,17 +69,18 @@ internal static class ActivitySystem
return started; return started;
} }
public static void Apply(School school, double gameMinutes) public static IReadOnlyList<string> Apply(School school, double gameMinutes)
{ {
if (school.Catalog is null || gameMinutes <= 0) if (school.Catalog is null || gameMinutes <= 0)
{ {
return; return [];
} }
var catalog = school.Catalog; var catalog = school.Catalog;
var minutes = (float)gameMinutes; var minutes = (float)gameMinutes;
var completed = new List<string>();
var world = school.World; 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) if (!activity.IsActive)
{ {
@@ -92,11 +93,18 @@ internal static class ActivitySystem
return; 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( var next = ActionStepper.Advance(
new ActivityProgress(activity.ActionId, activity.Thing, activity.RemainingMinutes), new ActivityProgress(activity.ActionId, activity.Thing, activity.RemainingMinutes),
minutes, minutes,
out var completed); out var finished);
if (!completed) if (!finished)
{ {
activity = new PersonActivity(next.ActionId, next.Thing, next.RemainingMinutes); activity = new PersonActivity(next.ActionId, next.Thing, next.RemainingMinutes);
return; return;
@@ -110,7 +118,10 @@ internal static class ActivitySystem
} }
activity = PersonActivity.Idle; activity = PersonActivity.Idle;
completed.Add(identity.Id);
}); });
completed.Sort(StringComparer.Ordinal);
return completed;
} }
private static int Occupied(School school, string nodeId, string thing) private static int Occupied(School school, string nodeId, string thing)
@@ -15,7 +15,7 @@ public readonly record struct PersonBody(
IReadOnlyDictionary<string, int> Numbers, IReadOnlyDictionary<string, int> Numbers,
IReadOnlyDictionary<string, string> Choices); IReadOnlyDictionary<string, string> Choices);
public readonly record struct PersonSkills(IReadOnlyDictionary<string, int> Values); public readonly record struct PersonSkills(Dictionary<string, float> Values);
public readonly record struct PersonTraits(IReadOnlyList<string> Ids); public readonly record struct PersonTraits(IReadOnlyList<string> Ids);
@@ -0,0 +1,131 @@
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Simulation;
/// <summary>
/// Grows skills for people who are actually in the lesson: at the room, not walking, not off
/// doing something else. The formula lives in <see cref="HSchool.Ai.LessonLearning"/>.
/// </summary>
internal static class LessonLearningSystem
{
private static readonly QueryDescription People =
new QueryDescription().WithAll<PersonIdentity, PersonSkills, PersonTraits, PersonNeeds, PersonRoles, Presence, PersonActivity>();
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;
}
}
+215 -38
View File
@@ -13,7 +13,7 @@ namespace HSchool.Simulation;
internal static class PresenceSystem internal static class PresenceSystem
{ {
private static readonly QueryDescription People = private static readonly QueryDescription People =
new QueryDescription().WithAll<PersonIdentity, PersonRoles, PersonTraits, Presence, PersonActivity>(); new QueryDescription().WithAll<PersonIdentity, PersonRoles, PersonNeeds, PersonTraits, Presence, PersonActivity, Intent>();
public static void Apply(School school, double gameMinutes) public static void Apply(School school, double gameMinutes)
{ {
@@ -23,13 +23,58 @@ internal static class PresenceSystem
} }
EnsurePlans(school); EnsurePlans(school);
school.DecisionBudget = school.MaxDecisionsPerTick;
EnqueueEvents(school); EnqueueEvents(school);
EnqueueTimeEvents(school, (float)gameMinutes); EnqueueTimeEvents(school, (float)gameMinutes);
DrainDecisions(school); DrainDecisions(school);
Move(school, (float)gameMinutes); Move(school, (float)gameMinutes);
FinishHome(school); FinishHome(school);
DrainDecisions(school);
} }
public static IReadOnlySet<string> BelowThreshold(School school)
{
var ids = new HashSet<string>(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<PersonIdentity, PersonNeeds, Presence>();
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<string> 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) public static bool IsEmpty(School school)
{ {
var empty = true; var empty = true;
@@ -48,7 +93,7 @@ internal static class PresenceSystem
{ {
var rows = new List<PresenceSnapshot>(); var rows = new List<PresenceSnapshot>();
var world = school.World; 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( rows.Add(new PresenceSnapshot(
identity.Id, identity.Id,
@@ -59,7 +104,11 @@ internal static class PresenceSystem
presence.Path, presence.Path,
activity.ActionId, activity.ActionId,
activity.Thing, 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)); rows.Sort((left, right) => StringComparer.Ordinal.Compare(left.PersonId, right.PersonId));
return rows; return rows;
@@ -77,12 +126,13 @@ internal static class PresenceSystem
var byId = saved var byId = saved
.Where(row => !string.IsNullOrWhiteSpace(row.PersonId)) .Where(row => !string.IsNullOrWhiteSpace(row.PersonId))
.ToDictionary(row => row.PersonId, StringComparer.Ordinal); .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)) if (!byId.TryGetValue(person.Id, out var row))
{ {
presence = PlaceByDuty(school, person); presence = PlaceByDuty(school, person);
activity = PersonActivity.Idle; activity = PersonActivity.Idle;
intent = Intent.None;
return; return;
} }
@@ -90,6 +140,7 @@ internal static class PresenceSystem
{ {
presence = Presence.OffCampus; presence = Presence.OffCampus;
activity = PersonActivity.Idle; activity = PersonActivity.Idle;
intent = Intent.None;
return; return;
} }
@@ -102,21 +153,33 @@ internal static class PresenceSystem
activity = string.IsNullOrWhiteSpace(row.ActionId) activity = string.IsNullOrWhiteSpace(row.ActionId)
? PersonActivity.Idle ? PersonActivity.Idle
: new PersonActivity(row.ActionId, row.ActionThing, row.ActionRemaining); : new PersonActivity(row.ActionId, row.ActionThing, row.ActionRemaining);
intent = ParseIntent(row);
}); });
} }
public static void PlaceMissingByDuty(School school) 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) if (!presence.IsOnCampus)
{ {
presence = PlaceByDuty(school, person); presence = PlaceByDuty(school, person);
activity = PersonActivity.Idle; 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) private static Presence PlaceByDuty(School school, Person person)
{ {
var room = Duty.RoomAt( 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) private static void Decide(School school, string personId)
{ {
var person = school.Roster!.People.FirstOrDefault(candidate => candidate.Id.Equals(personId, StringComparison.Ordinal)); var person = school.Roster!.People.FirstOrDefault(candidate => candidate.Id.Equals(personId, StringComparison.Ordinal));
@@ -226,26 +278,55 @@ internal static class PresenceSystem
return; return;
} }
var occupied = SnapshotOccupied(school, personId);
string? startAction = null;
var world = school.World; var world = school.World;
var found = false; var found = false;
world.Query(in People, (ref PersonIdentity identity, ref Presence presence) => world.Query(
{ in People,
if (found || !identity.Id.Equals(personId, StringComparison.Ordinal)) (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; found = true;
presence = NextPresence(school, person, plan, presence); _ = 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 now = school.Clock.Time;
var walks = school.Walks!; var walks = school.Walks!;
if (!presence.IsOnCampus) if (!presence.IsOnCampus)
{ {
intent = Intent.None;
if (plan.AppearAt is { } appear && now >= appear && (plan.WalkHomeAt is null || now < plan.WalkHomeAt)) if (plan.AppearAt is { } appear && now >= appear && (plan.WalkHomeAt is null || now < plan.WalkHomeAt))
{ {
var dest = plan.FirstRoom ?? Duty.RoomAt( var dest = plan.FirstRoom ?? Duty.RoomAt(
@@ -255,15 +336,22 @@ internal static class PresenceSystem
school.Catalog!, school.Catalog!,
now, now,
school.SchoolWeekDays); 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) 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( var duty = Duty.RoomAt(
@@ -275,15 +363,71 @@ internal static class PresenceSystem
school.SchoolWeekDays); school.SchoolWeekDays);
if (duty is null) 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) private static void Move(School school, float minutes)
@@ -295,13 +439,15 @@ internal static class PresenceSystem
var walks = school.Walks; var walks = school.Walks;
var world = school.World; var world = school.World;
world.Query(in People, (ref Presence presence) => var arrived = new List<string>();
world.Query(in People, (ref PersonIdentity identity, ref Presence presence) =>
{ {
if (!presence.IsOnCampus) if (!presence.IsOnCampus)
{ {
return; return;
} }
var travelling = presence.Path.Length > 0 || presence.RemainingMinutes > 0;
var remaining = presence.RemainingMinutes - minutes; var remaining = presence.RemainingMinutes - minutes;
var node = presence.NodeId!; var node = presence.NodeId!;
var path = presence.Path; var path = presence.Path;
@@ -320,7 +466,16 @@ internal static class PresenceSystem
var leftover = index >= path.Length ? [] : path[index..]; var leftover = index >= path.Length ? [] : path[index..];
presence = presence with { NodeId = node, RemainingMinutes = remaining, Path = leftover }; 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) private static void FinishHome(School school)
@@ -332,7 +487,7 @@ internal static class PresenceSystem
} }
var world = school.World; 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 if (presence.HeadingHome
&& presence.NodeId is not null && presence.NodeId is not null
@@ -342,10 +497,23 @@ internal static class PresenceSystem
{ {
presence = Presence.OffCampus; presence = Presence.OffCampus;
activity = PersonActivity.Idle; activity = PersonActivity.Idle;
intent = Intent.None;
} }
}); });
} }
private static Intent ParseIntent(PresenceSnapshot row)
{
if (string.IsNullOrWhiteSpace(row.GoalKind)
|| !Enum.TryParse<GoalKind>(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) private static SchoolClass? ClassOf(School school, Person person)
{ {
if (person.ClassId is null) if (person.ClassId is null)
@@ -359,17 +527,22 @@ internal static class PresenceSystem
private static IReadOnlyList<Person> OrderedPeople(School school) => private static IReadOnlyList<Person> OrderedPeople(School school) =>
school.Roster!.People.OrderBy(person => person.Id, StringComparer.Ordinal).ToArray(); 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) private static void ForEachPerson(School school, PersonAction action)
{ {
var roster = school.Roster!.People.ToDictionary(person => person.Id, StringComparer.Ordinal); var roster = school.Roster!.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var world = school.World; 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)) 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<string> Path, IReadOnlyList<string> Path,
string? ActionId = null, string? ActionId = null,
string? ActionThing = null, string? ActionThing = null,
float ActionRemaining = 0f); float ActionRemaining = 0f,
string? GoalKind = null,
string? GoalId = null,
float GoalWeight = 0f,
string? GoalAction = null);
+3 -2
View File
@@ -28,7 +28,7 @@ public static class RosterSpawner
world.Create( world.Create(
new PersonIdentity(person.Id, person.FamilyId, person.Female, person.BirthDate, person.Name), new PersonIdentity(person.Id, person.FamilyId, person.Female, person.BirthDate, person.Name),
new PersonBody(person.Numbers, person.Choices), 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 PersonTraits(person.Traits),
new PersonNeeds(new Dictionary<string, float>(person.Needs, StringComparer.Ordinal)), new PersonNeeds(new Dictionary<string, float>(person.Needs, StringComparer.Ordinal)),
new PersonRoles( new PersonRoles(
@@ -39,7 +39,8 @@ public static class RosterSpawner
person.Position, person.Position,
person.WorkplaceRoomId), person.WorkplaceRoomId),
Presence.OffCampus, Presence.OffCampus,
PersonActivity.Idle); PersonActivity.Idle,
Intent.None);
} }
} }
+20 -1
View File
@@ -101,6 +101,17 @@ public sealed class School : IDisposable
internal Queue<string> DecisionQueue { get; } = new(); internal Queue<string> 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);
}
/// <summary> /// <summary>
/// Installs a roster that already matches the map. Spawns entities; does not write to disk. /// Installs a roster that already matches the map. Spawns entities; does not write to disk.
/// </summary> /// </summary>
@@ -243,10 +254,18 @@ public sealed class School : IDisposable
} }
PresenceSystem.Apply(this, gameMinutes); 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) if (Catalog is not null)
{ {
var below = PresenceSystem.BelowThreshold(this);
NeedDecay.Apply(World, Catalog, gameMinutes); NeedDecay.Apply(World, Catalog, gameMinutes);
PresenceSystem.EnqueueNewlyUrgent(this, below);
PresenceSystem.DrainDecisions(this);
LessonLearningSystem.Apply(this, gameMinutes);
} }
} }
@@ -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<string, float>(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<string, float>(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<string, float>(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<string, float> 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<string, float> 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));
}
}
@@ -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);
}
}
@@ -180,6 +180,46 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.Contains(ru.Holidays, holiday => holiday.DefName == "SpringBreak"); 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));
}
/// <summary>
/// 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.
/// </summary>
[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] [Fact]
public async Task CreateSchool_WithABrokenMap_IsRejected() public async Task CreateSchool_WithABrokenMap_IsRejected()
{ {
@@ -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<PresenceSnapshot, bool> 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<PersonIdentity, PersonNeeds>();
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<PersonIdentity, PersonNeeds>();
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<PersonIdentity, PersonSkills>();
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<PersonIdentity, PersonActivity>();
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<string, string>(StringComparer.Ordinal);
var skills = new Dictionary<string, string>(StringComparer.Ordinal);
var needQuery = new QueryDescription().WithAll<PersonIdentity, PersonNeeds, PersonSkills>();
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<ContentDocument>();
foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories))
{
if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
&& !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var relative = Path.GetRelativePath(root, path).Replace('\\', '/');
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);
}
}