From 4137400621ac1dc6392b88be29954e5260a55bfc Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 19 Aug 2026 16:33:52 +0300 Subject: [PATCH] Enhance AI and simulation components with presence management and routing capabilities - Introduced the `HSchool.Ai` project, responsible for routing, day plans, and presence management. - Updated the `HSchool.Simulation` project to integrate with the new AI functionalities, improving decision-making and presence tracking. - Added `travelMinutes` to room and territory definitions, ensuring accurate movement calculations within the simulation. - Enhanced the `School` class to manage presence and implement empty-time skipping functionality. - Updated documentation to reflect the new AI features and their impact on school simulation. - Added tests for presence management and routing to ensure robust functionality and reliability. --- AGENTS.md | 8 +- README.md | 1 + docs/architecture.md | 11 +- docs/design/projects.md | 2 +- docs/phases/18-presence-walking.md | 64 +-- docs/phases/README.md | 2 +- h-school.sln | 30 ++ src/HSchool.Ai/DayPlan.cs | 125 ++++++ src/HSchool.Ai/Duty.cs | 157 ++++++++ src/HSchool.Ai/HSchool.Ai.csproj | 17 + src/HSchool.Ai/Presence.cs | 79 ++++ src/HSchool.Ai/WalkGraph.cs | 165 ++++++++ src/HSchool.Content/CatalogLoader.cs | 13 + src/HSchool.Content/Defs.cs | 9 +- src/HSchool.Content/PeopleDefs.cs | 5 + src/HSchool.Content/SchoolDay.cs | 107 +++++ src/HSchool.People/Seed.cs | 25 +- src/HSchool.Server/Game/GameLoopService.cs | 7 +- src/HSchool.Server/Game/SchoolStore.cs | 3 + src/HSchool.Server/Game/SchoolWorker.cs | 6 + src/HSchool.Server/Program.cs | 1 + src/HSchool.Server/appsettings.json | 3 +- .../mods/core/defs/rooms/academic.jsonc | 3 + .../mods/core/defs/rooms/circulation.jsonc | 4 +- .../mods/core/defs/rooms/corridor.jsonc | 2 +- .../mods/core/defs/rooms/gym-hall.jsonc | 1 + .../core/defs/rooms/principals-office.jsonc | 1 + .../mods/core/defs/rooms/service.jsonc | 6 + .../core/defs/territories/school-yard.jsonc | 2 +- .../mods/core/defs/traits/traits.jsonc | 2 + src/HSchool.Simulation/GameClock.cs | 11 + .../HSchool.Simulation.csproj | 1 + src/HSchool.Simulation/PresenceSystem.cs | 374 ++++++++++++++++++ src/HSchool.Simulation/RosterSpawner.cs | 4 +- src/HSchool.Simulation/School.cs | 104 +++++ src/HSchool.Simulation/SimulationOptions.cs | 6 + tests/HSchool.Ai.Tests/Fixtures.cs | 53 +++ .../HSchool.Ai.Tests/HSchool.Ai.Tests.csproj | 33 ++ tests/HSchool.Ai.Tests/WalkingTests.cs | 156 ++++++++ tests/HSchool.Content.Tests/CalendarTests.cs | 46 +++ .../CatalogLoaderTests.cs | 26 +- .../HSchool.Content.Tests/InheritanceTests.cs | 2 +- .../MapValidationTests.cs | 4 +- tests/HSchool.Content.Tests/MapViewTests.cs | 2 +- tests/HSchool.Content.Tests/PatchTests.cs | 3 +- .../HSchool.Content.Tests/VanillaCoreTests.cs | 7 + .../GameClockTests.cs | 13 + .../HSchool.Simulation.Tests.csproj | 2 + .../HSchool.Simulation.Tests/PresenceTests.cs | 323 +++++++++++++++ tests/HSchool.Simulation.Tests/SchoolTests.cs | 4 +- 50 files changed, 1978 insertions(+), 57 deletions(-) create mode 100644 src/HSchool.Ai/DayPlan.cs create mode 100644 src/HSchool.Ai/Duty.cs create mode 100644 src/HSchool.Ai/HSchool.Ai.csproj create mode 100644 src/HSchool.Ai/Presence.cs create mode 100644 src/HSchool.Ai/WalkGraph.cs create mode 100644 src/HSchool.Simulation/PresenceSystem.cs create mode 100644 tests/HSchool.Ai.Tests/Fixtures.cs create mode 100644 tests/HSchool.Ai.Tests/HSchool.Ai.Tests.csproj create mode 100644 tests/HSchool.Ai.Tests/WalkingTests.cs create mode 100644 tests/HSchool.Simulation.Tests/PresenceTests.cs 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