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); demand.Hours = leftover; } 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; } /// /// Picks the slot, and the order days are tried in is the whole difference between a timetable /// and a pile of lessons. Scanning day 0 upwards and taking the first free slot produced blocks /// of the same subject and a week that ran out by Thursday: Monday held 77 lessons, Friday 16. /// /// So days are ranked instead: first those that do not have this subject yet, then the emptiest /// one for this class. Periods stay ascending inside the chosen day, so a day has no holes. /// private static bool TryPlaceOne( Occupancy occupancy, string classId, string subject, string teacherId, IReadOnlyList rooms, int weekDays, int lessonCount, List placed) { var days = new int[weekDays]; for (var day = 0; day < weekDays; day++) { days[day] = day; } Array.Sort(days, (left, right) => { var bySubject = occupancy.SubjectLoad(classId, subject, left) .CompareTo(occupancy.SubjectLoad(classId, subject, right)); if (bySubject != 0) { return bySubject; } var byDay = occupancy.DayLoad(classId, left).CompareTo(occupancy.DayLoad(classId, right)); return byDay != 0 ? byDay : left.CompareTo(right); }); foreach (var day in days) { 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 = []; private readonly Dictionary<(string ClassId, int Day), int> _dayLoad = []; private readonly Dictionary<(string ClassId, string Subject, int Day), int> _subjectLoad = []; 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)); /// How many lessons this class already has that day — counted, not recomputed. public int DayLoad(string classId, int day) => _dayLoad.TryGetValue((classId, day), out var count) ? count : 0; /// How many lessons of this subject this class already has that day. public int SubjectLoad(string classId, string subject, int day) => _subjectLoad.TryGetValue((classId, subject, day), out var count) ? count : 0; 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)); var day = (lesson.ClassId, lesson.Day); _dayLoad[day] = (_dayLoad.TryGetValue(day, out var lessons) ? lessons : 0) + 1; var subject = (lesson.ClassId, lesson.Subject, lesson.Day); _subjectLoad[subject] = (_subjectLoad.TryGetValue(subject, out var same) ? same : 0) + 1; } } }