Enhance AI and simulation components with presence management and routing capabilities
ci / server (push) Failing after 3m39s
ci / client (push) Successful in 14s

- 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:
Leonid Pershin
2026-08-19 16:33:52 +03:00
parent c16c34a83c
commit 4137400621
50 changed files with 1978 additions and 57 deletions
+7 -1
View File
@@ -13,7 +13,8 @@ way; this file is *how to work in them*.
| skills, traits, body, needs, name sets | `src/HSchool.Content` |
| people generation, families, roster records, yearly intake | `src/HSchool.People` |
| timetable planning | `src/HSchool.Schedule` |
| people in a school's World, need decay | `src/HSchool.Simulation` |
| routes, day plans, who comes today | `src/HSchool.Ai` |
| people in a school's World, need decay, walking | `src/HSchool.Simulation` |
| the menu API (list, create, delete, mods, catalog) and the people list/card | `src/HSchool.Server/Api` **and** `docs/protocol.md` |
| what the socket carries | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` |
| connection handling, workers, saves | `src/HSchool.Server` |
@@ -111,6 +112,8 @@ say so explicitly in the change description.
produce the same roster; the suite does not boot a host.
- Timetable planning belongs in `tests/HSchool.Schedule.Tests`. Same staff, map and locks must
produce the same table; the suite does not boot a host.
- Walking and day plans belong in `tests/HSchool.Ai.Tests`. Same seed and map must produce the
same route and commute; the suite does not boot a host, and it does not reference Arch.
- Catalog, inheritance, patches and map validation belong in `tests/HSchool.Content.Tests`.
Feed the loader documents, not disk paths.
- Protocol changes need a round-trip test **and** a byte-layout assertion on both sides.
@@ -164,3 +167,6 @@ say so explicitly in the change description.
wrong for anything that must arrive exactly once — such a message would need its own path.
- `erasableSyntaxOnly` is off in `tsconfig.app.json` on purpose: constructor parameter properties
are used throughout.
- **A large one-shot `Tick(week)` jumps the clock, then applies all those minutes at the new
time.** Presence equality tests must loop `FixedDeltaTime` or one game minute. `string.GetHashCode`
is randomized — commute slack goes through `Seed.Mix`, never that.
+1
View File
@@ -121,6 +121,7 @@ Simulation tunables live under the `Simulation` section of
| `MinSaveIntervalMilliseconds` | 1000 | shortest gap between saves caused by pause or speed |
| `MonthlyPayrollCap` | 100000 | monthly payroll the player may commit; hires and assignments that would exceed it are rejected |
| `SchoolWeekDays` | 5 | working days from Monday (5 is MonFri; 6 adds Saturday) |
| `MaxDecisionsPerTick` | 64 | presence decisions processed per tick; overflow waits |
## What is deliberately missing
+7 -4
View File
@@ -36,14 +36,15 @@ there is no UI on the server.
| `src/HSchool.Content` | JSONC defs, inheritance, patches, locales, map instance and connectivity. No Arch, no ASP.NET. |
| `src/HSchool.People` | Roster generation from catalog, map and seed. No Arch, no ASP.NET. |
| `src/HSchool.Schedule` | Timetable from curriculum, assignments and map. No Arch, no ASP.NET. |
| `src/HSchool.Ai` | Walk graph, day plans, duty rooms. No Arch, no ASP.NET, no `DateTime.Now`. |
| `src/HSchool.Simulation` | Schools, the game clock, the Arch ECS world. Holds a frozen catalog and map; no HTTP. |
| `src/HSchool.Server` | ASP.NET Core host: the menu API, the WebSocket endpoint, per-school workers, `mods/` and `saves/`. |
| `src/HSchool.ServiceDefaults` | Shared Aspire wiring: OpenTelemetry, health checks, service discovery, resilience. |
| `src/HSchool.AppHost` | Aspire orchestration: which resources run and how they find each other. |
| `src/HSchool.Client` | Vite + TypeScript UI: main menu, creation form, the school screen. |
Dependency direction is one-way: `Protocol ← Server → Simulation → People → Content`, and
`Schedule → Content`. Nothing in `Simulation` or `Content` knows about HTTP, and nothing in
Dependency direction is one-way: `Protocol ← Server → Simulation → Ai → People / Schedule → Content`.
Nothing in `Simulation` or `Content` knows about HTTP, and nothing in
`Protocol` knows about schools.
## Two channels, on purpose
@@ -80,7 +81,9 @@ touching a school itself.
## Schools
A `School` is one save: an id, a name, a `GameClock`, a frozen catalog, a map, a roster and an
Arch `World`. Pupils, staff and parents live in that world as entities; they do not walk yet.
Arch `World`. Pupils, staff and parents live in that world as entities. Presence is a component:
each person occupies a map node or is off campus. Empty-time skip jumps the clock to the next
work morning when the campus is empty and the day frame is closed.
`GameClock` moves while it is running, in fixed steps:
`realSeconds × gameMinutesPerRealSecond × speedMultiplier`. At the defaults that is 5 game minutes
@@ -107,7 +110,7 @@ intake). Pause and speed changes are written
too, but coalesced to at most one write per `MinSaveIntervalMilliseconds`: a client can send those
as fast as the socket allows, and each one is a file write on the school's own thread. Shutdown
always flushes, so a pause is never lost. The clock file also stores the
mod pack ids and the map layout; the catalog is loaded again from `mods/` on start. A missing
mod pack ids, the map layout and each person's place on that map; the catalog is loaded again from `mods/` on start. A missing
mod folder, a map that no longer validates, or a roster that no longer fits the map leaves the
files in place and that school unstarted.
+1 -1
View File
@@ -1,6 +1,6 @@
# Нарезка проектов (целевая)
Сейчас в коде: `Protocol ← Server → Simulation → People → Content`. Каталог не в Simulation (рядом с Arch)
Сейчас в коде: `Protocol ← Server → Simulation → Ai → People / Schedule → Content`. Каталог не в Simulation (рядом с Arch)
и не в Server (рядом с Kestrel) — парсер JSONC и проверка графа не знают, что такое tick.
## Зависимости
+32 -32
View File
@@ -12,61 +12,61 @@
## Задачи
- [ ] Новый проект `HSchool.Ai` рядом с `People` и `Schedule`: зависит на `Content`, `People` и
- [x] Новый проект `HSchool.Ai` рядом с `People` и `Schedule`: зависит на `Content`, `People` и
`Schedule`, без Arch и ASP.NET. Рядом — `tests/HSchool.Ai.Tests`
- [ ] `travelMinutes` на `RoomDef` и `TerritoryDef`; в `core` проставлены кабинет 0.5, коридор
- [x] `travelMinutes` на `RoomDef` и `TerritoryDef`; в `core` проставлены кабинет 0.5, коридор
1.5, лестница 1.5, вестибюль 1, двор 3
- [ ] Маршрутизация по `MapLayout.Links` — в библиотеке: матрица следующих шагов считается
- [x] Маршрутизация по `MapLayout.Links` — в библиотеке: матрица следующих шагов считается
**один раз** при загрузке школы, не на каждом шаге
- [ ] План дня человека — тоже в библиотеке: во сколько выходить, чтобы успеть, и когда уходить.
- [x] План дня человека — тоже в библиотеке: во сколько выходить, чтобы успеть, и когда уходить.
Часы, компоненты и мир туда не проникают
- [ ] Место человека — компонент в `World`: узел, остаток пути, минуты до выхода из узла.
- [x] Место человека — компонент в `World`: узел, остаток пути, минуты до выхода из узла.
Человек всегда в узле; рёбер как состояний нет
- [ ] Состояние «вне школы» — не удаление сущности: человек остаётся в списке и в карточке
- [ ] Приход и уход: у кого сегодня нет уроков, тот не приходит вовсе. Ученик и учитель — по
- [x] Состояние «вне школы» — не удаление сущности: человек остаётся в списке и в карточке
- [x] Приход и уход: у кого сегодня нет уроков, тот не приходит вовсе. Ученик и учитель — по
своим урокам, прочий штат — по дню школы
- [ ] Запас на дорогу свой у каждого: путь от двора плюс несколько минут из своего потока
- [x] Запас на дорогу свой у каждого: путь от двора плюс несколько минут из своего потока
случайности, с поправкой на черты
- [ ] Обязанность как место назначения: урок — кабинет из расписания, должность — комната из
- [x] Обязанность как место назначения: урок — кабинет из расписания, должность — комната из
`RoomDef.works`. Выбора действий пока нет
- [ ] Движение по тику: остаток минут в узле уменьшается, при нуле человек переходит в следующий
- [x] Движение по тику: остаток минут в узле уменьшается, при нуле человек переходит в следующий
узел пути
- [ ] Стабильный порядок обхода людей — собственный список, а не порядок сущностей в Arch
- [ ] Адаптер в `HSchool.Simulation`: собирает значения из компонентов, зовёт библиотеку,
- [x] Стабильный порядок обхода людей — собственный список, а не порядок сущностей в Arch
- [x] Адаптер в `HSchool.Simulation`: собирает значения из компонентов, зовёт библиотеку,
записывает ответ обратно. Горячий путь — движение — границу не пересекает
- [ ] Ориентир в `AGENTS.md`, `docs/architecture.md` и `docs/design/projects.md` знает про новый
- [x] Ориентир в `AGENTS.md`, `docs/architecture.md` и `docs/design/projects.md` знает про новый
проект
- [ ] Место, путь и назначение едут в файл часов; кого в файле нет, тот расставляется по
- [x] Место, путь и назначение едут в файл часов; кого в файле нет, тот расставляется по
обязанности
- [ ] Потолок решений за тик — в `SimulationOptions`, как очередь, а не как отсечка
- [ ] Предикат «школа пуста»: в здании и во дворе нет никого
- [ ] Рабочее окно дня — от шести утра до последнего звонка по **каркасу дня**, а не по
- [x] Потолок решений за тик — в `SimulationOptions`, как очередь, а не как отсечка
- [x] Предикат «школа пуста»: в здании и во дворе нет никого
- [x] Рабочее окно дня — от шести утра до последнего звонка по **каркасу дня**, а не по
фактическому расписанию
- [ ] «Ближайшее шесть утра рабочего дня впереди»: ночь ведёт в то же утро, вечер — в следующее,
- [x] «Ближайшее шесть утра рабочего дня впереди»: ночь ведёт в то же утро, вечер — в следующее,
выходные, праздники и каникулы пропускаются. Поиск ограничен разумным числом дней и
честно отвечает «нет такого»
- [ ] Прыжок часов туда одним шагом: разрешён только при пустой школе и вне рабочего окна,
- [x] Прыжок часов туда одним шагом: разрешён только при пустой школе и вне рабочего окна,
отрабатывает первое сентября и обновление пула за весь пропуск, после себя пишет сейв
## Тесты, без которых фаза не закрыта
- [ ] Путь из кабинета 201 в санузел первого этажа идёт через лестницу и коридор, а не напрямую
- [ ] Сумма `travelMinutes` совпадает с временем, за которое человек реально доходит
- [ ] В воскресенье и в каникулы школа пуста: все вне школы
- [ ] Класс без уроков сегодня не приходит, а его учитель с уроками у другого класса — приходит
- [ ] К началу первого урока ученик находится в своём кабинете
- [ ] Физкультура после урока в главном корпусе: класс доходит до спортзала, и видно, что
- [x] Путь из кабинета 201 в санузел первого этажа идёт через лестницу и коридор, а не напрямую
- [x] Сумма `travelMinutes` совпадает с временем, за которое человек реально доходит
- [x] В воскресенье и в каникулы школа пуста: все вне школы
- [x] Класс без уроков сегодня не приходит, а его учитель с уроками у другого класса — приходит
- [x] К началу первого урока ученик находится в своём кабинете
- [x] Физкультура после урока в главном корпусе: класс доходит до спортзала, и видно, что
перемены на это едва хватает
- [ ] Тот же сид и та же карта дают то же расположение людей на тот же момент
- [ ] Сохранение и загрузка посреди перемены не телепортируют людей
- [ ] Прыжок с субботы попадает в понедельник, 6:00; с трёх ночи вторника — в утро того же
- [x] Тот же сид и та же карта дают то же расположение людей на тот же момент
- [x] Сохранение и загрузка посреди перемены не телепортируют людей
- [x] Прыжок с субботы попадает в понедельник, 6:00; с трёх ночи вторника — в утро того же
вторника; с десяти вечера — в утро среды
- [ ] Прыжок в учебное время отклоняется. В семь утра рабочего дня, когда школа ещё пуста, —
- [x] Прыжок в учебное время отклоняется. В семь утра рабочего дня, когда школа ещё пуста, —
тоже: это уже рабочее окно
- [ ] У школы без единого учителя рабочий день не пропускается
- [ ] Прыжок через летние каникулы проводит первое сентября: классы перешли, выпуск состоялся,
- [x] У школы без единого учителя рабочий день не пропускается
- [x] Прыжок через летние каникулы проводит первое сентября: классы перешли, выпуск состоялся,
набор пришёл
- [ ] Промотанная неделя и прожитая неделя дают одно состояние школы
- [x] Промотанная неделя и прожитая неделя дают одно состояние школы
## Критерий готовности
+1 -1
View File
@@ -82,7 +82,7 @@
| Фаза | Статус | Зачем |
| --- | --- | --- |
| [18. Присутствие и ходьба](18-presence-walking.md) | | `HSchool.Ai`, место человека, маршруты, приход и уход |
| [18. Присутствие и ходьба](18-presence-walking.md) | | `HSchool.Ai`, место человека, маршруты, приход и уход |
| [19. Присутствие на экране](19-presence-screen.md) | ⬜ | Своё сообщение ~2 Гц, числа в дереве, статический снимок карты, пропуск пустого времени |
**Этап B — поведение.** Появляются нужды, действия и выбор между ними и обязанностью.
+30
View File
@@ -35,6 +35,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Schedule", "src\HSc
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Schedule.Tests", "tests\HSchool.Schedule.Tests\HSchool.Schedule.Tests.csproj", "{85FF85BD-F572-4F0A-A11E-FA855D480C1D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Ai", "src\HSchool.Ai\HSchool.Ai.csproj", "{C21FB23F-F131-4651-8236-4F3E076B40BF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Ai.Tests", "tests\HSchool.Ai.Tests\HSchool.Ai.Tests.csproj", "{C355307C-D7D3-48C7-9615-CB5FBCF83D49}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -213,6 +217,30 @@ Global
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Release|x64.Build.0 = Release|Any CPU
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Release|x86.ActiveCfg = Release|Any CPU
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Release|x86.Build.0 = Release|Any CPU
{C21FB23F-F131-4651-8236-4F3E076B40BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C21FB23F-F131-4651-8236-4F3E076B40BF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C21FB23F-F131-4651-8236-4F3E076B40BF}.Debug|x64.ActiveCfg = Debug|Any CPU
{C21FB23F-F131-4651-8236-4F3E076B40BF}.Debug|x64.Build.0 = Debug|Any CPU
{C21FB23F-F131-4651-8236-4F3E076B40BF}.Debug|x86.ActiveCfg = Debug|Any CPU
{C21FB23F-F131-4651-8236-4F3E076B40BF}.Debug|x86.Build.0 = Debug|Any CPU
{C21FB23F-F131-4651-8236-4F3E076B40BF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C21FB23F-F131-4651-8236-4F3E076B40BF}.Release|Any CPU.Build.0 = Release|Any CPU
{C21FB23F-F131-4651-8236-4F3E076B40BF}.Release|x64.ActiveCfg = Release|Any CPU
{C21FB23F-F131-4651-8236-4F3E076B40BF}.Release|x64.Build.0 = Release|Any CPU
{C21FB23F-F131-4651-8236-4F3E076B40BF}.Release|x86.ActiveCfg = Release|Any CPU
{C21FB23F-F131-4651-8236-4F3E076B40BF}.Release|x86.Build.0 = Release|Any CPU
{C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Debug|x64.ActiveCfg = Debug|Any CPU
{C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Debug|x64.Build.0 = Debug|Any CPU
{C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Debug|x86.ActiveCfg = Debug|Any CPU
{C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Debug|x86.Build.0 = Debug|Any CPU
{C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|Any CPU.Build.0 = Release|Any CPU
{C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|x64.ActiveCfg = Release|Any CPU
{C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|x64.Build.0 = Release|Any CPU
{C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|x86.ActiveCfg = Release|Any CPU
{C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -232,6 +260,8 @@ Global
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{734AC2F7-B243-4211-95CF-3E662D2E20C9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{85FF85BD-F572-4F0A-A11E-FA855D480C1D} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{C21FB23F-F131-4651-8236-4F3E076B40BF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C355307C-D7D3-48C7-9615-CB5FBCF83D49} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {DD14EF4D-167E-4AC7-953A-AF606CC34829}
+125
View File
@@ -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;
}
}
+157
View File
@@ -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 [];
}
}
+17
View File
@@ -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>
+79
View File
@@ -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;
}
+165
View File
@@ -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;
}
}
+13
View File
@@ -468,6 +468,19 @@ public sealed class CatalogLoader
{
throw new ContentLoadException($"RoomDef '{room.DefName}' is not a homeroom and cannot set seatThing or defaultSeats.");
}
if (!room.Abstract && room.TravelMinutes <= 0)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' travelMinutes must be positive.");
}
}
foreach (var territory in catalog.Territories.Values)
{
if (!territory.Abstract && territory.TravelMinutes <= 0)
{
throw new ContentLoadException($"TerritoryDef '{territory.DefName}' travelMinutes must be positive.");
}
}
PeopleDefValidator.Validate(catalog);
+8 -1
View File
@@ -77,10 +77,17 @@ public sealed class RoomDef : Def
/// <summary>Editor default when placing a new homeroom. Vanilla classrooms are 16.</summary>
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 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; }
}
+5
View File
@@ -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.
/// </summary>
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
+107
View File
@@ -95,6 +95,113 @@ public static class SchoolDay
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 57 days.");
}
}
private static bool IsWeekday(DateTime time, int weekDays)
{
// Monday = 0 … Sunday = 6. A 5-day week is MonFri; 6 adds Saturday; 7 is every day.
+24 -1
View File
@@ -4,7 +4,7 @@ namespace HSchool.People;
/// Per-family streams derived from the school seed. Family N never consumes family N-1's rolls,
/// so appending a thirteenth family leaves the first twelve unchanged.
/// </summary>
internal static class Seed
public static class Seed
{
public const int ChildCountSalt = 1;
public const int AppearanceSalt = 2;
@@ -12,6 +12,7 @@ internal static class Seed
public const int SeatShuffleSalt = 4;
public const int HouseholdSalt = 5;
public const int ApplicantSalt = 6;
public const int CommuteSalt = 7;
/// <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);
@@ -24,6 +25,28 @@ internal static class Seed
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)
{
z += 0x9E3779B97F4A7C15UL;
+5 -2
View File
@@ -494,7 +494,8 @@ internal sealed class GameLoopService(
isNew: false,
save.ModIds,
save.Map,
save.NameSetId);
save.NameSetId,
save.Presence);
worker.Start();
try
@@ -541,7 +542,8 @@ internal sealed class GameLoopService(
bool isNew,
IReadOnlyList<string>? modIds,
MapLayout? map,
string? nameSetId) =>
string? nameSetId,
IReadOnlyList<PresenceSnapshot>? presence = null) =>
new(
id,
name,
@@ -552,6 +554,7 @@ internal sealed class GameLoopService(
modIds,
map,
nameSetId,
presence,
_options,
clients,
metrics,
+3
View File
@@ -27,6 +27,8 @@ internal sealed class SchoolSave
public MapLayout? Map { get; init; }
public string? NameSetId { get; init; }
public IReadOnlyList<PresenceSnapshot>? Presence { get; init; }
}
/// <summary>Allocates school ids that survive a process restart.</summary>
@@ -160,6 +162,7 @@ internal sealed class SchoolStore
ModIds = save.ModIds,
Map = save.Map,
NameSetId = save.NameSetId,
Presence = save.Presence,
});
}
catch (Exception ex)
+6
View File
@@ -33,6 +33,7 @@ internal sealed class SchoolWorker
private readonly IReadOnlyList<string>? _modIds;
private readonly MapLayout? _savedMap;
private readonly string? _nameSetId;
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
private readonly Action<int> _onFailed;
private readonly int _id;
@@ -64,6 +65,7 @@ internal sealed class SchoolWorker
IReadOnlyList<string>? modIds,
MapLayout? savedMap,
string? nameSetId,
IReadOnlyList<PresenceSnapshot>? savedPresence,
SimulationOptions options,
ClientRegistry clients,
GameMetrics metrics,
@@ -81,6 +83,7 @@ internal sealed class SchoolWorker
_modIds = modIds;
_savedMap = savedMap;
_nameSetId = nameSetId;
_savedPresence = savedPresence;
_options = options;
_clients = clients;
_metrics = metrics;
@@ -882,6 +885,8 @@ internal sealed class SchoolWorker
school.InstallPeople(roster, seed, nameSetId, applicants);
InstallTimetable(school);
school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick);
school.RestorePresence(_savedPresence);
return generated;
}
@@ -927,6 +932,7 @@ internal sealed class SchoolWorker
ModIds = school.Catalog?.PackIds,
Map = school.Map,
NameSetId = _nameSetId,
Presence = school.CapturePresence(),
});
}
catch (Exception ex)
+1
View File
@@ -23,6 +23,7 @@ builder.Services
.Validate(options => options.SaveIntervalSeconds is > 0 and <= 3600, "Simulation:SaveIntervalSeconds must be between 1 and 3600.")
.Validate(options => options.MonthlyPayrollCap > 0, "Simulation:MonthlyPayrollCap must be positive.")
.Validate(options => options.SchoolWeekDays is >= 5 and <= 7, "Simulation:SchoolWeekDays must be between 5 and 7.")
.Validate(options => options.MaxDecisionsPerTick is > 0 and <= 10_000, "Simulation:MaxDecisionsPerTick must be between 1 and 10000.")
.ValidateOnStart();
builder.Services.AddSingleton<GameCommandQueue>();
+2 -1
View File
@@ -15,6 +15,7 @@
"ModsDirectory": "mods",
"SaveIntervalSeconds": 30,
"MonthlyPayrollCap": 100000,
"SchoolWeekDays": 5
"SchoolWeekDays": 5,
"MaxDecisionsPerTick": 64
}
}
@@ -5,6 +5,7 @@
"seatThing": "StudentDesk",
"defaultSeats": 16,
"works": ["TeachLesson"],
"travelMinutes": 0.5,
},
{
"defName": "Library",
@@ -14,6 +15,7 @@
],
"positions": ["Librarian"],
"works": ["LibraryWork"],
"travelMinutes": 0.5,
},
{
"defName": "ComputerLab",
@@ -23,5 +25,6 @@
{ "key": "computers", "thing": "Computer", "count": 12 },
],
"works": ["TeachLesson"],
"travelMinutes": 0.5,
},
]
@@ -1,5 +1,5 @@
[
// Walkable rooms that exist to connect the graph: lobby, stairs. Empty on purpose.
{ "defName": "EntranceHall" },
{ "defName": "Stairwell" },
{ "defName": "EntranceHall", "travelMinutes": 1 },
{ "defName": "Stairwell", "travelMinutes": 1.5 },
]
@@ -1,2 +1,2 @@
// Empty on purpose: a corridor is a walkable room with no furniture of its own.
{ "defName": "Corridor" }
{ "defName": "Corridor", "travelMinutes": 1.5 }
@@ -4,4 +4,5 @@
{ "key": "benches", "thing": "Bench", "count": 4 },
],
"works": ["PELesson"],
"travelMinutes": 0.5,
}
@@ -7,4 +7,5 @@
],
"positions": ["Principal"],
"works": ["PrincipalOfficeWork", "TeachLesson", "WalkSchool"],
"travelMinutes": 0.5,
}
@@ -7,6 +7,7 @@
],
"positions": ["Secretary"],
"works": ["OfficeWork"],
"travelMinutes": 0.5,
},
{
"defName": "TeachersRoom",
@@ -14,6 +15,7 @@
{ "key": "table", "thing": "DiningTable" },
{ "key": "chairs", "thing": "Chair", "count": 6 },
],
"travelMinutes": 0.5,
},
{
"defName": "Cafeteria",
@@ -23,9 +25,11 @@
],
"positions": ["CafeteriaCook"],
"works": ["ServeLunch"],
"travelMinutes": 0.5,
},
{
"defName": "Restroom",
"travelMinutes": 0.5,
},
{
"defName": "MedicalOffice",
@@ -35,11 +39,13 @@
],
"positions": ["Nurse"],
"works": ["MedicalDuty"],
"travelMinutes": 0.5,
},
{
"defName": "ChangingRoom",
"slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 },
],
"travelMinutes": 0.5,
},
]
@@ -1 +1 @@
{ "defName": "SchoolYard" }
{ "defName": "SchoolYard", "travelMinutes": 3 }
@@ -2,6 +2,7 @@
{
"defName": "Diligent",
"weight": 8,
"commuteMinutes": 4,
"incompatible": ["Lazy", "AbsentMinded"],
"skillModifiers": [
{ "skill": "Mathematics", "offset": 8 },
@@ -47,6 +48,7 @@
"defName": "Lazy",
"weight": 6,
"incompatible": ["Diligent"],
"commuteMinutes": -4,
"skillModifiers": [
{ "skill": "PhysicalEducation", "offset": -8 },
{ "skill": "Mathematics", "offset": -6 },
+11
View File
@@ -50,6 +50,17 @@ public sealed class GameClock
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>
/// Advances the calendar by one fixed step of <paramref name="realSeconds"/>, scaled by the
/// base rate and the current speed. Does nothing while paused.
@@ -10,6 +10,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HSchool.Ai\HSchool.Ai.csproj" />
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
<ProjectReference Include="..\HSchool.People\HSchool.People.csproj" />
<ProjectReference Include="..\HSchool.Schedule\HSchool.Schedule.csproj" />
+374
View File
@@ -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);
+3 -1
View File
@@ -1,5 +1,6 @@
using Arch.Core;
using HSchool.People;
using HSchool.Ai;
namespace HSchool.Simulation;
@@ -36,7 +37,8 @@ public static class RosterSpawner
person.IsParent,
person.ClassId,
person.Position,
person.WorkplaceRoomId));
person.WorkplaceRoomId),
Presence.OffCampus);
}
}
+104
View File
@@ -1,4 +1,5 @@
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
@@ -83,6 +84,23 @@ public sealed class School : IDisposable
/// <summary>True after yearly intake until the worker rebuilds around remaining locks.</summary>
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>
/// Installs a roster that already matches the map. Spawns entities; does not write to disk.
/// </summary>
@@ -96,6 +114,61 @@ public sealed class School : IDisposable
NameSetId = nameSetId;
Applicants = applicants;
RosterSpawner.Spawn(World, roster);
PlanDay = null;
LastDecisionSlot = null;
Plans.Clear();
DecisionQueue.Clear();
}
public void ConfigurePresence(int weekDays = 5, int maxDecisionsPerTick = 64, int maxSkipDays = 400)
{
ObjectDisposedException.ThrowIf(_disposed, this);
SchoolWeekDays = weekDays;
MaxDecisionsPerTick = maxDecisionsPerTick;
MaxSkipDays = maxSkipDays;
if (Catalog is not null && Map is not null)
{
Walks = WalkGraph.Build(Catalog, Map);
}
}
public IReadOnlyList<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>
@@ -110,7 +183,9 @@ public sealed class School : IDisposable
Roster = roster;
Applicants = applicants;
var snapshot = PresenceSystem.Capture(this);
RosterSpawner.Replace(World, roster);
PresenceSystem.Restore(this, snapshot);
TimetableDirty = true;
}
@@ -120,6 +195,11 @@ public sealed class School : IDisposable
ArgumentNullException.ThrowIfNull(timetable);
Timetable = timetable;
TimetableDirty = false;
LastDecisionSlot = null;
foreach (var id in Roster?.People.Select(person => person.Id) ?? [])
{
DecisionQueue.Enqueue(id);
}
}
/// <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 |= TryApplicantRefresh();
if (peopleChanged)
{
PlanDay = null;
LastDecisionSlot = null;
}
PresenceSystem.Apply(this, gameMinutes);
if (Catalog is not null)
{
NeedDecay.Apply(World, Catalog, gameMinutes);
@@ -160,7 +247,9 @@ public sealed class School : IDisposable
if (changed)
{
var snapshot = PresenceSystem.Capture(this);
RosterSpawner.Replace(World, Roster);
PresenceSystem.Restore(this, snapshot);
TimetableDirty = true;
}
@@ -197,3 +286,18 @@ public sealed class School : IDisposable
Arch.Core.World.Destroy(World);
}
}
public enum SkipEmptyError
{
None,
PeoplePresent,
InWorkWindow,
NoMorning,
}
public readonly record struct SkipEmptyResult(SkipEmptyError Error, DateTime? Time, bool PeopleChanged)
{
public bool Succeeded => Error == SkipEmptyError.None;
public static SkipEmptyResult Fail(SkipEmptyError error) => new(error, null, false);
}
@@ -55,6 +55,12 @@ public sealed class SimulationOptions
/// </summary>
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>
/// Working days from Monday. Five is MonFri; six adds Saturday; seven is every day.
/// </summary>
+53
View File
@@ -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>
+156
View File
@@ -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);
}
[Fact]
public void NextWorkMorning_FromSaturdayLandsOnMondaySix()
{
var catalog = LoadVanilla();
var next = SchoolDay.NextWorkMorning(catalog, new DateTime(2012, 4, 7, 10, 0, 0, DateTimeKind.Utc), weekDays: 5);
Assert.Equal(new DateTime(2012, 4, 9, 6, 0, 0, DateTimeKind.Utc), next);
}
[Fact]
public void NextWorkMorning_FromTuesdayNightLandsOnThatMorning()
{
var catalog = LoadVanilla();
var next = SchoolDay.NextWorkMorning(catalog, new DateTime(2012, 4, 3, 3, 0, 0, DateTimeKind.Utc), weekDays: 5);
Assert.Equal(new DateTime(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc), next);
}
[Fact]
public void NextWorkMorning_FromTuesdayEveningLandsOnWednesday()
{
var catalog = LoadVanilla();
var next = SchoolDay.NextWorkMorning(catalog, new DateTime(2012, 4, 3, 22, 0, 0, DateTimeKind.Utc), weekDays: 5);
Assert.Equal(new DateTime(2012, 4, 4, 6, 0, 0, DateTimeKind.Utc), next);
}
[Fact]
public void InWorkWindow_SevenOnAWorkday_IsAlreadyOpen()
{
var catalog = LoadVanilla();
var seven = new DateTime(2012, 4, 3, 7, 0, 0, DateTimeKind.Utc);
Assert.True(SchoolDay.IsWorkday(catalog, seven, weekDays: 5));
Assert.True(SchoolDay.InWorkWindow(catalog, seven, weekDays: 5));
}
[Fact]
public void InWorkWindow_DoesNotNeedTeachers()
{
var catalog = LoadVanilla();
var morning = new DateTime(2012, 4, 3, 10, 0, 0, DateTimeKind.Utc);
Assert.True(SchoolDay.InWorkWindow(catalog, morning, weekDays: 5));
}
[Fact]
public void Subject_UnknownRoom_FailsTheCatalog()
{
@@ -106,7 +106,7 @@ public class CatalogLoaderTests
CatalogLoader.CorePackId,
"rooms",
"office",
"""{ "defName": "Office", "slots": [{ "key": "chair", "thing": "Chair" }], "positions": ["Principal"] }"""),
"""{ "defName": "Office", "slots": [{ "key": "chair", "thing": "Chair" }], "positions": ["Principal"], "travelMinutes": 1 }"""),
]));
Assert.Contains("Chair", ex.Message);
@@ -126,4 +126,28 @@ public class CatalogLoaderTests
Assert.Equal("Мебель", catalog.Label("ru", catalog.Things["Chair"]));
Assert.Equal("Chair", catalog.Label("en", catalog.Things["Chair"]));
}
[Fact]
public void ConcreteRoomWithoutTravelMinutes_FailsTheCatalog()
{
var ex = Assert.Throws<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(
[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" }"""),
]));
@@ -125,14 +125,14 @@ public class MapValidationTests
CatalogLoader.CorePackId,
"territories",
"yard",
abstractYard ? """{ "defName": "Yard", "abstract": true }""" : """{ "defName": "Yard" }"""),
abstractYard ? """{ "defName": "Yard", "abstract": true }""" : """{ "defName": "Yard", "travelMinutes": 1 }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "buildings", "main", """{ "defName": "Main" }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "floors", "floor", """{ "defName": "Floor" }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"rooms",
"office",
"""{ "defName": "Office", "slots": [{ "key": "seat", "thing": "Chair" }], "positions": ["Principal"] }"""),
"""{ "defName": "Office", "slots": [{ "key": "seat", "thing": "Chair" }], "positions": ["Principal"], "travelMinutes": 1 }"""),
];
private static MapLayout MiniMap(
+1 -1
View File
@@ -62,7 +62,7 @@ public class MapViewTests
new ContentDocument(
CatalogLoader.CorePackId,
"defs/territories/yard.jsonc",
"""{ "defName": "Yard" }"""),
"""{ "defName": "Yard", "travelMinutes": 1 }"""),
new ContentDocument(
CatalogLoader.CorePackId,
"localizations/ru.jsonc",
+2 -1
View File
@@ -74,7 +74,8 @@ public class PatchTests
{
"defName": "Office",
"slots": [ { "key": "seat", "thing": "Chair" } ],
"works": ["TeachLesson", "WalkSchool"]
"works": ["TeachLesson", "WalkSchool"],
"travelMinutes": 1
}
"""),
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.Equal(16, map.Rooms.Single(room => room.Id == "classroom-101").Seats);
Assert.Empty(map.Rooms.Single(room => room.Id == "classroom-101").Slots);
Assert.Equal(0.5f, catalog.Rooms["Classroom"].TravelMinutes);
Assert.Equal(1.5f, catalog.Rooms["Corridor"].TravelMinutes);
Assert.Equal(1.5f, catalog.Rooms["Stairwell"].TravelMinutes);
Assert.Equal(1f, catalog.Rooms["EntranceHall"].TravelMinutes);
Assert.Equal(3f, catalog.Territories["SchoolYard"].TravelMinutes);
Assert.Equal(4, catalog.Traits["Diligent"].CommuteMinutes);
Assert.Equal(-4, catalog.Traits["Lazy"].CommuteMinutes);
}
/// <summary>
@@ -120,6 +120,19 @@ public class GameClockTests
Assert.Equal(DateTimeKind.Utc, clock.Time.Kind);
}
[Fact]
public void JumpTo_MovesTheCalendarWithoutTicking()
{
var clock = new GameClock(Start) { IsRunning = false };
var target = new DateTime(2012, 4, 9, 6, 0, 0, DateTimeKind.Utc);
clock.JumpTo(target);
Assert.Equal(target, clock.Time);
Assert.Equal(DateTimeKind.Utc, clock.Time.Kind);
Assert.False(clock.IsRunning);
}
[Fact]
public void StartDateOutsideTheSupportedRange_Throws()
{
@@ -16,6 +16,8 @@
<ProjectReference Include="..\..\src\HSchool.Simulation\HSchool.Simulation.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" />
<ProjectReference Include="..\..\src\HSchool.Ai\HSchool.Ai.csproj" />
</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/things/chair.jsonc", """{ "defName": "Chair", "actions": ["Sit"] }"""),
new ContentDocument("core", "defs/territories/yard.jsonc", """{ "defName": "Yard" }"""),
new ContentDocument("core", "defs/territories/yard.jsonc", """{ "defName": "Yard", "travelMinutes": 1 }"""),
new ContentDocument("core", "defs/buildings/main.jsonc", """{ "defName": "Main" }"""),
new ContentDocument("core", "defs/floors/floor.jsonc", """{ "defName": "Floor" }"""),
new ContentDocument("core", "defs/rooms/office.jsonc", """{ "defName": "Office" }"""),
new ContentDocument("core", "defs/rooms/office.jsonc", """{ "defName": "Office", "travelMinutes": 1 }"""),
};
var catalog = loader.Load(["core"], documents);
var map = new MapLayout