From 25ce26064d487b0baea47765baad3d7969578f88 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 19 Aug 2026 12:51:38 +0300 Subject: [PATCH] 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. --- README.md | 2 +- docs/design/staffing.md | 21 ++- docs/phases/12-hiring-budget.md | 3 +- docs/protocol.md | 19 ++- src/HSchool.Client/src/i18n/strings.ts | 4 + src/HSchool.Client/src/net/api.ts | 1 + src/HSchool.Client/src/ui/managementPanel.ts | 6 + src/HSchool.Content/PeopleDefValidator.cs | 6 +- src/HSchool.Content/PeopleDefs.cs | 15 +- src/HSchool.People/Staffing.cs | 132 ++++++++++++++---- src/HSchool.Schedule/TimetablePlanner.cs | 52 +++++-- src/HSchool.Server/Api/PeopleModels.cs | 10 +- src/HSchool.Server/appsettings.json | 40 +++--- .../mods/core/defs/staffing/rules.jsonc | 5 +- src/HSchool.Simulation/SimulationOptions.cs | 2 +- .../HSchool.AppHost.Tests/StaffingApiTests.cs | 13 +- tests/HSchool.Content.Tests/PeopleDefTests.cs | 2 +- .../HSchool.Content.Tests/VanillaCoreTests.cs | 2 +- tests/HSchool.People.Tests/StaffingTests.cs | 109 ++++++++++++--- .../TimetablePlannerTests.cs | 44 ++++++ 20 files changed, 376 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index bc7b0dd..edba579 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/design/staffing.md b/docs/design/staffing.md index d4799e4..ec90211 100644 --- a/docs/design/staffing.md +++ b/docs/design/staffing.md @@ -135,11 +135,20 @@ учебного плана. Назначили человеку математику — он забирает все часы математики во всех параллелях, где она преподаётся, и это может оказаться много. +Ниже одной ставки (`BaseWeeklyHours`) не платят: нанятый стоит денег, даже если ему ничего не +назначили. Иначе набрать полный штат «про запас» было бы бесплатно. + Отсюда два ограничения вместо одного: -- **Деньги.** Фонд оплаты не может превысить выделенную сумму. -- **Человек.** Никто не может вести больше `MaxWeeklyHours` часов в неделю. Если часов предмета - больше, чем вытянет один, нужен второй учитель того же предмета — часы делятся между ними. +- **Деньги.** Фонд оплаты не может превысить выделенную сумму. Проверяется при найме и назначении. +- **Человек.** Никто не вытянет больше `MaxWeeklyHours` часов. Если часов предмета больше, нужен + второй учитель — часы делятся между ними, и первому становится легче и дешевле. + +**Предел часов не запрещает назначение, а помечает предмет непокрытым.** Запрет завёл бы в тупик: +первый учитель предмета всегда получает все его часы, поэтому первым не смог бы стать никто. +Считать часы «до предела» тоже нельзя — тогда седьмой предмет тому, кто уже за пределом, достаётся +бесплатно, а это ровно та дыра, ради которой почасовая оплата и вводилась. Поэтому часы считаются +целиком, деньги это показывают, а список непокрытого говорит, что один человек столько не выучит. Поэтому «нанять посильнее» и «нагрузить одного вместо двоих» — два разных способа потратить один фонд, и в этом весь выбор среза. Дешёвый учитель, взявший три предмета, упрётся в часы; дорогой @@ -218,8 +227,10 @@ | Пул | Общий, обновляется раз в неделю частично, детерминированно | | Учебный план | Часы в неделю по параллелям — поле `SubjectDef`, а не задел | | Запрос по деньгам | Цена часа: от навыков и от черт (самоуверенный просит больше) | -| Зарплата | Цена часа × недельная нагрузка из учебного плана | -| Предел нагрузки | `MaxWeeklyHours` на человека; сверх — нужен второй учитель предмета | +| Зарплата | Цена часа × недельная нагрузка из учебного плана, но не меньше одной ставки | +| Деление часов | Учителя одного предмета делят его часы поровну | +| Предел нагрузки | `MaxWeeklyHours` не запрещает назначение, а делает предмет непокрытым | +| Непокрытый предмет | Нет учителя **или** назначенные не вытягивают его часы | | Бюджет | Фиксированная сумма в месяц из настроек; баланса нет | | Предел | Фонд оплаты не может превысить выделенное; проверка в момент действия | | Экран | Два верхних раздела под часами: «Обзор» и «Управление» | diff --git a/docs/phases/12-hiring-budget.md b/docs/phases/12-hiring-budget.md index 10e1155..58b2c65 100644 --- a/docs/phases/12-hiring-budget.md +++ b/docs/phases/12-hiring-budget.md @@ -12,7 +12,8 @@ ## Задачи - [x] Выделяемая на месяц сумма — в `SimulationOptions`, с умолчанием -- [x] Фонд оплаты школы: сумма базовых ставок плюс надбавки за предметы сверх первого +- [x] Фонд оплаты школы: у каждого цена часа × недельная нагрузка, но не меньше одной ставки. + Нагрузка берётся из учебного плана и делится между учителями предмета - [x] Наём: соискатель уходит из пула, становится работником, попадает в ростер. Родитель остаётся родителем — новой сущности не заводится - [x] Назначение предмета нанятому и снятие предмета diff --git a/docs/protocol.md b/docs/protocol.md index ad1a918..eb5fa28 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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": "Математика" }] } ], diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 9a88971..9872f57 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -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})', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 87a6901..9d9bbde 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -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[]; } diff --git a/src/HSchool.Client/src/ui/managementPanel.ts b/src/HSchool.Client/src/ui/managementPanel.ts index 3070404..075eefa 100644 --- a/src/HSchool.Client/src/ui/managementPanel.ts +++ b/src/HSchool.Client/src/ui/managementPanel.ts @@ -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); } diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 7cd630d..73542e5 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -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."); } } diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index b210a33..013abc1 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -181,16 +181,19 @@ public sealed class StaffingDef : Def public float HourlyWagePerSkill { get; init; } - /// Hours assumed in the monthly base rate. Extra subjects add a surcharge on top. + /// + /// 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. + /// public float BaseWeeklyHours { get; init; } - public float WeeksPerMonth { get; init; } - /// - /// Added to the monthly base for each subject after the first. 0.25 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. /// - public float ExtraSubjectSurcharge { get; init; } + public float MaxWeeklyHours { get; init; } + + public float WeeksPerMonth { get; init; } } public enum BodyAttributeKind diff --git a/src/HSchool.People/Staffing.cs b/src/HSchool.People/Staffing.cs index bf50351..a2d12ce 100644 --- a/src/HSchool.People/Staffing.cs +++ b/src/HSchool.People/Staffing.cs @@ -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); + /// What one full rate costs at this price per hour — the floor under any hire. public static float MonthlyBase(StaffingDef rules, float hourlyAsk) => Round(hourlyAsk * rules.BaseWeeklyHours * rules.WeeksPerMonth); - public static float MonthlyPay(StaffingDef rules, float hourlyAsk, int subjectCount) + /// + /// 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. + /// + public static float MonthlyPay(StaffingDef rules, float hourlyAsk, float weeklyHours) => + Round(hourlyAsk * Math.Max(weeklyHours, rules.BaseWeeklyHours) * rules.WeeksPerMonth); + + /// + /// 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 . 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 + /// is what reports that one person cannot really teach all of it. + /// + 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 people) + /// Curriculum hours a subject needs each week across every class that studies it. + 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); } + /// + /// 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. + /// public static IReadOnlyList Uncovered(DefCatalog catalog, Roster roster) { var years = new HashSet(); @@ -71,7 +143,7 @@ public static class Staffing years.Add(schoolClass.Year); } - var taught = new HashSet(StringComparer.Ordinal); + var teachers = new Dictionary(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(); 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) => diff --git a/src/HSchool.Schedule/TimetablePlanner.cs b/src/HSchool.Schedule/TimetablePlanner.cs index 7ea664f..b983e4f 100644 --- a/src/HSchool.Schedule/TimetablePlanner.cs +++ b/src/HSchool.Schedule/TimetablePlanner.cs @@ -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; } + /// + /// 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. + /// private static bool TryPlaceOne( Occupancy occupancy, string classId, @@ -172,7 +173,26 @@ public static class TimetablePlanner int lessonCount, List 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)); + /// How many lessons this class already has that day — counted, not recomputed. + public int DayLoad(string classId, int day) => + _dayLoad.TryGetValue((classId, day), out var count) ? count : 0; + + /// How many lessons of this subject this class already has that day. + 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; } } } diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index d0e2060..11ea19d 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -211,6 +211,7 @@ internal sealed record StaffMemberResponse( string Position, string PositionLabel, float HourlyWageAsk, + float WeeklyHours, float MonthlyPay, IReadOnlyList 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); } diff --git a/src/HSchool.Server/appsettings.json b/src/HSchool.Server/appsettings.json index 8e3c2f8..18c0ec9 100644 --- a/src/HSchool.Server/appsettings.json +++ b/src/HSchool.Server/appsettings.json @@ -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 + } +} diff --git a/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc b/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc index 6dbaf56..e7cb673 100644 --- a/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc @@ -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, } diff --git a/src/HSchool.Simulation/SimulationOptions.cs b/src/HSchool.Simulation/SimulationOptions.cs index 275a2e3..231b18f 100644 --- a/src/HSchool.Simulation/SimulationOptions.cs +++ b/src/HSchool.Simulation/SimulationOptions.cs @@ -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. /// - public float MonthlyPayrollCap { get; set; } = 10_000f; + public float MonthlyPayrollCap { get; set; } = 40_000f; /// /// Working days from Monday. Five is Mon–Fri; six adds Saturday; seven is every day. diff --git a/tests/HSchool.AppHost.Tests/StaffingApiTests.cs b/tests/HSchool.AppHost.Tests/StaffingApiTests.cs index c47d1ca..01f9c7b 100644 --- a/tests/HSchool.AppHost.Tests/StaffingApiTests.cs +++ b/tests/HSchool.AppHost.Tests/StaffingApiTests.cs @@ -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); diff --git a/tests/HSchool.Content.Tests/PeopleDefTests.cs b/tests/HSchool.Content.Tests/PeopleDefTests.cs index 166b23a..e6e156c 100644 --- a/tests/HSchool.Content.Tests/PeopleDefTests.cs +++ b/tests/HSchool.Content.Tests/PeopleDefTests.cs @@ -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); diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index 17e6d0c..b0cfa25 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -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); diff --git a/tests/HSchool.People.Tests/StaffingTests.cs b/tests/HSchool.People.Tests/StaffingTests.cs index f612ec3..f84581e 100644 --- a/tests/HSchool.People.Tests/StaffingTests.cs +++ b/tests/HSchool.People.Tests/StaffingTests.cs @@ -4,16 +4,22 @@ public class StaffingTests { private const float Cap = 10_000f; + /// + /// 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. + /// [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); + } + + /// + /// 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. + /// + [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))); + } + + /// + /// 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. + /// + [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] diff --git a/tests/HSchool.Schedule.Tests/TimetablePlannerTests.cs b/tests/HSchool.Schedule.Tests/TimetablePlannerTests.cs index 4b079bb..e349b04 100644 --- a/tests/HSchool.Schedule.Tests/TimetablePlannerTests.cs +++ b/tests/HSchool.Schedule.Tests/TimetablePlannerTests.cs @@ -146,6 +146,50 @@ public class TimetablePlannerTests } } + /// + /// 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. + /// + [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',