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 | | `ModsDirectory` | `mods` | pack folders; `core` is required |
| `SaveIntervalSeconds` | 30 | rare clock snapshot; not every tick | | `SaveIntervalSeconds` | 30 | rare clock snapshot; not every tick |
| `MinSaveIntervalMilliseconds` | 1000 | shortest gap between saves caused by pause or speed | | `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) | | `SchoolWeekDays` | 5 | working days from Monday (5 is MonFri; 6 adds Saturday) |
## What is deliberately missing ## What is deliberately missing
+16 -5
View File
@@ -135,11 +135,20 @@
учебного плана. Назначили человеку математику — он забирает все часы математики во всех учебного плана. Назначили человеку математику — он забирает все часы математики во всех
параллелях, где она преподаётся, и это может оказаться много. параллелях, где она преподаётся, и это может оказаться много.
Ниже одной ставки (`BaseWeeklyHours`) не платят: нанятый стоит денег, даже если ему ничего не
назначили. Иначе набрать полный штат «про запас» было бы бесплатно.
Отсюда два ограничения вместо одного: Отсюда два ограничения вместо одного:
- **Деньги.** Фонд оплаты не может превысить выделенную сумму. - **Деньги.** Фонд оплаты не может превысить выделенную сумму. Проверяется при найме и назначении.
- **Человек.** Никто не может вести больше `MaxWeeklyHours` часов в неделю. Если часов предмета - **Человек.** Никто не вытянет больше `MaxWeeklyHours` часов. Если часов предмета больше, нужен
больше, чем вытянет один, нужен второй учитель того же предмета — часы делятся между ними. второй учитель — часы делятся между ними, и первому становится легче и дешевле.
**Предел часов не запрещает назначение, а помечает предмет непокрытым.** Запрет завёл бы в тупик:
первый учитель предмета всегда получает все его часы, поэтому первым не смог бы стать никто.
Считать часы «до предела» тоже нельзя — тогда седьмой предмет тому, кто уже за пределом, достаётся
бесплатно, а это ровно та дыра, ради которой почасовая оплата и вводилась. Поэтому часы считаются
целиком, деньги это показывают, а список непокрытого говорит, что один человек столько не выучит.
Поэтому «нанять посильнее» и «нагрузить одного вместо двоих» — два разных способа потратить один Поэтому «нанять посильнее» и «нагрузить одного вместо двоих» — два разных способа потратить один
фонд, и в этом весь выбор среза. Дешёвый учитель, взявший три предмета, упрётся в часы; дорогой фонд, и в этом весь выбор среза. Дешёвый учитель, взявший три предмета, упрётся в часы; дорогой
@@ -218,8 +227,10 @@
| Пул | Общий, обновляется раз в неделю частично, детерминированно | | Пул | Общий, обновляется раз в неделю частично, детерминированно |
| Учебный план | Часы в неделю по параллелям — поле `SubjectDef`, а не задел | | Учебный план | Часы в неделю по параллелям — поле `SubjectDef`, а не задел |
| Запрос по деньгам | Цена часа: от навыков и от черт (самоуверенный просит больше) | | Запрос по деньгам | Цена часа: от навыков и от черт (самоуверенный просит больше) |
| Зарплата | Цена часа × недельная нагрузка из учебного плана | | Зарплата | Цена часа × недельная нагрузка из учебного плана, но не меньше одной ставки |
| Предел нагрузки | `MaxWeeklyHours` на человека; сверх — нужен второй учитель предмета | | Деление часов | Учителя одного предмета делят его часы поровну |
| Предел нагрузки | `MaxWeeklyHours` не запрещает назначение, а делает предмет непокрытым |
| Непокрытый предмет | Нет учителя **или** назначенные не вытягивают его часы |
| Бюджет | Фиксированная сумма в месяц из настроек; баланса нет | | Бюджет | Фиксированная сумма в месяц из настроек; баланса нет |
| Предел | Фонд оплаты не может превысить выделенное; проверка в момент действия | | Предел | Фонд оплаты не может превысить выделенное; проверка в момент действия |
| Экран | Два верхних раздела под часами: «Обзор» и «Управление» | | Экран | Два верхних раздела под часами: «Обзор» и «Управление» |
+2 -1
View File
@@ -12,7 +12,8 @@
## Задачи ## Задачи
- [x] Выделяемая на месяц сумма — в `SimulationOptions`, с умолчанием - [x] Выделяемая на месяц сумма — в `SimulationOptions`, с умолчанием
- [x] Фонд оплаты школы: сумма базовых ставок плюс надбавки за предметы сверх первого - [x] Фонд оплаты школы: у каждого цена часа × недельная нагрузка, но не меньше одной ставки.
Нагрузка берётся из учебного плана и делится между учителями предмета
- [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 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. `404` `unknown-school`. `?lang=ru|en` labels subjects and positions.
`allocated` is `Simulation:MonthlyPayrollCap`. `payroll` is the sum of each staff member's `allocated` is `Simulation:MonthlyPayrollCap`. A staff member is paid
monthly base (`hourlyWageAsk × baseWeeklyHours × weeksPerMonth`) plus `hourlyWageAsk × weeklyHours × weeksPerMonth`, never less than one full rate
`extraSubjectSurcharge` of that base for every subject after the first. The cap is checked (`baseWeeklyHours`), and `payroll` is the sum over staff. The cap is checked when hiring or
when hiring or assigning, not at month end; money itself does not move. 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 ```json
{ {
@@ -270,7 +278,8 @@ when hiring or assigning, not at month end; money itself does not move.
"position": "Teacher", "position": "Teacher",
"positionLabel": "Учитель", "positionLabel": "Учитель",
"hourlyWageAsk": 50, "hourlyWageAsk": 50,
"monthlyPay": 5000, "weeklyHours": 35,
"monthlyPay": 7000,
"subjects": [{ "defName": "Mathematics", "label": "Математика" }] "subjects": [{ "defName": "Mathematics", "label": "Математика" }]
} }
], ],
+4
View File
@@ -142,6 +142,8 @@ const ru = {
staffColSkills: 'Навыки', staffColSkills: 'Навыки',
staffColPosition: 'Должность', staffColPosition: 'Должность',
staffColSubjects: 'Предметы', staffColSubjects: 'Предметы',
staffColHours: 'Часов',
staffHours: '{n} ч/нед',
staffColPay: 'В месяц', staffColPay: 'В месяц',
staffAsk: '{hourly}/ч · {monthly}/мес', staffAsk: '{hourly}/ч · {monthly}/мес',
staffSubjectRange: '{label} ({min}{max})', staffSubjectRange: '{label} ({min}{max})',
@@ -331,6 +333,8 @@ const en: Messages = {
staffColSkills: 'Skills', staffColSkills: 'Skills',
staffColPosition: 'Position', staffColPosition: 'Position',
staffColSubjects: 'Subjects', staffColSubjects: 'Subjects',
staffColHours: 'Hours',
staffHours: '{n} h/wk',
staffColPay: 'Monthly', staffColPay: 'Monthly',
staffAsk: '{hourly}/h · {monthly}/mo', staffAsk: '{hourly}/h · {monthly}/mo',
staffSubjectRange: '{label} ({min}{max})', staffSubjectRange: '{label} ({min}{max})',
+1
View File
@@ -320,6 +320,7 @@ export interface StaffMember {
readonly position: string; readonly position: string;
readonly positionLabel: string; readonly positionLabel: string;
readonly hourlyWageAsk: number; readonly hourlyWageAsk: number;
readonly weeklyHours: number;
readonly monthlyPay: number; readonly monthlyPay: number;
readonly subjects: readonly DefLabel[]; readonly subjects: readonly DefLabel[];
} }
@@ -338,6 +338,7 @@ export class ManagementPanel {
el('th', { text: t('staffColName') }), el('th', { text: t('staffColName') }),
el('th', { text: t('staffColPosition') }), el('th', { text: t('staffColPosition') }),
el('th', { text: t('staffColSubjects') }), el('th', { text: t('staffColSubjects') }),
el('th', { text: t('staffColHours') }),
el('th', { text: t('staffColPay') }), el('th', { text: t('staffColPay') }),
), ),
), ),
@@ -360,6 +361,7 @@ export class ManagementPanel {
name, name,
el('td', { text: member.positionLabel }), el('td', { text: member.positionLabel }),
el('td', { text: member.subjects.map((subject) => subject.label).join(', ') || '—' }), 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) }), el('td', { text: formatMoney(member.monthlyPay) }),
); );
body.append(row); 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 { function formatMoney(value: number): string {
return new Intl.NumberFormat(intlTag(), { maximumFractionDigits: 2 }).format(value); 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."); 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; } 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 BaseWeeklyHours { get; init; }
public float WeeksPerMonth { get; init; }
/// <summary> /// <summary>
/// Added to the monthly base for each subject after the first. <c>0.25</c> means a second /// Most hours one person can carry. A subject whose curriculum needs more than this needs a
/// subject costs 25% of the base rate. /// second teacher — the hours are then shared between them.
/// </summary> /// </summary>
public float ExtraSubjectSurcharge { get; init; } public float MaxWeeklyHours { get; init; }
public float WeeksPerMonth { get; init; }
} }
public enum BodyAttributeKind public enum BodyAttributeKind
+102 -28
View File
@@ -38,31 +38,103 @@ public static class Staffing
public static StaffingOutcome UnknownSchool() => public static StaffingOutcome UnknownSchool() =>
Fail(StaffingError.UnknownSchool, new Roster([], [], []), ApplicantPool.Empty, allocated: 0, payroll: 0, attempted: 0); 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) => public static float MonthlyBase(StaffingDef rules, float hourlyAsk) =>
Round(hourlyAsk * rules.BaseWeeklyHours * rules.WeeksPerMonth); 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); ArgumentNullException.ThrowIfNull(catalog);
return Round(MonthlyBase(rules, hourlyAsk) * (1f + (rules.ExtraSubjectSurcharge * extras))); ArgumentNullException.ThrowIfNull(roster);
ArgumentNullException.ThrowIfNull(person);
if (person.Subjects.Count == 0)
{
return 0f;
} }
public static float Payroll(StaffingDef rules, IEnumerable<Person> people) 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; var total = 0f;
foreach (var person in people) foreach (var person in roster.People)
{ {
if (!person.IsStaff || person.HourlyWageAsk is not { } ask) if (!person.IsStaff || person.HourlyWageAsk is not { } ask)
{ {
continue; continue;
} }
total += MonthlyPay(rules, ask, person.Subjects.Count); total += MonthlyPay(rules, ask, WeeklyHours(catalog, roster, person));
} }
return Round(total); 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) public static IReadOnlyList<SubjectDef> Uncovered(DefCatalog catalog, Roster roster)
{ {
var years = new HashSet<int>(); var years = new HashSet<int>();
@@ -71,7 +143,7 @@ public static class Staffing
years.Add(schoolClass.Year); 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) foreach (var person in roster.People)
{ {
if (!person.IsStaff) if (!person.IsStaff)
@@ -81,20 +153,25 @@ public static class Staffing
foreach (var subject in person.Subjects) 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>(); var uncovered = new List<SubjectDef>();
foreach (var subject in catalog.Subjects.Values) foreach (var subject in catalog.Subjects.Values)
{ {
if (subject.Abstract || !TouchesYears(subject, years) || taught.Contains(subject.DefName)) if (subject.Abstract || !TouchesYears(subject, years))
{ {
continue; continue;
} }
var assigned = teachers.GetValueOrDefault(subject.DefName);
if (assigned == 0 || (cap > 0f && SubjectHours(catalog, roster, subject.DefName) > assigned * cap))
{
uncovered.Add(subject); uncovered.Add(subject);
} }
}
return uncovered; return uncovered;
} }
@@ -109,7 +186,7 @@ public static class Staffing
float allocated) float allocated)
{ {
var rules = RequireRules(catalog); 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)) 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); 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 var hired = applicant.Person with
{ {
IsStaff = true, IsStaff = true,
@@ -148,9 +218,17 @@ public static class Staffing
Subjects = [], 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 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); var nextPool = pool.Without(personId);
return Ok(nextRoster, nextPool, allocated, Payroll(rules, nextRoster.People)); return Ok(nextRoster, nextPool, allocated, attempted);
} }
public static StaffingOutcome AssignSubject( public static StaffingOutcome AssignSubject(
@@ -162,7 +240,7 @@ public static class Staffing
float allocated) float allocated)
{ {
var rules = RequireRules(catalog); var rules = RequireRules(catalog);
var payroll = Payroll(rules, roster.People); var payroll = Payroll(catalog, rules, roster);
var index = IndexOf(roster, personId); var index = IndexOf(roster, personId);
if (index < 0) if (index < 0)
{ {
@@ -192,18 +270,14 @@ public static class Staffing
var ask = person.HourlyWageAsk ?? ApplicantPool.HourlyAsk(catalog, person); var ask = person.HourlyWageAsk ?? ApplicantPool.HourlyAsk(catalog, person);
var nextSubjects = person.Subjects.Append(subject).ToArray(); var nextSubjects = person.Subjects.Append(subject).ToArray();
var currentPay = person.HourlyWageAsk is float currentAsk var nextRoster = Replace(roster, index, person with { HourlyWageAsk = ask, Subjects = nextSubjects });
? MonthlyPay(rules, currentAsk, person.Subjects.Count) var attempted = Payroll(catalog, rules, nextRoster);
: MonthlyPay(rules, ask, person.Subjects.Count);
var nextPay = MonthlyPay(rules, ask, nextSubjects.Length);
var attempted = Round(payroll - currentPay + nextPay);
if (attempted > allocated) if (attempted > allocated)
{ {
return Fail(StaffingError.PayrollExceeded, roster, pool, allocated, payroll, attempted); 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, attempted);
return Ok(nextRoster, pool, allocated, Payroll(rules, nextRoster.People));
} }
public static StaffingOutcome UnassignSubject( public static StaffingOutcome UnassignSubject(
@@ -215,7 +289,7 @@ public static class Staffing
float allocated) float allocated)
{ {
var rules = RequireRules(catalog); var rules = RequireRules(catalog);
var payroll = Payroll(rules, roster.People); var payroll = Payroll(catalog, rules, roster);
var index = IndexOf(roster, personId); var index = IndexOf(roster, personId);
if (index < 0) if (index < 0)
{ {
@@ -235,7 +309,7 @@ public static class Staffing
var nextSubjects = person.Subjects.Where(name => !name.Equals(subject, StringComparison.Ordinal)).ToArray(); var nextSubjects = person.Subjects.Where(name => !name.Equals(subject, StringComparison.Ordinal)).ToArray();
var nextRoster = Replace(roster, index, person with { Subjects = nextSubjects }); 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) => private static StaffingOutcome Ok(Roster roster, ApplicantPool pool, float allocated, float payroll) =>
+43 -7
View File
@@ -56,15 +56,8 @@ public static class TimetablePlanner
demand.Hours, demand.Hours,
placed); placed);
if (leftover > 0)
{
demand.Hours = leftover; demand.Hours = leftover;
} }
else
{
demand.Hours = 0;
}
}
var uncovered = remaining var uncovered = remaining
.Where(row => row.Hours > 0) .Where(row => row.Hours > 0)
@@ -162,6 +155,14 @@ public static class TimetablePlanner
return leftover; 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( private static bool TryPlaceOne(
Occupancy occupancy, Occupancy occupancy,
string classId, string classId,
@@ -172,7 +173,26 @@ public static class TimetablePlanner
int lessonCount, int lessonCount,
List<LessonPlacement> placed) List<LessonPlacement> placed)
{ {
var days = new int[weekDays];
for (var day = 0; day < weekDays; day++) 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++) 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)> _classes = [];
private readonly HashSet<(string Id, int Day, int Period)> _teachers = []; private readonly HashSet<(string Id, int Day, int Period)> _teachers = [];
private readonly HashSet<(string Id, int Day, int Period)> _rooms = []; 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) => public bool IsFree(string classId, string teacherId, string roomId, int day, int period) =>
!_classes.Contains((classId, day, period)) !_classes.Contains((classId, day, period))
&& !_teachers.Contains((teacherId, day, period)) && !_teachers.Contains((teacherId, day, period))
&& !_rooms.Contains((roomId, 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) public void Add(LessonPlacement lesson)
{ {
_classes.Add((lesson.ClassId, lesson.Day, lesson.Period)); _classes.Add((lesson.ClassId, lesson.Day, lesson.Period));
_teachers.Add((lesson.TeacherId, lesson.Day, lesson.Period)); _teachers.Add((lesson.TeacherId, lesson.Day, lesson.Period));
_rooms.Add((lesson.RoomId, 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 Position,
string PositionLabel, string PositionLabel,
float HourlyWageAsk, float HourlyWageAsk,
float WeeklyHours,
float MonthlyPay, float MonthlyPay,
IReadOnlyList<DefLabelResponse> Subjects); IReadOnlyList<DefLabelResponse> Subjects);
@@ -227,7 +228,7 @@ internal static class StaffingMapper
roster ??= new Roster([], [], []); roster ??= new Roster([], [], []);
pool ??= ApplicantPool.Empty; pool ??= ApplicantPool.Empty;
var rules = catalog?.StaffingRules; 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 remaining = MathF.Round(MathF.Max(0f, allocated - payroll), 2);
var uncovered = catalog is null var uncovered = catalog is null
@@ -257,7 +258,7 @@ internal static class StaffingMapper
.Where(person => person.IsStaff) .Where(person => person.IsStaff)
.OrderBy(person => person.Name.Surname, StringComparer.Ordinal) .OrderBy(person => person.Name.Surname, StringComparer.Ordinal)
.ThenBy(person => person.Id, 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(); .ToArray();
var positions = catalog is null var positions = catalog is null
@@ -300,13 +301,15 @@ internal static class StaffingMapper
private static StaffMemberResponse Member( private static StaffMemberResponse Member(
Person person, Person person,
Roster roster,
DefCatalog? catalog, DefCatalog? catalog,
StaffingDef? rules, StaffingDef? rules,
string locale, string locale,
DateTime asOf) DateTime asOf)
{ {
var ask = person.HourlyWageAsk ?? 0f; 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 var subjects = person.Subjects
.Select(name => new DefLabelResponse( .Select(name => new DefLabelResponse(
name, name,
@@ -324,6 +327,7 @@ internal static class StaffingMapper
person.Position ?? string.Empty, person.Position ?? string.Empty,
PeopleListMapper.PositionLabel(catalog, locale, person.Position) ?? person.Position ?? string.Empty, PeopleListMapper.PositionLabel(catalog, locale, person.Position) ?? person.Position ?? string.Empty,
ask, ask,
MathF.Round(hours, 1),
pay, pay,
subjects); subjects);
} }
+1 -1
View File
@@ -14,7 +14,7 @@
"SavesDirectory": "saves", "SavesDirectory": "saves",
"ModsDirectory": "mods", "ModsDirectory": "mods",
"SaveIntervalSeconds": 30, "SaveIntervalSeconds": 30,
"MonthlyPayrollCap": 10000, "MonthlyPayrollCap": 40000,
"SchoolWeekDays": 5 "SchoolWeekDays": 5
} }
} }
@@ -5,7 +5,10 @@
"parentChance": 0.35, "parentChance": 0.35,
"hourlyWageBase": 30, "hourlyWageBase": 30,
"hourlyWagePerSkill": 0.6, "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, "baseWeeklyHours": 20,
// Nobody can carry more than this. Past it the subject needs a second teacher.
"maxWeeklyHours": 36,
"weeksPerMonth": 4, "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 /// Monthly payroll the player may commit. Hire and subject assignment that would exceed it
/// are rejected immediately; money itself does not move. /// are rejected immediately; money itself does not move.
/// </summary> /// </summary>
public float MonthlyPayrollCap { get; set; } = 10_000f; public float MonthlyPayrollCap { get; set; } = 40_000f;
/// <summary> /// <summary>
/// Working days from Monday. Five is MonFri; six adds Saturday; seven is every day. /// 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); 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(0f, staffing.Payroll);
Assert.Equal(10_000f, staffing.Remaining); Assert.Equal(40_000f, staffing.Remaining);
Assert.Equal(12, staffing.Applicants.Count); Assert.Equal(12, staffing.Applicants.Count);
Assert.Empty(staffing.Staff); Assert.Empty(staffing.Staff);
Assert.Contains(staffing.Uncovered, subject => subject.DefName == "Mathematics"); 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("payroll-exceeded", problem?.Code);
Assert.Equal(10_000f, problem?.Allocated); Assert.Equal(40_000f, problem?.Allocated);
Assert.True(problem?.Payroll <= 10_000f); Assert.True(problem?.Payroll <= 40_000f);
Assert.True(problem?.Attempted > 10_000f); Assert.True(problem?.Attempted > 40_000f);
Assert.True(staffing.Staff.Count >= 1); Assert.True(staffing.Staff.Count >= 1);
} }
@@ -165,7 +165,8 @@ public class StaffingApiTests(AppHostFixture fixture)
var teacher = hired.Staff.Single(); var teacher = hired.Staff.Single();
var assigned = await AssignAsync(client, school.Id, teacher.Id, "Mathematics"); 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"); assigned = await AssignAsync(client, school.Id, teacher.Id, "RussianLanguage");
Assert.True(assigned.Payroll > hired.Payroll); Assert.True(assigned.Payroll > hired.Payroll);
@@ -28,7 +28,7 @@ public class PeopleDefTests
Assert.Equal(12, catalog.StaffingRules.PoolSize); Assert.Equal(12, catalog.StaffingRules.PoolSize);
Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours); Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours);
Assert.Equal(4, catalog.StaffingRules.WeeksPerMonth); 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("Начальные классы", catalog.Label("ru", catalog.Subjects["PrimarySchool"]));
Assert.Equal("Primary", catalog.Label("en", catalog.Subjects["PrimarySchool"])); Assert.Equal("Primary", catalog.Label("en", catalog.Subjects["PrimarySchool"]));
Assert.NotNull(catalog.DayFrame); Assert.NotNull(catalog.DayFrame);
@@ -44,7 +44,7 @@ public class VanillaCoreTests
Assert.NotNull(catalog.StaffingRules); Assert.NotNull(catalog.StaffingRules);
Assert.Equal(12, catalog.StaffingRules.PoolSize); Assert.Equal(12, catalog.StaffingRules.PoolSize);
Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours); Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours);
Assert.Equal(0.25f, catalog.StaffingRules.ExtraSubjectSurcharge); Assert.Equal(36, catalog.StaffingRules.MaxWeeklyHours);
Assert.NotNull(catalog.DayFrame); Assert.NotNull(catalog.DayFrame);
Assert.Equal("08:30", catalog.DayFrame.FirstLesson); Assert.Equal("08:30", catalog.DayFrame.FirstLesson);
Assert.Equal(7, catalog.DayFrame.LessonCount); Assert.Equal(7, catalog.DayFrame.LessonCount);
+87 -22
View File
@@ -4,16 +4,22 @@ public class StaffingTests
{ {
private const float Cap = 10_000f; 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] [Fact]
public void MonthlyPay_AddsSurchargeAfterTheFirstSubject() public void MonthlyPay_IsHoursTimesTheHourlyAsk_WithAFullRateAsTheFloor()
{ {
var rules = Fixtures.Catalog().StaffingRules!; var rules = Fixtures.Catalog().StaffingRules!;
Assert.Equal(4_000f, Staffing.MonthlyBase(rules, 50f)); 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, 0f));
Assert.Equal(4_000f, Staffing.MonthlyPay(rules, 50f, 1)); Assert.Equal(4_000f, Staffing.MonthlyPay(rules, 50f, 12f));
Assert.Equal(5_000f, Staffing.MonthlyPay(rules, 50f, 2)); Assert.Equal(4_000f, Staffing.MonthlyPay(rules, 50f, 20f));
Assert.Equal(6_000f, Staffing.MonthlyPay(rules, 50f, 3)); Assert.Equal(6_000f, Staffing.MonthlyPay(rules, 50f, 30f));
Assert.Equal(7_200f, Staffing.MonthlyPay(rules, 50f, 36f));
} }
[Fact] [Fact]
@@ -53,25 +59,85 @@ public class StaffingTests
var hired = Staffing.Hire(catalog, map, roster, pool, first.Person.Id, Staffing.TeacherPosition, Cap); var hired = Staffing.Hire(catalog, map, roster, pool, first.Person.Id, Staffing.TeacherPosition, Cap);
Assert.Equal(StaffingError.None, hired.Error); 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; 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); loaded = Staffing.AssignSubject(catalog, loaded.Roster, loaded.Pool, first.Person.Id, subject, Cap);
Assert.Equal(StaffingError.None, loaded.Error); 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); var blocked = Staffing.Hire(catalog, map, loaded.Roster, loaded.Pool, second.Person.Id, Staffing.TeacherPosition, Cap);
Assert.Equal(StaffingError.PayrollExceeded, blocked.Error); 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(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); var fitted = Staffing.Hire(catalog, map, dropped.Roster, dropped.Pool, second.Person.Id, Staffing.TeacherPosition, Cap);
Assert.Equal(StaffingError.None, fitted.Error); 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] [Fact]
@@ -82,23 +148,22 @@ public class StaffingTests
pool = pool with { Applicants = [applicant, .. pool.Applicants.Skip(1)] }; pool = pool with { Applicants = [applicant, .. pool.Applicants.Skip(1)] };
var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, Cap); var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, Cap);
var loaded = hired; Assert.Equal(4_000f, hired.Payroll);
foreach (var subject in new[] { "Mathematics", "RussianLanguage", "Literature", "History", "Biology", "Physics", "Chemistry" })
{ // Mathematics is 35 hours: 50 × 35 × 4 = 7 000.
loaded = Staffing.AssignSubject(catalog, loaded.Roster, loaded.Pool, applicant.Person.Id, subject, Cap); var loaded = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", Cap);
Assert.Equal(StaffingError.None, loaded.Error); Assert.Equal(StaffingError.None, loaded.Error);
} Assert.Equal(7_000f, loaded.Payroll);
Assert.Equal(10_000f, loaded.Payroll); // 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);
var rejected = Staffing.AssignSubject(catalog, loaded.Roster, loaded.Pool, applicant.Person.Id, "Geography", Cap);
Assert.Equal(StaffingError.PayrollExceeded, rejected.Error); Assert.Equal(StaffingError.PayrollExceeded, rejected.Error);
Assert.Equal(10_000f, rejected.Payroll); Assert.Equal(7_000f, rejected.Payroll);
Assert.Equal(11_000f, rejected.Attempted); Assert.Equal(12_600f, rejected.Attempted);
Assert.Equal(0f, rejected.Remaining); Assert.Equal(3_000f, rejected.Remaining);
Assert.DoesNotContain( Assert.DoesNotContain(
rejected.Roster.People.Single(person => person.Id == applicant.Person.Id).Subjects, rejected.Roster.People.Single(person => person.Id == applicant.Person.Id).Subjects,
name => name == "Geography"); name => name == "RussianLanguage");
} }
[Fact] [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) => private static string Snapshot(Timetable table) =>
string.Join( string.Join(
'\n', '\n',