- Updated `protocol.md` to clarify the concept of uncovered subjects and the number of additional teachers required. - Introduced `teachersShort` property in the `StaffingSubject` interface to indicate how many more teachers are needed for a subject. - Enhanced localization strings to reflect the new uncovered teacher information in both English and Russian. - Updated the management panel UI to display the number of teachers short for each uncovered subject. - Revised the `Uncovered` method in the staffing logic to return detailed information about uncovered subjects, including the shortfall of teachers. - Added tests to validate the new uncovered teacher tracking functionality and ensure accurate reporting in the staffing API. - Marked related tasks as complete in the documentation for the golden fixtures phase.
463 lines
15 KiB
C#
463 lines
15 KiB
C#
using HSchool.Content;
|
|
|
|
namespace HSchool.People;
|
|
|
|
public enum StaffingError
|
|
{
|
|
None,
|
|
UnknownSchool,
|
|
UnknownApplicant,
|
|
AlreadyHired,
|
|
UnknownPosition,
|
|
UnknownSubject,
|
|
NotStaff,
|
|
NotTeacher,
|
|
AlreadyAssigned,
|
|
SubjectNotAssigned,
|
|
PayrollExceeded,
|
|
NoOpening,
|
|
}
|
|
|
|
/// <summary>
|
|
/// A subject the school cannot actually teach, and how many more teachers would cover it.
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>
|
|
/// Hire, subject assignment and the monthly payroll cap. Scale lives on <see cref="StaffingDef"/>;
|
|
/// the cap is supplied by the caller because it is a simulation option, not catalog data.
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>What one full rate costs at this price per hour — the floor under any hire.</summary>
|
|
public static float MonthlyBase(StaffingDef rules, float hourlyAsk) =>
|
|
Round(hourlyAsk * rules.BaseWeeklyHours * rules.WeeksPerMonth);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static float MonthlyPay(StaffingDef rules, float hourlyAsk, float weeklyHours) =>
|
|
Round(hourlyAsk * Math.Max(weeklyHours, rules.BaseWeeklyHours) * rules.WeeksPerMonth);
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="StaffingDef.MaxWeeklyHours"/>. 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
|
|
/// <see cref="Uncovered"/> is what reports that one person cannot really teach all of it.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Curriculum hours a subject needs each week across every class that studies it.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static int TeachersToCover(float hours, float maxWeeklyHours)
|
|
{
|
|
if (hours <= 0f)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
if (maxWeeklyHours <= 0f)
|
|
{
|
|
return 1;
|
|
}
|
|
|
|
return (int)Math.Ceiling(hours / maxWeeklyHours);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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. <see cref="UncoveredSubject.TeachersShort"/>
|
|
/// is how many more people it still needs.
|
|
/// </summary>
|
|
public static IReadOnlyList<UncoveredSubject> Uncovered(DefCatalog catalog, Roster roster)
|
|
{
|
|
var years = new HashSet<int>();
|
|
foreach (var schoolClass in roster.Classes)
|
|
{
|
|
years.Add(schoolClass.Year);
|
|
}
|
|
|
|
var teachers = new Dictionary<string, int>(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<UncoveredSubject>();
|
|
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<int> 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<string>(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 };
|
|
}
|
|
}
|