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;
}
}
}