Update staffing management to enhance payroll and hiring functionalities
- Increased the `MonthlyPayrollCap` from 10,000 to 40,000, allowing for greater flexibility in hiring and subject assignments. - Revised payroll calculation logic to ensure that staff members are compensated based on their actual weekly hours, with a minimum payment reflecting one full rate. - Updated documentation to clarify the new payroll structure and its implications for hiring and subject assignments. - Enhanced tests to validate the new payroll cap and ensure proper functionality in staffing scenarios, including the handling of uncovered subjects. - Improved localization strings to reflect changes in staffing and payroll terminology.
This commit is contained in:
@@ -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 Mon–Fri; 6 adds Saturday) |
|
||||
|
||||
## What is deliberately missing
|
||||
|
||||
+16
-5
@@ -135,11 +135,20 @@
|
||||
учебного плана. Назначили человеку математику — он забирает все часы математики во всех
|
||||
параллелях, где она преподаётся, и это может оказаться много.
|
||||
|
||||
Ниже одной ставки (`BaseWeeklyHours`) не платят: нанятый стоит денег, даже если ему ничего не
|
||||
назначили. Иначе набрать полный штат «про запас» было бы бесплатно.
|
||||
|
||||
Отсюда два ограничения вместо одного:
|
||||
|
||||
- **Деньги.** Фонд оплаты не может превысить выделенную сумму.
|
||||
- **Человек.** Никто не может вести больше `MaxWeeklyHours` часов в неделю. Если часов предмета
|
||||
больше, чем вытянет один, нужен второй учитель того же предмета — часы делятся между ними.
|
||||
- **Деньги.** Фонд оплаты не может превысить выделенную сумму. Проверяется при найме и назначении.
|
||||
- **Человек.** Никто не вытянет больше `MaxWeeklyHours` часов. Если часов предмета больше, нужен
|
||||
второй учитель — часы делятся между ними, и первому становится легче и дешевле.
|
||||
|
||||
**Предел часов не запрещает назначение, а помечает предмет непокрытым.** Запрет завёл бы в тупик:
|
||||
первый учитель предмета всегда получает все его часы, поэтому первым не смог бы стать никто.
|
||||
Считать часы «до предела» тоже нельзя — тогда седьмой предмет тому, кто уже за пределом, достаётся
|
||||
бесплатно, а это ровно та дыра, ради которой почасовая оплата и вводилась. Поэтому часы считаются
|
||||
целиком, деньги это показывают, а список непокрытого говорит, что один человек столько не выучит.
|
||||
|
||||
Поэтому «нанять посильнее» и «нагрузить одного вместо двоих» — два разных способа потратить один
|
||||
фонд, и в этом весь выбор среза. Дешёвый учитель, взявший три предмета, упрётся в часы; дорогой
|
||||
@@ -218,8 +227,10 @@
|
||||
| Пул | Общий, обновляется раз в неделю частично, детерминированно |
|
||||
| Учебный план | Часы в неделю по параллелям — поле `SubjectDef`, а не задел |
|
||||
| Запрос по деньгам | Цена часа: от навыков и от черт (самоуверенный просит больше) |
|
||||
| Зарплата | Цена часа × недельная нагрузка из учебного плана |
|
||||
| Предел нагрузки | `MaxWeeklyHours` на человека; сверх — нужен второй учитель предмета |
|
||||
| Зарплата | Цена часа × недельная нагрузка из учебного плана, но не меньше одной ставки |
|
||||
| Деление часов | Учителя одного предмета делят его часы поровну |
|
||||
| Предел нагрузки | `MaxWeeklyHours` не запрещает назначение, а делает предмет непокрытым |
|
||||
| Непокрытый предмет | Нет учителя **или** назначенные не вытягивают его часы |
|
||||
| Бюджет | Фиксированная сумма в месяц из настроек; баланса нет |
|
||||
| Предел | Фонд оплаты не может превысить выделенное; проверка в момент действия |
|
||||
| Экран | Два верхних раздела под часами: «Обзор» и «Управление» |
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
## Задачи
|
||||
|
||||
- [x] Выделяемая на месяц сумма — в `SimulationOptions`, с умолчанием
|
||||
- [x] Фонд оплаты школы: сумма базовых ставок плюс надбавки за предметы сверх первого
|
||||
- [x] Фонд оплаты школы: у каждого цена часа × недельная нагрузка, но не меньше одной ставки.
|
||||
Нагрузка берётся из учебного плана и делится между учителями предмета
|
||||
- [x] Наём: соискатель уходит из пула, становится работником, попадает в ростер. Родитель
|
||||
остаётся родителем — новой сущности не заводится
|
||||
- [x] Назначение предмета нанятому и снятие предмета
|
||||
|
||||
+14
-5
@@ -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": "Математика" }]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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})',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 Mon–Fri; 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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user