Update staffing management to enhance payroll and hiring functionalities
ci / server (push) Failing after 3m39s
ci / client (push) Successful in 14s

- Increased the `MonthlyPayrollCap` from 10,000 to 40,000, allowing for greater flexibility in hiring and subject assignments.
- Revised payroll calculation logic to ensure that staff members are compensated based on their actual weekly hours, with a minimum payment reflecting one full rate.
- Updated documentation to clarify the new payroll structure and its implications for hiring and subject assignments.
- Enhanced tests to validate the new payroll cap and ensure proper functionality in staffing scenarios, including the handling of uncovered subjects.
- Improved localization strings to reflect changes in staffing and payroll terminology.
This commit is contained in:
Leonid Pershin
2026-08-19 12:51:38 +03:00
parent d61240d5c1
commit 25ce26064d
20 changed files with 376 additions and 112 deletions
+103 -29
View File
@@ -38,31 +38,103 @@ public static class Staffing
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);
public static float MonthlyPay(StaffingDef rules, float hourlyAsk, int subjectCount)
/// <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)
{
var extras = Math.Max(0, subjectCount - 1);
return Round(MonthlyBase(rules, hourlyAsk) * (1f + (rules.ExtraSubjectSurcharge * extras)));
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;
}
public static float Payroll(StaffingDef rules, IEnumerable<Person> people)
/// <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 people)
foreach (var person in roster.People)
{
if (!person.IsStaff || person.HourlyWageAsk is not { } ask)
{
continue;
}
total += MonthlyPay(rules, ask, person.Subjects.Count);
total += MonthlyPay(rules, ask, WeeklyHours(catalog, roster, person));
}
return Round(total);
}
/// <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.
/// </summary>
public static IReadOnlyList<SubjectDef> Uncovered(DefCatalog catalog, Roster roster)
{
var years = new HashSet<int>();
@@ -71,7 +143,7 @@ public static class Staffing
years.Add(schoolClass.Year);
}
var taught = new HashSet<string>(StringComparer.Ordinal);
var teachers = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var person in roster.People)
{
if (!person.IsStaff)
@@ -81,19 +153,24 @@ public static class Staffing
foreach (var subject in person.Subjects)
{
taught.Add(subject);
teachers[subject] = teachers.TryGetValue(subject, out var count) ? count + 1 : 1;
}
}
var cap = catalog.StaffingRules?.MaxWeeklyHours ?? 0f;
var uncovered = new List<SubjectDef>();
foreach (var subject in catalog.Subjects.Values)
{
if (subject.Abstract || !TouchesYears(subject, years) || taught.Contains(subject.DefName))
if (subject.Abstract || !TouchesYears(subject, years))
{
continue;
}
uncovered.Add(subject);
var assigned = teachers.GetValueOrDefault(subject.DefName);
if (assigned == 0 || (cap > 0f && SubjectHours(catalog, roster, subject.DefName) > assigned * cap))
{
uncovered.Add(subject);
}
}
return uncovered;
@@ -109,7 +186,7 @@ public static class Staffing
float allocated)
{
var rules = RequireRules(catalog);
var payroll = Payroll(rules, roster.People);
var payroll = Payroll(catalog, rules, roster);
if (roster.People.Any(person => person.Id.Equals(personId, StringComparison.Ordinal) && person.IsStaff))
{
@@ -132,13 +209,6 @@ public static class Staffing
return Fail(StaffingError.NoOpening, roster, pool, allocated, payroll, payroll);
}
var pay = MonthlyPay(rules, applicant.HourlyWageAsk, subjectCount: 0);
var attempted = Round(payroll + pay);
if (attempted > allocated)
{
return Fail(StaffingError.PayrollExceeded, roster, pool, allocated, payroll, attempted);
}
var hired = applicant.Person with
{
IsStaff = true,
@@ -148,9 +218,17 @@ public static class Staffing
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, Payroll(rules, nextRoster.People));
return Ok(nextRoster, nextPool, allocated, attempted);
}
public static StaffingOutcome AssignSubject(
@@ -162,7 +240,7 @@ public static class Staffing
float allocated)
{
var rules = RequireRules(catalog);
var payroll = Payroll(rules, roster.People);
var payroll = Payroll(catalog, rules, roster);
var index = IndexOf(roster, personId);
if (index < 0)
{
@@ -192,18 +270,14 @@ public static class Staffing
var ask = person.HourlyWageAsk ?? ApplicantPool.HourlyAsk(catalog, person);
var nextSubjects = person.Subjects.Append(subject).ToArray();
var currentPay = person.HourlyWageAsk is float currentAsk
? MonthlyPay(rules, currentAsk, person.Subjects.Count)
: MonthlyPay(rules, ask, person.Subjects.Count);
var nextPay = MonthlyPay(rules, ask, nextSubjects.Length);
var attempted = Round(payroll - currentPay + nextPay);
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);
}
var nextRoster = Replace(roster, index, person with { HourlyWageAsk = ask, Subjects = nextSubjects });
return Ok(nextRoster, pool, allocated, Payroll(rules, nextRoster.People));
return Ok(nextRoster, pool, allocated, attempted);
}
public static StaffingOutcome UnassignSubject(
@@ -215,7 +289,7 @@ public static class Staffing
float allocated)
{
var rules = RequireRules(catalog);
var payroll = Payroll(rules, roster.People);
var payroll = Payroll(catalog, rules, roster);
var index = IndexOf(roster, personId);
if (index < 0)
{
@@ -235,7 +309,7 @@ public static class Staffing
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(rules, nextRoster.People));
return Ok(nextRoster, pool, allocated, Payroll(catalog, rules, nextRoster));
}
private static StaffingOutcome Ok(Roster roster, ApplicantPool pool, float allocated, float payroll) =>