using HSchool.Content; namespace HSchool.People; public enum StaffingError { None, UnknownSchool, UnknownApplicant, AlreadyHired, UnknownPosition, UnknownSubject, NotStaff, NotTeacher, AlreadyAssigned, SubjectNotAssigned, PayrollExceeded, NoOpening, } /// /// A subject the school cannot actually teach, and how many more teachers would cover it. /// public sealed record UncoveredSubject(SubjectDef Subject, int Assigned, int TeachersShort) { public string DefName => Subject.DefName; } public sealed record StaffingOutcome( StaffingError Error, Roster Roster, ApplicantPool Pool, float Allocated, float Payroll, float Remaining, float Attempted); /// /// Hire, subject assignment and the monthly payroll cap. Scale lives on ; /// the cap is supplied by the caller because it is a simulation option, not catalog data. /// public static class Staffing { public const string TeacherPosition = "Teacher"; public static StaffingOutcome UnknownSchool() => Fail(StaffingError.UnknownSchool, new Roster([], [], []), ApplicantPool.Empty, allocated: 0, payroll: 0, attempted: 0); /// What one full rate costs at this price per hour — the floor under any hire. public static float MonthlyBase(StaffingDef rules, float hourlyAsk) => Round(hourlyAsk * rules.BaseWeeklyHours * rules.WeeksPerMonth); /// /// Price of an hour times the hours actually carried, never below one full rate. A hired /// person costs something even with nothing assigned; loading them past the rate costs more. /// public static float MonthlyPay(StaffingDef rules, float hourlyAsk, float weeklyHours) => Round(hourlyAsk * Math.Max(weeklyHours, rules.BaseWeeklyHours) * rules.WeeksPerMonth); /// /// Weekly hours this person carries: for every subject assigned to them, the curriculum hours /// of that subject across the classes that study it, split between everyone teaching it. /// /// Deliberately not capped at . Refusing the /// assignment would deadlock — the first teacher of a subject always inherits all of its /// hours, so nobody could ever be the first. Capping what is paid would be worse: piling a /// seventh subject onto someone already at the cap would cost nothing, which is the loophole /// the hourly model exists to close. So the hours count in full, the money says so, and /// is what reports that one person cannot really teach all of it. /// public static float WeeklyHours(DefCatalog catalog, Roster roster, Person person) { ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(roster); ArgumentNullException.ThrowIfNull(person); if (person.Subjects.Count == 0) { return 0f; } var hours = 0f; foreach (var subject in person.Subjects) { hours += SubjectHours(catalog, roster, subject) / Math.Max(1, TeachersOf(roster, subject)); } return hours; } /// Curriculum hours a subject needs each week across every class that studies it. public static float SubjectHours(DefCatalog catalog, Roster roster, string subject) { if (!catalog.Subjects.TryGetValue(subject, out var def) || def.Abstract) { return 0f; } var classes = 0; foreach (var schoolClass in roster.Classes) { if (schoolClass.Year >= def.Grades.Min && schoolClass.Year <= def.Grades.Max) { classes++; } } return def.HoursPerWeek * classes; } private static int TeachersOf(Roster roster, string subject) { var count = 0; foreach (var person in roster.People) { if (person.IsStaff && person.Subjects.Contains(subject, StringComparer.Ordinal)) { count++; } } return count; } public static float Payroll(DefCatalog catalog, StaffingDef rules, Roster roster) { var total = 0f; foreach (var person in roster.People) { if (!person.IsStaff || person.HourlyWageAsk is not { } ask) { continue; } total += MonthlyPay(rules, ask, WeeklyHours(catalog, roster, person)); } return Round(total); } /// /// How many people a subject needs at the weekly cap. Primary school on a vanilla map is /// eighty hours: one person cannot carry it, and the uncovered row has to say so. /// public static int TeachersToCover(float hours, float maxWeeklyHours) { if (hours <= 0f) { return 0; } if (maxWeeklyHours <= 0f) { return 1; } return (int)Math.Ceiling(hours / maxWeeklyHours); } /// /// Subjects the school cannot actually teach: nobody is assigned them, or the people who are /// cannot between them carry the curriculum hours. Both read the same way to a player — the /// lessons will not happen — so both belong in one list. /// is how many more people it still needs. /// public static IReadOnlyList Uncovered(DefCatalog catalog, Roster roster) { var years = new HashSet(); foreach (var schoolClass in roster.Classes) { years.Add(schoolClass.Year); } var teachers = new Dictionary(StringComparer.Ordinal); foreach (var person in roster.People) { if (!person.IsStaff) { continue; } foreach (var subject in person.Subjects) { teachers[subject] = teachers.TryGetValue(subject, out var count) ? count + 1 : 1; } } var cap = catalog.StaffingRules?.MaxWeeklyHours ?? 0f; var uncovered = new List(); foreach (var subject in catalog.Subjects.Values) { if (subject.Abstract || !TouchesYears(subject, years)) { continue; } var assigned = teachers.GetValueOrDefault(subject.DefName); var hours = SubjectHours(catalog, roster, subject.DefName); var needed = TeachersToCover(hours, cap); var shortfall = Math.Max(0, needed - assigned); if (shortfall > 0) { uncovered.Add(new UncoveredSubject(subject, assigned, shortfall)); } } return uncovered; } public static StaffingOutcome Hire( DefCatalog catalog, MapLayout? map, Roster roster, ApplicantPool pool, string personId, string position, float allocated) { var rules = RequireRules(catalog); var payroll = Payroll(catalog, rules, roster); if (roster.People.Any(person => person.Id.Equals(personId, StringComparison.Ordinal) && person.IsStaff)) { return Fail(StaffingError.AlreadyHired, roster, pool, allocated, payroll, payroll); } var applicant = pool.Applicants.FirstOrDefault(candidate => candidate.Person.Id.Equals(personId, StringComparison.Ordinal)); if (applicant is null) { return Fail(StaffingError.UnknownApplicant, roster, pool, allocated, payroll, payroll); } if (!IsConcretePosition(catalog, position)) { return Fail(StaffingError.UnknownPosition, roster, pool, allocated, payroll, payroll); } if (!TryWorkplace(catalog, map, roster, position, out var workplace)) { return Fail(StaffingError.NoOpening, roster, pool, allocated, payroll, payroll); } var hired = applicant.Person with { IsStaff = true, Position = position, WorkplaceRoomId = workplace, HourlyWageAsk = applicant.HourlyWageAsk, Subjects = [], }; // Priced on the resulting roster, not by adding one salary: hours are shared between the // teachers of a subject, so one person joining can change what the others cost. var nextRoster = UpsertStaff(roster, hired); var attempted = Payroll(catalog, rules, nextRoster); if (attempted > allocated) { return Fail(StaffingError.PayrollExceeded, roster, pool, allocated, payroll, attempted); } var nextPool = pool.Without(personId); return Ok(nextRoster, nextPool, allocated, attempted); } public static StaffingOutcome AssignSubject( DefCatalog catalog, Roster roster, ApplicantPool pool, string personId, string subject, float allocated) { var rules = RequireRules(catalog); var payroll = Payroll(catalog, rules, roster); var index = IndexOf(roster, personId); if (index < 0) { return Fail(StaffingError.NotStaff, roster, pool, allocated, payroll, payroll); } var person = roster.People[index]; if (!person.IsStaff) { return Fail(StaffingError.NotStaff, roster, pool, allocated, payroll, payroll); } if (!TeacherPosition.Equals(person.Position, StringComparison.Ordinal)) { return Fail(StaffingError.NotTeacher, roster, pool, allocated, payroll, payroll); } if (!IsConcreteSubject(catalog, subject)) { return Fail(StaffingError.UnknownSubject, roster, pool, allocated, payroll, payroll); } if (person.Subjects.Contains(subject, StringComparer.Ordinal)) { return Fail(StaffingError.AlreadyAssigned, roster, pool, allocated, payroll, payroll); } var ask = person.HourlyWageAsk ?? ApplicantPool.HourlyAsk(catalog, person); var nextSubjects = person.Subjects.Append(subject).ToArray(); var nextRoster = Replace(roster, index, person with { HourlyWageAsk = ask, Subjects = nextSubjects }); var attempted = Payroll(catalog, rules, nextRoster); if (attempted > allocated) { return Fail(StaffingError.PayrollExceeded, roster, pool, allocated, payroll, attempted); } return Ok(nextRoster, pool, allocated, attempted); } public static StaffingOutcome UnassignSubject( DefCatalog catalog, Roster roster, ApplicantPool pool, string personId, string subject, float allocated) { var rules = RequireRules(catalog); var payroll = Payroll(catalog, rules, roster); var index = IndexOf(roster, personId); if (index < 0) { return Fail(StaffingError.NotStaff, roster, pool, allocated, payroll, payroll); } var person = roster.People[index]; if (!person.IsStaff) { return Fail(StaffingError.NotStaff, roster, pool, allocated, payroll, payroll); } if (!person.Subjects.Contains(subject, StringComparer.Ordinal)) { return Fail(StaffingError.SubjectNotAssigned, roster, pool, allocated, payroll, payroll); } var nextSubjects = person.Subjects.Where(name => !name.Equals(subject, StringComparison.Ordinal)).ToArray(); var nextRoster = Replace(roster, index, person with { Subjects = nextSubjects }); return Ok(nextRoster, pool, allocated, Payroll(catalog, rules, nextRoster)); } private static StaffingOutcome Ok(Roster roster, ApplicantPool pool, float allocated, float payroll) => new(StaffingError.None, roster, pool, allocated, payroll, Remaining(allocated, payroll), payroll); private static StaffingOutcome Fail( StaffingError error, Roster roster, ApplicantPool pool, float allocated, float payroll, float attempted) => new(error, roster, pool, allocated, payroll, Remaining(allocated, payroll), attempted); private static float Remaining(float allocated, float payroll) => Round(MathF.Max(0f, allocated - payroll)); private static float Round(float value) => MathF.Round(value, 2); private static StaffingDef RequireRules(DefCatalog catalog) => catalog.StaffingRules ?? throw new InvalidOperationException("The catalog has no StaffingDef."); private static bool IsConcretePosition(DefCatalog catalog, string position) => catalog.Positions.TryGetValue(position, out var def) && !def.Abstract; private static bool IsConcreteSubject(DefCatalog catalog, string subject) => catalog.Subjects.TryGetValue(subject, out var def) && !def.Abstract; private static bool TouchesYears(SubjectDef subject, HashSet years) { for (var year = subject.Grades.Min; year <= subject.Grades.Max; year++) { if (years.Contains(year)) { return true; } } return false; } private static bool TryWorkplace( DefCatalog catalog, MapLayout? map, Roster roster, string position, out string? workplace) { workplace = null; if (TeacherPosition.Equals(position, StringComparison.Ordinal)) { return true; } if (map is null) { return false; } var taken = new HashSet(StringComparer.Ordinal); foreach (var person in roster.People) { if (person.IsStaff && position.Equals(person.Position, StringComparison.Ordinal) && person.WorkplaceRoomId is { } roomId) { taken.Add(roomId); } } foreach (var opening in SchoolDemand.From(catalog, map).Staff) { if (opening.Position.Equals(position, StringComparison.Ordinal) && taken.Add(opening.RoomId)) { workplace = opening.RoomId; return true; } } return false; } private static int IndexOf(Roster roster, string personId) { for (var i = 0; i < roster.People.Count; i++) { if (roster.People[i].Id.Equals(personId, StringComparison.Ordinal)) { return i; } } return -1; } private static Roster Replace(Roster roster, int index, Person person) { var people = roster.People.ToArray(); people[index] = person; return roster with { People = people }; } private static Roster UpsertStaff(Roster roster, Person hired) { var index = IndexOf(roster, hired.Id); if (index >= 0) { return Replace(roster, index, hired); } var people = roster.People.Append(hired).ToArray(); if (roster.Families.Any(family => family.Id.Equals(hired.FamilyId, StringComparison.Ordinal))) { return roster with { People = people }; } var families = roster.Families.Append(new Family(hired.FamilyId, [hired.Id], [])).ToArray(); return roster with { People = people, Families = families }; } }