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
+4
View File
@@ -142,6 +142,8 @@ const ru = {
staffColSkills: 'Навыки',
staffColPosition: 'Должность',
staffColSubjects: 'Предметы',
staffColHours: 'Часов',
staffHours: '{n} ч/нед',
staffColPay: 'В месяц',
staffAsk: '{hourly}/ч · {monthly}/мес',
staffSubjectRange: '{label} ({min}{max})',
@@ -331,6 +333,8 @@ const en: Messages = {
staffColSkills: 'Skills',
staffColPosition: 'Position',
staffColSubjects: 'Subjects',
staffColHours: 'Hours',
staffHours: '{n} h/wk',
staffColPay: 'Monthly',
staffAsk: '{hourly}/h · {monthly}/mo',
staffSubjectRange: '{label} ({min}{max})',
+1
View File
@@ -320,6 +320,7 @@ export interface StaffMember {
readonly position: string;
readonly positionLabel: string;
readonly hourlyWageAsk: number;
readonly weeklyHours: number;
readonly monthlyPay: number;
readonly subjects: readonly DefLabel[];
}
@@ -338,6 +338,7 @@ export class ManagementPanel {
el('th', { text: t('staffColName') }),
el('th', { text: t('staffColPosition') }),
el('th', { text: t('staffColSubjects') }),
el('th', { text: t('staffColHours') }),
el('th', { text: t('staffColPay') }),
),
),
@@ -360,6 +361,7 @@ export class ManagementPanel {
name,
el('td', { text: member.positionLabel }),
el('td', { text: member.subjects.map((subject) => subject.label).join(', ') || '—' }),
el('td', { text: t('staffHours', { n: formatHours(member.weeklyHours) }) }),
el('td', { text: formatMoney(member.monthlyPay) }),
);
body.append(row);
@@ -668,6 +670,10 @@ function fillSelect(
}
}
function formatHours(value: number): string {
return new Intl.NumberFormat(intlTag(), { maximumFractionDigits: 1 }).format(value);
}
function formatMoney(value: number): string {
return new Intl.NumberFormat(intlTag(), { maximumFractionDigits: 2 }).format(value);
}
+4 -2
View File
@@ -327,9 +327,11 @@ internal static class PeopleDefValidator
throw new ContentLoadException($"StaffingDef '{staffing.DefName}' monthly hours must be positive.");
}
if (staffing.ExtraSubjectSurcharge < 0f)
if (staffing.MaxWeeklyHours < staffing.BaseWeeklyHours)
{
throw new ContentLoadException($"StaffingDef '{staffing.DefName}' extraSubjectSurcharge cannot be negative.");
throw new ContentLoadException(
$"StaffingDef '{staffing.DefName}' maxWeeklyHours must be at least baseWeeklyHours: "
+ "nobody can be paid for a full rate they are not allowed to work.");
}
}
+9 -6
View File
@@ -181,16 +181,19 @@ public sealed class StaffingDef : Def
public float HourlyWagePerSkill { get; init; }
/// <summary>Hours assumed in the monthly base rate. Extra subjects add a surcharge on top.</summary>
/// <summary>
/// One full rate. A hired person is paid for at least this many hours even with nothing
/// assigned; hours beyond it are paid on top.
/// </summary>
public float BaseWeeklyHours { get; init; }
public float WeeksPerMonth { get; init; }
/// <summary>
/// Added to the monthly base for each subject after the first. <c>0.25</c> means a second
/// subject costs 25% of the base rate.
/// Most hours one person can carry. A subject whose curriculum needs more than this needs a
/// second teacher — the hours are then shared between them.
/// </summary>
public float ExtraSubjectSurcharge { get; init; }
public float MaxWeeklyHours { get; init; }
public float WeeksPerMonth { get; init; }
}
public enum BodyAttributeKind
+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) =>
+44 -8
View File
@@ -56,14 +56,7 @@ public static class TimetablePlanner
demand.Hours,
placed);
if (leftover > 0)
{
demand.Hours = leftover;
}
else
{
demand.Hours = 0;
}
demand.Hours = leftover;
}
var uncovered = remaining
@@ -162,6 +155,14 @@ public static class TimetablePlanner
return leftover;
}
/// <summary>
/// Picks the slot, and the order days are tried in is the whole difference between a timetable
/// and a pile of lessons. Scanning day 0 upwards and taking the first free slot produced blocks
/// of the same subject and a week that ran out by Thursday: Monday held 77 lessons, Friday 16.
///
/// So days are ranked instead: first those that do not have this subject yet, then the emptiest
/// one for this class. Periods stay ascending inside the chosen day, so a day has no holes.
/// </summary>
private static bool TryPlaceOne(
Occupancy occupancy,
string classId,
@@ -172,7 +173,26 @@ public static class TimetablePlanner
int lessonCount,
List<LessonPlacement> placed)
{
var days = new int[weekDays];
for (var day = 0; day < weekDays; day++)
{
days[day] = day;
}
Array.Sort(days, (left, right) =>
{
var bySubject = occupancy.SubjectLoad(classId, subject, left)
.CompareTo(occupancy.SubjectLoad(classId, subject, right));
if (bySubject != 0)
{
return bySubject;
}
var byDay = occupancy.DayLoad(classId, left).CompareTo(occupancy.DayLoad(classId, right));
return byDay != 0 ? byDay : left.CompareTo(right);
});
foreach (var day in days)
{
for (var period = 1; period <= lessonCount; period++)
{
@@ -313,17 +333,33 @@ public static class TimetablePlanner
private readonly HashSet<(string Id, int Day, int Period)> _classes = [];
private readonly HashSet<(string Id, int Day, int Period)> _teachers = [];
private readonly HashSet<(string Id, int Day, int Period)> _rooms = [];
private readonly Dictionary<(string ClassId, int Day), int> _dayLoad = [];
private readonly Dictionary<(string ClassId, string Subject, int Day), int> _subjectLoad = [];
public bool IsFree(string classId, string teacherId, string roomId, int day, int period) =>
!_classes.Contains((classId, day, period))
&& !_teachers.Contains((teacherId, day, period))
&& !_rooms.Contains((roomId, day, period));
/// <summary>How many lessons this class already has that day — counted, not recomputed.</summary>
public int DayLoad(string classId, int day) =>
_dayLoad.TryGetValue((classId, day), out var count) ? count : 0;
/// <summary>How many lessons of this subject this class already has that day.</summary>
public int SubjectLoad(string classId, string subject, int day) =>
_subjectLoad.TryGetValue((classId, subject, day), out var count) ? count : 0;
public void Add(LessonPlacement lesson)
{
_classes.Add((lesson.ClassId, lesson.Day, lesson.Period));
_teachers.Add((lesson.TeacherId, lesson.Day, lesson.Period));
_rooms.Add((lesson.RoomId, lesson.Day, lesson.Period));
var day = (lesson.ClassId, lesson.Day);
_dayLoad[day] = (_dayLoad.TryGetValue(day, out var lessons) ? lessons : 0) + 1;
var subject = (lesson.ClassId, lesson.Subject, lesson.Day);
_subjectLoad[subject] = (_subjectLoad.TryGetValue(subject, out var same) ? same : 0) + 1;
}
}
}
+7 -3
View File
@@ -211,6 +211,7 @@ internal sealed record StaffMemberResponse(
string Position,
string PositionLabel,
float HourlyWageAsk,
float WeeklyHours,
float MonthlyPay,
IReadOnlyList<DefLabelResponse> Subjects);
@@ -227,7 +228,7 @@ internal static class StaffingMapper
roster ??= new Roster([], [], []);
pool ??= ApplicantPool.Empty;
var rules = catalog?.StaffingRules;
var payroll = rules is null ? 0f : Staffing.Payroll(rules, roster.People);
var payroll = rules is null || catalog is null ? 0f : Staffing.Payroll(catalog, rules, roster);
var remaining = MathF.Round(MathF.Max(0f, allocated - payroll), 2);
var uncovered = catalog is null
@@ -257,7 +258,7 @@ internal static class StaffingMapper
.Where(person => person.IsStaff)
.OrderBy(person => person.Name.Surname, StringComparer.Ordinal)
.ThenBy(person => person.Id, StringComparer.Ordinal)
.Select(person => Member(person, catalog, rules, locale, asOf))
.Select(person => Member(person, roster, catalog, rules, locale, asOf))
.ToArray();
var positions = catalog is null
@@ -300,13 +301,15 @@ internal static class StaffingMapper
private static StaffMemberResponse Member(
Person person,
Roster roster,
DefCatalog? catalog,
StaffingDef? rules,
string locale,
DateTime asOf)
{
var ask = person.HourlyWageAsk ?? 0f;
var pay = rules is null ? 0f : Staffing.MonthlyPay(rules, ask, person.Subjects.Count);
var hours = catalog is null ? 0f : Staffing.WeeklyHours(catalog, roster, person);
var pay = rules is null ? 0f : Staffing.MonthlyPay(rules, ask, hours);
var subjects = person.Subjects
.Select(name => new DefLabelResponse(
name,
@@ -324,6 +327,7 @@ internal static class StaffingMapper
person.Position ?? string.Empty,
PeopleListMapper.PositionLabel(catalog, locale, person.Position) ?? person.Position ?? string.Empty,
ask,
MathF.Round(hours, 1),
pay,
subjects);
}
+20 -20
View File
@@ -1,20 +1,20 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Simulation": {
"TickRate": 20,
"MaxSchools": 6,
"GameMinutesPerRealSecond": 5,
"DefaultStartDate": "2012-03-31T06:00:00",
"SavesDirectory": "saves",
"ModsDirectory": "mods",
"SaveIntervalSeconds": 30,
"MonthlyPayrollCap": 10000,
"SchoolWeekDays": 5
}
}
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Simulation": {
"TickRate": 20,
"MaxSchools": 6,
"GameMinutesPerRealSecond": 5,
"DefaultStartDate": "2012-03-31T06:00:00",
"SavesDirectory": "saves",
"ModsDirectory": "mods",
"SaveIntervalSeconds": 30,
"MonthlyPayrollCap": 40000,
"SchoolWeekDays": 5
}
}
@@ -5,7 +5,10 @@
"parentChance": 0.35,
"hourlyWageBase": 30,
"hourlyWagePerSkill": 0.6,
// One ставка. A hired person costs this much even with no subjects, and hours beyond it are
// paid on top — that is what makes loading one teacher instead of hiring two cost money.
"baseWeeklyHours": 20,
// Nobody can carry more than this. Past it the subject needs a second teacher.
"maxWeeklyHours": 36,
"weeksPerMonth": 4,
"extraSubjectSurcharge": 0.25,
}
+1 -1
View File
@@ -53,7 +53,7 @@ public sealed class SimulationOptions
/// Monthly payroll the player may commit. Hire and subject assignment that would exceed it
/// are rejected immediately; money itself does not move.
/// </summary>
public float MonthlyPayrollCap { get; set; } = 10_000f;
public float MonthlyPayrollCap { get; set; } = 40_000f;
/// <summary>
/// Working days from Monday. Five is MonFri; six adds Saturday; seven is every day.