Add timetable planning and related tests to school simulation
ci / server (push) Failing after 3m33s
ci / client (push) Successful in 13s

- Introduced a new project for timetable planning, dependent on HSchool.Content.
- Updated documentation to reflect the addition of timetable planning and its dependencies.
- Added tests for timetable planning to ensure deterministic behavior with the same staff and map.
- Revised architecture documentation to include the new HSchool.Schedule component and its interactions.
This commit is contained in:
Leonid Pershin
2026-08-19 00:55:02 +03:00
parent 2ebc783585
commit 2011d12b1d
12 changed files with 731 additions and 16 deletions
+329
View File
@@ -0,0 +1,329 @@
using HSchool.Content;
namespace HSchool.Schedule;
/// <summary>
/// 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.
/// </summary>
public static class TimetablePlanner
{
public static Timetable Build(
DefCatalog catalog,
MapLayout map,
IReadOnlyList<PlannerClass> classes,
IReadOnlyList<PlannerTeacher> teachers,
IReadOnlyList<LessonPlacement>? 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 57 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<LessonPlacement>();
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<Remaining> RemainingHours(
DefCatalog catalog,
IReadOnlyList<PlannerClass> classes,
IReadOnlyList<LessonPlacement> placed)
{
var remaining = new List<Remaining>();
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<string, RoomNode> rooms,
PlannerClass schoolClass,
IReadOnlyList<PlannerTeacher> teachers,
Occupancy occupancy,
int weekDays,
int lessonCount,
string subjectName,
int hours,
List<LessonPlacement> 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<RoomNode> rooms,
int weekDays,
int lessonCount,
List<LessonPlacement> 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<string, RoomNode> rooms,
IReadOnlyDictionary<string, PlannerClass> classes,
IReadOnlyList<PlannerTeacher> 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<PlannerTeacher> TeachersFor(
IReadOnlyList<PlannerTeacher> teachers,
string subject,
IReadOnlyList<LessonPlacement> 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<RoomNode> RoomsFor(
DefCatalog catalog,
IReadOnlyDictionary<string, RoomNode> 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);
}
/// <summary>
/// A room with no seated pupil slots (the gym) still hosts a class. A lab with twelve
/// computers does not host sixteen pupils.
/// </summary>
private static bool ClassFits(DefCatalog catalog, RoomNode room, int pupilCount)
{
var seats = RoomOccupancy.PupilSlots(catalog, room);
return seats == 0 || pupilCount <= seats;
}
private static IEnumerable<LessonPlacement> Ordered(IEnumerable<LessonPlacement> 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));
}
}
}