Files
h-school/src/HSchool.People/Staffing.cs
T
Leonid Pershin e5a0ae5b9d
ci / server (push) Failing after 3m43s
ci / client (push) Successful in 14s
Enhance staffing management with uncovered teacher tracking
- 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.
2026-08-19 23:53:37 +03:00

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