Update staffing management to enhance payroll and hiring functionalities
- 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:
+103
-29
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user