diff --git a/README.md b/README.md
index d8cbd86..a422d9e 100644
--- a/README.md
+++ b/README.md
@@ -119,6 +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 |
## What is deliberately missing
diff --git a/docs/phases/12-hiring-budget.md b/docs/phases/12-hiring-budget.md
index ac45ae6..10e1155 100644
--- a/docs/phases/12-hiring-budget.md
+++ b/docs/phases/12-hiring-budget.md
@@ -11,20 +11,20 @@
## Задачи
-- [ ] Выделяемая на месяц сумма — в `SimulationOptions`, с умолчанием
-- [ ] Фонд оплаты школы: сумма базовых ставок плюс надбавки за предметы сверх первого
-- [ ] Наём: соискатель уходит из пула, становится работником, попадает в ростер. Родитель
+- [x] Выделяемая на месяц сумма — в `SimulationOptions`, с умолчанием
+- [x] Фонд оплаты школы: сумма базовых ставок плюс надбавки за предметы сверх первого
+- [x] Наём: соискатель уходит из пула, становится работником, попадает в ростер. Родитель
остаётся родителем — новой сущности не заводится
-- [ ] Назначение предмета нанятому и снятие предмета
-- [ ] Предел проверяется **в момент действия**: наём или назначение, выводящее фонд за
+- [x] Назначение предмета нанятому и снятие предмета
+- [x] Предел проверяется **в момент действия**: наём или назначение, выводящее фонд за
выделенную сумму, отклоняется с внятным кодом ошибки, а не откладывается до конца месяца
-- [ ] Покрытие предметов: какие предметы преподаются в существующих параллелях и не имеют ни
+- [x] Покрытие предметов: какие предметы преподаются в существующих параллелях и не имеют ни
одного учителя
-- [ ] `GET /api/schools/{id}/staffing` — деньги, покрытие, пул и штат; читает опубликованный
+- [x] `GET /api/schools/{id}/staffing` — деньги, покрытие, пул и штат; читает опубликованный
снимок, воркер не трогает
-- [ ] Наём и назначение идут в воркер через мейлбокс с `TaskCompletionSource`, как create и delete
-- [ ] Состав пишется на диск при изменении, снимок публикуется заново
-- [ ] `docs/protocol.md` пополняется в том же коммите, что и обработчики
+- [x] Наём и назначение идут в воркер через мейлбокс с `TaskCompletionSource`, как create и delete
+- [x] Состав пишется на диск при изменении, снимок публикуется заново
+- [x] `docs/protocol.md` пополняется в том же коммите, что и обработчики
## Критерий готовности
diff --git a/docs/phases/README.md b/docs/phases/README.md
index a9d3cca..154321c 100644
--- a/docs/phases/README.md
+++ b/docs/phases/README.md
@@ -51,7 +51,7 @@
| --- | --- | --- |
| [10. Предметы и мебель](10-subjects.md) | ✅ | `SubjectDef`, одна учительская должность, кабинет как число мест |
| [11. Пустая школа и пул](11-applicants.md) | ✅ | Школа без сотрудников, соискатели с запросом по зарплате |
-| [12. Наём и бюджет](12-hiring-budget.md) | ⬜ | Наём, назначение предметов, предел фонда оплаты |
+| [12. Наём и бюджет](12-hiring-budget.md) | ✅ | Наём, назначение предметов, предел фонда оплаты |
| [13. Раздел «Управление»](13-management-tab.md) | ⬜ | Деньги, соискатели, штат и назначения на экране |
## Срез 4. Расписание
diff --git a/docs/protocol.md b/docs/protocol.md
index ffcdcbb..c963e35 100644
--- a/docs/protocol.md
+++ b/docs/protocol.md
@@ -212,6 +212,98 @@ cards.
}
```
+### `GET /api/schools/{id}/staffing`
+
+Money, uncovered subjects, the applicant pool and current staff. Reads the **published**
+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.
+
+```json
+{
+ "allocated": 10000,
+ "payroll": 5000,
+ "remaining": 5000,
+ "uncovered": [
+ {
+ "defName": "Mathematics",
+ "label": "Математика",
+ "gradeMin": 5,
+ "gradeMax": 11,
+ "hoursPerWeek": 5
+ }
+ ],
+ "applicants": [
+ {
+ "id": "a0.p0",
+ "fullName": "Соколов Иван Петрович",
+ "female": false,
+ "age": 34,
+ "isParent": false,
+ "hourlyWageAsk": 50,
+ "monthlyBase": 4000
+ }
+ ],
+ "staff": [
+ {
+ "id": "f3.p1",
+ "fullName": "Иванова Ольга Михайловна",
+ "female": true,
+ "age": 41,
+ "isParent": true,
+ "position": "Teacher",
+ "positionLabel": "Учитель",
+ "hourlyWageAsk": 50,
+ "monthlyPay": 5000,
+ "subjects": [{ "defName": "Mathematics", "label": "Математика" }]
+ }
+ ]
+}
+```
+
+Applicants here are the same people as in `saves/{id}.people.json`. A parent keeps the same
+id on the roster; hiring them sets `isStaff` on that person and does not create a second
+entity. Generated candidates (`aN.p0`) join the roster only when hired.
+
+### `POST /api/schools/{id}/staff/hire`
+
+Body: `{ "personId": "a0.p0", "position": "Teacher" }`. Goes through the school's mailbox.
+On success returns the same payload as `GET .../staffing`. `Teacher` needs no room opening;
+other positions fill the first free `RoomDef.positions` slot of that kind.
+
+| Status | `code` | When |
+| --- | --- | --- |
+| `404` | `unknown-school` | No school with that id. |
+| `404` | `unknown-applicant` | `personId` is not in the pool. |
+| `409` | `already-hired` | That person is already staff. |
+| `400` | `unknown-position` | Not a concrete `PositionDef`. |
+| `409` | `no-opening` | Every opening of that position is filled. |
+| `409` | `payroll-exceeded` | Hire would take `payroll` past `allocated`. |
+
+`payroll-exceeded` includes `allocated`, `payroll` (current), `remaining` and `attempted`
+(what payroll would become). Same RFC 7807 `code` field as the other errors.
+
+### `POST /api/schools/{id}/staff/{personId}/subjects`
+
+Body: `{ "subject": "Mathematics" }`. Teachers only. Same success payload as GET staffing.
+
+| Status | `code` | When |
+| --- | --- | --- |
+| `400` | `not-staff` | Person is not staff. |
+| `400` | `not-teacher` | Position is not `Teacher`. |
+| `400` | `unknown-subject` | Not a concrete `SubjectDef`. |
+| `409` | `already-assigned` | Already on this person. |
+| `409` | `payroll-exceeded` | Extra subject would exceed the cap. |
+
+### `DELETE /api/schools/{id}/staff/{personId}/subjects/{subject}`
+
+Removes one assignment. Payroll drops when the subject was not the only one. Unknown
+assignment is `404` `unknown-assignment`.
+
## WebSocket message ids
Client-to-server ids live in `0x00–0x7F`, server-to-client ids in `0x80–0xFF`, so a misrouted
diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs
index 742de73..27348f5 100644
--- a/src/HSchool.Content/PeopleDefValidator.cs
+++ b/src/HSchool.Content/PeopleDefValidator.cs
@@ -296,6 +296,16 @@ internal static class PeopleDefValidator
{
throw new ContentLoadException($"StaffingDef '{staffing.DefName}' wage scale cannot be negative.");
}
+
+ if (staffing.BaseWeeklyHours <= 0f || staffing.WeeksPerMonth <= 0f)
+ {
+ throw new ContentLoadException($"StaffingDef '{staffing.DefName}' monthly hours must be positive.");
+ }
+
+ if (staffing.ExtraSubjectSurcharge < 0f)
+ {
+ throw new ContentLoadException($"StaffingDef '{staffing.DefName}' extraSubjectSurcharge cannot be negative.");
+ }
}
private static void ValidateNameSet(NameSetDef names)
diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs
index 1d759d4..15d254f 100644
--- a/src/HSchool.Content/PeopleDefs.cs
+++ b/src/HSchool.Content/PeopleDefs.cs
@@ -175,6 +175,17 @@ public sealed class StaffingDef : Def
public float HourlyWageBase { get; init; }
public float HourlyWagePerSkill { get; init; }
+
+ /// Hours assumed in the monthly base rate. Extra subjects add a surcharge 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.
+ ///
+ public float ExtraSubjectSurcharge { get; init; }
}
public enum BodyAttributeKind
diff --git a/src/HSchool.People/ApplicantPool.cs b/src/HSchool.People/ApplicantPool.cs
index d039f13..57caa57 100644
--- a/src/HSchool.People/ApplicantPool.cs
+++ b/src/HSchool.People/ApplicantPool.cs
@@ -48,6 +48,14 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList
+ this with
+ {
+ Applicants = Applicants
+ .Where(applicant => !applicant.Person.Id.Equals(personId, StringComparison.Ordinal))
+ .ToArray(),
+ };
+
public static float HourlyAsk(DefCatalog catalog, Person person)
{
var rules = catalog.StaffingRules
diff --git a/src/HSchool.People/Roster.cs b/src/HSchool.People/Roster.cs
index 738d78a..20b0728 100644
--- a/src/HSchool.People/Roster.cs
+++ b/src/HSchool.People/Roster.cs
@@ -40,6 +40,12 @@ public sealed record Person
public required IReadOnlyDictionary Needs { get; init; }
+ /// SubjectDef names this staff member is assigned. Empty for everyone else.
+ public IReadOnlyList Subjects { get; init; } = [];
+
+ /// Hourly rate frozen at hire. Null until the person is staff.
+ public float? HourlyWageAsk { get; init; }
+
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
}
diff --git a/src/HSchool.People/Staffing.cs b/src/HSchool.People/Staffing.cs
new file mode 100644
index 0000000..bf50351
--- /dev/null
+++ b/src/HSchool.People/Staffing.cs
@@ -0,0 +1,357 @@
+using HSchool.Content;
+
+namespace HSchool.People;
+
+public enum StaffingError
+{
+ None,
+ UnknownSchool,
+ UnknownApplicant,
+ AlreadyHired,
+ UnknownPosition,
+ UnknownSubject,
+ NotStaff,
+ NotTeacher,
+ AlreadyAssigned,
+ SubjectNotAssigned,
+ PayrollExceeded,
+ NoOpening,
+}
+
+public sealed record StaffingOutcome(
+ StaffingError Error,
+ Roster Roster,
+ ApplicantPool Pool,
+ float Allocated,
+ float Payroll,
+ float Remaining,
+ float Attempted);
+
+///
+/// Hire, subject assignment and the monthly payroll cap. Scale lives on ;
+/// the cap is supplied by the caller because it is a simulation option, not catalog data.
+///
+public static class Staffing
+{
+ public const string TeacherPosition = "Teacher";
+
+ public static StaffingOutcome UnknownSchool() =>
+ Fail(StaffingError.UnknownSchool, new Roster([], [], []), ApplicantPool.Empty, allocated: 0, payroll: 0, attempted: 0);
+
+ public static float MonthlyBase(StaffingDef rules, float hourlyAsk) =>
+ Round(hourlyAsk * rules.BaseWeeklyHours * rules.WeeksPerMonth);
+
+ public static float MonthlyPay(StaffingDef rules, float hourlyAsk, int subjectCount)
+ {
+ var extras = Math.Max(0, subjectCount - 1);
+ return Round(MonthlyBase(rules, hourlyAsk) * (1f + (rules.ExtraSubjectSurcharge * extras)));
+ }
+
+ public static float Payroll(StaffingDef rules, IEnumerable people)
+ {
+ var total = 0f;
+ foreach (var person in people)
+ {
+ if (!person.IsStaff || person.HourlyWageAsk is not { } ask)
+ {
+ continue;
+ }
+
+ total += MonthlyPay(rules, ask, person.Subjects.Count);
+ }
+
+ return Round(total);
+ }
+
+ public static IReadOnlyList Uncovered(DefCatalog catalog, Roster roster)
+ {
+ var years = new HashSet();
+ foreach (var schoolClass in roster.Classes)
+ {
+ years.Add(schoolClass.Year);
+ }
+
+ var taught = new HashSet(StringComparer.Ordinal);
+ foreach (var person in roster.People)
+ {
+ if (!person.IsStaff)
+ {
+ continue;
+ }
+
+ foreach (var subject in person.Subjects)
+ {
+ taught.Add(subject);
+ }
+ }
+
+ var uncovered = new List();
+ foreach (var subject in catalog.Subjects.Values)
+ {
+ if (subject.Abstract || !TouchesYears(subject, years) || taught.Contains(subject.DefName))
+ {
+ continue;
+ }
+
+ uncovered.Add(subject);
+ }
+
+ return uncovered;
+ }
+
+ public static StaffingOutcome Hire(
+ DefCatalog catalog,
+ MapLayout? map,
+ Roster roster,
+ ApplicantPool pool,
+ string personId,
+ string position,
+ float allocated)
+ {
+ var rules = RequireRules(catalog);
+ var payroll = Payroll(rules, roster.People);
+
+ if (roster.People.Any(person => person.Id.Equals(personId, StringComparison.Ordinal) && person.IsStaff))
+ {
+ return Fail(StaffingError.AlreadyHired, roster, pool, allocated, payroll, payroll);
+ }
+
+ var applicant = pool.Applicants.FirstOrDefault(candidate => candidate.Person.Id.Equals(personId, StringComparison.Ordinal));
+ if (applicant is null)
+ {
+ return Fail(StaffingError.UnknownApplicant, roster, pool, allocated, payroll, payroll);
+ }
+
+ if (!IsConcretePosition(catalog, position))
+ {
+ return Fail(StaffingError.UnknownPosition, roster, pool, allocated, payroll, payroll);
+ }
+
+ if (!TryWorkplace(catalog, map, roster, position, out var workplace))
+ {
+ 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,
+ Position = position,
+ WorkplaceRoomId = workplace,
+ HourlyWageAsk = applicant.HourlyWageAsk,
+ Subjects = [],
+ };
+
+ var nextRoster = UpsertStaff(roster, hired);
+ var nextPool = pool.Without(personId);
+ return Ok(nextRoster, nextPool, allocated, Payroll(rules, nextRoster.People));
+ }
+
+ public static StaffingOutcome AssignSubject(
+ DefCatalog catalog,
+ Roster roster,
+ ApplicantPool pool,
+ string personId,
+ string subject,
+ float allocated)
+ {
+ var rules = RequireRules(catalog);
+ var payroll = Payroll(rules, roster.People);
+ var index = IndexOf(roster, personId);
+ if (index < 0)
+ {
+ return Fail(StaffingError.NotStaff, roster, pool, allocated, payroll, payroll);
+ }
+
+ var person = roster.People[index];
+ if (!person.IsStaff)
+ {
+ return Fail(StaffingError.NotStaff, roster, pool, allocated, payroll, payroll);
+ }
+
+ if (!TeacherPosition.Equals(person.Position, StringComparison.Ordinal))
+ {
+ return Fail(StaffingError.NotTeacher, roster, pool, allocated, payroll, payroll);
+ }
+
+ if (!IsConcreteSubject(catalog, subject))
+ {
+ return Fail(StaffingError.UnknownSubject, roster, pool, allocated, payroll, payroll);
+ }
+
+ if (person.Subjects.Contains(subject, StringComparer.Ordinal))
+ {
+ return Fail(StaffingError.AlreadyAssigned, roster, pool, allocated, payroll, payroll);
+ }
+
+ 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);
+ 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));
+ }
+
+ public static StaffingOutcome UnassignSubject(
+ DefCatalog catalog,
+ Roster roster,
+ ApplicantPool pool,
+ string personId,
+ string subject,
+ float allocated)
+ {
+ var rules = RequireRules(catalog);
+ var payroll = Payroll(rules, roster.People);
+ var index = IndexOf(roster, personId);
+ if (index < 0)
+ {
+ return Fail(StaffingError.NotStaff, roster, pool, allocated, payroll, payroll);
+ }
+
+ var person = roster.People[index];
+ if (!person.IsStaff)
+ {
+ return Fail(StaffingError.NotStaff, roster, pool, allocated, payroll, payroll);
+ }
+
+ if (!person.Subjects.Contains(subject, StringComparer.Ordinal))
+ {
+ return Fail(StaffingError.SubjectNotAssigned, roster, pool, allocated, payroll, payroll);
+ }
+
+ 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));
+ }
+
+ private static StaffingOutcome Ok(Roster roster, ApplicantPool pool, float allocated, float payroll) =>
+ new(StaffingError.None, roster, pool, allocated, payroll, Remaining(allocated, payroll), payroll);
+
+ private static StaffingOutcome Fail(
+ StaffingError error,
+ Roster roster,
+ ApplicantPool pool,
+ float allocated,
+ float payroll,
+ float attempted) =>
+ new(error, roster, pool, allocated, payroll, Remaining(allocated, payroll), attempted);
+
+ private static float Remaining(float allocated, float payroll) => Round(MathF.Max(0f, allocated - payroll));
+
+ private static float Round(float value) => MathF.Round(value, 2);
+
+ private static StaffingDef RequireRules(DefCatalog catalog) =>
+ catalog.StaffingRules ?? throw new InvalidOperationException("The catalog has no StaffingDef.");
+
+ private static bool IsConcretePosition(DefCatalog catalog, string position) =>
+ catalog.Positions.TryGetValue(position, out var def) && !def.Abstract;
+
+ private static bool IsConcreteSubject(DefCatalog catalog, string subject) =>
+ catalog.Subjects.TryGetValue(subject, out var def) && !def.Abstract;
+
+ private static bool TouchesYears(SubjectDef subject, HashSet years)
+ {
+ for (var year = subject.Grades.Min; year <= subject.Grades.Max; year++)
+ {
+ if (years.Contains(year))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool TryWorkplace(
+ DefCatalog catalog,
+ MapLayout? map,
+ Roster roster,
+ string position,
+ out string? workplace)
+ {
+ workplace = null;
+ if (TeacherPosition.Equals(position, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ if (map is null)
+ {
+ return false;
+ }
+
+ var taken = new HashSet(StringComparer.Ordinal);
+ foreach (var person in roster.People)
+ {
+ if (person.IsStaff
+ && position.Equals(person.Position, StringComparison.Ordinal)
+ && person.WorkplaceRoomId is { } roomId)
+ {
+ taken.Add(roomId);
+ }
+ }
+
+ foreach (var opening in SchoolDemand.From(catalog, map).Staff)
+ {
+ if (opening.Position.Equals(position, StringComparison.Ordinal) && taken.Add(opening.RoomId))
+ {
+ workplace = opening.RoomId;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static int IndexOf(Roster roster, string personId)
+ {
+ for (var i = 0; i < roster.People.Count; i++)
+ {
+ if (roster.People[i].Id.Equals(personId, StringComparison.Ordinal))
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ private static Roster Replace(Roster roster, int index, Person person)
+ {
+ var people = roster.People.ToArray();
+ people[index] = person;
+ return roster with { People = people };
+ }
+
+ private static Roster UpsertStaff(Roster roster, Person hired)
+ {
+ var index = IndexOf(roster, hired.Id);
+ if (index >= 0)
+ {
+ return Replace(roster, index, hired);
+ }
+
+ var people = roster.People.Append(hired).ToArray();
+ if (roster.Families.Any(family => family.Id.Equals(hired.FamilyId, StringComparison.Ordinal)))
+ {
+ return roster with { People = people };
+ }
+
+ var families = roster.Families.Append(new Family(hired.FamilyId, [hired.Id], [])).ToArray();
+ return roster with { People = people, Families = families };
+ }
+}
diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs
index 56b9050..6124a7c 100644
--- a/src/HSchool.Server/Api/PeopleModels.cs
+++ b/src/HSchool.Server/Api/PeopleModels.cs
@@ -169,3 +169,123 @@ internal static class PeopleListMapper
return new PeopleFilterOptionsResponse(years, letters, positions);
}
}
+
+internal sealed record HireStaffRequest(string? PersonId, string? Position);
+
+internal sealed record AssignSubjectRequest(string? Subject);
+
+internal sealed record StaffingResponse(
+ float Allocated,
+ float Payroll,
+ float Remaining,
+ IReadOnlyList Uncovered,
+ IReadOnlyList Applicants,
+ IReadOnlyList Staff);
+
+internal sealed record UncoveredSubjectResponse(
+ string DefName,
+ string Label,
+ int GradeMin,
+ int GradeMax,
+ int HoursPerWeek);
+
+internal sealed record ApplicantResponse(
+ string Id,
+ string FullName,
+ bool Female,
+ int Age,
+ bool IsParent,
+ float HourlyWageAsk,
+ float MonthlyBase);
+
+internal sealed record StaffMemberResponse(
+ string Id,
+ string FullName,
+ bool Female,
+ int Age,
+ bool IsParent,
+ string Position,
+ string PositionLabel,
+ float HourlyWageAsk,
+ float MonthlyPay,
+ IReadOnlyList Subjects);
+
+internal static class StaffingMapper
+{
+ public static StaffingResponse From(
+ Roster? roster,
+ ApplicantPool? pool,
+ DefCatalog? catalog,
+ DateTime asOf,
+ float allocated,
+ string locale)
+ {
+ roster ??= new Roster([], [], []);
+ pool ??= ApplicantPool.Empty;
+ var rules = catalog?.StaffingRules;
+ var payroll = rules is null ? 0f : Staffing.Payroll(rules, roster.People);
+ var remaining = MathF.Round(MathF.Max(0f, allocated - payroll), 2);
+
+ var uncovered = catalog is null
+ ? Array.Empty()
+ : Staffing.Uncovered(catalog, roster)
+ .Select(subject => new UncoveredSubjectResponse(
+ subject.DefName,
+ catalog.Label(locale, subject),
+ subject.Grades.Min,
+ subject.Grades.Max,
+ subject.HoursPerWeek))
+ .ToArray();
+
+ var applicants = pool.Applicants
+ .Select(applicant => new ApplicantResponse(
+ applicant.Person.Id,
+ applicant.Person.Name.Full,
+ applicant.Person.Female,
+ applicant.Person.AgeOn(asOf),
+ applicant.Person.IsParent,
+ applicant.HourlyWageAsk,
+ rules is null ? 0f : Staffing.MonthlyBase(rules, applicant.HourlyWageAsk)))
+ .ToArray();
+
+ var staff = roster.People
+ .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))
+ .ToArray();
+
+ return new StaffingResponse(allocated, payroll, remaining, uncovered, applicants, staff);
+ }
+
+ private static StaffMemberResponse Member(
+ Person person,
+ 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 subjects = person.Subjects
+ .Select(name => new DefLabelResponse(
+ name,
+ catalog is not null && catalog.Subjects.TryGetValue(name, out var def)
+ ? catalog.Label(locale, def)
+ : name))
+ .ToArray();
+
+ return new StaffMemberResponse(
+ person.Id,
+ person.Name.Full,
+ person.Female,
+ person.AgeOn(asOf),
+ person.IsParent,
+ person.Position ?? string.Empty,
+ PeopleListMapper.PositionLabel(catalog, locale, person.Position) ?? person.Position ?? string.Empty,
+ ask,
+ pay,
+ subjects);
+ }
+}
+
diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs
index bfd6aa4..db66c6d 100644
--- a/src/HSchool.Server/Api/SchoolEndpoints.cs
+++ b/src/HSchool.Server/Api/SchoolEndpoints.cs
@@ -6,8 +6,8 @@ using HSchool.Simulation;
namespace HSchool.Server.Api;
///
-/// The main menu talks to these: list, create, delete. The people list reads a published roster
-/// snapshot; the person card goes through the school's mailbox because needs are live.
+/// The main menu talks to these: list, create, delete. People list and staffing read published
+/// snapshots; the person card, hire and subject changes go through the school's mailbox.
///
internal static class SchoolEndpoints
{
@@ -156,6 +156,86 @@ internal static class SchoolEndpoints
};
})
.WithName("GetSchoolPerson");
+
+ schools.MapGet("/{id:int}/staffing", (
+ int id,
+ string? lang,
+ GameLoopService loop) =>
+ {
+ var published = loop.FindPeople(id);
+ if (published is null)
+ {
+ return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
+ }
+
+ return Results.Ok(MapStaffing(published, loop.Options.MonthlyPayrollCap, ParseLocale(lang)));
+ })
+ .WithName("GetSchoolStaffing");
+
+ schools.MapPost("/{id:int}/staff/hire", async (
+ int id,
+ HireStaffRequest request,
+ string? lang,
+ GameCommandQueue commands,
+ GameLoopService loop,
+ CancellationToken cancellationToken) =>
+ {
+ if (!TryPersonId(request.PersonId, out var personId, out var error)
+ || !TryDefName(request.Position, "position", out var position, out error))
+ {
+ return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
+ }
+
+ var command = new GameCommand.HireStaff(id, personId, position, NewCompletion());
+ commands.Enqueue(command);
+ var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
+ return StaffingResult(id, outcome, loop, ParseLocale(lang));
+ })
+ .WithName("HireSchoolStaff");
+
+ schools.MapPost("/{id:int}/staff/{personId}/subjects", async (
+ int id,
+ string personId,
+ AssignSubjectRequest request,
+ string? lang,
+ GameCommandQueue commands,
+ GameLoopService loop,
+ CancellationToken cancellationToken) =>
+ {
+ if (!TryPersonId(personId, out var idValue, out var error)
+ || !TryDefName(request.Subject, "subject", out var subject, out error))
+ {
+ return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
+ }
+
+ var command = new GameCommand.AssignSubject(id, idValue, subject, NewCompletion());
+ commands.Enqueue(command);
+ var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
+ return StaffingResult(id, outcome, loop, ParseLocale(lang));
+ })
+ .WithName("AssignSchoolSubject");
+
+ schools.MapDelete("/{id:int}/staff/{personId}/subjects/{subject}", async (
+ int id,
+ string personId,
+ string subject,
+ string? lang,
+ GameCommandQueue commands,
+ GameLoopService loop,
+ CancellationToken cancellationToken) =>
+ {
+ if (!TryPersonId(personId, out var idValue, out var error)
+ || !TryDefName(subject, "subject", out var subjectName, out error))
+ {
+ return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
+ }
+
+ var command = new GameCommand.UnassignSubject(id, idValue, subjectName, NewCompletion());
+ commands.Enqueue(command);
+ var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
+ return StaffingResult(id, outcome, loop, ParseLocale(lang));
+ })
+ .WithName("UnassignSchoolSubject");
}
/// The supervisor must never be blocked by a continuation of a waiting request.
@@ -279,11 +359,100 @@ internal static class SchoolEndpoints
return true;
}
- private static IResult Problem(int statusCode, string code, string detail) =>
- Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary
+ private static StaffingResponse MapStaffing(PublishedSchoolPeople published, float allocated, string locale) =>
+ StaffingMapper.From(
+ published.Roster,
+ published.Applicants,
+ published.Catalog,
+ published.School.GameTime,
+ allocated,
+ locale);
+
+ private static IResult StaffingResult(int schoolId, StaffingOutcome outcome, GameLoopService loop, string locale)
+ {
+ if (outcome.Error != StaffingError.None)
{
- ["code"] = code,
- });
+ return StaffingProblem(outcome);
+ }
+
+ var published = loop.FindPeople(schoolId);
+ if (published is null)
+ {
+ return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
+ }
+
+ return Results.Ok(MapStaffing(published, loop.Options.MonthlyPayrollCap, locale));
+ }
+
+ private static IResult StaffingProblem(StaffingOutcome outcome) =>
+ outcome.Error switch
+ {
+ StaffingError.UnknownApplicant =>
+ Problem(StatusCodes.Status404NotFound, "unknown-applicant", "That person is not in the applicant pool."),
+ StaffingError.AlreadyHired =>
+ Problem(StatusCodes.Status409Conflict, "already-hired", "That person is already on staff."),
+ StaffingError.UnknownPosition =>
+ Problem(StatusCodes.Status400BadRequest, "unknown-position", "That position is not in the catalog."),
+ StaffingError.UnknownSubject =>
+ Problem(StatusCodes.Status400BadRequest, "unknown-subject", "That subject is not in the catalog."),
+ StaffingError.NotStaff =>
+ Problem(StatusCodes.Status400BadRequest, "not-staff", "That person is not on staff."),
+ StaffingError.NotTeacher =>
+ Problem(StatusCodes.Status400BadRequest, "not-teacher", "Only a teacher can be assigned a subject."),
+ StaffingError.AlreadyAssigned =>
+ Problem(StatusCodes.Status409Conflict, "already-assigned", "That subject is already assigned to this person."),
+ StaffingError.SubjectNotAssigned =>
+ Problem(StatusCodes.Status404NotFound, "unknown-assignment", "That subject is not assigned to this person."),
+ StaffingError.PayrollExceeded =>
+ Problem(
+ StatusCodes.Status409Conflict,
+ "payroll-exceeded",
+ $"That change would take payroll from {outcome.Payroll} to {outcome.Attempted} against an allocation of {outcome.Allocated}.",
+ outcome),
+ StaffingError.NoOpening =>
+ Problem(StatusCodes.Status409Conflict, "no-opening", "There is no free opening for that position."),
+ _ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
+ };
+
+ private static bool TryPersonId(string? value, out string personId, out string error)
+ {
+ personId = value?.Trim() ?? string.Empty;
+ if (personId.Length is < 1 or > 64)
+ {
+ error = "The person id is not valid.";
+ return false;
+ }
+
+ error = string.Empty;
+ return true;
+ }
+
+ private static bool TryDefName(string? value, string field, out string name, out string error)
+ {
+ name = value?.Trim() ?? string.Empty;
+ if (name.Length is < 1 or > 64)
+ {
+ error = $"{field} is not valid.";
+ return false;
+ }
+
+ error = string.Empty;
+ return true;
+ }
+
+ private static IResult Problem(int statusCode, string code, string detail, StaffingOutcome? staffing = null)
+ {
+ var extensions = new Dictionary { ["code"] = code };
+ if (staffing is not null)
+ {
+ extensions["allocated"] = staffing.Allocated;
+ extensions["payroll"] = staffing.Payroll;
+ extensions["remaining"] = staffing.Remaining;
+ extensions["attempted"] = staffing.Attempted;
+ }
+
+ return Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: extensions);
+ }
}
/// Body of POST /api/schools. The start date is a game calendar date, not a real one.
diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs
index 63b24d8..2c9b0d5 100644
--- a/src/HSchool.Server/Game/GameCommand.cs
+++ b/src/HSchool.Server/Game/GameCommand.cs
@@ -1,4 +1,5 @@
using HSchool.Content;
+using HSchool.People;
using HSchool.Server.Api;
using HSchool.Simulation;
@@ -50,4 +51,22 @@ internal abstract record GameCommand
string PersonId,
string Locale,
TaskCompletionSource Result) : GameCommand;
+
+ internal sealed record HireStaff(
+ int SchoolId,
+ string PersonId,
+ string Position,
+ TaskCompletionSource Result) : GameCommand;
+
+ internal sealed record AssignSubject(
+ int SchoolId,
+ string PersonId,
+ string Subject,
+ TaskCompletionSource Result) : GameCommand;
+
+ internal sealed record UnassignSubject(
+ int SchoolId,
+ string PersonId,
+ string Subject,
+ TaskCompletionSource Result) : GameCommand;
}
diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs
index 9d16dae..a643662 100644
--- a/src/HSchool.Server/Game/GameLoopService.cs
+++ b/src/HSchool.Server/Game/GameLoopService.cs
@@ -171,6 +171,27 @@ internal sealed class GameLoopService(
case GameCommand.GetPerson getPerson:
HandleGetPerson(getPerson);
break;
+
+ case GameCommand.HireStaff hire:
+ HandleStaffing(
+ hire.SchoolId,
+ new WorkerCommand.HireStaff(hire.PersonId, hire.Position, hire.Result),
+ hire.Result);
+ break;
+
+ case GameCommand.AssignSubject assign:
+ HandleStaffing(
+ assign.SchoolId,
+ new WorkerCommand.AssignSubject(assign.PersonId, assign.Subject, assign.Result),
+ assign.Result);
+ break;
+
+ case GameCommand.UnassignSubject unassign:
+ HandleStaffing(
+ unassign.SchoolId,
+ new WorkerCommand.UnassignSubject(unassign.PersonId, unassign.Subject, unassign.Result),
+ unassign.Result);
+ break;
}
}
@@ -183,6 +204,14 @@ internal sealed class GameLoopService(
}
}
+ private void HandleStaffing(int schoolId, WorkerCommand command, TaskCompletionSource result)
+ {
+ if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
+ {
+ result.TrySetResult(Staffing.UnknownSchool());
+ }
+ }
+
///
/// A school's thread died. Drop it from the table so the menu stops drawing a card whose clock
/// never moves again, and tell anybody watching it to go back to the menu. The save file stays
diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs
index b69bcf4..feb6115 100644
--- a/src/HSchool.Server/Game/SchoolWorker.cs
+++ b/src/HSchool.Server/Game/SchoolWorker.cs
@@ -342,10 +342,7 @@ internal sealed class SchoolWorker
{
while (_mailbox.Reader.TryRead(out var orphan))
{
- if (orphan is WorkerCommand.GetPerson getPerson)
- {
- getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
- }
+ CompleteOrphan(orphan);
}
return;
@@ -393,14 +390,23 @@ internal sealed class SchoolWorker
? new PersonCardResult(null, PersonLookupError.UnknownPerson)
: new PersonCardResult(card, PersonLookupError.None));
break;
+
+ case WorkerCommand.HireStaff hire:
+ hire.Result.TrySetResult(ApplyHire(school, hire.PersonId, hire.Position));
+ break;
+
+ case WorkerCommand.AssignSubject assign:
+ assign.Result.TrySetResult(ApplyAssign(school, assign.PersonId, assign.Subject));
+ break;
+
+ case WorkerCommand.UnassignSubject unassign:
+ unassign.Result.TrySetResult(ApplyUnassign(school, unassign.PersonId, unassign.Subject));
+ break;
}
}
catch (Exception ex)
{
- if (command is WorkerCommand.GetPerson failed)
- {
- failed.Result.TrySetException(ex);
- }
+ FailCommand(command, ex);
_logger.LogError(
ex,
@@ -421,6 +427,76 @@ internal sealed class SchoolWorker
}
}
+ private static void CompleteOrphan(WorkerCommand command)
+ {
+ switch (command)
+ {
+ case WorkerCommand.GetPerson getPerson:
+ getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
+ break;
+ case WorkerCommand.HireStaff hire:
+ hire.Result.TrySetResult(Staffing.UnknownSchool());
+ break;
+ case WorkerCommand.AssignSubject assign:
+ assign.Result.TrySetResult(Staffing.UnknownSchool());
+ break;
+ case WorkerCommand.UnassignSubject unassign:
+ unassign.Result.TrySetResult(Staffing.UnknownSchool());
+ break;
+ }
+ }
+
+ private static void FailCommand(WorkerCommand command, Exception exception)
+ {
+ switch (command)
+ {
+ case WorkerCommand.GetPerson getPerson:
+ getPerson.Result.TrySetException(exception);
+ break;
+ case WorkerCommand.HireStaff hire:
+ hire.Result.TrySetException(exception);
+ break;
+ case WorkerCommand.AssignSubject assign:
+ assign.Result.TrySetException(exception);
+ break;
+ case WorkerCommand.UnassignSubject unassign:
+ unassign.Result.TrySetException(exception);
+ break;
+ }
+ }
+
+ private StaffingOutcome ApplyHire(School school, string personId, string position) =>
+ ApplyStaffingChange(school, (catalog, roster, pool) =>
+ Staffing.Hire(catalog, school.Map, roster, pool, personId, position, _options.MonthlyPayrollCap));
+
+ private StaffingOutcome ApplyAssign(School school, string personId, string subject) =>
+ ApplyStaffingChange(school, (catalog, roster, pool) =>
+ Staffing.AssignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap));
+
+ private StaffingOutcome ApplyUnassign(School school, string personId, string subject) =>
+ ApplyStaffingChange(school, (catalog, roster, pool) =>
+ Staffing.UnassignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap));
+
+ private StaffingOutcome ApplyStaffingChange(
+ School school,
+ Func apply)
+ {
+ if (school.Roster is null || school.Applicants is null || school.Catalog is null)
+ {
+ return Staffing.UnknownSchool();
+ }
+
+ var outcome = apply(school.Catalog, school.Roster, school.Applicants);
+ if (outcome.Error == StaffingError.None)
+ {
+ school.ApplyStaffing(outcome.Roster, outcome.Pool);
+ PersistPeople();
+ PublishSnapshot();
+ }
+
+ return outcome;
+ }
+
///
/// Writes pause/speed changes, at most once per .
/// A single click still lands within that window; a burst collapses into one write.
@@ -459,7 +535,7 @@ internal sealed class SchoolWorker
///
/// Writes the composition file. Not called from the 30-second clock save — the roster and
- /// applicant pool change on create, weekly refresh and yearly intake, not every tick.
+ /// applicant pool change on create, hire, weekly refresh and yearly intake, not every tick.
///
private void PersistPeople()
{
diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs
index f3a3fb7..e93f5c9 100644
--- a/src/HSchool.Server/Game/WorkerCommand.cs
+++ b/src/HSchool.Server/Game/WorkerCommand.cs
@@ -1,3 +1,4 @@
+using HSchool.People;
using HSchool.Server.Api;
using HSchool.Server.Net;
@@ -21,4 +22,19 @@ internal abstract record WorkerCommand
string PersonId,
string Locale,
TaskCompletionSource Result) : WorkerCommand;
+
+ internal sealed record HireStaff(
+ string PersonId,
+ string Position,
+ TaskCompletionSource Result) : WorkerCommand;
+
+ internal sealed record AssignSubject(
+ string PersonId,
+ string Subject,
+ TaskCompletionSource Result) : WorkerCommand;
+
+ internal sealed record UnassignSubject(
+ string PersonId,
+ string Subject,
+ TaskCompletionSource Result) : WorkerCommand;
}
diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs
index a3da41f..a2c9955 100644
--- a/src/HSchool.Server/Program.cs
+++ b/src/HSchool.Server/Program.cs
@@ -21,6 +21,7 @@ builder.Services
.Validate(options => !string.IsNullOrWhiteSpace(options.SavesDirectory), "Simulation:SavesDirectory must be set.")
.Validate(options => !string.IsNullOrWhiteSpace(options.ModsDirectory), "Simulation:ModsDirectory must be set.")
.Validate(options => options.SaveIntervalSeconds is > 0 and <= 3600, "Simulation:SaveIntervalSeconds must be between 1 and 3600.")
+ .Validate(options => options.MonthlyPayrollCap > 0, "Simulation:MonthlyPayrollCap must be positive.")
.ValidateOnStart();
builder.Services.AddSingleton();
diff --git a/src/HSchool.Server/appsettings.json b/src/HSchool.Server/appsettings.json
index 06685bb..f362471 100644
--- a/src/HSchool.Server/appsettings.json
+++ b/src/HSchool.Server/appsettings.json
@@ -13,6 +13,7 @@
"DefaultStartDate": "2012-03-31T06:00:00",
"SavesDirectory": "saves",
"ModsDirectory": "mods",
- "SaveIntervalSeconds": 30
+ "SaveIntervalSeconds": 30,
+ "MonthlyPayrollCap": 10000
}
}
diff --git a/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc b/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc
index 2aad1ac..6dbaf56 100644
--- a/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc
+++ b/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc
@@ -5,4 +5,7 @@
"parentChance": 0.35,
"hourlyWageBase": 30,
"hourlyWagePerSkill": 0.6,
+ "baseWeeklyHours": 20,
+ "weeksPerMonth": 4,
+ "extraSubjectSurcharge": 0.25,
}
diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs
index 63948b4..081348b 100644
--- a/src/HSchool.Simulation/School.cs
+++ b/src/HSchool.Simulation/School.cs
@@ -91,6 +91,21 @@ public sealed class School : IDisposable
RosterSpawner.Spawn(World, roster);
}
+ ///
+ /// Replaces the roster and applicant pool after hire or a subject change. The World is rebuilt
+ /// so the people list and card match; needs reset to the roster snapshot, same as yearly intake.
+ ///
+ public void ApplyStaffing(Roster roster, ApplicantPool applicants)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(roster);
+ ArgumentNullException.ThrowIfNull(applicants);
+
+ Roster = roster;
+ Applicants = applicants;
+ RosterSpawner.Replace(World, roster);
+ }
+
/// Runs one fixed step of the school: calendar, yearly intake, applicant refresh, then need decay.
/// when the roster or the applicant pool changed this step.
public bool Tick(double deltaTime, double gameMinutesPerRealSecond)
diff --git a/src/HSchool.Simulation/SimulationOptions.cs b/src/HSchool.Simulation/SimulationOptions.cs
index b36bb54..f661d57 100644
--- a/src/HSchool.Simulation/SimulationOptions.cs
+++ b/src/HSchool.Simulation/SimulationOptions.cs
@@ -49,6 +49,12 @@ public sealed class SimulationOptions
///
public int MinSaveIntervalMilliseconds { get; set; } = 1000;
+ ///
+ /// 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;
+
/// Length of one fixed step.
public double FixedDeltaTime => 1d / TickRate;
diff --git a/tests/HSchool.AppHost.Tests/StaffingApiTests.cs b/tests/HSchool.AppHost.Tests/StaffingApiTests.cs
new file mode 100644
index 0000000..50861c9
--- /dev/null
+++ b/tests/HSchool.AppHost.Tests/StaffingApiTests.cs
@@ -0,0 +1,260 @@
+using System.Net.Http.Json;
+
+namespace HSchool.AppHost.Tests;
+
+[Collection(AppHostCollection.Name)]
+public class StaffingApiTests(AppHostFixture fixture)
+{
+ private static readonly DateTime Start = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public async Task GetStaffing_UnknownSchool_IsNotFound()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+
+ using var response = await client.GetAsync("/api/schools/999999/staffing", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ Assert.Equal("unknown-school", await ProblemCodeAsync(response));
+ }
+
+ [Fact]
+ public async Task GetStaffing_EmptySchool_HasPoolAndNoPayroll()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var school = await SchoolApiTests.CreateAsync(client, "Штат пустой", Start);
+
+ var staffing = await GetStaffingAsync(client, school.Id);
+
+ Assert.Equal(10_000f, staffing.Allocated);
+ Assert.Equal(0f, staffing.Payroll);
+ Assert.Equal(10_000f, staffing.Remaining);
+ Assert.Equal(12, staffing.Applicants.Count);
+ Assert.Empty(staffing.Staff);
+ Assert.Contains(staffing.Uncovered, subject => subject.DefName == "Mathematics");
+ Assert.Contains(staffing.Uncovered, subject => subject.DefName == "PrimarySchool");
+ }
+
+ [Fact]
+ public async Task Hire_AddsStaff_RemovesApplicant_AndRejectsASecondHireOfTheSamePerson()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var school = await SchoolApiTests.CreateAsync(client, "Штат наём", Start);
+ var before = await GetStaffingAsync(client, school.Id);
+ var applicant = before.Applicants[0];
+
+ var after = await HireAsync(client, school.Id, applicant.Id, "Teacher");
+
+ Assert.Contains(after.Staff, member => member.Id == applicant.Id && member.Position == "Teacher");
+ Assert.DoesNotContain(after.Applicants, candidate => candidate.Id == applicant.Id);
+ Assert.True(after.Payroll > 0f);
+ Assert.True(after.Payroll <= after.Allocated);
+
+ using var again = await client.PostAsJsonAsync(
+ $"/api/schools/{school.Id}/staff/hire",
+ new { personId = applicant.Id, position = "Teacher" },
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
+ Assert.Equal("already-hired", await ProblemCodeAsync(again));
+
+ var people = await client.GetFromJsonAsync(
+ $"/api/schools/{school.Id}/people?role=staff&pageSize=10",
+ TestContext.Current.CancellationToken);
+ Assert.NotNull(people);
+ Assert.Contains(people.People, person => person.Id == applicant.Id);
+ }
+
+ [Fact]
+ public async Task Hire_UnknownApplicant_IsNotFound()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var school = await SchoolApiTests.CreateAsync(client, "Штат неизвестный", Start);
+
+ using var response = await client.PostAsJsonAsync(
+ $"/api/schools/{school.Id}/staff/hire",
+ new { personId = "nobody", position = "Teacher" },
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ Assert.Equal("unknown-applicant", await ProblemCodeAsync(response));
+ }
+
+ [Fact]
+ public async Task Hire_PastTheCap_IsRejectedWithTheNumbers()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var school = await SchoolApiTests.CreateAsync(client, "Штат предел", Start);
+
+ var staffing = await GetStaffingAsync(client, school.Id);
+ ProblemResponse? problem = null;
+ while (staffing.Applicants.Count > 0)
+ {
+ var applicant = staffing.Applicants[0];
+ using var response = await client.PostAsJsonAsync(
+ $"/api/schools/{school.Id}/staff/hire",
+ new { personId = applicant.Id, position = "Teacher" },
+ TestContext.Current.CancellationToken);
+ if (response.StatusCode == HttpStatusCode.Conflict)
+ {
+ problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ break;
+ }
+
+ response.EnsureSuccessStatusCode();
+ staffing = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(staffing);
+ }
+
+ 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.True(staffing.Staff.Count >= 1);
+ }
+
+ [Fact]
+ public async Task AssignAndUnassign_HonourTheCap_AndReloadKeepsAssignments()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var school = await SchoolApiTests.CreateAsync(client, "Штат предметы", Start);
+ var before = await GetStaffingAsync(client, school.Id);
+ var hired = await HireAsync(client, school.Id, before.Applicants[0].Id, "Teacher");
+ var teacher = hired.Staff.Single();
+
+ var assigned = await AssignAsync(client, school.Id, teacher.Id, "Mathematics");
+ Assert.Equal(hired.Payroll, assigned.Payroll);
+
+ assigned = await AssignAsync(client, school.Id, teacher.Id, "RussianLanguage");
+ Assert.True(assigned.Payroll > hired.Payroll);
+ Assert.Equal(2, assigned.Staff.Single().Subjects.Count);
+
+ using var over = await client.PostAsJsonAsync(
+ $"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher.Id)}/subjects",
+ new { subject = "Literature" },
+ TestContext.Current.CancellationToken);
+ // Cheap asks may still fit; only the numbers matter when the cap actually bites.
+ if (over.StatusCode == HttpStatusCode.Conflict)
+ {
+ var problem = await over.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.Equal("payroll-exceeded", problem?.Code);
+ Assert.True(problem?.Attempted > problem?.Allocated);
+ Assert.Equal(assigned.Payroll, problem?.Payroll);
+ }
+ else
+ {
+ over.EnsureSuccessStatusCode();
+ assigned = await over.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(assigned);
+ }
+
+ var payrollWithSubjects = assigned.Payroll;
+ using var unassign = await client.DeleteAsync(
+ $"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher.Id)}/subjects/RussianLanguage",
+ TestContext.Current.CancellationToken);
+ unassign.EnsureSuccessStatusCode();
+ var afterDrop = await unassign.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(afterDrop);
+ Assert.True(afterDrop.Payroll < payrollWithSubjects);
+
+ using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
+ reload.EnsureSuccessStatusCode();
+
+ var restored = await GetStaffingAsync(client, school.Id);
+ Assert.Equal(afterDrop.Payroll, restored.Payroll);
+ Assert.Equal(teacher.Id, restored.Staff.Single().Id);
+ Assert.Equal(
+ afterDrop.Staff.Single().Subjects.Select(subject => subject.DefName),
+ restored.Staff.Single().Subjects.Select(subject => subject.DefName));
+ }
+
+ private static async Task GetStaffingAsync(HttpClient client, int schoolId)
+ {
+ var staffing = await client.GetFromJsonAsync(
+ $"/api/schools/{schoolId}/staffing?lang=ru",
+ TestContext.Current.CancellationToken);
+ Assert.NotNull(staffing);
+ return staffing;
+ }
+
+ private static async Task HireAsync(HttpClient client, int schoolId, string personId, string position)
+ {
+ using var response = await client.PostAsJsonAsync(
+ $"/api/schools/{schoolId}/staff/hire",
+ new { personId, position },
+ TestContext.Current.CancellationToken);
+ response.EnsureSuccessStatusCode();
+ var staffing = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(staffing);
+ return staffing;
+ }
+
+ private static async Task AssignAsync(HttpClient client, int schoolId, string personId, string subject)
+ {
+ using var response = await client.PostAsJsonAsync(
+ $"/api/schools/{schoolId}/staff/{Uri.EscapeDataString(personId)}/subjects",
+ new { subject },
+ TestContext.Current.CancellationToken);
+ response.EnsureSuccessStatusCode();
+ var staffing = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(staffing);
+ return staffing;
+ }
+
+ private static async Task ProblemCodeAsync(HttpResponseMessage response)
+ {
+ var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ return problem?.Code;
+ }
+
+ private sealed record ProblemResponse(string? Code, float? Allocated, float? Payroll, float? Remaining, float? Attempted);
+
+ private sealed record StaffingResponse(
+ float Allocated,
+ float Payroll,
+ float Remaining,
+ IReadOnlyList Uncovered,
+ IReadOnlyList Applicants,
+ IReadOnlyList Staff);
+
+ private sealed record UncoveredSubjectResponse(string DefName, string Label, int GradeMin, int GradeMax, int HoursPerWeek);
+
+ private sealed record ApplicantResponse(string Id, string FullName, bool Female, int Age, bool IsParent, float HourlyWageAsk, float MonthlyBase);
+
+ private sealed record StaffMemberResponse(
+ string Id,
+ string FullName,
+ bool Female,
+ int Age,
+ bool IsParent,
+ string Position,
+ string PositionLabel,
+ float HourlyWageAsk,
+ float MonthlyPay,
+ IReadOnlyList Subjects);
+
+ private sealed record DefLabelResponse(string DefName, string Label);
+
+ private sealed record PeopleListResponse(
+ int Total,
+ int Page,
+ int PageSize,
+ IReadOnlyList People,
+ object Filters);
+
+ private sealed record PersonListItemResponse(
+ string Id,
+ string FullName,
+ string Surname,
+ string Given,
+ string Patronymic,
+ bool Female,
+ int Age,
+ IReadOnlyList Roles,
+ int? ClassYear,
+ string? ClassLetter,
+ string? Position,
+ string? PositionLabel);
+}
diff --git a/tests/HSchool.Content.Tests/PeopleDefTests.cs b/tests/HSchool.Content.Tests/PeopleDefTests.cs
index 7f26a24..a990f11 100644
--- a/tests/HSchool.Content.Tests/PeopleDefTests.cs
+++ b/tests/HSchool.Content.Tests/PeopleDefTests.cs
@@ -26,6 +26,9 @@ public class PeopleDefTests
Assert.True(catalog.Subjects.ContainsKey("PrimarySchool"));
Assert.NotNull(catalog.StaffingRules);
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("Начальные классы", catalog.Label("ru", catalog.Subjects["PrimarySchool"]));
Assert.Equal("Primary", catalog.Label("en", catalog.Subjects["PrimarySchool"]));
}
diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs
index 6638acf..929385f 100644
--- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs
+++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs
@@ -43,6 +43,8 @@ public class VanillaCoreTests
Assert.True(catalog.Subjects.ContainsKey("PhysicalEducation"));
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(2, map.Buildings.Count);
var homerooms = map.Rooms.Where(room => room.Def == "Classroom").ToList();
Assert.Equal(11, homerooms.Count);
diff --git a/tests/HSchool.People.Tests/StaffingTests.cs b/tests/HSchool.People.Tests/StaffingTests.cs
new file mode 100644
index 0000000..f612ec3
--- /dev/null
+++ b/tests/HSchool.People.Tests/StaffingTests.cs
@@ -0,0 +1,212 @@
+namespace HSchool.People.Tests;
+
+public class StaffingTests
+{
+ private const float Cap = 10_000f;
+
+ [Fact]
+ public void MonthlyPay_AddsSurchargeAfterTheFirstSubject()
+ {
+ 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));
+ }
+
+ [Fact]
+ public void Hire_TwoPeopleAtThreeAndSevenThousand_RejectsTheThird()
+ {
+ var (catalog, map, roster, pool) = Fresh();
+ var cheap = WithAsk(pool.Applicants[0], 37.5f);
+ var expensive = WithAsk(pool.Applicants[1], 87.5f);
+ var extra = WithAsk(pool.Applicants[2], 40f);
+ pool = pool with { Applicants = [cheap, expensive, extra] };
+
+ var first = Staffing.Hire(catalog, map, roster, pool, cheap.Person.Id, Staffing.TeacherPosition, Cap);
+ Assert.Equal(StaffingError.None, first.Error);
+ Assert.Equal(3_000f, first.Payroll);
+
+ var second = Staffing.Hire(catalog, map, first.Roster, first.Pool, expensive.Person.Id, Staffing.TeacherPosition, Cap);
+ Assert.Equal(StaffingError.None, second.Error);
+ Assert.Equal(10_000f, second.Payroll);
+ Assert.Equal(0f, second.Remaining);
+
+ var third = Staffing.Hire(catalog, map, second.Roster, second.Pool, extra.Person.Id, Staffing.TeacherPosition, Cap);
+ Assert.Equal(StaffingError.PayrollExceeded, third.Error);
+ Assert.Equal(10_000f, third.Payroll);
+ Assert.True(third.Attempted > Cap);
+ Assert.Equal(2, second.Roster.People.Count(person => person.IsStaff));
+ Assert.DoesNotContain(third.Roster.People, person => person.Id == extra.Person.Id && person.IsStaff);
+ }
+
+ [Fact]
+ public void UnassignSubject_LowersPayrollSoAnotherHireFits()
+ {
+ var (catalog, map, roster, pool) = Fresh();
+ var first = WithAsk(pool.Applicants[0], 37.5f);
+ var second = WithAsk(pool.Applicants[1], 50f);
+ pool = pool with { Applicants = [first, second, .. pool.Applicants.Skip(2)] };
+
+ var hired = Staffing.Hire(catalog, map, roster, pool, first.Person.Id, Staffing.TeacherPosition, Cap);
+ Assert.Equal(StaffingError.None, hired.Error);
+
+ var loaded = hired;
+ foreach (var subject in new[] { "Mathematics", "RussianLanguage", "Literature", "History", "Biology", "ForeignLanguage" })
+ {
+ 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);
+
+ 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);
+ Assert.Equal(StaffingError.None, dropped.Error);
+ Assert.Equal(6_000f, 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);
+ }
+
+ [Fact]
+ public void AssignSubject_ThatWouldExceedTheCap_IsRejected()
+ {
+ var (catalog, map, roster, pool) = Fresh();
+ var applicant = WithAsk(pool.Applicants[0], 50f);
+ 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(10_000f, loaded.Payroll);
+
+ var rejected = Staffing.AssignSubject(catalog, loaded.Roster, loaded.Pool, applicant.Person.Id, "Geography", 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.DoesNotContain(
+ rejected.Roster.People.Single(person => person.Id == applicant.Person.Id).Subjects,
+ name => name == "Geography");
+ }
+
+ [Fact]
+ public void Hire_ParentKeepsTheSameId_GeneratedJoinsTheRoster()
+ {
+ var catalog = Fixtures.Catalog();
+ var map = Fixtures.VanillaMap();
+ Roster? roster = null;
+ Applicant? parent = null;
+ Applicant? generated = null;
+ ApplicantPool? pool = null;
+ for (var seed = 1; seed <= 30 && pool is null; seed++)
+ {
+ var candidate = Fixtures.Generate(map, seed);
+ var created = ApplicantPool.Create(catalog, candidate, seed, "Slavic", Fixtures.AsOf);
+ var rosterIds = candidate.People.Select(person => person.Id).ToHashSet(StringComparer.Ordinal);
+ var parentHere = created.Applicants.FirstOrDefault(applicant => rosterIds.Contains(applicant.Person.Id));
+ var generatedHere = created.Applicants.FirstOrDefault(applicant => applicant.Person.Id.StartsWith('a'));
+ if (parentHere is not null && generatedHere is not null)
+ {
+ roster = candidate;
+ pool = created;
+ parent = parentHere;
+ generated = generatedHere;
+ }
+ }
+
+ Assert.NotNull(roster);
+ Assert.NotNull(pool);
+ Assert.NotNull(parent);
+ Assert.NotNull(generated);
+
+ var afterParent = Staffing.Hire(catalog, map, roster, pool, parent.Person.Id, Staffing.TeacherPosition, 50_000f);
+ Assert.Equal(StaffingError.None, afterParent.Error);
+ var staffParent = afterParent.Roster.People.Single(person => person.Id == parent.Person.Id);
+ Assert.True(staffParent.IsParent);
+ Assert.True(staffParent.IsStaff);
+ Assert.Equal(1, afterParent.Roster.People.Count(person => person.Id == parent.Person.Id));
+ Assert.DoesNotContain(afterParent.Pool.Applicants, applicant => applicant.Person.Id == parent.Person.Id);
+
+ var afterGenerated = Staffing.Hire(catalog, map, afterParent.Roster, afterParent.Pool, generated.Person.Id, Staffing.TeacherPosition, 50_000f);
+ Assert.Equal(StaffingError.None, afterGenerated.Error);
+ Assert.Contains(afterGenerated.Roster.People, person => person.Id == generated.Person.Id && person.IsStaff && !person.IsParent);
+ Assert.Contains(afterGenerated.Roster.Families, family => family.Id == generated.Person.FamilyId);
+
+ var again = Staffing.Hire(catalog, map, afterGenerated.Roster, afterGenerated.Pool, parent.Person.Id, Staffing.TeacherPosition, 50_000f);
+ Assert.Equal(StaffingError.AlreadyHired, again.Error);
+ }
+
+ [Fact]
+ public void Uncovered_ListsSubjectsTaughtInExistingYearsWithoutATeacher()
+ {
+ var (catalog, map, roster, pool) = Fresh();
+ var uncovered = Staffing.Uncovered(catalog, roster);
+ Assert.Contains(uncovered, subject => subject.DefName == "PrimarySchool");
+ Assert.Contains(uncovered, subject => subject.DefName == "Mathematics");
+ Assert.Contains(uncovered, subject => subject.DefName == "PhysicalEducation");
+ Assert.Equal(catalog.Subjects.Values.Count(subject => !subject.Abstract), uncovered.Count);
+
+ var applicant = pool.Applicants[0];
+ var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, Cap);
+ var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", Cap);
+
+ Assert.DoesNotContain(Staffing.Uncovered(catalog, assigned.Roster), subject => subject.DefName == "Mathematics");
+ Assert.Contains(Staffing.Uncovered(catalog, assigned.Roster), subject => subject.DefName == "PrimarySchool");
+ }
+
+ [Fact]
+ public void PeopleJson_RoundTripsHiredStaffAndSubjects()
+ {
+ var (catalog, map, roster, pool) = Fresh();
+ var applicant = pool.Applicants[0];
+ var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, Cap);
+ var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", Cap);
+
+ var json = RosterJson.Serialize(RosterDocument.From(Fixtures.SchoolSeed, assigned.Roster, assigned.Pool));
+ var loaded = RosterJson.Parse(json);
+ var person = loaded.People.Single(member => member.Id == applicant.Person.Id);
+
+ Assert.True(person.IsStaff);
+ Assert.Equal(Staffing.TeacherPosition, person.Position);
+ Assert.Equal(applicant.HourlyWageAsk, person.HourlyWageAsk);
+ Assert.Equal(["Mathematics"], person.Subjects);
+ Assert.DoesNotContain(loaded.Applicants!.Applicants, candidate => candidate.Person.Id == applicant.Person.Id);
+ }
+
+ [Fact]
+ public void Hire_UnknownApplicantAndUnknownPosition()
+ {
+ var (catalog, map, roster, pool) = Fresh();
+
+ Assert.Equal(
+ StaffingError.UnknownApplicant,
+ Staffing.Hire(catalog, map, roster, pool, "nobody", Staffing.TeacherPosition, Cap).Error);
+ Assert.Equal(
+ StaffingError.UnknownPosition,
+ Staffing.Hire(catalog, map, roster, pool, pool.Applicants[0].Person.Id, "Janitor", Cap).Error);
+ }
+
+ private static (DefCatalog Catalog, MapLayout Map, Roster Roster, ApplicantPool Pool) Fresh()
+ {
+ var catalog = Fixtures.Catalog();
+ var map = Fixtures.VanillaMap();
+ var roster = Fixtures.Generate(map);
+ var pool = ApplicantPool.Create(catalog, roster, Fixtures.SchoolSeed, "Slavic", Fixtures.AsOf);
+ return (catalog, map, roster, pool);
+ }
+
+ private static Applicant WithAsk(Applicant applicant, float hourlyAsk) =>
+ applicant with { HourlyWageAsk = hourlyAsk };
+}