diff --git a/AGENTS.md b/AGENTS.md
index 8104e82..16d6e7a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,7 +13,8 @@ way; this file is *how to work in them*.
| skills, traits, body, needs, name sets | `src/HSchool.Content` |
| people generation, families, roster records, yearly intake | `src/HSchool.People` |
| timetable planning | `src/HSchool.Schedule` |
-| people in a school's World, need decay | `src/HSchool.Simulation` |
+| routes, day plans, who comes today | `src/HSchool.Ai` |
+| people in a school's World, need decay, walking | `src/HSchool.Simulation` |
| the menu API (list, create, delete, mods, catalog) and the people list/card | `src/HSchool.Server/Api` **and** `docs/protocol.md` |
| what the socket carries | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` |
| connection handling, workers, saves | `src/HSchool.Server` |
@@ -111,6 +112,8 @@ say so explicitly in the change description.
produce the same roster; the suite does not boot a host.
- Timetable planning belongs in `tests/HSchool.Schedule.Tests`. Same staff, map and locks must
produce the same table; the suite does not boot a host.
+- Walking and day plans belong in `tests/HSchool.Ai.Tests`. Same seed and map must produce the
+ same route and commute; the suite does not boot a host, and it does not reference Arch.
- Catalog, inheritance, patches and map validation belong in `tests/HSchool.Content.Tests`.
Feed the loader documents, not disk paths.
- Protocol changes need a round-trip test **and** a byte-layout assertion on both sides.
@@ -164,3 +167,6 @@ say so explicitly in the change description.
wrong for anything that must arrive exactly once — such a message would need its own path.
- `erasableSyntaxOnly` is off in `tsconfig.app.json` on purpose: constructor parameter properties
are used throughout.
+- **A large one-shot `Tick(week)` jumps the clock, then applies all those minutes at the new
+ time.** Presence equality tests must loop `FixedDeltaTime` or one game minute. `string.GetHashCode`
+ is randomized — commute slack goes through `Seed.Mix`, never that.
diff --git a/README.md b/README.md
index f52610c..4bd8b8b 100644
--- a/README.md
+++ b/README.md
@@ -121,6 +121,7 @@ Simulation tunables live under the `Simulation` section of
| `MinSaveIntervalMilliseconds` | 1000 | shortest gap between saves caused by pause or speed |
| `MonthlyPayrollCap` | 100000 | monthly payroll the player may commit; hires and assignments that would exceed it are rejected |
| `SchoolWeekDays` | 5 | working days from Monday (5 is Mon–Fri; 6 adds Saturday) |
+| `MaxDecisionsPerTick` | 64 | presence decisions processed per tick; overflow waits |
## What is deliberately missing
diff --git a/docs/architecture.md b/docs/architecture.md
index 1eb1cdd..4b9cfb7 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -36,14 +36,15 @@ there is no UI on the server.
| `src/HSchool.Content` | JSONC defs, inheritance, patches, locales, map instance and connectivity. No Arch, no ASP.NET. |
| `src/HSchool.People` | Roster generation from catalog, map and seed. No Arch, no ASP.NET. |
| `src/HSchool.Schedule` | Timetable from curriculum, assignments and map. No Arch, no ASP.NET. |
+| `src/HSchool.Ai` | Walk graph, day plans, duty rooms. No Arch, no ASP.NET, no `DateTime.Now`. |
| `src/HSchool.Simulation` | Schools, the game clock, the Arch ECS world. Holds a frozen catalog and map; no HTTP. |
| `src/HSchool.Server` | ASP.NET Core host: the menu API, the WebSocket endpoint, per-school workers, `mods/` and `saves/`. |
| `src/HSchool.ServiceDefaults` | Shared Aspire wiring: OpenTelemetry, health checks, service discovery, resilience. |
| `src/HSchool.AppHost` | Aspire orchestration: which resources run and how they find each other. |
| `src/HSchool.Client` | Vite + TypeScript UI: main menu, creation form, the school screen. |
-Dependency direction is one-way: `Protocol ← Server → Simulation → People → Content`, and
-`Schedule → Content`. Nothing in `Simulation` or `Content` knows about HTTP, and nothing in
+Dependency direction is one-way: `Protocol ← Server → Simulation → Ai → People / Schedule → Content`.
+Nothing in `Simulation` or `Content` knows about HTTP, and nothing in
`Protocol` knows about schools.
## Two channels, on purpose
@@ -80,7 +81,9 @@ touching a school itself.
## Schools
A `School` is one save: an id, a name, a `GameClock`, a frozen catalog, a map, a roster and an
-Arch `World`. Pupils, staff and parents live in that world as entities; they do not walk yet.
+Arch `World`. Pupils, staff and parents live in that world as entities. Presence is a component:
+each person occupies a map node or is off campus. Empty-time skip jumps the clock to the next
+work morning when the campus is empty and the day frame is closed.
`GameClock` moves while it is running, in fixed steps:
`realSeconds × gameMinutesPerRealSecond × speedMultiplier`. At the defaults that is 5 game minutes
@@ -107,7 +110,7 @@ intake). Pause and speed changes are written
too, but coalesced to at most one write per `MinSaveIntervalMilliseconds`: a client can send those
as fast as the socket allows, and each one is a file write on the school's own thread. Shutdown
always flushes, so a pause is never lost. The clock file also stores the
-mod pack ids and the map layout; the catalog is loaded again from `mods/` on start. A missing
+mod pack ids, the map layout and each person's place on that map; the catalog is loaded again from `mods/` on start. A missing
mod folder, a map that no longer validates, or a roster that no longer fits the map leaves the
files in place and that school unstarted.
diff --git a/docs/design/projects.md b/docs/design/projects.md
index 9cff5fa..d6a5df5 100644
--- a/docs/design/projects.md
+++ b/docs/design/projects.md
@@ -1,6 +1,6 @@
# Нарезка проектов (целевая)
-Сейчас в коде: `Protocol ← Server → Simulation → People → Content`. Каталог не в Simulation (рядом с Arch)
+Сейчас в коде: `Protocol ← Server → Simulation → Ai → People / Schedule → Content`. Каталог не в Simulation (рядом с Arch)
и не в Server (рядом с Kestrel) — парсер JSONC и проверка графа не знают, что такое tick.
## Зависимости
diff --git a/docs/phases/18-presence-walking.md b/docs/phases/18-presence-walking.md
index 5175212..a5592c0 100644
--- a/docs/phases/18-presence-walking.md
+++ b/docs/phases/18-presence-walking.md
@@ -12,61 +12,61 @@
## Задачи
-- [ ] Новый проект `HSchool.Ai` рядом с `People` и `Schedule`: зависит на `Content`, `People` и
+- [x] Новый проект `HSchool.Ai` рядом с `People` и `Schedule`: зависит на `Content`, `People` и
`Schedule`, без Arch и ASP.NET. Рядом — `tests/HSchool.Ai.Tests`
-- [ ] `travelMinutes` на `RoomDef` и `TerritoryDef`; в `core` проставлены кабинет 0.5, коридор
+- [x] `travelMinutes` на `RoomDef` и `TerritoryDef`; в `core` проставлены кабинет 0.5, коридор
1.5, лестница 1.5, вестибюль 1, двор 3
-- [ ] Маршрутизация по `MapLayout.Links` — в библиотеке: матрица следующих шагов считается
+- [x] Маршрутизация по `MapLayout.Links` — в библиотеке: матрица следующих шагов считается
**один раз** при загрузке школы, не на каждом шаге
-- [ ] План дня человека — тоже в библиотеке: во сколько выходить, чтобы успеть, и когда уходить.
+- [x] План дня человека — тоже в библиотеке: во сколько выходить, чтобы успеть, и когда уходить.
Часы, компоненты и мир туда не проникают
-- [ ] Место человека — компонент в `World`: узел, остаток пути, минуты до выхода из узла.
+- [x] Место человека — компонент в `World`: узел, остаток пути, минуты до выхода из узла.
Человек всегда в узле; рёбер как состояний нет
-- [ ] Состояние «вне школы» — не удаление сущности: человек остаётся в списке и в карточке
-- [ ] Приход и уход: у кого сегодня нет уроков, тот не приходит вовсе. Ученик и учитель — по
+- [x] Состояние «вне школы» — не удаление сущности: человек остаётся в списке и в карточке
+- [x] Приход и уход: у кого сегодня нет уроков, тот не приходит вовсе. Ученик и учитель — по
своим урокам, прочий штат — по дню школы
-- [ ] Запас на дорогу свой у каждого: путь от двора плюс несколько минут из своего потока
+- [x] Запас на дорогу свой у каждого: путь от двора плюс несколько минут из своего потока
случайности, с поправкой на черты
-- [ ] Обязанность как место назначения: урок — кабинет из расписания, должность — комната из
+- [x] Обязанность как место назначения: урок — кабинет из расписания, должность — комната из
`RoomDef.works`. Выбора действий пока нет
-- [ ] Движение по тику: остаток минут в узле уменьшается, при нуле человек переходит в следующий
+- [x] Движение по тику: остаток минут в узле уменьшается, при нуле человек переходит в следующий
узел пути
-- [ ] Стабильный порядок обхода людей — собственный список, а не порядок сущностей в Arch
-- [ ] Адаптер в `HSchool.Simulation`: собирает значения из компонентов, зовёт библиотеку,
+- [x] Стабильный порядок обхода людей — собственный список, а не порядок сущностей в Arch
+- [x] Адаптер в `HSchool.Simulation`: собирает значения из компонентов, зовёт библиотеку,
записывает ответ обратно. Горячий путь — движение — границу не пересекает
-- [ ] Ориентир в `AGENTS.md`, `docs/architecture.md` и `docs/design/projects.md` знает про новый
+- [x] Ориентир в `AGENTS.md`, `docs/architecture.md` и `docs/design/projects.md` знает про новый
проект
-- [ ] Место, путь и назначение едут в файл часов; кого в файле нет, тот расставляется по
+- [x] Место, путь и назначение едут в файл часов; кого в файле нет, тот расставляется по
обязанности
-- [ ] Потолок решений за тик — в `SimulationOptions`, как очередь, а не как отсечка
-- [ ] Предикат «школа пуста»: в здании и во дворе нет никого
-- [ ] Рабочее окно дня — от шести утра до последнего звонка по **каркасу дня**, а не по
+- [x] Потолок решений за тик — в `SimulationOptions`, как очередь, а не как отсечка
+- [x] Предикат «школа пуста»: в здании и во дворе нет никого
+- [x] Рабочее окно дня — от шести утра до последнего звонка по **каркасу дня**, а не по
фактическому расписанию
-- [ ] «Ближайшее шесть утра рабочего дня впереди»: ночь ведёт в то же утро, вечер — в следующее,
+- [x] «Ближайшее шесть утра рабочего дня впереди»: ночь ведёт в то же утро, вечер — в следующее,
выходные, праздники и каникулы пропускаются. Поиск ограничен разумным числом дней и
честно отвечает «нет такого»
-- [ ] Прыжок часов туда одним шагом: разрешён только при пустой школе и вне рабочего окна,
+- [x] Прыжок часов туда одним шагом: разрешён только при пустой школе и вне рабочего окна,
отрабатывает первое сентября и обновление пула за весь пропуск, после себя пишет сейв
## Тесты, без которых фаза не закрыта
-- [ ] Путь из кабинета 201 в санузел первого этажа идёт через лестницу и коридор, а не напрямую
-- [ ] Сумма `travelMinutes` совпадает с временем, за которое человек реально доходит
-- [ ] В воскресенье и в каникулы школа пуста: все вне школы
-- [ ] Класс без уроков сегодня не приходит, а его учитель с уроками у другого класса — приходит
-- [ ] К началу первого урока ученик находится в своём кабинете
-- [ ] Физкультура после урока в главном корпусе: класс доходит до спортзала, и видно, что
+- [x] Путь из кабинета 201 в санузел первого этажа идёт через лестницу и коридор, а не напрямую
+- [x] Сумма `travelMinutes` совпадает с временем, за которое человек реально доходит
+- [x] В воскресенье и в каникулы школа пуста: все вне школы
+- [x] Класс без уроков сегодня не приходит, а его учитель с уроками у другого класса — приходит
+- [x] К началу первого урока ученик находится в своём кабинете
+- [x] Физкультура после урока в главном корпусе: класс доходит до спортзала, и видно, что
перемены на это едва хватает
-- [ ] Тот же сид и та же карта дают то же расположение людей на тот же момент
-- [ ] Сохранение и загрузка посреди перемены не телепортируют людей
-- [ ] Прыжок с субботы попадает в понедельник, 6:00; с трёх ночи вторника — в утро того же
+- [x] Тот же сид и та же карта дают то же расположение людей на тот же момент
+- [x] Сохранение и загрузка посреди перемены не телепортируют людей
+- [x] Прыжок с субботы попадает в понедельник, 6:00; с трёх ночи вторника — в утро того же
вторника; с десяти вечера — в утро среды
-- [ ] Прыжок в учебное время отклоняется. В семь утра рабочего дня, когда школа ещё пуста, —
+- [x] Прыжок в учебное время отклоняется. В семь утра рабочего дня, когда школа ещё пуста, —
тоже: это уже рабочее окно
-- [ ] У школы без единого учителя рабочий день не пропускается
-- [ ] Прыжок через летние каникулы проводит первое сентября: классы перешли, выпуск состоялся,
+- [x] У школы без единого учителя рабочий день не пропускается
+- [x] Прыжок через летние каникулы проводит первое сентября: классы перешли, выпуск состоялся,
набор пришёл
-- [ ] Промотанная неделя и прожитая неделя дают одно состояние школы
+- [x] Промотанная неделя и прожитая неделя дают одно состояние школы
## Критерий готовности
diff --git a/docs/phases/README.md b/docs/phases/README.md
index fbcd084..6d6ddc2 100644
--- a/docs/phases/README.md
+++ b/docs/phases/README.md
@@ -82,7 +82,7 @@
| Фаза | Статус | Зачем |
| --- | --- | --- |
-| [18. Присутствие и ходьба](18-presence-walking.md) | ⬜ | `HSchool.Ai`, место человека, маршруты, приход и уход |
+| [18. Присутствие и ходьба](18-presence-walking.md) | ✅ | `HSchool.Ai`, место человека, маршруты, приход и уход |
| [19. Присутствие на экране](19-presence-screen.md) | ⬜ | Своё сообщение ~2 Гц, числа в дереве, статический снимок карты, пропуск пустого времени |
**Этап B — поведение.** Появляются нужды, действия и выбор между ними и обязанностью.
diff --git a/h-school.sln b/h-school.sln
index 50249d9..f891b5a 100644
--- a/h-school.sln
+++ b/h-school.sln
@@ -35,6 +35,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Schedule", "src\HSc
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Schedule.Tests", "tests\HSchool.Schedule.Tests\HSchool.Schedule.Tests.csproj", "{85FF85BD-F572-4F0A-A11E-FA855D480C1D}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Ai", "src\HSchool.Ai\HSchool.Ai.csproj", "{C21FB23F-F131-4651-8236-4F3E076B40BF}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Ai.Tests", "tests\HSchool.Ai.Tests\HSchool.Ai.Tests.csproj", "{C355307C-D7D3-48C7-9615-CB5FBCF83D49}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -213,6 +217,30 @@ Global
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Release|x64.Build.0 = Release|Any CPU
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Release|x86.ActiveCfg = Release|Any CPU
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Release|x86.Build.0 = Release|Any CPU
+ {C21FB23F-F131-4651-8236-4F3E076B40BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C21FB23F-F131-4651-8236-4F3E076B40BF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C21FB23F-F131-4651-8236-4F3E076B40BF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C21FB23F-F131-4651-8236-4F3E076B40BF}.Debug|x64.Build.0 = Debug|Any CPU
+ {C21FB23F-F131-4651-8236-4F3E076B40BF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C21FB23F-F131-4651-8236-4F3E076B40BF}.Debug|x86.Build.0 = Debug|Any CPU
+ {C21FB23F-F131-4651-8236-4F3E076B40BF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C21FB23F-F131-4651-8236-4F3E076B40BF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C21FB23F-F131-4651-8236-4F3E076B40BF}.Release|x64.ActiveCfg = Release|Any CPU
+ {C21FB23F-F131-4651-8236-4F3E076B40BF}.Release|x64.Build.0 = Release|Any CPU
+ {C21FB23F-F131-4651-8236-4F3E076B40BF}.Release|x86.ActiveCfg = Release|Any CPU
+ {C21FB23F-F131-4651-8236-4F3E076B40BF}.Release|x86.Build.0 = Release|Any CPU
+ {C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Debug|x64.Build.0 = Debug|Any CPU
+ {C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Debug|x86.Build.0 = Debug|Any CPU
+ {C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|x64.ActiveCfg = Release|Any CPU
+ {C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|x64.Build.0 = Release|Any CPU
+ {C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|x86.ActiveCfg = Release|Any CPU
+ {C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -232,6 +260,8 @@ Global
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{734AC2F7-B243-4211-95CF-3E662D2E20C9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{85FF85BD-F572-4F0A-A11E-FA855D480C1D} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {C21FB23F-F131-4651-8236-4F3E076B40BF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {C355307C-D7D3-48C7-9615-CB5FBCF83D49} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {DD14EF4D-167E-4AC7-953A-AF606CC34829}
diff --git a/src/HSchool.Ai/DayPlan.cs b/src/HSchool.Ai/DayPlan.cs
new file mode 100644
index 0000000..35c6ce5
--- /dev/null
+++ b/src/HSchool.Ai/DayPlan.cs
@@ -0,0 +1,125 @@
+using HSchool.Content;
+using HSchool.People;
+using HSchool.Schedule;
+
+namespace HSchool.Ai;
+
+/// When this person appears at the yard and when they start walking home today.
+public readonly record struct DayPlan(DateOnly Day, DateTime? AppearAt, DateTime? WalkHomeAt, string? FirstRoom)
+{
+ public bool Comes => AppearAt is not null;
+}
+
+public static class DayPlans
+{
+ private const int ExtraRollMax = 7;
+
+ public static DayPlan Build(
+ DefCatalog catalog,
+ WalkGraph walks,
+ Person person,
+ SchoolClass? schoolClass,
+ Timetable? timetable,
+ DateTime time,
+ int weekDays,
+ int schoolSeed)
+ {
+ ArgumentNullException.ThrowIfNull(catalog);
+ ArgumentNullException.ThrowIfNull(walks);
+ ArgumentNullException.ThrowIfNull(person);
+
+ var utc = DateTime.SpecifyKind(time, DateTimeKind.Utc);
+ var day = DateOnly.FromDateTime(utc);
+ if (!Duty.ComesToday(person, timetable, catalog, utc, weekDays) || catalog.DayFrame is null)
+ {
+ return new DayPlan(day, null, null, null);
+ }
+
+ var weekday = SchoolDay.WeekdayIndex(utc);
+ var firstRoom = FirstRoom(person, schoolClass, timetable, catalog, weekday);
+ if (firstRoom is null)
+ {
+ return new DayPlan(day, null, null, null);
+ }
+
+ var firstStart = FirstStart(person, schoolClass, timetable, catalog, weekday);
+ var lastEnd = LastEnd(person, schoolClass, timetable, catalog, weekday);
+ var travel = walks.Minutes(walks.TerritoryId, firstRoom);
+ if (float.IsInfinity(travel))
+ {
+ travel = 0f;
+ }
+
+ var slack = SlackMinutes(catalog, person, schoolSeed, day);
+ var appear = DateTime.SpecifyKind(utc.Date.Add(firstStart.ToTimeSpan()).AddMinutes(-(travel + slack)), DateTimeKind.Utc);
+ var walkHome = DateTime.SpecifyKind(utc.Date.Add(lastEnd.ToTimeSpan()), DateTimeKind.Utc);
+ return new DayPlan(day, appear, walkHome, firstRoom);
+ }
+
+ private static string? FirstRoom(
+ Person person,
+ SchoolClass? schoolClass,
+ Timetable? timetable,
+ DefCatalog catalog,
+ int weekday)
+ {
+ if (Duty.IsOtherStaff(person))
+ {
+ return person.WorkplaceRoomId;
+ }
+
+ var lessons = Duty.LessonsToday(person, schoolClass, timetable, weekday);
+ return lessons.Count > 0 ? lessons[0].RoomId : schoolClass?.RoomId;
+ }
+
+ private static TimeOnly FirstStart(
+ Person person,
+ SchoolClass? schoolClass,
+ Timetable? timetable,
+ DefCatalog catalog,
+ int weekday)
+ {
+ var frame = catalog.DayFrame!;
+ if (Duty.IsOtherStaff(person))
+ {
+ return SchoolDay.PeriodStart(frame, 1);
+ }
+
+ var lessons = Duty.LessonsToday(person, schoolClass, timetable, weekday);
+ return lessons.Count > 0 ? SchoolDay.PeriodStart(frame, lessons[0].Period) : SchoolDay.PeriodStart(frame, 1);
+ }
+
+ private static TimeOnly LastEnd(
+ Person person,
+ SchoolClass? schoolClass,
+ Timetable? timetable,
+ DefCatalog catalog,
+ int weekday)
+ {
+ var frame = catalog.DayFrame!;
+ if (Duty.IsOtherStaff(person))
+ {
+ return SchoolDay.PeriodEnd(frame, frame.LessonCount);
+ }
+
+ var lessons = Duty.LessonsToday(person, schoolClass, timetable, weekday);
+ return lessons.Count > 0
+ ? SchoolDay.PeriodEnd(frame, lessons[^1].Period)
+ : SchoolDay.PeriodEnd(frame, frame.LessonCount);
+ }
+
+ private static int SlackMinutes(DefCatalog catalog, Person person, int schoolSeed, DateOnly day)
+ {
+ var rng = new Random(Seed.Mix(schoolSeed, person.Id, day.DayNumber, Seed.CommuteSalt));
+ var extra = rng.Next(0, ExtraRollMax);
+ foreach (var name in person.Traits)
+ {
+ if (catalog.Traits.TryGetValue(name, out var trait))
+ {
+ extra += trait.CommuteMinutes;
+ }
+ }
+
+ return extra;
+ }
+}
diff --git a/src/HSchool.Ai/Duty.cs b/src/HSchool.Ai/Duty.cs
new file mode 100644
index 0000000..1c64f62
--- /dev/null
+++ b/src/HSchool.Ai/Duty.cs
@@ -0,0 +1,157 @@
+using HSchool.Content;
+using HSchool.People;
+using HSchool.Schedule;
+
+namespace HSchool.Ai;
+
+///
+/// Where this person ought to be right now. The timetable and the job, not the walk graph.
+///
+public static class Duty
+{
+ ///
+ /// The room of the current obligation, or when they should be off campus.
+ /// A hole in the class table sends the pupil to their homeroom.
+ ///
+ public static string? RoomAt(
+ Person person,
+ SchoolClass? schoolClass,
+ Timetable? timetable,
+ DefCatalog catalog,
+ DateTime time,
+ int weekDays)
+ {
+ ArgumentNullException.ThrowIfNull(person);
+ ArgumentNullException.ThrowIfNull(catalog);
+
+ if (!ComesToday(person, timetable, catalog, time, weekDays))
+ {
+ return null;
+ }
+
+ if (IsOtherStaff(person))
+ {
+ return person.WorkplaceRoomId;
+ }
+
+ var utc = DateTime.SpecifyKind(time, DateTimeKind.Utc);
+ if (!SchoolDay.IsWorkday(catalog, utc, weekDays))
+ {
+ return null;
+ }
+
+ var slot = SchoolDay.At(catalog, utc, weekDays);
+ var day = SchoolDay.WeekdayIndex(utc);
+ var lessons = LessonsToday(person, schoolClass, timetable, day);
+ if (lessons.Count == 0)
+ {
+ return person.WorkplaceRoomId;
+ }
+
+ if (slot.Kind == DaySlotKind.Lesson)
+ {
+ var current = lessons.FirstOrDefault(lesson => lesson.Period == slot.Index);
+ if (current is not null)
+ {
+ return current.RoomId;
+ }
+
+ return schoolClass?.RoomId ?? person.WorkplaceRoomId;
+ }
+
+ if (slot.Kind == DaySlotKind.Break)
+ {
+ var next = lessons.Where(lesson => lesson.Period > slot.Index).OrderBy(lesson => lesson.Period).FirstOrDefault();
+ return next?.RoomId ?? schoolClass?.RoomId ?? person.WorkplaceRoomId;
+ }
+
+ var frame = catalog.DayFrame;
+ if (frame is not null
+ && SchoolDay.TryParseTime(frame.FirstLesson, out var first)
+ && utc.TimeOfDay < first.ToTimeSpan())
+ {
+ return lessons[0].RoomId;
+ }
+
+ if (frame is not null
+ && SchoolDay.TryLastBell(catalog, out var last)
+ && utc.TimeOfDay >= last.ToTimeSpan())
+ {
+ return null;
+ }
+
+ return lessons[0].RoomId;
+ }
+
+ public static bool ComesToday(
+ Person person,
+ Timetable? timetable,
+ DefCatalog catalog,
+ DateTime time,
+ int weekDays)
+ {
+ if (person.IsParent && !person.IsStaff && !person.IsStudent)
+ {
+ return false;
+ }
+
+ if (!SchoolDay.IsWorkday(catalog, time, weekDays))
+ {
+ return false;
+ }
+
+ if (IsOtherStaff(person))
+ {
+ return true;
+ }
+
+ var day = SchoolDay.WeekdayIndex(time);
+ if (person.IsStudent)
+ {
+ return timetable?.Lessons.Any(lesson =>
+ lesson.ClassId == person.ClassId && lesson.Day == day) == true;
+ }
+
+ if (person.IsStaff)
+ {
+ return timetable?.Lessons.Any(lesson =>
+ lesson.TeacherId == person.Id && lesson.Day == day) == true;
+ }
+
+ return false;
+ }
+
+ public static bool IsOtherStaff(Person person) =>
+ person.IsStaff && !Staffing.TeacherPosition.Equals(person.Position, StringComparison.Ordinal);
+
+ public static IReadOnlyList LessonsToday(
+ Person person,
+ SchoolClass? schoolClass,
+ Timetable? timetable,
+ int day)
+ {
+ if (timetable is null)
+ {
+ return [];
+ }
+
+ if (person.IsStudent)
+ {
+ var classId = person.ClassId ?? schoolClass?.Id;
+ return timetable.Lessons
+ .Where(lesson => lesson.ClassId == classId && lesson.Day == day)
+ .OrderBy(lesson => lesson.Period)
+ .ToArray();
+ }
+
+ if (person.IsStaff)
+ {
+ return timetable.Lessons
+ .Where(lesson => lesson.TeacherId == person.Id && lesson.Day == day)
+ .OrderBy(lesson => lesson.Period)
+ .ToArray();
+ }
+
+ return [];
+ }
+}
diff --git a/src/HSchool.Ai/HSchool.Ai.csproj b/src/HSchool.Ai/HSchool.Ai.csproj
new file mode 100644
index 0000000..7c60b96
--- /dev/null
+++ b/src/HSchool.Ai/HSchool.Ai.csproj
@@ -0,0 +1,17 @@
+
+
+
+ HSchool.Ai
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/HSchool.Ai/Presence.cs b/src/HSchool.Ai/Presence.cs
new file mode 100644
index 0000000..90fbd18
--- /dev/null
+++ b/src/HSchool.Ai/Presence.cs
@@ -0,0 +1,79 @@
+namespace HSchool.Ai;
+
+///
+/// One person's place on the graph. is null when they are off campus.
+/// There is no "on an edge" state — remaining minutes are spent occupying the current node.
+///
+public readonly record struct Presence(
+ string? NodeId,
+ float RemainingMinutes,
+ string? DestinationId,
+ bool HeadingHome,
+ string[] Path)
+{
+ public static Presence OffCampus { get; } = new(null, 0f, null, false, []);
+
+ public bool IsOnCampus => NodeId is not null;
+}
+
+///
+/// Hot-path movement in the library so tests can prove travel time without a world. Simulation
+/// copies the same loop onto components and does not call this every tick.
+///
+public static class PresenceStepper
+{
+ public static Presence Advance(Presence presence, WalkGraph walks, float minutes)
+ {
+ ArgumentNullException.ThrowIfNull(walks);
+ if (presence.NodeId is null || minutes <= 0)
+ {
+ return presence;
+ }
+
+ var remaining = presence.RemainingMinutes - minutes;
+ var node = presence.NodeId;
+ var path = presence.Path;
+ var index = 0;
+ while (remaining <= 0 && index < path.Length)
+ {
+ node = path[index];
+ index++;
+ remaining += walks.TravelMinutes(node);
+ }
+
+ if (remaining < 0)
+ {
+ remaining = 0;
+ }
+
+ var leftover = index >= path.Length ? [] : path[index..];
+ return presence with { NodeId = node, RemainingMinutes = remaining, Path = leftover };
+ }
+
+ public static Presence StartWalk(Presence presence, WalkGraph walks, string destination, bool headingHome)
+ {
+ ArgumentNullException.ThrowIfNull(walks);
+ if (presence.NodeId is null)
+ {
+ var path = walks.Path(walks.TerritoryId, destination);
+ return new Presence(walks.TerritoryId, 0f, destination, headingHome, [.. path]);
+ }
+
+ var hops = walks.Path(presence.NodeId, destination);
+ return presence with
+ {
+ DestinationId = destination,
+ HeadingHome = headingHome,
+ Path = [.. hops],
+ };
+ }
+
+ public static Presence ArriveOffCampus(Presence presence) =>
+ presence.NodeId is not null
+ && presence.HeadingHome
+ && presence.Path.Length == 0
+ && presence.RemainingMinutes <= 0
+ && presence.NodeId.Equals(presence.DestinationId, StringComparison.Ordinal)
+ ? Presence.OffCampus
+ : presence;
+}
diff --git a/src/HSchool.Ai/WalkGraph.cs b/src/HSchool.Ai/WalkGraph.cs
new file mode 100644
index 0000000..b641128
--- /dev/null
+++ b/src/HSchool.Ai/WalkGraph.cs
@@ -0,0 +1,165 @@
+using HSchool.Content;
+
+namespace HSchool.Ai;
+
+///
+/// Next-hop matrix for a school's walkable graph. Built once when the school loads; querying a
+/// path does not search again.
+///
+public sealed class WalkGraph
+{
+ private const float Unreachable = float.PositiveInfinity;
+
+ private readonly string[] _nodes;
+ private readonly Dictionary _index;
+ private readonly float[] _travel;
+ private readonly float[,] _distance;
+ private readonly int[,] _next;
+
+ private WalkGraph(
+ string territoryId,
+ string[] nodes,
+ Dictionary index,
+ float[] travel,
+ float[,] distance,
+ int[,] next)
+ {
+ TerritoryId = territoryId;
+ _nodes = nodes;
+ _index = index;
+ _travel = travel;
+ _distance = distance;
+ _next = next;
+ }
+
+ public string TerritoryId { get; }
+
+ public IReadOnlyList Nodes => _nodes;
+
+ public static WalkGraph Build(DefCatalog catalog, MapLayout map)
+ {
+ ArgumentNullException.ThrowIfNull(catalog);
+ ArgumentNullException.ThrowIfNull(map);
+ if (map.Territory is null)
+ {
+ throw new ArgumentException("A walk graph needs a territory node.", nameof(map));
+ }
+
+ var nodes = new List { map.Territory.Id };
+ foreach (var room in map.Rooms.OrderBy(room => room.Id, StringComparer.Ordinal))
+ {
+ nodes.Add(room.Id);
+ }
+
+ var count = nodes.Count;
+ var index = new Dictionary(count, StringComparer.Ordinal);
+ var travel = new float[count];
+ for (var i = 0; i < count; i++)
+ {
+ var id = nodes[i];
+ index[id] = i;
+ travel[i] = TravelOf(catalog, map, id);
+ }
+
+ var distance = new float[count, count];
+ var next = new int[count, count];
+ for (var i = 0; i < count; i++)
+ {
+ for (var j = 0; j < count; j++)
+ {
+ distance[i, j] = i == j ? 0f : Unreachable;
+ next[i, j] = -1;
+ }
+ }
+
+ foreach (var link in map.Links)
+ {
+ if (!index.TryGetValue(link.A, out var a) || !index.TryGetValue(link.B, out var b))
+ {
+ continue;
+ }
+
+ distance[a, b] = travel[b];
+ next[a, b] = b;
+ distance[b, a] = travel[a];
+ next[b, a] = a;
+ }
+
+ for (var k = 0; k < count; k++)
+ {
+ for (var i = 0; i < count; i++)
+ {
+ for (var j = 0; j < count; j++)
+ {
+ var via = distance[i, k] + distance[k, j];
+ if (via < distance[i, j])
+ {
+ distance[i, j] = via;
+ next[i, j] = next[i, k];
+ }
+ }
+ }
+ }
+
+ return new WalkGraph(map.Territory.Id, [.. nodes], index, travel, distance, next);
+ }
+
+ public float TravelMinutes(string nodeId) =>
+ _index.TryGetValue(nodeId, out var i) ? _travel[i] : 0f;
+
+ /// Hops after , including . Empty when already there.
+ public IReadOnlyList Path(string from, string to)
+ {
+ if (from.Equals(to, StringComparison.Ordinal))
+ {
+ return [];
+ }
+
+ if (!_index.TryGetValue(from, out var i) || !_index.TryGetValue(to, out var j) || _next[i, j] < 0)
+ {
+ return [];
+ }
+
+ var hops = new List();
+ while (i != j)
+ {
+ i = _next[i, j];
+ if (i < 0)
+ {
+ return [];
+ }
+
+ hops.Add(_nodes[i]);
+ }
+
+ return hops;
+ }
+
+ public float Minutes(string from, string to)
+ {
+ if (!_index.TryGetValue(from, out var i) || !_index.TryGetValue(to, out var j))
+ {
+ return Unreachable;
+ }
+
+ return _distance[i, j];
+ }
+
+ private static float TravelOf(DefCatalog catalog, MapLayout map, string id)
+ {
+ if (map.Territory is not null && id.Equals(map.Territory.Id, StringComparison.Ordinal))
+ {
+ return catalog.Territories.TryGetValue(map.Territory.Def, out var territory)
+ ? territory.TravelMinutes
+ : 0f;
+ }
+
+ var room = map.Rooms.FirstOrDefault(candidate => candidate.Id.Equals(id, StringComparison.Ordinal));
+ if (room is not null && catalog.Rooms.TryGetValue(room.Def, out var def))
+ {
+ return def.TravelMinutes;
+ }
+
+ return 0f;
+ }
+}
diff --git a/src/HSchool.Content/CatalogLoader.cs b/src/HSchool.Content/CatalogLoader.cs
index eb2edb2..a97e0a0 100644
--- a/src/HSchool.Content/CatalogLoader.cs
+++ b/src/HSchool.Content/CatalogLoader.cs
@@ -468,6 +468,19 @@ public sealed class CatalogLoader
{
throw new ContentLoadException($"RoomDef '{room.DefName}' is not a homeroom and cannot set seatThing or defaultSeats.");
}
+
+ if (!room.Abstract && room.TravelMinutes <= 0)
+ {
+ throw new ContentLoadException($"RoomDef '{room.DefName}' travelMinutes must be positive.");
+ }
+ }
+
+ foreach (var territory in catalog.Territories.Values)
+ {
+ if (!territory.Abstract && territory.TravelMinutes <= 0)
+ {
+ throw new ContentLoadException($"TerritoryDef '{territory.DefName}' travelMinutes must be positive.");
+ }
}
PeopleDefValidator.Validate(catalog);
diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs
index 1dcbbfc..155d488 100644
--- a/src/HSchool.Content/Defs.cs
+++ b/src/HSchool.Content/Defs.cs
@@ -77,10 +77,17 @@ public sealed class RoomDef : Def
/// Editor default when placing a new homeroom. Vanilla classrooms are 16.
public int DefaultSeats { get; init; }
+
+ /// Game minutes spent occupying this room when walking through it.
+ public float TravelMinutes { get; init; }
}
public sealed class BuildingDef : Def;
public sealed class FloorDef : Def;
-public sealed class TerritoryDef : Def;
+public sealed class TerritoryDef : Def
+{
+ /// Game minutes spent occupying the yard when walking through it.
+ public float TravelMinutes { get; init; }
+}
diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs
index 013abc1..aaf179c 100644
--- a/src/HSchool.Content/PeopleDefs.cs
+++ b/src/HSchool.Content/PeopleDefs.cs
@@ -167,6 +167,11 @@ public sealed class TraitDef : Def
/// Added to the hourly wage ask. Positive means the person wants more at the same skills.
///
public float WageAsk { get; init; }
+
+ ///
+ /// Extra minutes of commute slack. Positive arrives earlier; negative cuts it closer.
+ ///
+ public int CommuteMinutes { get; init; }
}
public sealed class StaffingDef : Def
diff --git a/src/HSchool.Content/SchoolDay.cs b/src/HSchool.Content/SchoolDay.cs
index 37924ad..9b4ee6b 100644
--- a/src/HSchool.Content/SchoolDay.cs
+++ b/src/HSchool.Content/SchoolDay.cs
@@ -95,6 +95,113 @@ public static class SchoolDay
return stamp >= start || stamp <= end;
}
+ /// The work window opens at six — the same hour a new school starts.
+ public static TimeOnly DayStart { get; } = new(6, 0);
+
+ public static bool IsWorkday(DefCatalog catalog, DateTime time, int weekDays)
+ {
+ ArgumentNullException.ThrowIfNull(catalog);
+ EnsureWeekDays(weekDays);
+ var utc = DateTime.SpecifyKind(time, DateTimeKind.Utc);
+ return IsWeekday(utc, weekDays) && !IsHoliday(catalog, utc);
+ }
+
+ ///
+ /// Six in the morning until the last bell of the day frame — not the actual timetable.
+ /// A school with no teachers still has a work window on a workday.
+ ///
+ public static bool InWorkWindow(DefCatalog catalog, DateTime time, int weekDays)
+ {
+ if (!IsWorkday(catalog, time, weekDays) || !TryLastBell(catalog, out var lastBell))
+ {
+ return false;
+ }
+
+ var clock = DateTime.SpecifyKind(time, DateTimeKind.Utc).TimeOfDay;
+ return clock >= DayStart.ToTimeSpan() && clock < lastBell.ToTimeSpan();
+ }
+
+ public static bool TryLastBell(DefCatalog catalog, out TimeOnly lastBell)
+ {
+ lastBell = default;
+ var frame = catalog.DayFrame;
+ if (frame is null || !TryParseTime(frame.FirstLesson, out _))
+ {
+ return false;
+ }
+
+ lastBell = PeriodEnd(frame, frame.LessonCount);
+ return true;
+ }
+
+ public static TimeOnly PeriodStart(DayFrameDef frame, int period)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ if (period < 1 || period > frame.LessonCount)
+ {
+ throw new ArgumentOutOfRangeException(nameof(period), period, "Period is not on the day frame.");
+ }
+
+ if (!TryParseTime(frame.FirstLesson, out var first))
+ {
+ throw new ArgumentException($"Day frame firstLesson '{frame.FirstLesson}' is not a time.", nameof(frame));
+ }
+
+ var cursor = first.ToTimeSpan();
+ var lesson = TimeSpan.FromMinutes(frame.LessonMinutes);
+ for (var i = 1; i < period; i++)
+ {
+ cursor += lesson;
+ cursor += TimeSpan.FromMinutes(i == frame.LongBreakAfter ? frame.LongBreakMinutes : frame.BreakMinutes);
+ }
+
+ return TimeOnly.FromTimeSpan(cursor);
+ }
+
+ public static TimeOnly PeriodEnd(DayFrameDef frame, int period) =>
+ PeriodStart(frame, period).AddMinutes(frame.LessonMinutes);
+
+ ///
+ /// The next 6:00 of a workday that is still ahead. Night lands on the same morning;
+ /// evening, weekends and holidays walk forward. when none exists
+ /// within .
+ ///
+ public static DateTime? NextWorkMorning(DefCatalog catalog, DateTime time, int weekDays, int maxDays = 400)
+ {
+ ArgumentNullException.ThrowIfNull(catalog);
+ EnsureWeekDays(weekDays);
+ if (maxDays < 1)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maxDays), maxDays, "Search must look at least one day ahead.");
+ }
+
+ var utc = DateTime.SpecifyKind(time, DateTimeKind.Utc);
+ var day = utc.Date;
+ if (utc.TimeOfDay < DayStart.ToTimeSpan() && IsWorkday(catalog, day, weekDays))
+ {
+ return DateTime.SpecifyKind(day.Add(DayStart.ToTimeSpan()), DateTimeKind.Utc);
+ }
+
+ for (var i = 1; i <= maxDays; i++)
+ {
+ var candidate = day.AddDays(i);
+ if (IsWorkday(catalog, candidate, weekDays))
+ {
+ return DateTime.SpecifyKind(candidate.Add(DayStart.ToTimeSpan()), DateTimeKind.Utc);
+ }
+ }
+
+ return null;
+ }
+
+ private static void EnsureWeekDays(int weekDays)
+ {
+ if (weekDays is < 5 or > 7)
+ {
+ throw new ArgumentOutOfRangeException(nameof(weekDays), weekDays, "School week must be 5–7 days.");
+ }
+ }
+
private static bool IsWeekday(DateTime time, int weekDays)
{
// Monday = 0 … Sunday = 6. A 5-day week is Mon–Fri; 6 adds Saturday; 7 is every day.
diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs
index 669e848..fe88def 100644
--- a/src/HSchool.People/Seed.cs
+++ b/src/HSchool.People/Seed.cs
@@ -4,7 +4,7 @@ namespace HSchool.People;
/// Per-family streams derived from the school seed. Family N never consumes family N-1's rolls,
/// so appending a thirteenth family leaves the first twelve unchanged.
///
-internal static class Seed
+public static class Seed
{
public const int ChildCountSalt = 1;
public const int AppearanceSalt = 2;
@@ -12,6 +12,7 @@ internal static class Seed
public const int SeatShuffleSalt = 4;
public const int HouseholdSalt = 5;
public const int ApplicantSalt = 6;
+ public const int CommuteSalt = 7;
/// A stream that belongs to the school rather than to one family.
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
@@ -24,6 +25,28 @@ internal static class Seed
return (int)z;
}
+ /// A stream that belongs to one person on one calendar day — commute slack, not looks.
+ public static int Mix(int schoolSeed, string personId, int dayNumber, int salt)
+ {
+ ArgumentNullException.ThrowIfNull(personId);
+ var z = Mix64((uint)schoolSeed);
+ z = Mix64(z ^ Stable(personId));
+ z = Mix64(z ^ (uint)(dayNumber + 1));
+ z = Mix64(z ^ (uint)(salt + 1));
+ return (int)z;
+ }
+
+ private static uint Stable(string value)
+ {
+ ulong z = 0;
+ foreach (var character in value)
+ {
+ z = Mix64(z ^ character);
+ }
+
+ return (uint)z;
+ }
+
private static ulong Mix64(ulong z)
{
z += 0x9E3779B97F4A7C15UL;
diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs
index f1dbb68..b66ebdb 100644
--- a/src/HSchool.Server/Game/GameLoopService.cs
+++ b/src/HSchool.Server/Game/GameLoopService.cs
@@ -494,7 +494,8 @@ internal sealed class GameLoopService(
isNew: false,
save.ModIds,
save.Map,
- save.NameSetId);
+ save.NameSetId,
+ save.Presence);
worker.Start();
try
@@ -541,7 +542,8 @@ internal sealed class GameLoopService(
bool isNew,
IReadOnlyList? modIds,
MapLayout? map,
- string? nameSetId) =>
+ string? nameSetId,
+ IReadOnlyList? presence = null) =>
new(
id,
name,
@@ -552,6 +554,7 @@ internal sealed class GameLoopService(
modIds,
map,
nameSetId,
+ presence,
_options,
clients,
metrics,
diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs
index d789ba7..61c5618 100644
--- a/src/HSchool.Server/Game/SchoolStore.cs
+++ b/src/HSchool.Server/Game/SchoolStore.cs
@@ -27,6 +27,8 @@ internal sealed class SchoolSave
public MapLayout? Map { get; init; }
public string? NameSetId { get; init; }
+
+ public IReadOnlyList? Presence { get; init; }
}
/// Allocates school ids that survive a process restart.
@@ -160,6 +162,7 @@ internal sealed class SchoolStore
ModIds = save.ModIds,
Map = save.Map,
NameSetId = save.NameSetId,
+ Presence = save.Presence,
});
}
catch (Exception ex)
diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs
index 00b472e..163d8b6 100644
--- a/src/HSchool.Server/Game/SchoolWorker.cs
+++ b/src/HSchool.Server/Game/SchoolWorker.cs
@@ -33,6 +33,7 @@ internal sealed class SchoolWorker
private readonly IReadOnlyList? _modIds;
private readonly MapLayout? _savedMap;
private readonly string? _nameSetId;
+ private readonly IReadOnlyList? _savedPresence;
private readonly Action _onFailed;
private readonly int _id;
@@ -64,6 +65,7 @@ internal sealed class SchoolWorker
IReadOnlyList? modIds,
MapLayout? savedMap,
string? nameSetId,
+ IReadOnlyList? savedPresence,
SimulationOptions options,
ClientRegistry clients,
GameMetrics metrics,
@@ -81,6 +83,7 @@ internal sealed class SchoolWorker
_modIds = modIds;
_savedMap = savedMap;
_nameSetId = nameSetId;
+ _savedPresence = savedPresence;
_options = options;
_clients = clients;
_metrics = metrics;
@@ -882,6 +885,8 @@ internal sealed class SchoolWorker
school.InstallPeople(roster, seed, nameSetId, applicants);
InstallTimetable(school);
+ school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick);
+ school.RestorePresence(_savedPresence);
return generated;
}
@@ -927,6 +932,7 @@ internal sealed class SchoolWorker
ModIds = school.Catalog?.PackIds,
Map = school.Map,
NameSetId = _nameSetId,
+ Presence = school.CapturePresence(),
});
}
catch (Exception ex)
diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs
index 7feaa46..b121b78 100644
--- a/src/HSchool.Server/Program.cs
+++ b/src/HSchool.Server/Program.cs
@@ -23,6 +23,7 @@ builder.Services
.Validate(options => options.SaveIntervalSeconds is > 0 and <= 3600, "Simulation:SaveIntervalSeconds must be between 1 and 3600.")
.Validate(options => options.MonthlyPayrollCap > 0, "Simulation:MonthlyPayrollCap must be positive.")
.Validate(options => options.SchoolWeekDays is >= 5 and <= 7, "Simulation:SchoolWeekDays must be between 5 and 7.")
+ .Validate(options => options.MaxDecisionsPerTick is > 0 and <= 10_000, "Simulation:MaxDecisionsPerTick must be between 1 and 10000.")
.ValidateOnStart();
builder.Services.AddSingleton();
diff --git a/src/HSchool.Server/appsettings.json b/src/HSchool.Server/appsettings.json
index b483337..fed9c80 100644
--- a/src/HSchool.Server/appsettings.json
+++ b/src/HSchool.Server/appsettings.json
@@ -15,6 +15,7 @@
"ModsDirectory": "mods",
"SaveIntervalSeconds": 30,
"MonthlyPayrollCap": 100000,
- "SchoolWeekDays": 5
+ "SchoolWeekDays": 5,
+ "MaxDecisionsPerTick": 64
}
}
diff --git a/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc b/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc
index 48ea214..5a8ba01 100644
--- a/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc
+++ b/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc
@@ -5,6 +5,7 @@
"seatThing": "StudentDesk",
"defaultSeats": 16,
"works": ["TeachLesson"],
+ "travelMinutes": 0.5,
},
{
"defName": "Library",
@@ -14,6 +15,7 @@
],
"positions": ["Librarian"],
"works": ["LibraryWork"],
+ "travelMinutes": 0.5,
},
{
"defName": "ComputerLab",
@@ -23,5 +25,6 @@
{ "key": "computers", "thing": "Computer", "count": 12 },
],
"works": ["TeachLesson"],
+ "travelMinutes": 0.5,
},
]
diff --git a/src/HSchool.Server/mods/core/defs/rooms/circulation.jsonc b/src/HSchool.Server/mods/core/defs/rooms/circulation.jsonc
index 2ba38c2..baf3faa 100644
--- a/src/HSchool.Server/mods/core/defs/rooms/circulation.jsonc
+++ b/src/HSchool.Server/mods/core/defs/rooms/circulation.jsonc
@@ -1,5 +1,5 @@
[
// Walkable rooms that exist to connect the graph: lobby, stairs. Empty on purpose.
- { "defName": "EntranceHall" },
- { "defName": "Stairwell" },
+ { "defName": "EntranceHall", "travelMinutes": 1 },
+ { "defName": "Stairwell", "travelMinutes": 1.5 },
]
diff --git a/src/HSchool.Server/mods/core/defs/rooms/corridor.jsonc b/src/HSchool.Server/mods/core/defs/rooms/corridor.jsonc
index f5a0b4b..d76892b 100644
--- a/src/HSchool.Server/mods/core/defs/rooms/corridor.jsonc
+++ b/src/HSchool.Server/mods/core/defs/rooms/corridor.jsonc
@@ -1,2 +1,2 @@
// Empty on purpose: a corridor is a walkable room with no furniture of its own.
-{ "defName": "Corridor" }
+{ "defName": "Corridor", "travelMinutes": 1.5 }
diff --git a/src/HSchool.Server/mods/core/defs/rooms/gym-hall.jsonc b/src/HSchool.Server/mods/core/defs/rooms/gym-hall.jsonc
index 7ecafb8..568e817 100644
--- a/src/HSchool.Server/mods/core/defs/rooms/gym-hall.jsonc
+++ b/src/HSchool.Server/mods/core/defs/rooms/gym-hall.jsonc
@@ -4,4 +4,5 @@
{ "key": "benches", "thing": "Bench", "count": 4 },
],
"works": ["PELesson"],
+ "travelMinutes": 0.5,
}
diff --git a/src/HSchool.Server/mods/core/defs/rooms/principals-office.jsonc b/src/HSchool.Server/mods/core/defs/rooms/principals-office.jsonc
index 3482d93..c7ee218 100644
--- a/src/HSchool.Server/mods/core/defs/rooms/principals-office.jsonc
+++ b/src/HSchool.Server/mods/core/defs/rooms/principals-office.jsonc
@@ -7,4 +7,5 @@
],
"positions": ["Principal"],
"works": ["PrincipalOfficeWork", "TeachLesson", "WalkSchool"],
+ "travelMinutes": 0.5,
}
diff --git a/src/HSchool.Server/mods/core/defs/rooms/service.jsonc b/src/HSchool.Server/mods/core/defs/rooms/service.jsonc
index 55e8feb..ed1990e 100644
--- a/src/HSchool.Server/mods/core/defs/rooms/service.jsonc
+++ b/src/HSchool.Server/mods/core/defs/rooms/service.jsonc
@@ -7,6 +7,7 @@
],
"positions": ["Secretary"],
"works": ["OfficeWork"],
+ "travelMinutes": 0.5,
},
{
"defName": "TeachersRoom",
@@ -14,6 +15,7 @@
{ "key": "table", "thing": "DiningTable" },
{ "key": "chairs", "thing": "Chair", "count": 6 },
],
+ "travelMinutes": 0.5,
},
{
"defName": "Cafeteria",
@@ -23,9 +25,11 @@
],
"positions": ["CafeteriaCook"],
"works": ["ServeLunch"],
+ "travelMinutes": 0.5,
},
{
"defName": "Restroom",
+ "travelMinutes": 0.5,
},
{
"defName": "MedicalOffice",
@@ -35,11 +39,13 @@
],
"positions": ["Nurse"],
"works": ["MedicalDuty"],
+ "travelMinutes": 0.5,
},
{
"defName": "ChangingRoom",
"slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 },
],
+ "travelMinutes": 0.5,
},
]
diff --git a/src/HSchool.Server/mods/core/defs/territories/school-yard.jsonc b/src/HSchool.Server/mods/core/defs/territories/school-yard.jsonc
index f2f6fa4..8e5002a 100644
--- a/src/HSchool.Server/mods/core/defs/territories/school-yard.jsonc
+++ b/src/HSchool.Server/mods/core/defs/territories/school-yard.jsonc
@@ -1 +1 @@
-{ "defName": "SchoolYard" }
+{ "defName": "SchoolYard", "travelMinutes": 3 }
diff --git a/src/HSchool.Server/mods/core/defs/traits/traits.jsonc b/src/HSchool.Server/mods/core/defs/traits/traits.jsonc
index 8fcc332..f737af4 100644
--- a/src/HSchool.Server/mods/core/defs/traits/traits.jsonc
+++ b/src/HSchool.Server/mods/core/defs/traits/traits.jsonc
@@ -2,6 +2,7 @@
{
"defName": "Diligent",
"weight": 8,
+ "commuteMinutes": 4,
"incompatible": ["Lazy", "AbsentMinded"],
"skillModifiers": [
{ "skill": "Mathematics", "offset": 8 },
@@ -47,6 +48,7 @@
"defName": "Lazy",
"weight": 6,
"incompatible": ["Diligent"],
+ "commuteMinutes": -4,
"skillModifiers": [
{ "skill": "PhysicalEducation", "offset": -8 },
{ "skill": "Mathematics", "offset": -6 },
diff --git a/src/HSchool.Simulation/GameClock.cs b/src/HSchool.Simulation/GameClock.cs
index 68c17a6..72d0f64 100644
--- a/src/HSchool.Simulation/GameClock.cs
+++ b/src/HSchool.Simulation/GameClock.cs
@@ -50,6 +50,17 @@ public sealed class GameClock
public static bool IsValidStartDate(DateTime date) => date >= MinStartDate && date <= MaxStartDate;
+ /// Empty-time skip. Not a tick — the calendar jumps to an instant already known to be legal.
+ public void JumpTo(DateTime time)
+ {
+ if (!IsValidStartDate(time))
+ {
+ throw new ArgumentOutOfRangeException(nameof(time), time, "Jump target is outside the supported range.");
+ }
+
+ Time = DateTime.SpecifyKind(time, DateTimeKind.Utc);
+ }
+
///
/// Advances the calendar by one fixed step of , scaled by the
/// base rate and the current speed. Does nothing while paused.
diff --git a/src/HSchool.Simulation/HSchool.Simulation.csproj b/src/HSchool.Simulation/HSchool.Simulation.csproj
index 7646613..6f95035 100644
--- a/src/HSchool.Simulation/HSchool.Simulation.csproj
+++ b/src/HSchool.Simulation/HSchool.Simulation.csproj
@@ -10,6 +10,7 @@
+
diff --git a/src/HSchool.Simulation/PresenceSystem.cs b/src/HSchool.Simulation/PresenceSystem.cs
new file mode 100644
index 0000000..bf89d42
--- /dev/null
+++ b/src/HSchool.Simulation/PresenceSystem.cs
@@ -0,0 +1,374 @@
+using Arch.Core;
+using HSchool.Ai;
+using HSchool.Content;
+using HSchool.People;
+using HSchool.Schedule;
+
+namespace HSchool.Simulation;
+
+///
+/// Decisions go through ; the per-tick walk does not. Order is the
+/// roster id list, never Arch's entity order.
+///
+internal static class PresenceSystem
+{
+ private static readonly QueryDescription People =
+ new QueryDescription().WithAll();
+
+ public static void Apply(School school, double gameMinutes)
+ {
+ if (school.Catalog is null || school.Walks is null || school.Roster is null)
+ {
+ return;
+ }
+
+ EnsurePlans(school);
+ EnqueueEvents(school);
+ EnqueueTimeEvents(school, (float)gameMinutes);
+ DrainDecisions(school);
+ Move(school, (float)gameMinutes);
+ FinishHome(school);
+ }
+
+ public static bool IsEmpty(School school)
+ {
+ var empty = true;
+ var world = school.World;
+ world.Query(in People, (ref Presence presence) =>
+ {
+ if (presence.IsOnCampus)
+ {
+ empty = false;
+ }
+ });
+ return empty;
+ }
+
+ public static IReadOnlyList Capture(School school)
+ {
+ var rows = new List();
+ var world = school.World;
+ world.Query(in People, (ref PersonIdentity identity, ref Presence presence) =>
+ {
+ rows.Add(new PresenceSnapshot(
+ identity.Id,
+ presence.NodeId,
+ presence.RemainingMinutes,
+ presence.DestinationId,
+ presence.HeadingHome,
+ presence.Path));
+ });
+ rows.Sort((left, right) => StringComparer.Ordinal.Compare(left.PersonId, right.PersonId));
+ return rows;
+ }
+
+ public static void Restore(School school, IReadOnlyList? saved)
+ {
+ // A new school has no snapshot: people stay off campus and walk in. Missing ids inside
+ // a real snapshot are hires and intake, and those land on their duty room.
+ if (saved is null)
+ {
+ return;
+ }
+
+ var byId = saved
+ .Where(row => !string.IsNullOrWhiteSpace(row.PersonId))
+ .ToDictionary(row => row.PersonId, StringComparer.Ordinal);
+ ForEachPerson(school, (person, _, ref presence) =>
+ {
+ if (!byId.TryGetValue(person.Id, out var row))
+ {
+ presence = PlaceByDuty(school, person);
+ return;
+ }
+
+ if (row.NodeId is null)
+ {
+ presence = Presence.OffCampus;
+ return;
+ }
+
+ presence = new Presence(
+ row.NodeId,
+ row.RemainingMinutes,
+ row.DestinationId,
+ row.HeadingHome,
+ row.Path?.ToArray() ?? []);
+ });
+ }
+
+ public static void PlaceMissingByDuty(School school)
+ {
+ ForEachPerson(school, (person, _, ref presence) =>
+ {
+ if (!presence.IsOnCampus)
+ {
+ presence = PlaceByDuty(school, person);
+ }
+ });
+ }
+
+ private static Presence PlaceByDuty(School school, Person person)
+ {
+ var room = Duty.RoomAt(
+ person,
+ ClassOf(school, person),
+ school.Timetable,
+ school.Catalog!,
+ school.Clock.Time,
+ school.SchoolWeekDays);
+ if (room is null || school.Walks is null)
+ {
+ return Presence.OffCampus;
+ }
+
+ return new Presence(room, 0f, room, false, []);
+ }
+
+ private static void EnsurePlans(School school)
+ {
+ var day = DateOnly.FromDateTime(school.Clock.Time);
+ if (school.PlanDay == day && school.Plans.Count == school.Roster!.People.Count)
+ {
+ return;
+ }
+
+ school.PlanDay = day;
+ school.Plans.Clear();
+ foreach (var person in school.Roster!.People)
+ {
+ school.Plans[person.Id] = DayPlans.Build(
+ school.Catalog!,
+ school.Walks!,
+ person,
+ ClassOf(school, person),
+ school.Timetable,
+ school.Clock.Time,
+ school.SchoolWeekDays,
+ school.PeopleSeed);
+ }
+
+ school.DecisionQueue.Clear();
+ foreach (var person in OrderedPeople(school))
+ {
+ school.DecisionQueue.Enqueue(person.Id);
+ }
+ }
+
+ private static void EnqueueEvents(School school)
+ {
+ var slot = SchoolDay.At(school.Catalog!, school.Clock.Time, school.SchoolWeekDays);
+ if (school.LastDecisionSlot == slot)
+ {
+ return;
+ }
+
+ school.LastDecisionSlot = slot;
+ foreach (var person in OrderedPeople(school))
+ {
+ school.DecisionQueue.Enqueue(person.Id);
+ }
+ }
+
+ private static void EnqueueTimeEvents(School school, float minutes)
+ {
+ if (minutes <= 0)
+ {
+ return;
+ }
+
+ var now = school.Clock.Time;
+ var previous = now.AddMinutes(-minutes);
+ foreach (var person in OrderedPeople(school))
+ {
+ if (!school.Plans.TryGetValue(person.Id, out var plan))
+ {
+ continue;
+ }
+
+ if (plan.AppearAt is { } appear && previous < appear && now >= appear)
+ {
+ school.DecisionQueue.Enqueue(person.Id);
+ }
+
+ if (plan.WalkHomeAt is { } leave && previous < leave && now >= leave)
+ {
+ school.DecisionQueue.Enqueue(person.Id);
+ }
+ }
+ }
+
+ 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));
+ if (person is null || !school.Plans.TryGetValue(personId, out var plan))
+ {
+ return;
+ }
+
+ 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))
+ {
+ return;
+ }
+
+ found = true;
+ presence = NextPresence(school, person, plan, presence);
+ });
+ }
+
+ private static Presence NextPresence(School school, Person person, DayPlan plan, Presence presence)
+ {
+ var now = school.Clock.Time;
+ var walks = school.Walks!;
+ if (!presence.IsOnCampus)
+ {
+ if (plan.AppearAt is { } appear && now >= appear && (plan.WalkHomeAt is null || now < plan.WalkHomeAt))
+ {
+ var dest = plan.FirstRoom ?? Duty.RoomAt(
+ person,
+ ClassOf(school, person),
+ school.Timetable,
+ school.Catalog!,
+ now,
+ school.SchoolWeekDays);
+ return dest is null ? Presence.OffCampus : PresenceStepper.StartWalk(Presence.OffCampus, walks, dest, headingHome: false);
+ }
+
+ return Presence.OffCampus;
+ }
+
+ if (plan.WalkHomeAt is { } leave && now >= leave)
+ {
+ return PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true);
+ }
+
+ var duty = Duty.RoomAt(
+ person,
+ ClassOf(school, person),
+ school.Timetable,
+ school.Catalog!,
+ now,
+ school.SchoolWeekDays);
+ if (duty is null)
+ {
+ return PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true);
+ }
+
+ if (presence.HeadingHome || !duty.Equals(presence.DestinationId, StringComparison.Ordinal))
+ {
+ return PresenceStepper.StartWalk(presence, walks, duty, headingHome: false);
+ }
+
+ return presence;
+ }
+
+ private static void Move(School school, float minutes)
+ {
+ if (minutes <= 0 || school.Walks is null)
+ {
+ return;
+ }
+
+ var walks = school.Walks;
+ var world = school.World;
+ world.Query(in People, (ref Presence presence) =>
+ {
+ if (!presence.IsOnCampus)
+ {
+ return;
+ }
+
+ var remaining = presence.RemainingMinutes - minutes;
+ var node = presence.NodeId!;
+ var path = presence.Path;
+ var index = 0;
+ while (remaining <= 0 && index < path.Length)
+ {
+ node = path[index];
+ index++;
+ remaining += walks.TravelMinutes(node);
+ }
+
+ if (remaining < 0)
+ {
+ remaining = 0;
+ }
+
+ var leftover = index >= path.Length ? [] : path[index..];
+ presence = presence with { NodeId = node, RemainingMinutes = remaining, Path = leftover };
+ });
+ }
+
+ private static void FinishHome(School school)
+ {
+ var yard = school.Walks?.TerritoryId;
+ if (yard is null)
+ {
+ return;
+ }
+
+ var world = school.World;
+ world.Query(in People, (ref Presence presence) =>
+ {
+ if (presence.HeadingHome
+ && presence.NodeId is not null
+ && presence.Path.Length == 0
+ && presence.RemainingMinutes <= 0
+ && presence.NodeId.Equals(yard, StringComparison.Ordinal))
+ {
+ presence = Presence.OffCampus;
+ }
+ });
+ }
+
+ 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 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);
+
+ 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) =>
+ {
+ if (roster.TryGetValue(identity.Id, out var person))
+ {
+ action(person, identity, ref presence);
+ }
+ });
+ }
+}
+
+public sealed record PresenceSnapshot(
+ string PersonId,
+ string? NodeId,
+ float RemainingMinutes,
+ string? DestinationId,
+ bool HeadingHome,
+ IReadOnlyList Path);
diff --git a/src/HSchool.Simulation/RosterSpawner.cs b/src/HSchool.Simulation/RosterSpawner.cs
index 71dfbcd..5d7ff41 100644
--- a/src/HSchool.Simulation/RosterSpawner.cs
+++ b/src/HSchool.Simulation/RosterSpawner.cs
@@ -1,5 +1,6 @@
using Arch.Core;
using HSchool.People;
+using HSchool.Ai;
namespace HSchool.Simulation;
@@ -36,7 +37,8 @@ public static class RosterSpawner
person.IsParent,
person.ClassId,
person.Position,
- person.WorkplaceRoomId));
+ person.WorkplaceRoomId),
+ Presence.OffCampus);
}
}
diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs
index f22fe82..627679c 100644
--- a/src/HSchool.Simulation/School.cs
+++ b/src/HSchool.Simulation/School.cs
@@ -1,4 +1,5 @@
using Arch.Core;
+using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
@@ -83,6 +84,23 @@ public sealed class School : IDisposable
/// True after yearly intake until the worker rebuilds around remaining locks.
public bool TimetableDirty { get; private set; }
+ /// Walk matrix for this map. Null in clock-only tests.
+ internal WalkGraph? Walks { get; private set; }
+
+ internal int SchoolWeekDays { get; private set; } = 5;
+
+ internal int MaxDecisionsPerTick { get; private set; } = 64;
+
+ internal int MaxSkipDays { get; private set; } = 400;
+
+ internal DateOnly? PlanDay { get; set; }
+
+ internal DaySlot? LastDecisionSlot { get; set; }
+
+ internal Dictionary Plans { get; } = new(StringComparer.Ordinal);
+
+ internal Queue DecisionQueue { get; } = new();
+
///
/// Installs a roster that already matches the map. Spawns entities; does not write to disk.
///
@@ -96,6 +114,61 @@ public sealed class School : IDisposable
NameSetId = nameSetId;
Applicants = applicants;
RosterSpawner.Spawn(World, roster);
+ PlanDay = null;
+ LastDecisionSlot = null;
+ Plans.Clear();
+ DecisionQueue.Clear();
+ }
+
+ public void ConfigurePresence(int weekDays = 5, int maxDecisionsPerTick = 64, int maxSkipDays = 400)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ SchoolWeekDays = weekDays;
+ MaxDecisionsPerTick = maxDecisionsPerTick;
+ MaxSkipDays = maxSkipDays;
+ if (Catalog is not null && Map is not null)
+ {
+ Walks = WalkGraph.Build(Catalog, Map);
+ }
+ }
+
+ public IReadOnlyList CapturePresence() => PresenceSystem.Capture(this);
+
+ public void RestorePresence(IReadOnlyList? saved) => PresenceSystem.Restore(this, saved);
+
+ public bool IsCampusEmpty() => PresenceSystem.IsEmpty(this);
+
+ public SkipEmptyResult TrySkipEmpty()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (Catalog is null)
+ {
+ return SkipEmptyResult.Fail(SkipEmptyError.NoMorning);
+ }
+
+ if (!IsCampusEmpty())
+ {
+ return SkipEmptyResult.Fail(SkipEmptyError.PeoplePresent);
+ }
+
+ if (SchoolDay.InWorkWindow(Catalog, Clock.Time, SchoolWeekDays))
+ {
+ return SkipEmptyResult.Fail(SkipEmptyError.InWorkWindow);
+ }
+
+ var next = SchoolDay.NextWorkMorning(Catalog, Clock.Time, SchoolWeekDays, MaxSkipDays);
+ if (next is null)
+ {
+ return SkipEmptyResult.Fail(SkipEmptyError.NoMorning);
+ }
+
+ var before = Clock.Time;
+ Clock.JumpTo(next.Value);
+ var peopleChanged = TryYearlyIntake(before, next.Value);
+ peopleChanged |= TryApplicantRefresh();
+ PlanDay = null;
+ LastDecisionSlot = null;
+ return new SkipEmptyResult(SkipEmptyError.None, next.Value, peopleChanged);
}
///
@@ -110,7 +183,9 @@ public sealed class School : IDisposable
Roster = roster;
Applicants = applicants;
+ var snapshot = PresenceSystem.Capture(this);
RosterSpawner.Replace(World, roster);
+ PresenceSystem.Restore(this, snapshot);
TimetableDirty = true;
}
@@ -120,6 +195,11 @@ public sealed class School : IDisposable
ArgumentNullException.ThrowIfNull(timetable);
Timetable = timetable;
TimetableDirty = false;
+ LastDecisionSlot = null;
+ foreach (var id in Roster?.People.Select(person => person.Id) ?? [])
+ {
+ DecisionQueue.Enqueue(id);
+ }
}
/// Runs one fixed step of the school: calendar, yearly intake, applicant refresh, then need decay.
@@ -135,6 +215,13 @@ public sealed class School : IDisposable
{
peopleChanged = TryYearlyIntake(before, Clock.Time);
peopleChanged |= TryApplicantRefresh();
+ if (peopleChanged)
+ {
+ PlanDay = null;
+ LastDecisionSlot = null;
+ }
+
+ PresenceSystem.Apply(this, gameMinutes);
if (Catalog is not null)
{
NeedDecay.Apply(World, Catalog, gameMinutes);
@@ -160,7 +247,9 @@ public sealed class School : IDisposable
if (changed)
{
+ var snapshot = PresenceSystem.Capture(this);
RosterSpawner.Replace(World, Roster);
+ PresenceSystem.Restore(this, snapshot);
TimetableDirty = true;
}
@@ -197,3 +286,18 @@ public sealed class School : IDisposable
Arch.Core.World.Destroy(World);
}
}
+
+public enum SkipEmptyError
+{
+ None,
+ PeoplePresent,
+ InWorkWindow,
+ NoMorning,
+}
+
+public readonly record struct SkipEmptyResult(SkipEmptyError Error, DateTime? Time, bool PeopleChanged)
+{
+ public bool Succeeded => Error == SkipEmptyError.None;
+
+ public static SkipEmptyResult Fail(SkipEmptyError error) => new(error, null, false);
+}
diff --git a/src/HSchool.Simulation/SimulationOptions.cs b/src/HSchool.Simulation/SimulationOptions.cs
index e9a8ac3..6ebb783 100644
--- a/src/HSchool.Simulation/SimulationOptions.cs
+++ b/src/HSchool.Simulation/SimulationOptions.cs
@@ -55,6 +55,12 @@ public sealed class SimulationOptions
///
public float MonthlyPayrollCap { get; set; } = 100_000f;
+ ///
+ /// How many people may change destination in one tick. Overflow waits for the next tick
+ /// instead of being dropped — a queue, not a cutoff.
+ ///
+ public int MaxDecisionsPerTick { get; set; } = 64;
+
///
/// Working days from Monday. Five is Mon–Fri; six adds Saturday; seven is every day.
///
diff --git a/tests/HSchool.Ai.Tests/Fixtures.cs b/tests/HSchool.Ai.Tests/Fixtures.cs
new file mode 100644
index 0000000..71da36d
--- /dev/null
+++ b/tests/HSchool.Ai.Tests/Fixtures.cs
@@ -0,0 +1,53 @@
+using HSchool.Content;
+
+namespace HSchool.Ai.Tests;
+
+internal static class PackDocuments
+{
+ public static IReadOnlyList FromDirectory(string packId, string packRoot)
+ {
+ var documents = new List();
+ foreach (var path in Directory.EnumerateFiles(packRoot, "*.*", SearchOption.AllDirectories))
+ {
+ if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
+ && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var relative = Path.GetRelativePath(packRoot, path).Replace('\\', '/');
+ documents.Add(new ContentDocument(packId, relative, File.ReadAllText(path)));
+ }
+
+ return documents;
+ }
+}
+
+internal static class Fixtures
+{
+ public static (DefCatalog Catalog, MapLayout Map) Vanilla()
+ {
+ var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
+ var documents = PackDocuments.FromDirectory(CatalogLoader.CorePackId, root);
+ var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
+ var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
+ Assert.NotNull(map);
+ return (catalog, map);
+ }
+
+ public static string RepoRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "h-school.sln")))
+ {
+ dir = dir.Parent;
+ }
+
+ if (dir is null)
+ {
+ throw new InvalidOperationException("Could not find h-school.sln from the test output directory.");
+ }
+
+ return dir.FullName;
+ }
+}
diff --git a/tests/HSchool.Ai.Tests/HSchool.Ai.Tests.csproj b/tests/HSchool.Ai.Tests/HSchool.Ai.Tests.csproj
new file mode 100644
index 0000000..d8bef11
--- /dev/null
+++ b/tests/HSchool.Ai.Tests/HSchool.Ai.Tests.csproj
@@ -0,0 +1,33 @@
+
+
+
+ HSchool.Ai.Tests
+ true
+ Exe
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ vanilla\%(RecursiveDir)%(Filename)%(Extension)
+ PreserveNewest
+
+
+
+
diff --git a/tests/HSchool.Ai.Tests/WalkingTests.cs b/tests/HSchool.Ai.Tests/WalkingTests.cs
new file mode 100644
index 0000000..198420e
--- /dev/null
+++ b/tests/HSchool.Ai.Tests/WalkingTests.cs
@@ -0,0 +1,156 @@
+using HSchool.Content;
+using HSchool.People;
+using HSchool.Schedule;
+
+namespace HSchool.Ai.Tests;
+
+public class WalkingTests
+{
+ private static readonly DateTime TuesdayLesson = new(2012, 4, 3, 8, 45, 0, DateTimeKind.Utc);
+ private static readonly DateTime Sunday = new(2012, 4, 8, 10, 0, 0, DateTimeKind.Utc);
+ private static readonly DateTime SpringBreak = new(2012, 3, 31, 10, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void PathFrom201ToFirstFloorRestroom_GoesThroughStairsAndCorridors()
+ {
+ var (catalog, map) = Fixtures.Vanilla();
+ var walks = WalkGraph.Build(catalog, map);
+
+ var hops = walks.Path("classroom-201", "restroom-1");
+
+ Assert.Equal(["corridor-2", "stairs-2", "stairs-1", "corridor-1", "restroom-1"], hops);
+ Assert.DoesNotContain("yard", hops);
+ Assert.Equal(6.5f, walks.Minutes("classroom-201", "restroom-1"));
+ }
+
+ [Fact]
+ public void PresenceStepper_TakesTheSameMinutesAsTheGraph()
+ {
+ var (catalog, map) = Fixtures.Vanilla();
+ var walks = WalkGraph.Build(catalog, map);
+ var hops = walks.Path("classroom-201", "restroom-1");
+ var cost = walks.Minutes("classroom-201", "restroom-1");
+ var presence = new Presence("classroom-201", 0f, "restroom-1", HeadingHome: false, [.. hops]);
+
+ var walked = PresenceStepper.Advance(presence, walks, cost);
+
+ Assert.Equal("restroom-1", walked.NodeId);
+ Assert.Empty(walked.Path);
+ Assert.Equal(0f, walked.RemainingMinutes);
+
+ var stepwise = presence;
+ var elapsed = 0f;
+ const float step = 0.25f;
+ while (stepwise.NodeId != "restroom-1" || stepwise.Path.Length > 0 || stepwise.RemainingMinutes > 0)
+ {
+ stepwise = PresenceStepper.Advance(stepwise, walks, step);
+ elapsed += step;
+ Assert.True(elapsed <= cost + step);
+ }
+
+ Assert.Equal(cost, elapsed);
+ }
+
+ [Fact]
+ public void GymAfterClassroom101_CostsSixMinutesAgainstATenMinuteBreak()
+ {
+ var (catalog, map) = Fixtures.Vanilla();
+ var walks = WalkGraph.Build(catalog, map);
+ var hops = walks.Path("classroom-101", "gym-hall");
+
+ Assert.Equal(["corridor-1", "porch", "yard", "gym-hall"], hops);
+ Assert.Equal(6f, walks.Minutes("classroom-101", "gym-hall"));
+ Assert.Equal(10, catalog.DayFrame!.BreakMinutes);
+ Assert.True(walks.Minutes("classroom-101", "gym-hall") < catalog.DayFrame.BreakMinutes);
+ }
+
+ [Fact]
+ public void ComesToday_IsFalseOnSundayAndHolidays()
+ {
+ var (catalog, map) = Fixtures.Vanilla();
+ var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", TuesdayLesson);
+ var pupil = roster.People.First(person => person.IsStudent);
+ var schoolClass = roster.Classes.First(row => row.Id == pupil.ClassId);
+ var table = new Timetable(
+ [new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1)],
+ []);
+
+ Assert.True(Duty.ComesToday(pupil, table, catalog, TuesdayLesson, weekDays: 5));
+ Assert.False(Duty.ComesToday(pupil, table, catalog, Sunday, weekDays: 5));
+ Assert.False(Duty.ComesToday(pupil, table, catalog, SpringBreak, weekDays: 5));
+ Assert.False(Duty.ComesToday(
+ roster.People.First(person => person.IsParent && !person.IsStaff && !person.IsStudent),
+ table,
+ catalog,
+ TuesdayLesson,
+ weekDays: 5));
+ }
+
+ [Fact]
+ public void ClassWithoutLessons_StaysAway_TeacherWithAnotherClassComes()
+ {
+ var (catalog, map) = Fixtures.Vanilla();
+ var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", TuesdayLesson);
+ var idle = roster.Classes[0];
+ var busy = roster.Classes[1];
+ var idlePupil = roster.People.First(person => person.Id == idle.PupilIds[0]);
+ var busyPupil = roster.People.First(person => person.Id == busy.PupilIds[0]);
+ var teacher = roster.People.First(person => person.IsParent && !person.IsStudent && !person.IsStaff) with
+ {
+ IsStaff = true,
+ Position = Staffing.TeacherPosition,
+ };
+ var table = new Timetable(
+ [new LessonPlacement(busy.Id, "Mathematics", teacher.Id, busy.RoomId, Day: 1, Period: 1)],
+ []);
+
+ Assert.False(Duty.ComesToday(idlePupil, table, catalog, TuesdayLesson, weekDays: 5));
+ Assert.True(Duty.ComesToday(busyPupil, table, catalog, TuesdayLesson, weekDays: 5));
+ Assert.True(Duty.ComesToday(teacher, table, catalog, TuesdayLesson, weekDays: 5));
+ Assert.Null(Duty.RoomAt(idlePupil, idle, table, catalog, TuesdayLesson, weekDays: 5));
+ Assert.Equal(busy.RoomId, Duty.RoomAt(busyPupil, busy, table, catalog, TuesdayLesson, weekDays: 5));
+ Assert.Equal(busy.RoomId, Duty.RoomAt(teacher, null, table, catalog, TuesdayLesson, weekDays: 5));
+ }
+
+ [Fact]
+ public void SameSeedAndMap_YieldTheSameDayPlan()
+ {
+ var (catalog, map) = Fixtures.Vanilla();
+ var walks = WalkGraph.Build(catalog, map);
+ var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 9, "Slavic", TuesdayLesson);
+ var pupil = roster.People.First(person => person.IsStudent);
+ var schoolClass = roster.Classes.First(row => row.Id == pupil.ClassId);
+ var table = new Timetable(
+ [new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1)],
+ []);
+
+ var first = DayPlans.Build(catalog, walks, pupil, schoolClass, table, TuesdayLesson, weekDays: 5, schoolSeed: 9);
+ var second = DayPlans.Build(catalog, walks, pupil, schoolClass, table, TuesdayLesson, weekDays: 5, schoolSeed: 9);
+
+ Assert.Equal(first, second);
+ Assert.True(first.Comes);
+ Assert.Equal(schoolClass.RoomId, first.FirstRoom);
+ Assert.True(first.AppearAt < DateTime.SpecifyKind(TuesdayLesson.Date.Add(new TimeSpan(8, 30, 0)), DateTimeKind.Utc));
+ }
+
+ [Fact]
+ public void Assembly_DoesNotReferenceArchAspNetOrSockets()
+ {
+ var names = typeof(WalkGraph).Assembly.GetReferencedAssemblies().Select(assembly => assembly.Name!);
+ Assert.DoesNotContain(names, name => name.StartsWith("Arch", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(names, name => name.Contains("AspNet", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(names, name => name.Contains("Sockets", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void Sources_DoNotUseWallClock()
+ {
+ var root = Path.Combine(Fixtures.RepoRoot(), "src", "HSchool.Ai");
+ foreach (var path in Directory.EnumerateFiles(root, "*.cs"))
+ {
+ var text = File.ReadAllText(path);
+ Assert.DoesNotContain("DateTime.Now", text, StringComparison.Ordinal);
+ Assert.DoesNotContain("DateTime.UtcNow", text, StringComparison.Ordinal);
+ }
+ }
+}
diff --git a/tests/HSchool.Content.Tests/CalendarTests.cs b/tests/HSchool.Content.Tests/CalendarTests.cs
index 6e376e4..cd6167a 100644
--- a/tests/HSchool.Content.Tests/CalendarTests.cs
+++ b/tests/HSchool.Content.Tests/CalendarTests.cs
@@ -86,6 +86,52 @@ public class CalendarTests
Assert.Equal(DaySlot.BreakAfter(2), slot);
}
+ [Fact]
+ public void NextWorkMorning_FromSaturdayLandsOnMondaySix()
+ {
+ var catalog = LoadVanilla();
+ var next = SchoolDay.NextWorkMorning(catalog, new DateTime(2012, 4, 7, 10, 0, 0, DateTimeKind.Utc), weekDays: 5);
+
+ Assert.Equal(new DateTime(2012, 4, 9, 6, 0, 0, DateTimeKind.Utc), next);
+ }
+
+ [Fact]
+ public void NextWorkMorning_FromTuesdayNightLandsOnThatMorning()
+ {
+ var catalog = LoadVanilla();
+ var next = SchoolDay.NextWorkMorning(catalog, new DateTime(2012, 4, 3, 3, 0, 0, DateTimeKind.Utc), weekDays: 5);
+
+ Assert.Equal(new DateTime(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc), next);
+ }
+
+ [Fact]
+ public void NextWorkMorning_FromTuesdayEveningLandsOnWednesday()
+ {
+ var catalog = LoadVanilla();
+ var next = SchoolDay.NextWorkMorning(catalog, new DateTime(2012, 4, 3, 22, 0, 0, DateTimeKind.Utc), weekDays: 5);
+
+ Assert.Equal(new DateTime(2012, 4, 4, 6, 0, 0, DateTimeKind.Utc), next);
+ }
+
+ [Fact]
+ public void InWorkWindow_SevenOnAWorkday_IsAlreadyOpen()
+ {
+ var catalog = LoadVanilla();
+ var seven = new DateTime(2012, 4, 3, 7, 0, 0, DateTimeKind.Utc);
+
+ Assert.True(SchoolDay.IsWorkday(catalog, seven, weekDays: 5));
+ Assert.True(SchoolDay.InWorkWindow(catalog, seven, weekDays: 5));
+ }
+
+ [Fact]
+ public void InWorkWindow_DoesNotNeedTeachers()
+ {
+ var catalog = LoadVanilla();
+ var morning = new DateTime(2012, 4, 3, 10, 0, 0, DateTimeKind.Utc);
+
+ Assert.True(SchoolDay.InWorkWindow(catalog, morning, weekDays: 5));
+ }
+
[Fact]
public void Subject_UnknownRoom_FailsTheCatalog()
{
diff --git a/tests/HSchool.Content.Tests/CatalogLoaderTests.cs b/tests/HSchool.Content.Tests/CatalogLoaderTests.cs
index 5402c1b..2d6c650 100644
--- a/tests/HSchool.Content.Tests/CatalogLoaderTests.cs
+++ b/tests/HSchool.Content.Tests/CatalogLoaderTests.cs
@@ -106,7 +106,7 @@ public class CatalogLoaderTests
CatalogLoader.CorePackId,
"rooms",
"office",
- """{ "defName": "Office", "slots": [{ "key": "chair", "thing": "Chair" }], "positions": ["Principal"] }"""),
+ """{ "defName": "Office", "slots": [{ "key": "chair", "thing": "Chair" }], "positions": ["Principal"], "travelMinutes": 1 }"""),
]));
Assert.Contains("Chair", ex.Message);
@@ -126,4 +126,28 @@ public class CatalogLoaderTests
Assert.Equal("Мебель", catalog.Label("ru", catalog.Things["Chair"]));
Assert.Equal("Chair", catalog.Label("en", catalog.Things["Chair"]));
}
+
+ [Fact]
+ public void ConcreteRoomWithoutTravelMinutes_FailsTheCatalog()
+ {
+ var ex = Assert.Throws(() => _loader.Load(
+ [CatalogLoader.CorePackId],
+ [
+ PackDocuments.Def(CatalogLoader.CorePackId, "rooms", "office", """{ "defName": "Office" }"""),
+ ]));
+
+ Assert.Contains("travelMinutes", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void ConcreteTerritoryWithoutTravelMinutes_FailsTheCatalog()
+ {
+ var ex = Assert.Throws(() => _loader.Load(
+ [CatalogLoader.CorePackId],
+ [
+ PackDocuments.Def(CatalogLoader.CorePackId, "territories", "yard", """{ "defName": "Yard" }"""),
+ ]));
+
+ Assert.Contains("travelMinutes", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
}
diff --git a/tests/HSchool.Content.Tests/InheritanceTests.cs b/tests/HSchool.Content.Tests/InheritanceTests.cs
index 89b7133..69d6d7c 100644
--- a/tests/HSchool.Content.Tests/InheritanceTests.cs
+++ b/tests/HSchool.Content.Tests/InheritanceTests.cs
@@ -48,7 +48,7 @@ public class InheritanceTests
var ex = Assert.Throws(() => _loader.Load(
[CatalogLoader.CorePackId],
[
- PackDocuments.Def(CatalogLoader.CorePackId, "rooms", "office", """{ "defName": "Office" }"""),
+ PackDocuments.Def(CatalogLoader.CorePackId, "rooms", "office", """{ "defName": "Office", "travelMinutes": 1 }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "parent": "Office" }"""),
]));
diff --git a/tests/HSchool.Content.Tests/MapValidationTests.cs b/tests/HSchool.Content.Tests/MapValidationTests.cs
index 726c202..12474ec 100644
--- a/tests/HSchool.Content.Tests/MapValidationTests.cs
+++ b/tests/HSchool.Content.Tests/MapValidationTests.cs
@@ -125,14 +125,14 @@ public class MapValidationTests
CatalogLoader.CorePackId,
"territories",
"yard",
- abstractYard ? """{ "defName": "Yard", "abstract": true }""" : """{ "defName": "Yard" }"""),
+ abstractYard ? """{ "defName": "Yard", "abstract": true }""" : """{ "defName": "Yard", "travelMinutes": 1 }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "buildings", "main", """{ "defName": "Main" }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "floors", "floor", """{ "defName": "Floor" }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"rooms",
"office",
- """{ "defName": "Office", "slots": [{ "key": "seat", "thing": "Chair" }], "positions": ["Principal"] }"""),
+ """{ "defName": "Office", "slots": [{ "key": "seat", "thing": "Chair" }], "positions": ["Principal"], "travelMinutes": 1 }"""),
];
private static MapLayout MiniMap(
diff --git a/tests/HSchool.Content.Tests/MapViewTests.cs b/tests/HSchool.Content.Tests/MapViewTests.cs
index abc73b8..04b2127 100644
--- a/tests/HSchool.Content.Tests/MapViewTests.cs
+++ b/tests/HSchool.Content.Tests/MapViewTests.cs
@@ -62,7 +62,7 @@ public class MapViewTests
new ContentDocument(
CatalogLoader.CorePackId,
"defs/territories/yard.jsonc",
- """{ "defName": "Yard" }"""),
+ """{ "defName": "Yard", "travelMinutes": 1 }"""),
new ContentDocument(
CatalogLoader.CorePackId,
"localizations/ru.jsonc",
diff --git a/tests/HSchool.Content.Tests/PatchTests.cs b/tests/HSchool.Content.Tests/PatchTests.cs
index 7ee0d11..640da59 100644
--- a/tests/HSchool.Content.Tests/PatchTests.cs
+++ b/tests/HSchool.Content.Tests/PatchTests.cs
@@ -74,7 +74,8 @@ public class PatchTests
{
"defName": "Office",
"slots": [ { "key": "seat", "thing": "Chair" } ],
- "works": ["TeachLesson", "WalkSchool"]
+ "works": ["TeachLesson", "WalkSchool"],
+ "travelMinutes": 1
}
"""),
PackDocuments.Patch(
diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs
index b0cfa25..ff8e4f1 100644
--- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs
+++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs
@@ -57,6 +57,13 @@ public class VanillaCoreTests
Assert.DoesNotContain(map.Rooms, room => room.Label is "1A" or "1B" or "2A" or "2B");
Assert.Equal(16, map.Rooms.Single(room => room.Id == "classroom-101").Seats);
Assert.Empty(map.Rooms.Single(room => room.Id == "classroom-101").Slots);
+ Assert.Equal(0.5f, catalog.Rooms["Classroom"].TravelMinutes);
+ Assert.Equal(1.5f, catalog.Rooms["Corridor"].TravelMinutes);
+ Assert.Equal(1.5f, catalog.Rooms["Stairwell"].TravelMinutes);
+ Assert.Equal(1f, catalog.Rooms["EntranceHall"].TravelMinutes);
+ Assert.Equal(3f, catalog.Territories["SchoolYard"].TravelMinutes);
+ Assert.Equal(4, catalog.Traits["Diligent"].CommuteMinutes);
+ Assert.Equal(-4, catalog.Traits["Lazy"].CommuteMinutes);
}
///
diff --git a/tests/HSchool.Simulation.Tests/GameClockTests.cs b/tests/HSchool.Simulation.Tests/GameClockTests.cs
index 3afcec8..bd7a7b9 100644
--- a/tests/HSchool.Simulation.Tests/GameClockTests.cs
+++ b/tests/HSchool.Simulation.Tests/GameClockTests.cs
@@ -120,6 +120,19 @@ public class GameClockTests
Assert.Equal(DateTimeKind.Utc, clock.Time.Kind);
}
+ [Fact]
+ public void JumpTo_MovesTheCalendarWithoutTicking()
+ {
+ var clock = new GameClock(Start) { IsRunning = false };
+ var target = new DateTime(2012, 4, 9, 6, 0, 0, DateTimeKind.Utc);
+
+ clock.JumpTo(target);
+
+ Assert.Equal(target, clock.Time);
+ Assert.Equal(DateTimeKind.Utc, clock.Time.Kind);
+ Assert.False(clock.IsRunning);
+ }
+
[Fact]
public void StartDateOutsideTheSupportedRange_Throws()
{
diff --git a/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj b/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj
index 5738cea..d270cf9 100644
--- a/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj
+++ b/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj
@@ -16,6 +16,8 @@
+
+
diff --git a/tests/HSchool.Simulation.Tests/PresenceTests.cs b/tests/HSchool.Simulation.Tests/PresenceTests.cs
new file mode 100644
index 0000000..a9e4f75
--- /dev/null
+++ b/tests/HSchool.Simulation.Tests/PresenceTests.cs
@@ -0,0 +1,323 @@
+using HSchool.Ai;
+using HSchool.Content;
+using HSchool.People;
+using HSchool.Schedule;
+
+namespace HSchool.Simulation.Tests;
+
+public class PresenceTests
+{
+ private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
+ private static readonly float Cap = 100_000f;
+
+ [Fact]
+ public void SundayAndHoliday_LeaveTheCampusEmpty()
+ {
+ var (catalog, map) = Vanilla();
+ var sunday = new DateTime(2012, 4, 8, 10, 0, 0, DateTimeKind.Utc);
+ using var onSunday = Open(catalog, map, sunday, seed: 1);
+ onSunday.Tick(0.2d, 5d);
+ Assert.True(onSunday.IsCampusEmpty());
+ Assert.All(onSunday.CapturePresence(), row => Assert.Null(row.NodeId));
+
+ var holiday = new DateTime(2012, 6, 1, 10, 0, 0, DateTimeKind.Utc);
+ using var onHoliday = Open(catalog, map, holiday, seed: 1);
+ onHoliday.Tick(0.2d, 5d);
+ Assert.True(onHoliday.IsCampusEmpty());
+ }
+
+ [Fact]
+ public void ClassWithoutLessons_StaysAway_TeacherWithAnotherClassComes()
+ {
+ 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 idle = roster.Classes[0];
+ var busy = roster.Classes[1];
+ var hired = Staffing.Hire(catalog, map, roster, pool, pool.Applicants[0].Person.Id, Staffing.TeacherPosition, Cap);
+ Assert.Equal(StaffingError.None, hired.Error);
+
+ using var school = School.Create(1, "Два класса", TuesdayMorning, catalog, map);
+ school.InstallPeople(hired.Roster, seed: 1, "Slavic", hired.Pool);
+ school.SetTimetable(new Timetable(
+ [new LessonPlacement(busy.Id, "Mathematics", hired.Roster.People.First(person => person.IsStaff).Id, busy.RoomId, Day: 1, Period: 1)],
+ []));
+ school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
+
+ AdvanceTo(school, new DateTime(2012, 4, 3, 8, 30, 0, DateTimeKind.Utc));
+
+ var teacherId = school.Roster!.People.First(person => person.IsStaff).Id;
+ var idlePupil = idle.PupilIds[0];
+ var byId = school.CapturePresence().ToDictionary(row => row.PersonId, StringComparer.Ordinal);
+ Assert.Null(byId[idlePupil].NodeId);
+ Assert.NotNull(byId[teacherId].NodeId);
+ }
+
+ [Fact]
+ public void FirstLesson_PutsThePupilInTheirHomeroom()
+ {
+ var (school, homeroom, pupilId) = StaffedFirstFloorClass();
+ using (school)
+ {
+ AdvanceTo(school, new DateTime(2012, 4, 3, 8, 30, 0, DateTimeKind.Utc));
+
+ var row = school.CapturePresence().Single(item => item.PersonId == pupilId);
+ Assert.Equal(homeroom, row.NodeId);
+ Assert.Empty(row.Path);
+ }
+ }
+
+ [Fact]
+ public void PhysicalEducationAfterAClassroomLesson_ReachesTheGymOnTheBreak()
+ {
+ var (catalog, map) = Vanilla();
+ var walks = WalkGraph.Build(catalog, map);
+ Assert.Equal(6f, walks.Minutes("classroom-101", "gym-hall"));
+ Assert.True(walks.Minutes("classroom-101", "gym-hall") < catalog.DayFrame!.BreakMinutes);
+
+ var (school, _, pupilId) = StaffedFirstFloorClass();
+ using (school)
+ {
+ AdvanceTo(school, new DateTime(2012, 4, 3, 9, 25, 0, DateTimeKind.Utc));
+ var row = school.CapturePresence().Single(item => item.PersonId == pupilId);
+ Assert.Equal("gym-hall", row.NodeId);
+ Assert.Empty(row.Path);
+ }
+ }
+
+ [Fact]
+ public void SameSeedAndMap_YieldTheSamePlaces()
+ {
+ 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, 3, 9, 20, 0, DateTimeKind.Utc);
+ AdvanceTo(a, until);
+ AdvanceTo(b, until);
+
+ Assert.Equal(Fingerprint(a.CapturePresence()), Fingerprint(b.CapturePresence()));
+ }
+
+ [Fact]
+ public void SaveAndLoadMidBreak_DoesNotTeleport()
+ {
+ var (catalog, map) = Vanilla();
+ using var live = OpenStaffed(catalog, map, seed: 4);
+ AdvanceTo(live, new DateTime(2012, 4, 3, 9, 18, 0, DateTimeKind.Utc));
+ var snapshot = live.CapturePresence();
+ Assert.Contains(snapshot, row => row.NodeId is not null && (row.Path.Count > 0 || row.RemainingMinutes > 0));
+
+ using var loaded = School.Load(2, "Сейв", live.Clock.Time, running: true, speedIndex: 0, catalog, map);
+ loaded.InstallPeople(live.Roster!, seed: 4, "Slavic", live.Applicants);
+ loaded.SetTimetable(live.Timetable!);
+ loaded.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
+ loaded.RestorePresence(snapshot);
+
+ Assert.Equal(Fingerprint(snapshot), Fingerprint(loaded.CapturePresence()));
+ }
+
+ [Fact]
+ public void SkipEmpty_FromSaturdayLandsOnMondaySix()
+ {
+ using var school = OpenEmpty(new DateTime(2012, 4, 7, 10, 0, 0, DateTimeKind.Utc));
+ var result = school.TrySkipEmpty();
+
+ Assert.True(result.Succeeded);
+ Assert.Equal(new DateTime(2012, 4, 9, 6, 0, 0, DateTimeKind.Utc), school.Clock.Time);
+ }
+
+ [Fact]
+ public void SkipEmpty_FromTuesdayNightLandsOnThatMorning()
+ {
+ using var school = OpenEmpty(new DateTime(2012, 4, 3, 3, 0, 0, DateTimeKind.Utc));
+ var result = school.TrySkipEmpty();
+
+ Assert.True(result.Succeeded);
+ Assert.Equal(new DateTime(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc), school.Clock.Time);
+ }
+
+ [Fact]
+ public void SkipEmpty_FromTuesdayEveningLandsOnWednesday()
+ {
+ using var school = OpenEmpty(new DateTime(2012, 4, 3, 22, 0, 0, DateTimeKind.Utc));
+ var result = school.TrySkipEmpty();
+
+ Assert.True(result.Succeeded);
+ Assert.Equal(new DateTime(2012, 4, 4, 6, 0, 0, DateTimeKind.Utc), school.Clock.Time);
+ }
+
+ [Fact]
+ public void SkipEmpty_InTheWorkWindow_IsRejectedEvenWhenEmpty()
+ {
+ using var school = OpenEmpty(new DateTime(2012, 4, 3, 7, 0, 0, DateTimeKind.Utc));
+ Assert.True(school.IsCampusEmpty());
+
+ var result = school.TrySkipEmpty();
+
+ Assert.False(result.Succeeded);
+ Assert.Equal(SkipEmptyError.InWorkWindow, result.Error);
+ Assert.Equal(new DateTime(2012, 4, 3, 7, 0, 0, DateTimeKind.Utc), school.Clock.Time);
+ }
+
+ [Fact]
+ public void SkipEmpty_SchoolWithoutTeachers_StillHasAWorkDay()
+ {
+ using var school = OpenEmpty(new DateTime(2012, 4, 3, 10, 0, 0, DateTimeKind.Utc));
+ Assert.Equal(0, school.Roster!.People.Count(person => person.IsStaff));
+
+ var result = school.TrySkipEmpty();
+
+ Assert.False(result.Succeeded);
+ Assert.Equal(SkipEmptyError.InWorkWindow, result.Error);
+ }
+
+ [Fact]
+ public void SkipEmpty_OverSummer_RunsFirstSeptemberIntake()
+ {
+ var start = new DateTime(2012, 6, 1, 22, 0, 0, DateTimeKind.Utc);
+ using var school = OpenEmpty(start);
+ var before = school.Roster!;
+ var oldest = before.Classes.Max(row => row.Year);
+ var graduated = before.Classes
+ .Where(row => row.Year == oldest)
+ .SelectMany(row => row.PupilIds)
+ .ToHashSet(StringComparer.Ordinal);
+
+ var result = school.TrySkipEmpty();
+
+ Assert.True(result.Succeeded);
+ Assert.True(result.PeopleChanged);
+ Assert.Equal(new DateTime(2012, 9, 3, 6, 0, 0, DateTimeKind.Utc), school.Clock.Time);
+ Assert.Contains(school.Roster!.Classes, row => row.Year == 1);
+ Assert.DoesNotContain(school.Roster.People, person => graduated.Contains(person.Id));
+ Assert.Equal(before.People.Count(person => person.IsStudent), school.Roster.People.Count(person => person.IsStudent));
+ }
+
+ [Fact]
+ public void SkipEmpty_Week_MatchesALivedWeek()
+ {
+ var start = new DateTime(2012, 4, 6, 22, 0, 0, DateTimeKind.Utc);
+ var until = new DateTime(2012, 4, 9, 6, 0, 0, DateTimeKind.Utc);
+ using var skipped = OpenEmpty(start);
+ using var lived = OpenEmpty(start);
+
+ Assert.True(skipped.TrySkipEmpty().Succeeded);
+ while (lived.Clock.Time < until)
+ {
+ lived.Tick(0.2d, 5d);
+ }
+
+ Assert.Equal(until, skipped.Clock.Time);
+ Assert.Equal(until, lived.Clock.Time);
+ Assert.Equal(skipped.Applicants!.Week, lived.Applicants!.Week);
+ Assert.Equal(
+ skipped.Applicants.Applicants.Select(row => row.Person.Id),
+ lived.Applicants.Applicants.Select(row => row.Person.Id));
+ Assert.Equal(
+ skipped.Roster!.People.Select(person => person.Id).Order(StringComparer.Ordinal),
+ lived.Roster!.People.Select(person => person.Id).Order(StringComparer.Ordinal));
+ Assert.True(skipped.IsCampusEmpty());
+ Assert.True(lived.IsCampusEmpty());
+ Assert.Equal(Fingerprint(skipped.CapturePresence()), Fingerprint(lived.CapturePresence()));
+ }
+
+ [Fact]
+ public void Simulation_DoesNotReferenceSockets()
+ {
+ var names = typeof(School).Assembly.GetReferencedAssemblies().Select(assembly => assembly.Name!);
+ Assert.DoesNotContain(names, name => name.Contains("Sockets", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(names, name => name.Contains("AspNet", StringComparison.OrdinalIgnoreCase));
+ }
+
+ 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 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 School Open(DefCatalog catalog, MapLayout map, DateTime start, int seed)
+ {
+ var roster = RosterGenerator.Generate(catalog, map, seed, "Slavic", start);
+ var pool = ApplicantPool.Create(catalog, roster, seed, "Slavic", start);
+ var schoolClass = roster.Classes[0];
+ var school = School.Create(seed, "Присутствие", start, catalog, map);
+ school.InstallPeople(roster, seed, "Slavic", pool);
+ school.SetTimetable(new Timetable(
+ [new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 0, Period: 1)],
+ []));
+ school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
+ return school;
+ }
+
+ private static School OpenEmpty(DateTime start)
+ {
+ var (catalog, map) = Vanilla();
+ var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", start);
+ var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", start);
+ var school = School.Create(1, "Пустая", start, catalog, map);
+ school.InstallPeople(roster, seed: 1, "Slavic", pool);
+ 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 string Fingerprint(IReadOnlyList rows) =>
+ string.Join(
+ "|",
+ rows.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)}"));
+
+ 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);
+ }
+}
diff --git a/tests/HSchool.Simulation.Tests/SchoolTests.cs b/tests/HSchool.Simulation.Tests/SchoolTests.cs
index fab7737..ac5ec53 100644
--- a/tests/HSchool.Simulation.Tests/SchoolTests.cs
+++ b/tests/HSchool.Simulation.Tests/SchoolTests.cs
@@ -50,10 +50,10 @@ public class SchoolTests
{
new ContentDocument("core", "defs/actions/sit.jsonc", """{ "defName": "Sit" }"""),
new ContentDocument("core", "defs/things/chair.jsonc", """{ "defName": "Chair", "actions": ["Sit"] }"""),
- new ContentDocument("core", "defs/territories/yard.jsonc", """{ "defName": "Yard" }"""),
+ new ContentDocument("core", "defs/territories/yard.jsonc", """{ "defName": "Yard", "travelMinutes": 1 }"""),
new ContentDocument("core", "defs/buildings/main.jsonc", """{ "defName": "Main" }"""),
new ContentDocument("core", "defs/floors/floor.jsonc", """{ "defName": "Floor" }"""),
- new ContentDocument("core", "defs/rooms/office.jsonc", """{ "defName": "Office" }"""),
+ new ContentDocument("core", "defs/rooms/office.jsonc", """{ "defName": "Office", "travelMinutes": 1 }"""),
};
var catalog = loader.Load(["core"], documents);
var map = new MapLayout