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
+1 -1
View File
@@ -119,7 +119,7 @@ Simulation tunables live under the `Simulation` section of
| `ModsDirectory` | `mods` | pack folders; `core` is required |
| `SaveIntervalSeconds` | 30 | rare clock snapshot; not every tick |
| `MinSaveIntervalMilliseconds` | 1000 | shortest gap between saves caused by pause or speed |
| `MonthlyPayrollCap` | 10000 | monthly payroll the player may commit; hire and extra subjects that would exceed it are rejected |
| `MonthlyPayrollCap` | 40000 | monthly payroll the player may commit; hires and assignments that would exceed it are rejected |
| `SchoolWeekDays` | 5 | working days from Monday (5 is MonFri; 6 adds Saturday) |
## What is deliberately missing
+16 -5
View File
@@ -135,11 +135,20 @@
учебного плана. Назначили человеку математику — он забирает все часы математики во всех
параллелях, где она преподаётся, и это может оказаться много.
Ниже одной ставки (`BaseWeeklyHours`) не платят: нанятый стоит денег, даже если ему ничего не
назначили. Иначе набрать полный штат «про запас» было бы бесплатно.
Отсюда два ограничения вместо одного:
- **Деньги.** Фонд оплаты не может превысить выделенную сумму.
- **Человек.** Никто не может вести больше `MaxWeeklyHours` часов в неделю. Если часов предмета
больше, чем вытянет один, нужен второй учитель того же предмета — часы делятся между ними.
- **Деньги.** Фонд оплаты не может превысить выделенную сумму. Проверяется при найме и назначении.
- **Человек.** Никто не вытянет больше `MaxWeeklyHours` часов. Если часов предмета больше, нужен
второй учитель — часы делятся между ними, и первому становится легче и дешевле.
**Предел часов не запрещает назначение, а помечает предмет непокрытым.** Запрет завёл бы в тупик:
первый учитель предмета всегда получает все его часы, поэтому первым не смог бы стать никто.
Считать часы «до предела» тоже нельзя — тогда седьмой предмет тому, кто уже за пределом, достаётся
бесплатно, а это ровно та дыра, ради которой почасовая оплата и вводилась. Поэтому часы считаются
целиком, деньги это показывают, а список непокрытого говорит, что один человек столько не выучит.
Поэтому «нанять посильнее» и «нагрузить одного вместо двоих» — два разных способа потратить один
фонд, и в этом весь выбор среза. Дешёвый учитель, взявший три предмета, упрётся в часы; дорогой
@@ -218,8 +227,10 @@
| Пул | Общий, обновляется раз в неделю частично, детерминированно |
| Учебный план | Часы в неделю по параллелям — поле `SubjectDef`, а не задел |
| Запрос по деньгам | Цена часа: от навыков и от черт (самоуверенный просит больше) |
| Зарплата | Цена часа × недельная нагрузка из учебного плана |
| Предел нагрузки | `MaxWeeklyHours` на человека; сверх — нужен второй учитель предмета |
| Зарплата | Цена часа × недельная нагрузка из учебного плана, но не меньше одной ставки |
| Деление часов | Учителя одного предмета делят его часы поровну |
| Предел нагрузки | `MaxWeeklyHours` не запрещает назначение, а делает предмет непокрытым |
| Непокрытый предмет | Нет учителя **или** назначенные не вытягивают его часы |
| Бюджет | Фиксированная сумма в месяц из настроек; баланса нет |
| Предел | Фонд оплаты не может превысить выделенное; проверка в момент действия |
| Экран | Два верхних раздела под часами: «Обзор» и «Управление» |
+2 -1
View File
@@ -12,7 +12,8 @@
## Задачи
- [x] Выделяемая на месяц сумма — в `SimulationOptions`, с умолчанием
- [x] Фонд оплаты школы: сумма базовых ставок плюс надбавки за предметы сверх первого
- [x] Фонд оплаты школы: у каждого цена часа × недельная нагрузка, но не меньше одной ставки.
Нагрузка берётся из учебного плана и делится между учителями предмета
- [x] Наём: соискатель уходит из пула, становится работником, попадает в ростер. Родитель
остаётся родителем — новой сущности не заводится
- [x] Назначение предмета нанятому и снятие предмета
+14 -5
View File
@@ -229,10 +229,18 @@ Money, uncovered subjects, the applicant pool and current staff. Reads the **pub
roster, applicant snapshot and catalog — it does not post to the worker. Unknown `{id}` is
`404` `unknown-school`. `?lang=ru|en` labels subjects and positions.
`allocated` is `Simulation:MonthlyPayrollCap`. `payroll` is the sum of each staff member's
monthly base (`hourlyWageAsk × baseWeeklyHours × weeksPerMonth`) plus
`extraSubjectSurcharge` of that base for every subject after the first. The cap is checked
when hiring or assigning, not at month end; money itself does not move.
`allocated` is `Simulation:MonthlyPayrollCap`. A staff member is paid
`hourlyWageAsk × weeklyHours × weeksPerMonth`, never less than one full rate
(`baseWeeklyHours`), and `payroll` is the sum over staff. The cap is checked when hiring or
assigning, not at month end; money itself does not move.
`weeklyHours` is not stored: it is the curriculum. For every subject assigned to a person,
`hoursPerWeek` of that subject across the classes that study it, divided between everyone
teaching it. So a second teacher of a subject halves what the first one carries — and costs.
A subject appears in `uncovered` when nobody teaches it **or** when the people who do cannot
between them carry its hours (`maxWeeklyHours` each). Both mean the same thing to a player:
those lessons will not happen.
```json
{
@@ -270,7 +278,8 @@ when hiring or assigning, not at month end; money itself does not move.
"position": "Teacher",
"positionLabel": "Учитель",
"hourlyWageAsk": 50,
"monthlyPay": 5000,
"weeklyHours": 35,
"monthlyPay": 7000,
"subjects": [{ "defName": "Mathematics", "label": "Математика" }]
}
],
+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.
@@ -26,9 +26,9 @@ public class StaffingApiTests(AppHostFixture fixture)
var staffing = await GetStaffingAsync(client, school.Id);
Assert.Equal(10_000f, staffing.Allocated);
Assert.Equal(40_000f, staffing.Allocated);
Assert.Equal(0f, staffing.Payroll);
Assert.Equal(10_000f, staffing.Remaining);
Assert.Equal(40_000f, staffing.Remaining);
Assert.Equal(12, staffing.Applicants.Count);
Assert.Empty(staffing.Staff);
Assert.Contains(staffing.Uncovered, subject => subject.DefName == "Mathematics");
@@ -148,9 +148,9 @@ public class StaffingApiTests(AppHostFixture fixture)
}
Assert.Equal("payroll-exceeded", problem?.Code);
Assert.Equal(10_000f, problem?.Allocated);
Assert.True(problem?.Payroll <= 10_000f);
Assert.True(problem?.Attempted > 10_000f);
Assert.Equal(40_000f, problem?.Allocated);
Assert.True(problem?.Payroll <= 40_000f);
Assert.True(problem?.Attempted > 40_000f);
Assert.True(staffing.Staff.Count >= 1);
}
@@ -165,7 +165,8 @@ public class StaffingApiTests(AppHostFixture fixture)
var teacher = hired.Staff.Single();
var assigned = await AssignAsync(client, school.Id, teacher.Id, "Mathematics");
Assert.Equal(hired.Payroll, assigned.Payroll);
// The first subject already costs: 35 curriculum hours is well past one full rate.
Assert.True(assigned.Payroll > hired.Payroll);
assigned = await AssignAsync(client, school.Id, teacher.Id, "RussianLanguage");
Assert.True(assigned.Payroll > hired.Payroll);
@@ -28,7 +28,7 @@ public class PeopleDefTests
Assert.Equal(12, catalog.StaffingRules.PoolSize);
Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours);
Assert.Equal(4, catalog.StaffingRules.WeeksPerMonth);
Assert.Equal(0.25f, catalog.StaffingRules.ExtraSubjectSurcharge);
Assert.Equal(36, catalog.StaffingRules.MaxWeeklyHours);
Assert.Equal("Начальные классы", catalog.Label("ru", catalog.Subjects["PrimarySchool"]));
Assert.Equal("Primary", catalog.Label("en", catalog.Subjects["PrimarySchool"]));
Assert.NotNull(catalog.DayFrame);
@@ -44,7 +44,7 @@ public class VanillaCoreTests
Assert.NotNull(catalog.StaffingRules);
Assert.Equal(12, catalog.StaffingRules.PoolSize);
Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours);
Assert.Equal(0.25f, catalog.StaffingRules.ExtraSubjectSurcharge);
Assert.Equal(36, catalog.StaffingRules.MaxWeeklyHours);
Assert.NotNull(catalog.DayFrame);
Assert.Equal("08:30", catalog.DayFrame.FirstLesson);
Assert.Equal(7, catalog.DayFrame.LessonCount);
+87 -22
View File
@@ -4,16 +4,22 @@ public class StaffingTests
{
private const float Cap = 10_000f;
/// <summary>
/// Price of an hour times the hours carried, with one full rate as the floor: a hired person
/// costs something even idle, and loading them past the rate costs more. The number of
/// subjects does not enter into it — twenty hours are twenty hours.
/// </summary>
[Fact]
public void MonthlyPay_AddsSurchargeAfterTheFirstSubject()
public void MonthlyPay_IsHoursTimesTheHourlyAsk_WithAFullRateAsTheFloor()
{
var rules = Fixtures.Catalog().StaffingRules!;
Assert.Equal(4_000f, Staffing.MonthlyBase(rules, 50f));
Assert.Equal(4_000f, Staffing.MonthlyPay(rules, 50f, 0));
Assert.Equal(4_000f, Staffing.MonthlyPay(rules, 50f, 1));
Assert.Equal(5_000f, Staffing.MonthlyPay(rules, 50f, 2));
Assert.Equal(6_000f, Staffing.MonthlyPay(rules, 50f, 3));
Assert.Equal(4_000f, Staffing.MonthlyPay(rules, 50f, 0f));
Assert.Equal(4_000f, Staffing.MonthlyPay(rules, 50f, 12f));
Assert.Equal(4_000f, Staffing.MonthlyPay(rules, 50f, 20f));
Assert.Equal(6_000f, Staffing.MonthlyPay(rules, 50f, 30f));
Assert.Equal(7_200f, Staffing.MonthlyPay(rules, 50f, 36f));
}
[Fact]
@@ -53,25 +59,85 @@ public class StaffingTests
var hired = Staffing.Hire(catalog, map, roster, pool, first.Person.Id, Staffing.TeacherPosition, Cap);
Assert.Equal(StaffingError.None, hired.Error);
// Mathematics is 35 curriculum hours a week, Literature 21. At 37.5 an hour that is
// 37.5 × 56 × 4 = 8 400 a month for one person — and no room left for a second.
var loaded = hired;
foreach (var subject in new[] { "Mathematics", "RussianLanguage", "Literature", "History", "Biology", "ForeignLanguage" })
foreach (var subject in new[] { "Mathematics", "Literature" })
{
loaded = Staffing.AssignSubject(catalog, loaded.Roster, loaded.Pool, first.Person.Id, subject, Cap);
Assert.Equal(StaffingError.None, loaded.Error);
}
Assert.Equal(6_750f, loaded.Payroll);
Assert.Equal(8_400f, loaded.Payroll);
var blocked = Staffing.Hire(catalog, map, loaded.Roster, loaded.Pool, second.Person.Id, Staffing.TeacherPosition, Cap);
Assert.Equal(StaffingError.PayrollExceeded, blocked.Error);
var dropped = Staffing.UnassignSubject(catalog, loaded.Roster, loaded.Pool, first.Person.Id, "Biology", Cap);
var dropped = Staffing.UnassignSubject(catalog, loaded.Roster, loaded.Pool, first.Person.Id, "Literature", Cap);
Assert.Equal(StaffingError.None, dropped.Error);
Assert.Equal(6_000f, dropped.Payroll);
Assert.Equal(5_250f, dropped.Payroll);
var fitted = Staffing.Hire(catalog, map, dropped.Roster, dropped.Pool, second.Person.Id, Staffing.TeacherPosition, Cap);
Assert.Equal(StaffingError.None, fitted.Error);
Assert.Equal(10_000f, fitted.Payroll);
Assert.Equal(9_250f, fitted.Payroll);
}
/// <summary>
/// Two teachers of one subject split its hours, so the second one costs a full rate and makes
/// the first cheaper. That is the whole reason hiring another is not simply worse than loading
/// the one you have.
/// </summary>
[Fact]
public void TwoTeachersOfASubject_ShareItsHours()
{
var (catalog, map, roster, pool) = Fresh();
var first = WithAsk(pool.Applicants[0], 50f);
var second = WithAsk(pool.Applicants[1], 50f);
pool = pool with { Applicants = [first, second, .. pool.Applicants.Skip(2)] };
var one = Staffing.Hire(catalog, map, roster, pool, first.Person.Id, Staffing.TeacherPosition, 50_000f);
one = Staffing.AssignSubject(catalog, one.Roster, one.Pool, first.Person.Id, "Mathematics", 50_000f);
// All 35 hours on one person: 50 × 35 × 4.
Assert.Equal(7_000f, one.Payroll);
var two = Staffing.Hire(catalog, map, one.Roster, one.Pool, second.Person.Id, Staffing.TeacherPosition, 50_000f);
two = Staffing.AssignSubject(catalog, two.Roster, two.Pool, second.Person.Id, "Mathematics", 50_000f);
// 17.5 hours each, which is under a full rate, so both are paid the rate: 4 000 × 2.
Assert.Equal(8_000f, two.Payroll);
Assert.All(
two.Roster.People.Where(person => person.IsStaff),
person => Assert.Equal(17.5f, Staffing.WeeklyHours(catalog, two.Roster, person)));
}
/// <summary>
/// Primary school is twenty hours a week for each of four classes. One teacher cannot carry
/// eighty hours whatever the budget says, so the subject stays on the uncovered list until
/// enough people share it.
/// </summary>
[Fact]
public void SubjectNeedingMoreHoursThanOnePersonCarries_StaysUncovered()
{
var (catalog, map, roster, pool) = Fresh();
var rules = catalog.StaffingRules!;
Assert.Equal(80f, Staffing.SubjectHours(catalog, roster, "PrimarySchool"));
Assert.True(80f > rules.MaxWeeklyHours);
var outcome = new StaffingOutcome(StaffingError.None, roster, pool, 0, 0, 0, 0);
for (var i = 0; i < 3; i++)
{
var applicant = outcome.Pool.Applicants[0];
outcome = Staffing.Hire(catalog, map, outcome.Roster, outcome.Pool, applicant.Person.Id, Staffing.TeacherPosition, 500_000f);
Assert.Equal(StaffingError.None, outcome.Error);
outcome = Staffing.AssignSubject(catalog, outcome.Roster, outcome.Pool, applicant.Person.Id, "PrimarySchool", 500_000f);
Assert.Equal(StaffingError.None, outcome.Error);
var stillShort = 80f > (i + 1) * rules.MaxWeeklyHours;
Assert.Equal(
stillShort,
Staffing.Uncovered(catalog, outcome.Roster).Any(subject => subject.DefName == "PrimarySchool"));
}
}
[Fact]
@@ -82,23 +148,22 @@ public class StaffingTests
pool = pool with { Applicants = [applicant, .. pool.Applicants.Skip(1)] };
var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, Cap);
var loaded = hired;
foreach (var subject in new[] { "Mathematics", "RussianLanguage", "Literature", "History", "Biology", "Physics", "Chemistry" })
{
loaded = Staffing.AssignSubject(catalog, loaded.Roster, loaded.Pool, applicant.Person.Id, subject, Cap);
Assert.Equal(StaffingError.None, loaded.Error);
}
Assert.Equal(4_000f, hired.Payroll);
Assert.Equal(10_000f, loaded.Payroll);
// Mathematics is 35 hours: 50 × 35 × 4 = 7 000.
var loaded = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", Cap);
Assert.Equal(StaffingError.None, loaded.Error);
Assert.Equal(7_000f, loaded.Payroll);
var rejected = Staffing.AssignSubject(catalog, loaded.Roster, loaded.Pool, applicant.Person.Id, "Geography", Cap);
// Russian adds 28 more. 63 hours at 50 is 12 600 a month — past the 10 000 allocation.
var rejected = Staffing.AssignSubject(catalog, loaded.Roster, loaded.Pool, applicant.Person.Id, "RussianLanguage", Cap);
Assert.Equal(StaffingError.PayrollExceeded, rejected.Error);
Assert.Equal(10_000f, rejected.Payroll);
Assert.Equal(11_000f, rejected.Attempted);
Assert.Equal(0f, rejected.Remaining);
Assert.Equal(7_000f, rejected.Payroll);
Assert.Equal(12_600f, rejected.Attempted);
Assert.Equal(3_000f, rejected.Remaining);
Assert.DoesNotContain(
rejected.Roster.People.Single(person => person.Id == applicant.Person.Id).Subjects,
name => name == "Geography");
name => name == "RussianLanguage");
}
[Fact]
@@ -146,6 +146,50 @@ public class TimetablePlannerTests
}
}
/// <summary>
/// The four bans alone allowed a legal but unusable week. Taking the first free slot day by
/// day gave a full school 77 lessons on Monday and 16 on Friday, with three foreign-language
/// lessons back to back. These are the numbers a timetable is judged by, so they are asserted.
/// </summary>
[Fact]
public void AWeek_IsSpreadAcrossDaysAndSubjectsDoNotRunInBlocks()
{
var catalog = Fixtures.Catalog();
var map = Fixtures.ClassroomsAndGym(11);
var classes = Enumerable.Range(0, 11).Select(index => Fixtures.Class(index, index + 1, 16)).ToArray();
var teachers = catalog.Subjects.Values
.Where(subject => !subject.Abstract)
.SelectMany(subject => Enumerable
.Range(0, 3)
.Select(copy => Fixtures.Teacher($"t{subject.DefName}{copy}", subject.DefName)))
.ToArray();
var table = TimetablePlanner.Build(catalog, map, classes, teachers);
var perDay = Enumerable.Range(0, 5).Select(day => table.Lessons.Count(lesson => lesson.Day == day)).ToArray();
Assert.All(perDay, count => Assert.True(count > 0, $"a school day is empty: {string.Join("/", perDay)}"));
Assert.True(
perDay.Max() <= perDay.Min() * 1.5,
$"the week is lopsided: {string.Join("/", perDay)}");
// Spread as evenly as the curriculum allows, not "never twice a day": primary school is
// twenty hours over five days, so four a day is the best that exists.
foreach (var group in table.Lessons.GroupBy(lesson => (lesson.ClassId, lesson.Subject)))
{
var bySubjectDay = Enumerable
.Range(0, 5)
.Select(day => group.Count(lesson => lesson.Day == day))
.ToArray();
// Two, not one: the day order is a preference, and a busy room or teacher can still
// push an hour onto a day that already has the subject. Before the fix the same
// measurement read 5/5/5/0/0.
Assert.True(
bySubjectDay.Max() - bySubjectDay.Min() <= 2,
$"{group.Key.ClassId} has {group.Key.Subject} bunched into days: {string.Join("/", bySubjectDay)}");
}
}
private static string Snapshot(Timetable table) =>
string.Join(
'\n',