diff --git a/AGENTS.md b/AGENTS.md
index 8f01f8f..8104e82 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,6 +12,7 @@ way; this file is *how to work in them*.
| defs, JSONC catalog, map validation | `src/HSchool.Content` |
| 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` |
| 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` |
@@ -108,6 +109,8 @@ say so explicitly in the change description.
Putting a roster into `World` and ticking needs belongs there too.
- People generation belongs in `tests/HSchool.People.Tests`. Same seed, map and name set must
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.
- 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.
diff --git a/docs/architecture.md b/docs/architecture.md
index 2a82174..1eb1cdd 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -34,14 +34,17 @@ there is no UI on the server.
| --- | --- |
| `src/HSchool.Protocol` | Binary wire format. No dependencies, referenced by everything that talks to the socket. |
| `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.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 → Content`. Nothing in
-`Simulation` or `Content` knows about HTTP, and nothing in `Protocol` knows about schools.
+Dependency direction is one-way: `Protocol ← Server → Simulation → People → Content`, and
+`Schedule → Content`. Nothing in `Simulation` or `Content` knows about HTTP, and nothing in
+`Protocol` knows about schools.
## Two channels, on purpose
diff --git a/docs/design/projects.md b/docs/design/projects.md
index fc27e2f..fd44a59 100644
--- a/docs/design/projects.md
+++ b/docs/design/projects.md
@@ -9,6 +9,7 @@
Protocol — ни на кого из игровых проектов (только байты)
Content — ни на Protocol, ни на Simulation, ни на ASP.NET
People → Content (defs, карта, склонения; без Arch и хоста)
+Schedule → Content (раскладка уроков; без Arch и хоста)
Simulation → People, Content (ростер в World; без HTTP)
Server → Protocol, Simulation, People, Content
Client — своя сторона Protocol (TS) + HTTP
@@ -22,6 +23,7 @@ Client — своя сторона Protocol (TS) + HTTP
| --- | --- | --- |
| **HSchool.Content** | Типы def, JSONC-загрузчик, наследование, патчи, слияние локалей, граф карты, валидация связности | Arch, часы, HTTP, потоки, пути `mods/` с диска хоста |
| **HSchool.People** | Генерация ростера: семьи, классы, тело/навыки/черты из каталога и сида | Arch, ASP.NET, сокеты, `DateTime.Now`, файлы сейва |
+| **HSchool.Schedule** | Раскладка учебного плана в таблицу уроков вокруг закреплённых правок | Arch, ASP.NET, сокеты, `DateTime.Now`, файлы сейва |
| **HSchool.Simulation** | `School`, `GameClock`, Arch `World`, тик, применение раскладки к миру | Kestrel, сокеты, `Directory.Enumerate`, сейв-файлы |
| **HSchool.Server** | Хост, супервизор, работник-поток, `mods/` и `saves/` из конфига, HTTP/WS, DTO запросов | Правила «комната должна быть связана», схема Chair |
| **HSchool.Protocol** | Кадры сокета, в том числе снимок карты и locale в Hello | Имена комнат, JSONC |
@@ -49,6 +51,7 @@ Arch и фиксированный тик — про живую школу. Па
- `tests/HSchool.Content.Tests` — фикстуры JSONC, коллизии имён, дырявый граф, двор обязателен.
- `tests/HSchool.People.Tests` — тот же сид даёт тот же ростер; места и должности заполнены.
+- `tests/HSchool.Schedule.Tests` — тот же штат и карта дают ту же таблицу; четыре запрета.
- `HSchool.Simulation.Tests` — часы, тик, «школа с таким каталогом живёт».
- `HSchool.AppHost.Tests` — create с картой, рестарт, сейв на диске.
diff --git a/docs/phases/15-timetable-planner.md b/docs/phases/15-timetable-planner.md
index 6379864..0cf95c4 100644
--- a/docs/phases/15-timetable-planner.md
+++ b/docs/phases/15-timetable-planner.md
@@ -12,24 +12,24 @@
## Задачи
-- [ ] Новый проект рядом с `HSchool.People`: зависит только на `HSchool.Content`, без Arch и
+- [x] Новый проект рядом с `HSchool.People`: зависит только на `HSchool.Content`, без Arch и
ASP.NET; отдаёт простую таблицу уроков
-- [ ] Раскладка часов учебного плана по слотам недели для каждого класса
-- [ ] Кабинет урока: требование предмета, иначе кабинет класса
-- [ ] Четыре запрета соблюдаются: учитель, класс и кабинет не заняты дважды, класс влезает в кабинет
-- [ ] Непокрытые часы не ставятся, а возвращаются отдельным списком — их покажет «Управление»
-- [ ] Закреплённые игроком уроки принимаются на вход и переживают перестроение; остальное
+- [x] Раскладка часов учебного плана по слотам недели для каждого класса
+- [x] Кабинет урока: требование предмета, иначе кабинет класса
+- [x] Четыре запрета соблюдаются: учитель, класс и кабинет не заняты дважды, класс влезает в кабинет
+- [x] Непокрытые часы не ставятся, а возвращаются отдельным списком — их покажет «Управление»
+- [x] Закреплённые игроком уроки принимаются на вход и переживают перестроение; остальное
раскладывается вокруг них
-- [ ] Детерминированность: те же штат, назначения и карта дают ту же таблицу
+- [x] Детерминированность: те же штат, назначения и карта дают ту же таблицу
## Тесты, без которых фаза не закрыта
-- [ ] Один спортзал на одиннадцать классов — физкультура разъезжается по слотам, а не дублируется
-- [ ] Учитель с двумя предметами не стоит в двух местах в одном слоте
-- [ ] Класс на 16 не попадает в кабинет на 12
-- [ ] Предмет без учителя даёт дыру и запись в непокрытых
-- [ ] Закреплённый урок остаётся на месте после найма ещё одного учителя
-- [ ] Те же входы — та же таблица
+- [x] Один спортзал на одиннадцать классов — физкультура разъезжается по слотам, а не дублируется
+- [x] Учитель с двумя предметами не стоит в двух местах в одном слоте
+- [x] Класс на 16 не попадает в кабинет на 12
+- [x] Предмет без учителя даёт дыру и запись в непокрытых
+- [x] Закреплённый урок остаётся на месте после найма ещё одного учителя
+- [x] Те же входы — та же таблица
## Критерий готовности
diff --git a/docs/phases/README.md b/docs/phases/README.md
index 9cfbf6d..f32aef9 100644
--- a/docs/phases/README.md
+++ b/docs/phases/README.md
@@ -65,6 +65,6 @@
| Фаза | Статус | Зачем |
| --- | --- | --- |
| [14. Каркас дня и каникулы](14-school-calendar.md) | ✅ | Звонки, длина недели, каникулы |
-| [15. Планировщик](15-timetable-planner.md) | ⬜ | Раскладка часов по слотам, четыре запрета |
+| [15. Планировщик](15-timetable-planner.md) | ✅ | Раскладка часов по слотам, четыре запрета |
| [16. Расписание в школе](16-timetable-in-school.md) | ⬜ | Сейв, снимок, «кто где сейчас» |
| [17. Расписание на экране](17-timetable-screen.md) | ⬜ | Скобки в дереве, сетка класса, расписание учителя |
diff --git a/h-school.sln b/h-school.sln
index e077706..50249d9 100644
--- a/h-school.sln
+++ b/h-school.sln
@@ -31,6 +31,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.People", "src\HScho
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.People.Tests", "tests\HSchool.People.Tests\HSchool.People.Tests.csproj", "{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Schedule", "src\HSchool.Schedule\HSchool.Schedule.csproj", "{734AC2F7-B243-4211-95CF-3E662D2E20C9}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Schedule.Tests", "tests\HSchool.Schedule.Tests\HSchool.Schedule.Tests.csproj", "{85FF85BD-F572-4F0A-A11E-FA855D480C1D}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -185,6 +189,30 @@ Global
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Release|x64.Build.0 = Release|Any CPU
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Release|x86.ActiveCfg = Release|Any CPU
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Release|x86.Build.0 = Release|Any CPU
+ {734AC2F7-B243-4211-95CF-3E662D2E20C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {734AC2F7-B243-4211-95CF-3E662D2E20C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {734AC2F7-B243-4211-95CF-3E662D2E20C9}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {734AC2F7-B243-4211-95CF-3E662D2E20C9}.Debug|x64.Build.0 = Debug|Any CPU
+ {734AC2F7-B243-4211-95CF-3E662D2E20C9}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {734AC2F7-B243-4211-95CF-3E662D2E20C9}.Debug|x86.Build.0 = Debug|Any CPU
+ {734AC2F7-B243-4211-95CF-3E662D2E20C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {734AC2F7-B243-4211-95CF-3E662D2E20C9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {734AC2F7-B243-4211-95CF-3E662D2E20C9}.Release|x64.ActiveCfg = Release|Any CPU
+ {734AC2F7-B243-4211-95CF-3E662D2E20C9}.Release|x64.Build.0 = Release|Any CPU
+ {734AC2F7-B243-4211-95CF-3E662D2E20C9}.Release|x86.ActiveCfg = Release|Any CPU
+ {734AC2F7-B243-4211-95CF-3E662D2E20C9}.Release|x86.Build.0 = Release|Any CPU
+ {85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Debug|x64.Build.0 = Debug|Any CPU
+ {85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Debug|x86.Build.0 = Debug|Any CPU
+ {85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Release|x64.ActiveCfg = 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.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -202,6 +230,8 @@ Global
{C59BD649-ED81-40EE-864D-9C7D5378B956} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{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}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {DD14EF4D-167E-4AC7-953A-AF606CC34829}
diff --git a/src/HSchool.Schedule/HSchool.Schedule.csproj b/src/HSchool.Schedule/HSchool.Schedule.csproj
new file mode 100644
index 0000000..7775b52
--- /dev/null
+++ b/src/HSchool.Schedule/HSchool.Schedule.csproj
@@ -0,0 +1,15 @@
+
+
+
+ HSchool.Schedule
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/HSchool.Schedule/Timetable.cs b/src/HSchool.Schedule/Timetable.cs
new file mode 100644
index 0000000..141a6e0
--- /dev/null
+++ b/src/HSchool.Schedule/Timetable.cs
@@ -0,0 +1,34 @@
+namespace HSchool.Schedule;
+
+/// A homeroom class the planner needs. Not a roster record — that lives in People.
+public sealed record PlannerClass(
+ string Id,
+ int Year,
+ string Letter,
+ string HomeroomId,
+ int PupilCount);
+
+/// Staff who can take lessons. Subjects are SubjectDef names already assigned.
+public sealed record PlannerTeacher(
+ string Id,
+ IReadOnlyList Subjects);
+
+///
+/// One placed lesson. is 0 = Monday. is the 1-based
+/// lesson number from the day frame. Locked lessons survive a rebuild.
+///
+public sealed record LessonPlacement(
+ string ClassId,
+ string Subject,
+ string TeacherId,
+ string RoomId,
+ int Day,
+ int Period,
+ bool Locked = false);
+
+/// Curriculum hours that could not be placed: no teacher, no room, or no free slot.
+public sealed record UncoveredDemand(string ClassId, string Subject, int Hours);
+
+public sealed record Timetable(
+ IReadOnlyList Lessons,
+ IReadOnlyList Uncovered);
diff --git a/src/HSchool.Schedule/TimetablePlanner.cs b/src/HSchool.Schedule/TimetablePlanner.cs
new file mode 100644
index 0000000..7ea664f
--- /dev/null
+++ b/src/HSchool.Schedule/TimetablePlanner.cs
@@ -0,0 +1,329 @@
+using HSchool.Content;
+
+namespace HSchool.Schedule;
+
+///
+/// Lays the curriculum onto the week. Deterministic: same catalog, map, staff, locks and week
+/// length always yield the same table. No clock, no world, no host.
+///
+public static class TimetablePlanner
+{
+ public static Timetable Build(
+ DefCatalog catalog,
+ MapLayout map,
+ IReadOnlyList classes,
+ IReadOnlyList teachers,
+ IReadOnlyList? locked = null,
+ int weekDays = 5)
+ {
+ ArgumentNullException.ThrowIfNull(catalog);
+ ArgumentNullException.ThrowIfNull(map);
+ ArgumentNullException.ThrowIfNull(classes);
+ ArgumentNullException.ThrowIfNull(teachers);
+ if (weekDays is < 5 or > 7)
+ {
+ throw new ArgumentOutOfRangeException(nameof(weekDays), weekDays, "School week must be 5–7 days.");
+ }
+
+ var frame = catalog.DayFrame;
+ var lessonCount = frame?.LessonCount ?? 0;
+ var rooms = map.Rooms.ToDictionary(room => room.Id, StringComparer.Ordinal);
+ var classById = classes.ToDictionary(item => item.Id, StringComparer.Ordinal);
+ var occupancy = new Occupancy();
+ var placed = new List();
+
+ foreach (var lesson in Ordered(locked ?? []))
+ {
+ if (TryKeepLock(catalog, rooms, classById, teachers, occupancy, weekDays, lessonCount, lesson))
+ {
+ occupancy.Add(lesson);
+ placed.Add(lesson with { Locked = true });
+ }
+ }
+
+ var remaining = RemainingHours(catalog, classes, placed);
+ foreach (var demand in remaining)
+ {
+ var leftover = PlaceDemand(
+ catalog,
+ rooms,
+ classById[demand.ClassId],
+ teachers,
+ occupancy,
+ weekDays,
+ lessonCount,
+ demand.Subject,
+ demand.Hours,
+ placed);
+
+ if (leftover > 0)
+ {
+ demand.Hours = leftover;
+ }
+ else
+ {
+ demand.Hours = 0;
+ }
+ }
+
+ var uncovered = remaining
+ .Where(row => row.Hours > 0)
+ .Select(row => new UncoveredDemand(row.ClassId, row.Subject, row.Hours))
+ .OrderBy(row => row.ClassId, StringComparer.Ordinal)
+ .ThenBy(row => row.Subject, StringComparer.Ordinal)
+ .ToArray();
+
+ var lessons = placed
+ .OrderBy(lesson => lesson.Day)
+ .ThenBy(lesson => lesson.Period)
+ .ThenBy(lesson => lesson.ClassId, StringComparer.Ordinal)
+ .ThenBy(lesson => lesson.Subject, StringComparer.Ordinal)
+ .ToArray();
+
+ return new Timetable(lessons, uncovered);
+ }
+
+ private sealed class Remaining
+ {
+ public required string ClassId { get; init; }
+
+ public required string Subject { get; init; }
+
+ public int Hours { get; set; }
+ }
+
+ private static List RemainingHours(
+ DefCatalog catalog,
+ IReadOnlyList classes,
+ IReadOnlyList placed)
+ {
+ var remaining = new List();
+ foreach (var schoolClass in classes.OrderBy(item => item.Year).ThenBy(item => item.Letter, StringComparer.Ordinal).ThenBy(item => item.Id, StringComparer.Ordinal))
+ {
+ foreach (var subject in catalog.Subjects.Values
+ .Where(def => !def.Abstract && def.HoursPerWeek > 0 && schoolClass.Year >= def.Grades.Min && schoolClass.Year <= def.Grades.Max)
+ .OrderBy(def => def.DefName, StringComparer.Ordinal))
+ {
+ var already = placed.Count(lesson =>
+ lesson.ClassId == schoolClass.Id && lesson.Subject == subject.DefName);
+ var hours = subject.HoursPerWeek - already;
+ if (hours > 0)
+ {
+ remaining.Add(new Remaining
+ {
+ ClassId = schoolClass.Id,
+ Subject = subject.DefName,
+ Hours = hours,
+ });
+ }
+ }
+ }
+
+ return remaining;
+ }
+
+ private static int PlaceDemand(
+ DefCatalog catalog,
+ IReadOnlyDictionary rooms,
+ PlannerClass schoolClass,
+ IReadOnlyList teachers,
+ Occupancy occupancy,
+ int weekDays,
+ int lessonCount,
+ string subjectName,
+ int hours,
+ List placed)
+ {
+ if (!catalog.Subjects.TryGetValue(subjectName, out var subject) || hours <= 0)
+ {
+ return hours;
+ }
+
+ var candidates = RoomsFor(catalog, rooms, schoolClass, subject);
+ if (candidates.Count == 0)
+ {
+ return hours;
+ }
+
+ var leftover = hours;
+ foreach (var teacher in TeachersFor(teachers, subjectName, placed))
+ {
+ while (leftover > 0 && TryPlaceOne(occupancy, schoolClass.Id, subjectName, teacher.Id, candidates, weekDays, lessonCount, placed))
+ {
+ leftover--;
+ }
+
+ if (leftover == 0)
+ {
+ break;
+ }
+ }
+
+ return leftover;
+ }
+
+ private static bool TryPlaceOne(
+ Occupancy occupancy,
+ string classId,
+ string subject,
+ string teacherId,
+ IReadOnlyList rooms,
+ int weekDays,
+ int lessonCount,
+ List placed)
+ {
+ for (var day = 0; day < weekDays; day++)
+ {
+ for (var period = 1; period <= lessonCount; period++)
+ {
+ foreach (var room in rooms)
+ {
+ if (!occupancy.IsFree(classId, teacherId, room.Id, day, period))
+ {
+ continue;
+ }
+
+ var lesson = new LessonPlacement(classId, subject, teacherId, room.Id, day, period);
+ occupancy.Add(lesson);
+ placed.Add(lesson);
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private static bool TryKeepLock(
+ DefCatalog catalog,
+ IReadOnlyDictionary rooms,
+ IReadOnlyDictionary classes,
+ IReadOnlyList teachers,
+ Occupancy occupancy,
+ int weekDays,
+ int lessonCount,
+ LessonPlacement lesson)
+ {
+ if (lesson.Day < 0 || lesson.Day >= weekDays || lesson.Period < 1 || lesson.Period > lessonCount)
+ {
+ return false;
+ }
+
+ if (!classes.TryGetValue(lesson.ClassId, out var schoolClass))
+ {
+ return false;
+ }
+
+ if (!catalog.Subjects.TryGetValue(lesson.Subject, out var subject)
+ || subject.Abstract
+ || schoolClass.Year < subject.Grades.Min
+ || schoolClass.Year > subject.Grades.Max)
+ {
+ return false;
+ }
+
+ if (!teachers.Any(teacher => teacher.Id == lesson.TeacherId && HasSubject(teacher, lesson.Subject)))
+ {
+ return false;
+ }
+
+ if (!rooms.TryGetValue(lesson.RoomId, out var room) || !RoomMatches(catalog, room, schoolClass, subject))
+ {
+ return false;
+ }
+
+ return occupancy.IsFree(lesson.ClassId, lesson.TeacherId, lesson.RoomId, lesson.Day, lesson.Period);
+ }
+
+ private static IReadOnlyList TeachersFor(
+ IReadOnlyList teachers,
+ string subject,
+ IReadOnlyList placed)
+ {
+ return teachers
+ .Where(teacher => HasSubject(teacher, subject))
+ .OrderBy(teacher => placed.Count(lesson => lesson.TeacherId == teacher.Id))
+ .ThenBy(teacher => teacher.Id, StringComparer.Ordinal)
+ .ToArray();
+ }
+
+ private static bool HasSubject(PlannerTeacher teacher, string subject) =>
+ teacher.Subjects.Contains(subject, StringComparer.Ordinal);
+
+ private static IReadOnlyList RoomsFor(
+ DefCatalog catalog,
+ IReadOnlyDictionary rooms,
+ PlannerClass schoolClass,
+ SubjectDef subject)
+ {
+ if (!string.IsNullOrWhiteSpace(subject.Room))
+ {
+ return rooms.Values
+ .Where(room => RoomMatches(catalog, room, schoolClass, subject))
+ .OrderBy(room => room.Id, StringComparer.Ordinal)
+ .ToArray();
+ }
+
+ if (rooms.TryGetValue(schoolClass.HomeroomId, out var homeroom)
+ && RoomMatches(catalog, homeroom, schoolClass, subject))
+ {
+ return [homeroom];
+ }
+
+ return [];
+ }
+
+ private static bool RoomMatches(DefCatalog catalog, RoomNode room, PlannerClass schoolClass, SubjectDef subject)
+ {
+ if (!string.IsNullOrWhiteSpace(subject.Room))
+ {
+ if (!room.Def.Equals(subject.Room, StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+ else if (!room.Id.Equals(schoolClass.HomeroomId, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return ClassFits(catalog, room, schoolClass.PupilCount);
+ }
+
+ ///
+ /// A room with no seated pupil slots (the gym) still hosts a class. A lab with twelve
+ /// computers does not host sixteen pupils.
+ ///
+ private static bool ClassFits(DefCatalog catalog, RoomNode room, int pupilCount)
+ {
+ var seats = RoomOccupancy.PupilSlots(catalog, room);
+ return seats == 0 || pupilCount <= seats;
+ }
+
+ private static IEnumerable Ordered(IEnumerable lessons) =>
+ lessons
+ .OrderBy(lesson => lesson.Day)
+ .ThenBy(lesson => lesson.Period)
+ .ThenBy(lesson => lesson.ClassId, StringComparer.Ordinal)
+ .ThenBy(lesson => lesson.Subject, StringComparer.Ordinal)
+ .ThenBy(lesson => lesson.RoomId, StringComparer.Ordinal);
+
+ private sealed class Occupancy
+ {
+ private readonly HashSet<(string Id, int Day, int Period)> _classes = [];
+ private readonly HashSet<(string Id, int Day, int Period)> _teachers = [];
+ private readonly HashSet<(string Id, int Day, int Period)> _rooms = [];
+
+ public bool IsFree(string classId, string teacherId, string roomId, int day, int period) =>
+ !_classes.Contains((classId, day, period))
+ && !_teachers.Contains((teacherId, day, period))
+ && !_rooms.Contains((roomId, day, period));
+
+ public void Add(LessonPlacement lesson)
+ {
+ _classes.Add((lesson.ClassId, lesson.Day, lesson.Period));
+ _teachers.Add((lesson.TeacherId, lesson.Day, lesson.Period));
+ _rooms.Add((lesson.RoomId, lesson.Day, lesson.Period));
+ }
+ }
+}
diff --git a/tests/HSchool.Schedule.Tests/Fixtures.cs b/tests/HSchool.Schedule.Tests/Fixtures.cs
new file mode 100644
index 0000000..fd397f0
--- /dev/null
+++ b/tests/HSchool.Schedule.Tests/Fixtures.cs
@@ -0,0 +1,112 @@
+using HSchool.Content;
+
+namespace HSchool.Schedule.Tests;
+
+internal static class PackDocuments
+{
+ public static IReadOnlyList FromDirectory(string packId, string packRoot)
+ {
+ var documents = new List();
+ foreach (var path in Directory.EnumerateFiles(packRoot, "*.*", SearchOption.AllDirectories))
+ {
+ if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
+ && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var relative = Path.GetRelativePath(packRoot, path).Replace('\\', '/');
+ documents.Add(new ContentDocument(packId, relative, File.ReadAllText(path)));
+ }
+
+ return documents;
+ }
+}
+
+internal static class Fixtures
+{
+ public static DefCatalog Catalog()
+ {
+ var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
+ return new CatalogLoader().Load(
+ [CatalogLoader.CorePackId],
+ PackDocuments.FromDirectory(CatalogLoader.CorePackId, root));
+ }
+
+ public static MapLayout ClassroomsAndGym(int classes, int desks = 16)
+ {
+ var rooms = new List(classes + 1);
+ for (var i = 0; i < classes; i++)
+ {
+ rooms.Add(Classroom(i, desks));
+ }
+
+ rooms.Add(new RoomNode
+ {
+ Id = "gym-hall",
+ Def = "GymHall",
+ Building = "gym",
+ Floor = "gym-floor",
+ Slots = [new SlotFill { Key = "benches", Thing = "Bench", Count = 4 }],
+ });
+
+ return new MapLayout { Rooms = rooms };
+ }
+
+ public static MapLayout ClassroomAndLab(int desks = 16, int computers = 12)
+ {
+ return new MapLayout
+ {
+ Rooms =
+ [
+ Classroom(0, desks),
+ new RoomNode
+ {
+ Id = "computer-lab",
+ Def = "ComputerLab",
+ Building = "main",
+ Floor = "floor-1",
+ Slots =
+ [
+ new SlotFill { Key = "teacherDesk", Thing = "Desk", Count = 1 },
+ new SlotFill { Key = "teacherChair", Thing = "Chair", Count = 1 },
+ new SlotFill { Key = "computers", Thing = "Computer", Count = computers },
+ ],
+ },
+ ],
+ };
+ }
+
+ public static PlannerClass Class(int index, int year, int pupils, string letter = "A") =>
+ new($"y{year}{letter}", year, letter, $"classroom-{index:00}", pupils);
+
+ public static PlannerTeacher Teacher(string id, params string[] subjects) =>
+ new(id, subjects);
+
+ public static RoomNode Classroom(int index, int desks) =>
+ new()
+ {
+ Id = $"classroom-{index:00}",
+ Def = "Classroom",
+ Building = "main",
+ Floor = "floor-1",
+ Label = $"{101 + index}",
+ Seats = desks,
+ };
+
+ public static string RepoRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "h-school.sln")))
+ {
+ dir = dir.Parent;
+ }
+
+ if (dir is null)
+ {
+ throw new InvalidOperationException("Could not find h-school.sln from the test output directory.");
+ }
+
+ return dir.FullName;
+ }
+}
diff --git a/tests/HSchool.Schedule.Tests/HSchool.Schedule.Tests.csproj b/tests/HSchool.Schedule.Tests/HSchool.Schedule.Tests.csproj
new file mode 100644
index 0000000..fd0ad00
--- /dev/null
+++ b/tests/HSchool.Schedule.Tests/HSchool.Schedule.Tests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ HSchool.Schedule.Tests
+ true
+ Exe
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ vanilla\%(RecursiveDir)%(Filename)%(Extension)
+ PreserveNewest
+
+
+
+
diff --git a/tests/HSchool.Schedule.Tests/TimetablePlannerTests.cs b/tests/HSchool.Schedule.Tests/TimetablePlannerTests.cs
new file mode 100644
index 0000000..4b079bb
--- /dev/null
+++ b/tests/HSchool.Schedule.Tests/TimetablePlannerTests.cs
@@ -0,0 +1,155 @@
+using HSchool.Content;
+
+namespace HSchool.Schedule.Tests;
+
+public class TimetablePlannerTests
+{
+ private readonly DefCatalog _catalog = Fixtures.Catalog();
+
+ [Fact]
+ public void OneGym_SpreadsPhysicalEducationAcrossSlots()
+ {
+ var classes = Enumerable.Range(1, 11).Select(year => Fixtures.Class(year - 1, year, pupils: 16)).ToArray();
+ var map = Fixtures.ClassroomsAndGym(11);
+ var teachers = new[] { Fixtures.Teacher("pe", "PhysicalEducation") };
+
+ var table = TimetablePlanner.Build(_catalog, map, classes, teachers);
+
+ var pe = table.Lessons.Where(lesson => lesson.Subject == "PhysicalEducation").ToArray();
+ Assert.Equal(11 * 3, pe.Length);
+ Assert.All(pe, lesson => Assert.Equal("gym-hall", lesson.RoomId));
+ Assert.Equal(pe.Length, pe.Select(lesson => (lesson.Day, lesson.Period)).Distinct().Count());
+ Assert.All(pe, lesson => Assert.Equal("pe", lesson.TeacherId));
+ }
+
+ [Fact]
+ public void TeacherWithTwoSubjects_IsNeverInTwoRoomsAtOnce()
+ {
+ var classes = new[]
+ {
+ Fixtures.Class(0, year: 5, pupils: 16, letter: "A"),
+ Fixtures.Class(1, year: 5, pupils: 16, letter: "B"),
+ };
+ var map = Fixtures.ClassroomsAndGym(2);
+ var teachers = new[] { Fixtures.Teacher("t1", "Mathematics", "PhysicalEducation") };
+
+ var table = TimetablePlanner.Build(_catalog, map, classes, teachers);
+
+ Assert.Contains(table.Lessons, lesson => lesson.Subject == "Mathematics");
+ Assert.Contains(table.Lessons, lesson => lesson.Subject == "PhysicalEducation");
+ Assert.Equal(
+ table.Lessons.Count,
+ table.Lessons.Select(lesson => (lesson.TeacherId, lesson.Day, lesson.Period)).Distinct().Count());
+ Assert.Equal(
+ table.Lessons.Count,
+ table.Lessons.Select(lesson => (lesson.ClassId, lesson.Day, lesson.Period)).Distinct().Count());
+ Assert.Equal(
+ table.Lessons.Count,
+ table.Lessons.Select(lesson => (lesson.RoomId, lesson.Day, lesson.Period)).Distinct().Count());
+ }
+
+ [Fact]
+ public void ClassOfSixteen_DoesNotEnterALabOfTwelve()
+ {
+ var classes = new[] { Fixtures.Class(0, year: 5, pupils: 16) };
+ var map = Fixtures.ClassroomAndLab(desks: 16, computers: 12);
+ var teachers = new[] { Fixtures.Teacher("inf", "Informatics") };
+
+ var table = TimetablePlanner.Build(_catalog, map, classes, teachers);
+
+ Assert.DoesNotContain(table.Lessons, lesson => lesson.Subject == "Informatics");
+ Assert.Contains(table.Uncovered, row => row.ClassId == "y5A" && row.Subject == "Informatics" && row.Hours == 1);
+ }
+
+ [Fact]
+ public void SubjectWithoutATeacher_IsUncoveredAndLeavesAGap()
+ {
+ var classes = new[] { Fixtures.Class(0, year: 5, pupils: 16) };
+ var map = Fixtures.ClassroomsAndGym(1);
+ var teachers = new[] { Fixtures.Teacher("pe", "PhysicalEducation") };
+
+ var table = TimetablePlanner.Build(_catalog, map, classes, teachers);
+
+ Assert.DoesNotContain(table.Lessons, lesson => lesson.Subject == "Mathematics");
+ Assert.Contains(table.Uncovered, row => row.ClassId == "y5A" && row.Subject == "Mathematics" && row.Hours == 5);
+ Assert.Equal(3, table.Lessons.Count(lesson => lesson.Subject == "PhysicalEducation"));
+ }
+
+ [Fact]
+ public void LockedLesson_SurvivesHiringAnotherTeacher()
+ {
+ var classes = new[] { Fixtures.Class(0, year: 5, pupils: 16) };
+ var map = Fixtures.ClassroomsAndGym(1);
+ var first = TimetablePlanner.Build(
+ _catalog,
+ map,
+ classes,
+ [Fixtures.Teacher("t1", "Mathematics")]);
+
+ var pinned = first.Lessons.First(lesson => lesson.Subject == "Mathematics");
+ var locked = pinned with { Locked = true };
+
+ var rebuilt = TimetablePlanner.Build(
+ _catalog,
+ map,
+ classes,
+ [Fixtures.Teacher("t1", "Mathematics"), Fixtures.Teacher("t2", "Mathematics")],
+ [locked]);
+
+ Assert.Contains(
+ rebuilt.Lessons,
+ lesson =>
+ lesson.ClassId == locked.ClassId
+ && lesson.Subject == locked.Subject
+ && lesson.TeacherId == locked.TeacherId
+ && lesson.RoomId == locked.RoomId
+ && lesson.Day == locked.Day
+ && lesson.Period == locked.Period
+ && lesson.Locked);
+ }
+
+ [Fact]
+ public void SameInputs_YieldTheSameTable()
+ {
+ var classes = Enumerable.Range(1, 11).Select(year => Fixtures.Class(year - 1, year, pupils: 16)).ToArray();
+ var map = Fixtures.ClassroomsAndGym(11);
+ var teachers = new[]
+ {
+ Fixtures.Teacher("pe", "PhysicalEducation"),
+ Fixtures.Teacher("math", "Mathematics"),
+ };
+
+ var a = TimetablePlanner.Build(_catalog, map, classes, teachers);
+ var b = TimetablePlanner.Build(_catalog, map, classes, teachers);
+
+ Assert.Equal(Snapshot(a), Snapshot(b));
+ }
+
+ [Fact]
+ public void Assembly_DoesNotReferenceArchAspNetOrSockets()
+ {
+ var names = typeof(TimetablePlanner).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.Schedule");
+ 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);
+ }
+ }
+
+ private static string Snapshot(Timetable table) =>
+ string.Join(
+ '\n',
+ table.Lessons.Select(lesson =>
+ $"{lesson.Day}/{lesson.Period} {lesson.ClassId} {lesson.Subject} {lesson.TeacherId} {lesson.RoomId} {(lesson.Locked ? "L" : "")}")
+ .Concat(table.Uncovered.Select(row => $"U {row.ClassId} {row.Subject} {row.Hours}")));
+}