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',