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.
This commit is contained in:
@@ -13,7 +13,8 @@ way; this file is *how to work in them*.
|
|||||||
| skills, traits, body, needs, name sets | `src/HSchool.Content` |
|
| skills, traits, body, needs, name sets | `src/HSchool.Content` |
|
||||||
| people generation, families, roster records, yearly intake | `src/HSchool.People` |
|
| people generation, families, roster records, yearly intake | `src/HSchool.People` |
|
||||||
| timetable planning | `src/HSchool.Schedule` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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.
|
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
|
- 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.
|
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`.
|
- Catalog, inheritance, patches and map validation belong in `tests/HSchool.Content.Tests`.
|
||||||
Feed the loader documents, not disk paths.
|
Feed the loader documents, not disk paths.
|
||||||
- Protocol changes need a round-trip test **and** a byte-layout assertion on both sides.
|
- 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.
|
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
|
- `erasableSyntaxOnly` is off in `tsconfig.app.json` on purpose: constructor parameter properties
|
||||||
are used throughout.
|
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.
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ Simulation tunables live under the `Simulation` section of
|
|||||||
| `MinSaveIntervalMilliseconds` | 1000 | shortest gap between saves caused by pause or speed |
|
| `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 |
|
| `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) |
|
| `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
|
## What is deliberately missing
|
||||||
|
|
||||||
|
|||||||
@@ -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.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.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.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.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.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.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.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. |
|
| `src/HSchool.Client` | Vite + TypeScript UI: main menu, creation form, the school screen. |
|
||||||
|
|
||||||
Dependency direction is one-way: `Protocol ← Server → Simulation → People → Content`, and
|
Dependency direction is one-way: `Protocol ← Server → Simulation → Ai → People / Schedule → Content`.
|
||||||
`Schedule → Content`. Nothing in `Simulation` or `Content` knows about HTTP, and nothing in
|
Nothing in `Simulation` or `Content` knows about HTTP, and nothing in
|
||||||
`Protocol` knows about schools.
|
`Protocol` knows about schools.
|
||||||
|
|
||||||
## Two channels, on purpose
|
## Two channels, on purpose
|
||||||
@@ -80,7 +81,9 @@ touching a school itself.
|
|||||||
## Schools
|
## Schools
|
||||||
|
|
||||||
A `School` is one save: an id, a name, a `GameClock`, a frozen catalog, a map, a roster and an
|
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:
|
`GameClock` moves while it is running, in fixed steps:
|
||||||
`realSeconds × gameMinutesPerRealSecond × speedMultiplier`. At the defaults that is 5 game minutes
|
`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
|
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
|
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
|
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
|
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.
|
files in place and that school unstarted.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Нарезка проектов (целевая)
|
# Нарезка проектов (целевая)
|
||||||
|
|
||||||
Сейчас в коде: `Protocol ← Server → Simulation → People → Content`. Каталог не в Simulation (рядом с Arch)
|
Сейчас в коде: `Protocol ← Server → Simulation → Ai → People / Schedule → Content`. Каталог не в Simulation (рядом с Arch)
|
||||||
и не в Server (рядом с Kestrel) — парсер JSONC и проверка графа не знают, что такое tick.
|
и не в Server (рядом с Kestrel) — парсер JSONC и проверка графа не знают, что такое tick.
|
||||||
|
|
||||||
## Зависимости
|
## Зависимости
|
||||||
|
|||||||
@@ -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`
|
`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
|
1.5, лестница 1.5, вестибюль 1, двор 3
|
||||||
- [ ] Маршрутизация по `MapLayout.Links` — в библиотеке: матрица следующих шагов считается
|
- [x] Маршрутизация по `MapLayout.Links` — в библиотеке: матрица следующих шагов считается
|
||||||
**один раз** при загрузке школы, не на каждом шаге
|
**один раз** при загрузке школы, не на каждом шаге
|
||||||
- [ ] План дня человека — тоже в библиотеке: во сколько выходить, чтобы успеть, и когда уходить.
|
- [x] План дня человека — тоже в библиотеке: во сколько выходить, чтобы успеть, и когда уходить.
|
||||||
Часы, компоненты и мир туда не проникают
|
Часы, компоненты и мир туда не проникают
|
||||||
- [ ] Место человека — компонент в `World`: узел, остаток пути, минуты до выхода из узла.
|
- [x] Место человека — компонент в `World`: узел, остаток пути, минуты до выхода из узла.
|
||||||
Человек всегда в узле; рёбер как состояний нет
|
Человек всегда в узле; рёбер как состояний нет
|
||||||
- [ ] Состояние «вне школы» — не удаление сущности: человек остаётся в списке и в карточке
|
- [x] Состояние «вне школы» — не удаление сущности: человек остаётся в списке и в карточке
|
||||||
- [ ] Приход и уход: у кого сегодня нет уроков, тот не приходит вовсе. Ученик и учитель — по
|
- [x] Приход и уход: у кого сегодня нет уроков, тот не приходит вовсе. Ученик и учитель — по
|
||||||
своим урокам, прочий штат — по дню школы
|
своим урокам, прочий штат — по дню школы
|
||||||
- [ ] Запас на дорогу свой у каждого: путь от двора плюс несколько минут из своего потока
|
- [x] Запас на дорогу свой у каждого: путь от двора плюс несколько минут из своего потока
|
||||||
случайности, с поправкой на черты
|
случайности, с поправкой на черты
|
||||||
- [ ] Обязанность как место назначения: урок — кабинет из расписания, должность — комната из
|
- [x] Обязанность как место назначения: урок — кабинет из расписания, должность — комната из
|
||||||
`RoomDef.works`. Выбора действий пока нет
|
`RoomDef.works`. Выбора действий пока нет
|
||||||
- [ ] Движение по тику: остаток минут в узле уменьшается, при нуле человек переходит в следующий
|
- [x] Движение по тику: остаток минут в узле уменьшается, при нуле человек переходит в следующий
|
||||||
узел пути
|
узел пути
|
||||||
- [ ] Стабильный порядок обхода людей — собственный список, а не порядок сущностей в Arch
|
- [x] Стабильный порядок обхода людей — собственный список, а не порядок сущностей в Arch
|
||||||
- [ ] Адаптер в `HSchool.Simulation`: собирает значения из компонентов, зовёт библиотеку,
|
- [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 в санузел первого этажа идёт через лестницу и коридор, а не напрямую
|
- [x] Путь из кабинета 201 в санузел первого этажа идёт через лестницу и коридор, а не напрямую
|
||||||
- [ ] Сумма `travelMinutes` совпадает с временем, за которое человек реально доходит
|
- [x] Сумма `travelMinutes` совпадает с временем, за которое человек реально доходит
|
||||||
- [ ] В воскресенье и в каникулы школа пуста: все вне школы
|
- [x] В воскресенье и в каникулы школа пуста: все вне школы
|
||||||
- [ ] Класс без уроков сегодня не приходит, а его учитель с уроками у другого класса — приходит
|
- [x] Класс без уроков сегодня не приходит, а его учитель с уроками у другого класса — приходит
|
||||||
- [ ] К началу первого урока ученик находится в своём кабинете
|
- [x] К началу первого урока ученик находится в своём кабинете
|
||||||
- [ ] Физкультура после урока в главном корпусе: класс доходит до спортзала, и видно, что
|
- [x] Физкультура после урока в главном корпусе: класс доходит до спортзала, и видно, что
|
||||||
перемены на это едва хватает
|
перемены на это едва хватает
|
||||||
- [ ] Тот же сид и та же карта дают то же расположение людей на тот же момент
|
- [x] Тот же сид и та же карта дают то же расположение людей на тот же момент
|
||||||
- [ ] Сохранение и загрузка посреди перемены не телепортируют людей
|
- [x] Сохранение и загрузка посреди перемены не телепортируют людей
|
||||||
- [ ] Прыжок с субботы попадает в понедельник, 6:00; с трёх ночи вторника — в утро того же
|
- [x] Прыжок с субботы попадает в понедельник, 6:00; с трёх ночи вторника — в утро того же
|
||||||
вторника; с десяти вечера — в утро среды
|
вторника; с десяти вечера — в утро среды
|
||||||
- [ ] Прыжок в учебное время отклоняется. В семь утра рабочего дня, когда школа ещё пуста, —
|
- [x] Прыжок в учебное время отклоняется. В семь утра рабочего дня, когда школа ещё пуста, —
|
||||||
тоже: это уже рабочее окно
|
тоже: это уже рабочее окно
|
||||||
- [ ] У школы без единого учителя рабочий день не пропускается
|
- [x] У школы без единого учителя рабочий день не пропускается
|
||||||
- [ ] Прыжок через летние каникулы проводит первое сентября: классы перешли, выпуск состоялся,
|
- [x] Прыжок через летние каникулы проводит первое сентября: классы перешли, выпуск состоялся,
|
||||||
набор пришёл
|
набор пришёл
|
||||||
- [ ] Промотанная неделя и прожитая неделя дают одно состояние школы
|
- [x] Промотанная неделя и прожитая неделя дают одно состояние школы
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
|
|
||||||
| Фаза | Статус | Зачем |
|
| Фаза | Статус | Зачем |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [18. Присутствие и ходьба](18-presence-walking.md) | ⬜ | `HSchool.Ai`, место человека, маршруты, приход и уход |
|
| [18. Присутствие и ходьба](18-presence-walking.md) | ✅ | `HSchool.Ai`, место человека, маршруты, приход и уход |
|
||||||
| [19. Присутствие на экране](19-presence-screen.md) | ⬜ | Своё сообщение ~2 Гц, числа в дереве, статический снимок карты, пропуск пустого времени |
|
| [19. Присутствие на экране](19-presence-screen.md) | ⬜ | Своё сообщение ~2 Гц, числа в дереве, статический снимок карты, пропуск пустого времени |
|
||||||
|
|
||||||
**Этап B — поведение.** Появляются нужды, действия и выбор между ними и обязанностью.
|
**Этап B — поведение.** Появляются нужды, действия и выбор между ними и обязанностью.
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Schedule", "src\HSc
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Schedule.Tests", "tests\HSchool.Schedule.Tests\HSchool.Schedule.Tests.csproj", "{85FF85BD-F572-4F0A-A11E-FA855D480C1D}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Schedule.Tests", "tests\HSchool.Schedule.Tests\HSchool.Schedule.Tests.csproj", "{85FF85BD-F572-4F0A-A11E-FA855D480C1D}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -232,6 +260,8 @@ Global
|
|||||||
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
{734AC2F7-B243-4211-95CF-3E662D2E20C9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{734AC2F7-B243-4211-95CF-3E662D2E20C9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{85FF85BD-F572-4F0A-A11E-FA855D480C1D} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {DD14EF4D-167E-4AC7-953A-AF606CC34829}
|
SolutionGuid = {DD14EF4D-167E-4AC7-953A-AF606CC34829}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
|
using HSchool.Schedule;
|
||||||
|
|
||||||
|
namespace HSchool.Ai;
|
||||||
|
|
||||||
|
/// <summary>When this person appears at the yard and when they start walking home today.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
|
using HSchool.Schedule;
|
||||||
|
|
||||||
|
namespace HSchool.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where this person ought to be right now. The timetable and the job, not the walk graph.
|
||||||
|
/// </summary>
|
||||||
|
public static class Duty
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The room of the current obligation, or <see langword="null"/> when they should be off campus.
|
||||||
|
/// A hole in the class table sends the pupil to their homeroom.
|
||||||
|
/// </summary>
|
||||||
|
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<LessonPlacement> 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>HSchool.Ai</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
|
||||||
|
<ProjectReference Include="..\HSchool.People\HSchool.People.csproj" />
|
||||||
|
<ProjectReference Include="..\HSchool.Schedule\HSchool.Schedule.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="HSchool.Ai.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
namespace HSchool.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One person's place on the graph. <see cref="NodeId"/> is null when they are off campus.
|
||||||
|
/// There is no "on an edge" state — remaining minutes are spent occupying the current node.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
|
namespace HSchool.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Next-hop matrix for a school's walkable graph. Built once when the school loads; querying a
|
||||||
|
/// path does not search again.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WalkGraph
|
||||||
|
{
|
||||||
|
private const float Unreachable = float.PositiveInfinity;
|
||||||
|
|
||||||
|
private readonly string[] _nodes;
|
||||||
|
private readonly Dictionary<string, int> _index;
|
||||||
|
private readonly float[] _travel;
|
||||||
|
private readonly float[,] _distance;
|
||||||
|
private readonly int[,] _next;
|
||||||
|
|
||||||
|
private WalkGraph(
|
||||||
|
string territoryId,
|
||||||
|
string[] nodes,
|
||||||
|
Dictionary<string, int> 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<string> 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<string> { 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<string, int>(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;
|
||||||
|
|
||||||
|
/// <summary>Hops after <paramref name="from"/>, including <paramref name="to"/>. Empty when already there.</summary>
|
||||||
|
public IReadOnlyList<string> 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<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -468,6 +468,19 @@ public sealed class CatalogLoader
|
|||||||
{
|
{
|
||||||
throw new ContentLoadException($"RoomDef '{room.DefName}' is not a homeroom and cannot set seatThing or defaultSeats.");
|
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);
|
PeopleDefValidator.Validate(catalog);
|
||||||
|
|||||||
@@ -77,10 +77,17 @@ public sealed class RoomDef : Def
|
|||||||
|
|
||||||
/// <summary>Editor default when placing a new homeroom. Vanilla classrooms are 16.</summary>
|
/// <summary>Editor default when placing a new homeroom. Vanilla classrooms are 16.</summary>
|
||||||
public int DefaultSeats { get; init; }
|
public int DefaultSeats { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Game minutes spent occupying this room when walking through it.</summary>
|
||||||
|
public float TravelMinutes { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class BuildingDef : Def;
|
public sealed class BuildingDef : Def;
|
||||||
|
|
||||||
public sealed class FloorDef : Def;
|
public sealed class FloorDef : Def;
|
||||||
|
|
||||||
public sealed class TerritoryDef : Def;
|
public sealed class TerritoryDef : Def
|
||||||
|
{
|
||||||
|
/// <summary>Game minutes spent occupying the yard when walking through it.</summary>
|
||||||
|
public float TravelMinutes { get; init; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
/// Added to the hourly wage ask. Positive means the person wants more at the same skills.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float WageAsk { get; init; }
|
public float WageAsk { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extra minutes of commute slack. Positive arrives earlier; negative cuts it closer.
|
||||||
|
/// </summary>
|
||||||
|
public int CommuteMinutes { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StaffingDef : Def
|
public sealed class StaffingDef : Def
|
||||||
|
|||||||
@@ -95,6 +95,113 @@ public static class SchoolDay
|
|||||||
return stamp >= start || stamp <= end;
|
return stamp >= start || stamp <= end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The work window opens at six — the same hour a new school starts.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The next 6:00 of a workday that is still ahead. Night lands on the same morning;
|
||||||
|
/// evening, weekends and holidays walk forward. <see langword="null"/> when none exists
|
||||||
|
/// within <paramref name="maxDays"/>.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
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.
|
// Monday = 0 … Sunday = 6. A 5-day week is Mon–Fri; 6 adds Saturday; 7 is every day.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace HSchool.People;
|
|||||||
/// Per-family streams derived from the school seed. Family N never consumes family N-1's rolls,
|
/// 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.
|
/// so appending a thirteenth family leaves the first twelve unchanged.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class Seed
|
public static class Seed
|
||||||
{
|
{
|
||||||
public const int ChildCountSalt = 1;
|
public const int ChildCountSalt = 1;
|
||||||
public const int AppearanceSalt = 2;
|
public const int AppearanceSalt = 2;
|
||||||
@@ -12,6 +12,7 @@ internal static class Seed
|
|||||||
public const int SeatShuffleSalt = 4;
|
public const int SeatShuffleSalt = 4;
|
||||||
public const int HouseholdSalt = 5;
|
public const int HouseholdSalt = 5;
|
||||||
public const int ApplicantSalt = 6;
|
public const int ApplicantSalt = 6;
|
||||||
|
public const int CommuteSalt = 7;
|
||||||
|
|
||||||
/// <summary>A stream that belongs to the school rather than to one family.</summary>
|
/// <summary>A stream that belongs to the school rather than to one family.</summary>
|
||||||
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
|
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
|
||||||
@@ -24,6 +25,28 @@ internal static class Seed
|
|||||||
return (int)z;
|
return (int)z;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A stream that belongs to one person on one calendar day — commute slack, not looks.</summary>
|
||||||
|
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)
|
private static ulong Mix64(ulong z)
|
||||||
{
|
{
|
||||||
z += 0x9E3779B97F4A7C15UL;
|
z += 0x9E3779B97F4A7C15UL;
|
||||||
|
|||||||
@@ -494,7 +494,8 @@ internal sealed class GameLoopService(
|
|||||||
isNew: false,
|
isNew: false,
|
||||||
save.ModIds,
|
save.ModIds,
|
||||||
save.Map,
|
save.Map,
|
||||||
save.NameSetId);
|
save.NameSetId,
|
||||||
|
save.Presence);
|
||||||
worker.Start();
|
worker.Start();
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -541,7 +542,8 @@ internal sealed class GameLoopService(
|
|||||||
bool isNew,
|
bool isNew,
|
||||||
IReadOnlyList<string>? modIds,
|
IReadOnlyList<string>? modIds,
|
||||||
MapLayout? map,
|
MapLayout? map,
|
||||||
string? nameSetId) =>
|
string? nameSetId,
|
||||||
|
IReadOnlyList<PresenceSnapshot>? presence = null) =>
|
||||||
new(
|
new(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -552,6 +554,7 @@ internal sealed class GameLoopService(
|
|||||||
modIds,
|
modIds,
|
||||||
map,
|
map,
|
||||||
nameSetId,
|
nameSetId,
|
||||||
|
presence,
|
||||||
_options,
|
_options,
|
||||||
clients,
|
clients,
|
||||||
metrics,
|
metrics,
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ internal sealed class SchoolSave
|
|||||||
public MapLayout? Map { get; init; }
|
public MapLayout? Map { get; init; }
|
||||||
|
|
||||||
public string? NameSetId { get; init; }
|
public string? NameSetId { get; init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<PresenceSnapshot>? Presence { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Allocates school ids that survive a process restart.</summary>
|
/// <summary>Allocates school ids that survive a process restart.</summary>
|
||||||
@@ -160,6 +162,7 @@ internal sealed class SchoolStore
|
|||||||
ModIds = save.ModIds,
|
ModIds = save.ModIds,
|
||||||
Map = save.Map,
|
Map = save.Map,
|
||||||
NameSetId = save.NameSetId,
|
NameSetId = save.NameSetId,
|
||||||
|
Presence = save.Presence,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ internal sealed class SchoolWorker
|
|||||||
private readonly IReadOnlyList<string>? _modIds;
|
private readonly IReadOnlyList<string>? _modIds;
|
||||||
private readonly MapLayout? _savedMap;
|
private readonly MapLayout? _savedMap;
|
||||||
private readonly string? _nameSetId;
|
private readonly string? _nameSetId;
|
||||||
|
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
||||||
private readonly Action<int> _onFailed;
|
private readonly Action<int> _onFailed;
|
||||||
|
|
||||||
private readonly int _id;
|
private readonly int _id;
|
||||||
@@ -64,6 +65,7 @@ internal sealed class SchoolWorker
|
|||||||
IReadOnlyList<string>? modIds,
|
IReadOnlyList<string>? modIds,
|
||||||
MapLayout? savedMap,
|
MapLayout? savedMap,
|
||||||
string? nameSetId,
|
string? nameSetId,
|
||||||
|
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
||||||
SimulationOptions options,
|
SimulationOptions options,
|
||||||
ClientRegistry clients,
|
ClientRegistry clients,
|
||||||
GameMetrics metrics,
|
GameMetrics metrics,
|
||||||
@@ -81,6 +83,7 @@ internal sealed class SchoolWorker
|
|||||||
_modIds = modIds;
|
_modIds = modIds;
|
||||||
_savedMap = savedMap;
|
_savedMap = savedMap;
|
||||||
_nameSetId = nameSetId;
|
_nameSetId = nameSetId;
|
||||||
|
_savedPresence = savedPresence;
|
||||||
_options = options;
|
_options = options;
|
||||||
_clients = clients;
|
_clients = clients;
|
||||||
_metrics = metrics;
|
_metrics = metrics;
|
||||||
@@ -882,6 +885,8 @@ internal sealed class SchoolWorker
|
|||||||
|
|
||||||
school.InstallPeople(roster, seed, nameSetId, applicants);
|
school.InstallPeople(roster, seed, nameSetId, applicants);
|
||||||
InstallTimetable(school);
|
InstallTimetable(school);
|
||||||
|
school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick);
|
||||||
|
school.RestorePresence(_savedPresence);
|
||||||
return generated;
|
return generated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,6 +932,7 @@ internal sealed class SchoolWorker
|
|||||||
ModIds = school.Catalog?.PackIds,
|
ModIds = school.Catalog?.PackIds,
|
||||||
Map = school.Map,
|
Map = school.Map,
|
||||||
NameSetId = _nameSetId,
|
NameSetId = _nameSetId,
|
||||||
|
Presence = school.CapturePresence(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -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.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.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.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();
|
.ValidateOnStart();
|
||||||
|
|
||||||
builder.Services.AddSingleton<GameCommandQueue>();
|
builder.Services.AddSingleton<GameCommandQueue>();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"ModsDirectory": "mods",
|
"ModsDirectory": "mods",
|
||||||
"SaveIntervalSeconds": 30,
|
"SaveIntervalSeconds": 30,
|
||||||
"MonthlyPayrollCap": 100000,
|
"MonthlyPayrollCap": 100000,
|
||||||
"SchoolWeekDays": 5
|
"SchoolWeekDays": 5,
|
||||||
|
"MaxDecisionsPerTick": 64
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"seatThing": "StudentDesk",
|
"seatThing": "StudentDesk",
|
||||||
"defaultSeats": 16,
|
"defaultSeats": 16,
|
||||||
"works": ["TeachLesson"],
|
"works": ["TeachLesson"],
|
||||||
|
"travelMinutes": 0.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"defName": "Library",
|
"defName": "Library",
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
],
|
],
|
||||||
"positions": ["Librarian"],
|
"positions": ["Librarian"],
|
||||||
"works": ["LibraryWork"],
|
"works": ["LibraryWork"],
|
||||||
|
"travelMinutes": 0.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"defName": "ComputerLab",
|
"defName": "ComputerLab",
|
||||||
@@ -23,5 +25,6 @@
|
|||||||
{ "key": "computers", "thing": "Computer", "count": 12 },
|
{ "key": "computers", "thing": "Computer", "count": 12 },
|
||||||
],
|
],
|
||||||
"works": ["TeachLesson"],
|
"works": ["TeachLesson"],
|
||||||
|
"travelMinutes": 0.5,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[
|
[
|
||||||
// Walkable rooms that exist to connect the graph: lobby, stairs. Empty on purpose.
|
// Walkable rooms that exist to connect the graph: lobby, stairs. Empty on purpose.
|
||||||
{ "defName": "EntranceHall" },
|
{ "defName": "EntranceHall", "travelMinutes": 1 },
|
||||||
{ "defName": "Stairwell" },
|
{ "defName": "Stairwell", "travelMinutes": 1.5 },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Empty on purpose: a corridor is a walkable room with no furniture of its own.
|
// Empty on purpose: a corridor is a walkable room with no furniture of its own.
|
||||||
{ "defName": "Corridor" }
|
{ "defName": "Corridor", "travelMinutes": 1.5 }
|
||||||
|
|||||||
@@ -4,4 +4,5 @@
|
|||||||
{ "key": "benches", "thing": "Bench", "count": 4 },
|
{ "key": "benches", "thing": "Bench", "count": 4 },
|
||||||
],
|
],
|
||||||
"works": ["PELesson"],
|
"works": ["PELesson"],
|
||||||
|
"travelMinutes": 0.5,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,5 @@
|
|||||||
],
|
],
|
||||||
"positions": ["Principal"],
|
"positions": ["Principal"],
|
||||||
"works": ["PrincipalOfficeWork", "TeachLesson", "WalkSchool"],
|
"works": ["PrincipalOfficeWork", "TeachLesson", "WalkSchool"],
|
||||||
|
"travelMinutes": 0.5,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
],
|
],
|
||||||
"positions": ["Secretary"],
|
"positions": ["Secretary"],
|
||||||
"works": ["OfficeWork"],
|
"works": ["OfficeWork"],
|
||||||
|
"travelMinutes": 0.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"defName": "TeachersRoom",
|
"defName": "TeachersRoom",
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
{ "key": "table", "thing": "DiningTable" },
|
{ "key": "table", "thing": "DiningTable" },
|
||||||
{ "key": "chairs", "thing": "Chair", "count": 6 },
|
{ "key": "chairs", "thing": "Chair", "count": 6 },
|
||||||
],
|
],
|
||||||
|
"travelMinutes": 0.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"defName": "Cafeteria",
|
"defName": "Cafeteria",
|
||||||
@@ -23,9 +25,11 @@
|
|||||||
],
|
],
|
||||||
"positions": ["CafeteriaCook"],
|
"positions": ["CafeteriaCook"],
|
||||||
"works": ["ServeLunch"],
|
"works": ["ServeLunch"],
|
||||||
|
"travelMinutes": 0.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"defName": "Restroom",
|
"defName": "Restroom",
|
||||||
|
"travelMinutes": 0.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"defName": "MedicalOffice",
|
"defName": "MedicalOffice",
|
||||||
@@ -35,11 +39,13 @@
|
|||||||
],
|
],
|
||||||
"positions": ["Nurse"],
|
"positions": ["Nurse"],
|
||||||
"works": ["MedicalDuty"],
|
"works": ["MedicalDuty"],
|
||||||
|
"travelMinutes": 0.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"defName": "ChangingRoom",
|
"defName": "ChangingRoom",
|
||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "lockers", "thing": "Locker", "count": 12 },
|
{ "key": "lockers", "thing": "Locker", "count": 12 },
|
||||||
],
|
],
|
||||||
|
"travelMinutes": 0.5,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{ "defName": "SchoolYard" }
|
{ "defName": "SchoolYard", "travelMinutes": 3 }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
{
|
{
|
||||||
"defName": "Diligent",
|
"defName": "Diligent",
|
||||||
"weight": 8,
|
"weight": 8,
|
||||||
|
"commuteMinutes": 4,
|
||||||
"incompatible": ["Lazy", "AbsentMinded"],
|
"incompatible": ["Lazy", "AbsentMinded"],
|
||||||
"skillModifiers": [
|
"skillModifiers": [
|
||||||
{ "skill": "Mathematics", "offset": 8 },
|
{ "skill": "Mathematics", "offset": 8 },
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"defName": "Lazy",
|
"defName": "Lazy",
|
||||||
"weight": 6,
|
"weight": 6,
|
||||||
"incompatible": ["Diligent"],
|
"incompatible": ["Diligent"],
|
||||||
|
"commuteMinutes": -4,
|
||||||
"skillModifiers": [
|
"skillModifiers": [
|
||||||
{ "skill": "PhysicalEducation", "offset": -8 },
|
{ "skill": "PhysicalEducation", "offset": -8 },
|
||||||
{ "skill": "Mathematics", "offset": -6 },
|
{ "skill": "Mathematics", "offset": -6 },
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ public sealed class GameClock
|
|||||||
|
|
||||||
public static bool IsValidStartDate(DateTime date) => date >= MinStartDate && date <= MaxStartDate;
|
public static bool IsValidStartDate(DateTime date) => date >= MinStartDate && date <= MaxStartDate;
|
||||||
|
|
||||||
|
/// <summary>Empty-time skip. Not a tick — the calendar jumps to an instant already known to be legal.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Advances the calendar by one fixed step of <paramref name="realSeconds"/>, scaled by the
|
/// Advances the calendar by one fixed step of <paramref name="realSeconds"/>, scaled by the
|
||||||
/// base rate and the current speed. Does nothing while paused.
|
/// base rate and the current speed. Does nothing while paused.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\HSchool.Ai\HSchool.Ai.csproj" />
|
||||||
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
|
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
|
||||||
<ProjectReference Include="..\HSchool.People\HSchool.People.csproj" />
|
<ProjectReference Include="..\HSchool.People\HSchool.People.csproj" />
|
||||||
<ProjectReference Include="..\HSchool.Schedule\HSchool.Schedule.csproj" />
|
<ProjectReference Include="..\HSchool.Schedule\HSchool.Schedule.csproj" />
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
using Arch.Core;
|
||||||
|
using HSchool.Ai;
|
||||||
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
|
using HSchool.Schedule;
|
||||||
|
|
||||||
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decisions go through <see cref="HSchool.Ai"/>; the per-tick walk does not. Order is the
|
||||||
|
/// roster id list, never Arch's entity order.
|
||||||
|
/// </summary>
|
||||||
|
internal static class PresenceSystem
|
||||||
|
{
|
||||||
|
private static readonly QueryDescription People =
|
||||||
|
new QueryDescription().WithAll<PersonIdentity, PersonRoles, PersonTraits, Presence>();
|
||||||
|
|
||||||
|
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<PresenceSnapshot> Capture(School school)
|
||||||
|
{
|
||||||
|
var rows = new List<PresenceSnapshot>();
|
||||||
|
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<PresenceSnapshot>? 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<Person> 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<string> Path);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Arch.Core;
|
using Arch.Core;
|
||||||
using HSchool.People;
|
using HSchool.People;
|
||||||
|
using HSchool.Ai;
|
||||||
|
|
||||||
namespace HSchool.Simulation;
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
@@ -36,7 +37,8 @@ public static class RosterSpawner
|
|||||||
person.IsParent,
|
person.IsParent,
|
||||||
person.ClassId,
|
person.ClassId,
|
||||||
person.Position,
|
person.Position,
|
||||||
person.WorkplaceRoomId));
|
person.WorkplaceRoomId),
|
||||||
|
Presence.OffCampus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Arch.Core;
|
using Arch.Core;
|
||||||
|
using HSchool.Ai;
|
||||||
using HSchool.Content;
|
using HSchool.Content;
|
||||||
using HSchool.People;
|
using HSchool.People;
|
||||||
using HSchool.Schedule;
|
using HSchool.Schedule;
|
||||||
@@ -83,6 +84,23 @@ public sealed class School : IDisposable
|
|||||||
/// <summary>True after yearly intake until the worker rebuilds around remaining locks.</summary>
|
/// <summary>True after yearly intake until the worker rebuilds around remaining locks.</summary>
|
||||||
public bool TimetableDirty { get; private set; }
|
public bool TimetableDirty { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Walk matrix for this map. Null in clock-only tests.</summary>
|
||||||
|
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<string, DayPlan> Plans { get; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
internal Queue<string> DecisionQueue { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Installs a roster that already matches the map. Spawns entities; does not write to disk.
|
/// Installs a roster that already matches the map. Spawns entities; does not write to disk.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -96,6 +114,61 @@ public sealed class School : IDisposable
|
|||||||
NameSetId = nameSetId;
|
NameSetId = nameSetId;
|
||||||
Applicants = applicants;
|
Applicants = applicants;
|
||||||
RosterSpawner.Spawn(World, roster);
|
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<PresenceSnapshot> CapturePresence() => PresenceSystem.Capture(this);
|
||||||
|
|
||||||
|
public void RestorePresence(IReadOnlyList<PresenceSnapshot>? 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -110,7 +183,9 @@ public sealed class School : IDisposable
|
|||||||
|
|
||||||
Roster = roster;
|
Roster = roster;
|
||||||
Applicants = applicants;
|
Applicants = applicants;
|
||||||
|
var snapshot = PresenceSystem.Capture(this);
|
||||||
RosterSpawner.Replace(World, roster);
|
RosterSpawner.Replace(World, roster);
|
||||||
|
PresenceSystem.Restore(this, snapshot);
|
||||||
TimetableDirty = true;
|
TimetableDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +195,11 @@ public sealed class School : IDisposable
|
|||||||
ArgumentNullException.ThrowIfNull(timetable);
|
ArgumentNullException.ThrowIfNull(timetable);
|
||||||
Timetable = timetable;
|
Timetable = timetable;
|
||||||
TimetableDirty = false;
|
TimetableDirty = false;
|
||||||
|
LastDecisionSlot = null;
|
||||||
|
foreach (var id in Roster?.People.Select(person => person.Id) ?? [])
|
||||||
|
{
|
||||||
|
DecisionQueue.Enqueue(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, then need decay.</summary>
|
/// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, then need decay.</summary>
|
||||||
@@ -135,6 +215,13 @@ public sealed class School : IDisposable
|
|||||||
{
|
{
|
||||||
peopleChanged = TryYearlyIntake(before, Clock.Time);
|
peopleChanged = TryYearlyIntake(before, Clock.Time);
|
||||||
peopleChanged |= TryApplicantRefresh();
|
peopleChanged |= TryApplicantRefresh();
|
||||||
|
if (peopleChanged)
|
||||||
|
{
|
||||||
|
PlanDay = null;
|
||||||
|
LastDecisionSlot = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
PresenceSystem.Apply(this, gameMinutes);
|
||||||
if (Catalog is not null)
|
if (Catalog is not null)
|
||||||
{
|
{
|
||||||
NeedDecay.Apply(World, Catalog, gameMinutes);
|
NeedDecay.Apply(World, Catalog, gameMinutes);
|
||||||
@@ -160,7 +247,9 @@ public sealed class School : IDisposable
|
|||||||
|
|
||||||
if (changed)
|
if (changed)
|
||||||
{
|
{
|
||||||
|
var snapshot = PresenceSystem.Capture(this);
|
||||||
RosterSpawner.Replace(World, Roster);
|
RosterSpawner.Replace(World, Roster);
|
||||||
|
PresenceSystem.Restore(this, snapshot);
|
||||||
TimetableDirty = true;
|
TimetableDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,3 +286,18 @@ public sealed class School : IDisposable
|
|||||||
Arch.Core.World.Destroy(World);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ public sealed class SimulationOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public float MonthlyPayrollCap { get; set; } = 100_000f;
|
public float MonthlyPayrollCap { get; set; } = 100_000f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many people may change destination in one tick. Overflow waits for the next tick
|
||||||
|
/// instead of being dropped — a queue, not a cutoff.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxDecisionsPerTick { get; set; } = 64;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Working days from Monday. Five is Mon–Fri; six adds Saturday; seven is every day.
|
/// Working days from Monday. Five is Mon–Fri; six adds Saturday; seven is every day.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
|
namespace HSchool.Ai.Tests;
|
||||||
|
|
||||||
|
internal static class PackDocuments
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<ContentDocument> FromDirectory(string packId, string packRoot)
|
||||||
|
{
|
||||||
|
var documents = new List<ContentDocument>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>HSchool.Ai.Tests</RootNamespace>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit.v3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\HSchool.Ai\HSchool.Ai.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\HSchool.Content\HSchool.Content.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\HSchool.People\HSchool.People.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\HSchool.Schedule\HSchool.Schedule.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\..\src\HSchool.Server\mods\core\**\*">
|
||||||
|
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,52 @@ public class CalendarTests
|
|||||||
Assert.Equal(DaySlot.BreakAfter(2), slot);
|
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]
|
[Fact]
|
||||||
public void Subject_UnknownRoom_FailsTheCatalog()
|
public void Subject_UnknownRoom_FailsTheCatalog()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ public class CatalogLoaderTests
|
|||||||
CatalogLoader.CorePackId,
|
CatalogLoader.CorePackId,
|
||||||
"rooms",
|
"rooms",
|
||||||
"office",
|
"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);
|
Assert.Contains("Chair", ex.Message);
|
||||||
@@ -126,4 +126,28 @@ public class CatalogLoaderTests
|
|||||||
Assert.Equal("Мебель", catalog.Label("ru", catalog.Things["Chair"]));
|
Assert.Equal("Мебель", catalog.Label("ru", catalog.Things["Chair"]));
|
||||||
Assert.Equal("Chair", catalog.Label("en", catalog.Things["Chair"]));
|
Assert.Equal("Chair", catalog.Label("en", catalog.Things["Chair"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConcreteRoomWithoutTravelMinutes_FailsTheCatalog()
|
||||||
|
{
|
||||||
|
var ex = Assert.Throws<ContentLoadException>(() => _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<ContentLoadException>(() => _loader.Load(
|
||||||
|
[CatalogLoader.CorePackId],
|
||||||
|
[
|
||||||
|
PackDocuments.Def(CatalogLoader.CorePackId, "territories", "yard", """{ "defName": "Yard" }"""),
|
||||||
|
]));
|
||||||
|
|
||||||
|
Assert.Contains("travelMinutes", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ public class InheritanceTests
|
|||||||
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
|
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
|
||||||
[CatalogLoader.CorePackId],
|
[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" }"""),
|
PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "parent": "Office" }"""),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
|
|||||||
@@ -125,14 +125,14 @@ public class MapValidationTests
|
|||||||
CatalogLoader.CorePackId,
|
CatalogLoader.CorePackId,
|
||||||
"territories",
|
"territories",
|
||||||
"yard",
|
"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, "buildings", "main", """{ "defName": "Main" }"""),
|
||||||
PackDocuments.Def(CatalogLoader.CorePackId, "floors", "floor", """{ "defName": "Floor" }"""),
|
PackDocuments.Def(CatalogLoader.CorePackId, "floors", "floor", """{ "defName": "Floor" }"""),
|
||||||
PackDocuments.Def(
|
PackDocuments.Def(
|
||||||
CatalogLoader.CorePackId,
|
CatalogLoader.CorePackId,
|
||||||
"rooms",
|
"rooms",
|
||||||
"office",
|
"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(
|
private static MapLayout MiniMap(
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public class MapViewTests
|
|||||||
new ContentDocument(
|
new ContentDocument(
|
||||||
CatalogLoader.CorePackId,
|
CatalogLoader.CorePackId,
|
||||||
"defs/territories/yard.jsonc",
|
"defs/territories/yard.jsonc",
|
||||||
"""{ "defName": "Yard" }"""),
|
"""{ "defName": "Yard", "travelMinutes": 1 }"""),
|
||||||
new ContentDocument(
|
new ContentDocument(
|
||||||
CatalogLoader.CorePackId,
|
CatalogLoader.CorePackId,
|
||||||
"localizations/ru.jsonc",
|
"localizations/ru.jsonc",
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ public class PatchTests
|
|||||||
{
|
{
|
||||||
"defName": "Office",
|
"defName": "Office",
|
||||||
"slots": [ { "key": "seat", "thing": "Chair" } ],
|
"slots": [ { "key": "seat", "thing": "Chair" } ],
|
||||||
"works": ["TeachLesson", "WalkSchool"]
|
"works": ["TeachLesson", "WalkSchool"],
|
||||||
|
"travelMinutes": 1
|
||||||
}
|
}
|
||||||
"""),
|
"""),
|
||||||
PackDocuments.Patch(
|
PackDocuments.Patch(
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ public class VanillaCoreTests
|
|||||||
Assert.DoesNotContain(map.Rooms, room => room.Label is "1A" or "1B" or "2A" or "2B");
|
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.Equal(16, map.Rooms.Single(room => room.Id == "classroom-101").Seats);
|
||||||
Assert.Empty(map.Rooms.Single(room => room.Id == "classroom-101").Slots);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -120,6 +120,19 @@ public class GameClockTests
|
|||||||
Assert.Equal(DateTimeKind.Utc, clock.Time.Kind);
|
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]
|
[Fact]
|
||||||
public void StartDateOutsideTheSupportedRange_Throws()
|
public void StartDateOutsideTheSupportedRange_Throws()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
<ProjectReference Include="..\..\src\HSchool.Simulation\HSchool.Simulation.csproj" />
|
<ProjectReference Include="..\..\src\HSchool.Simulation\HSchool.Simulation.csproj" />
|
||||||
<ProjectReference Include="..\..\src\HSchool.Content\HSchool.Content.csproj" />
|
<ProjectReference Include="..\..\src\HSchool.Content\HSchool.Content.csproj" />
|
||||||
<ProjectReference Include="..\..\src\HSchool.People\HSchool.People.csproj" />
|
<ProjectReference Include="..\..\src\HSchool.People\HSchool.People.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\HSchool.Schedule\HSchool.Schedule.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\HSchool.Ai\HSchool.Ai.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -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<PresenceSnapshot> 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<ContentDocument>();
|
||||||
|
foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var relative = Path.GetRelativePath(root, path).Replace('\\', '/');
|
||||||
|
documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
|
||||||
|
var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
|
||||||
|
Assert.NotNull(map);
|
||||||
|
return (catalog, map);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,10 +50,10 @@ public class SchoolTests
|
|||||||
{
|
{
|
||||||
new ContentDocument("core", "defs/actions/sit.jsonc", """{ "defName": "Sit" }"""),
|
new ContentDocument("core", "defs/actions/sit.jsonc", """{ "defName": "Sit" }"""),
|
||||||
new ContentDocument("core", "defs/things/chair.jsonc", """{ "defName": "Chair", "actions": ["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/buildings/main.jsonc", """{ "defName": "Main" }"""),
|
||||||
new ContentDocument("core", "defs/floors/floor.jsonc", """{ "defName": "Floor" }"""),
|
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 catalog = loader.Load(["core"], documents);
|
||||||
var map = new MapLayout
|
var map = new MapLayout
|
||||||
|
|||||||
Reference in New Issue
Block a user