Enhance staffing management in school simulation by introducing API endpoints for hiring staff and assigning subjects. Implement payroll cap validation to ensure hiring and subject assignments do not exceed the allocated budget. Update the simulation options to include a monthly payroll cap and revise related classes to support new staffing functionalities. Enhance documentation to reflect these changes and update tests to validate the new features.

This commit is contained in:
Leonid Pershin
2026-08-19 00:19:09 +03:00
parent c189578680
commit 94842ab192
24 changed files with 1444 additions and 27 deletions
+1
View File
@@ -119,6 +119,7 @@ Simulation tunables live under the `Simulation` section of
| `ModsDirectory` | `mods` | pack folders; `core` is required | | `ModsDirectory` | `mods` | pack folders; `core` is required |
| `SaveIntervalSeconds` | 30 | rare clock snapshot; not every tick | | `SaveIntervalSeconds` | 30 | rare clock snapshot; not every tick |
| `MinSaveIntervalMilliseconds` | 1000 | shortest gap between saves caused by pause or speed | | `MinSaveIntervalMilliseconds` | 1000 | shortest gap between saves caused by pause or speed |
| `MonthlyPayrollCap` | 10000 | monthly payroll the player may commit; hire and extra subjects that would exceed it are rejected |
## What is deliberately missing ## What is deliberately missing
+10 -10
View File
@@ -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 - [x] Наём и назначение идут в воркер через мейлбокс с `TaskCompletionSource`, как create и delete
- [ ] Состав пишется на диск при изменении, снимок публикуется заново - [x] Состав пишется на диск при изменении, снимок публикуется заново
- [ ] `docs/protocol.md` пополняется в том же коммите, что и обработчики - [x] `docs/protocol.md` пополняется в том же коммите, что и обработчики
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -51,7 +51,7 @@
| --- | --- | --- | | --- | --- | --- |
| [10. Предметы и мебель](10-subjects.md) | ✅ | `SubjectDef`, одна учительская должность, кабинет как число мест | | [10. Предметы и мебель](10-subjects.md) | ✅ | `SubjectDef`, одна учительская должность, кабинет как число мест |
| [11. Пустая школа и пул](11-applicants.md) | ✅ | Школа без сотрудников, соискатели с запросом по зарплате | | [11. Пустая школа и пул](11-applicants.md) | ✅ | Школа без сотрудников, соискатели с запросом по зарплате |
| [12. Наём и бюджет](12-hiring-budget.md) | | Наём, назначение предметов, предел фонда оплаты | | [12. Наём и бюджет](12-hiring-budget.md) | | Наём, назначение предметов, предел фонда оплаты |
| [13. Раздел «Управление»](13-management-tab.md) | ⬜ | Деньги, соискатели, штат и назначения на экране | | [13. Раздел «Управление»](13-management-tab.md) | ⬜ | Деньги, соискатели, штат и назначения на экране |
## Срез 4. Расписание ## Срез 4. Расписание
+92
View File
@@ -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 ## WebSocket message ids
Client-to-server ids live in `0x000x7F`, server-to-client ids in `0x800xFF`, so a misrouted Client-to-server ids live in `0x000x7F`, server-to-client ids in `0x800xFF`, so a misrouted
+10
View File
@@ -296,6 +296,16 @@ internal static class PeopleDefValidator
{ {
throw new ContentLoadException($"StaffingDef '{staffing.DefName}' wage scale cannot be negative."); 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) private static void ValidateNameSet(NameSetDef names)
+11
View File
@@ -175,6 +175,17 @@ public sealed class StaffingDef : Def
public float HourlyWageBase { get; init; } public float HourlyWageBase { get; init; }
public float HourlyWagePerSkill { get; init; } public float HourlyWagePerSkill { get; init; }
/// <summary>Hours assumed in the monthly base rate. Extra subjects add a surcharge on top.</summary>
public float BaseWeeklyHours { get; init; }
public float WeeksPerMonth { get; init; }
/// <summary>
/// Added to the monthly base for each subject after the first. <c>0.25</c> means a second
/// subject costs 25% of the base rate.
/// </summary>
public float ExtraSubjectSurcharge { get; init; }
} }
public enum BodyAttributeKind public enum BodyAttributeKind
+8
View File
@@ -48,6 +48,14 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
return pool; return pool;
} }
public ApplicantPool Without(string personId) =>
this with
{
Applicants = Applicants
.Where(applicant => !applicant.Person.Id.Equals(personId, StringComparison.Ordinal))
.ToArray(),
};
public static float HourlyAsk(DefCatalog catalog, Person person) public static float HourlyAsk(DefCatalog catalog, Person person)
{ {
var rules = catalog.StaffingRules var rules = catalog.StaffingRules
+6
View File
@@ -40,6 +40,12 @@ public sealed record Person
public required IReadOnlyDictionary<string, float> Needs { get; init; } public required IReadOnlyDictionary<string, float> Needs { get; init; }
/// <summary>SubjectDef names this staff member is assigned. Empty for everyone else.</summary>
public IReadOnlyList<string> Subjects { get; init; } = [];
/// <summary>Hourly rate frozen at hire. Null until the person is staff.</summary>
public float? HourlyWageAsk { get; init; }
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf); public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
} }
+357
View File
@@ -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);
/// <summary>
/// Hire, subject assignment and the monthly payroll cap. Scale lives on <see cref="StaffingDef"/>;
/// the cap is supplied by the caller because it is a simulation option, not catalog data.
/// </summary>
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<Person> 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<SubjectDef> Uncovered(DefCatalog catalog, Roster roster)
{
var years = new HashSet<int>();
foreach (var schoolClass in roster.Classes)
{
years.Add(schoolClass.Year);
}
var taught = new HashSet<string>(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<SubjectDef>();
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<int> 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<string>(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 };
}
}
+120
View File
@@ -169,3 +169,123 @@ internal static class PeopleListMapper
return new PeopleFilterOptionsResponse(years, letters, positions); 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<UncoveredSubjectResponse> Uncovered,
IReadOnlyList<ApplicantResponse> Applicants,
IReadOnlyList<StaffMemberResponse> 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<DefLabelResponse> 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<UncoveredSubjectResponse>()
: 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);
}
}
+175 -6
View File
@@ -6,8 +6,8 @@ using HSchool.Simulation;
namespace HSchool.Server.Api; namespace HSchool.Server.Api;
/// <summary> /// <summary>
/// The main menu talks to these: list, create, delete. The people list reads a published roster /// The main menu talks to these: list, create, delete. People list and staffing read published
/// snapshot; the person card goes through the school's mailbox because needs are live. /// snapshots; the person card, hire and subject changes go through the school's mailbox.
/// </summary> /// </summary>
internal static class SchoolEndpoints internal static class SchoolEndpoints
{ {
@@ -156,6 +156,86 @@ internal static class SchoolEndpoints
}; };
}) })
.WithName("GetSchoolPerson"); .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<StaffingOutcome>());
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<StaffingOutcome>());
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<StaffingOutcome>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return StaffingResult(id, outcome, loop, ParseLocale(lang));
})
.WithName("UnassignSchoolSubject");
} }
/// <summary>The supervisor must never be blocked by a continuation of a waiting request.</summary> /// <summary>The supervisor must never be blocked by a continuation of a waiting request.</summary>
@@ -279,11 +359,100 @@ internal static class SchoolEndpoints
return true; return true;
} }
private static IResult Problem(int statusCode, string code, string detail) => private static StaffingResponse MapStaffing(PublishedSchoolPeople published, float allocated, string locale) =>
Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?> 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)
{ {
["code"] = code, if (outcome.Error != StaffingError.None)
}); {
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<string, object?> { ["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);
}
} }
/// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary> /// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary>
+19
View File
@@ -1,4 +1,5 @@
using HSchool.Content; using HSchool.Content;
using HSchool.People;
using HSchool.Server.Api; using HSchool.Server.Api;
using HSchool.Simulation; using HSchool.Simulation;
@@ -50,4 +51,22 @@ internal abstract record GameCommand
string PersonId, string PersonId,
string Locale, string Locale,
TaskCompletionSource<PersonCardResult> Result) : GameCommand; TaskCompletionSource<PersonCardResult> Result) : GameCommand;
internal sealed record HireStaff(
int SchoolId,
string PersonId,
string Position,
TaskCompletionSource<StaffingOutcome> Result) : GameCommand;
internal sealed record AssignSubject(
int SchoolId,
string PersonId,
string Subject,
TaskCompletionSource<StaffingOutcome> Result) : GameCommand;
internal sealed record UnassignSubject(
int SchoolId,
string PersonId,
string Subject,
TaskCompletionSource<StaffingOutcome> Result) : GameCommand;
} }
@@ -171,6 +171,27 @@ internal sealed class GameLoopService(
case GameCommand.GetPerson getPerson: case GameCommand.GetPerson getPerson:
HandleGetPerson(getPerson); HandleGetPerson(getPerson);
break; 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<StaffingOutcome> result)
{
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
{
result.TrySetResult(Staffing.UnknownSchool());
}
}
/// <summary> /// <summary>
/// A school's thread died. Drop it from the table so the menu stops drawing a card whose clock /// 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 /// never moves again, and tell anybody watching it to go back to the menu. The save file stays
+85 -9
View File
@@ -342,10 +342,7 @@ internal sealed class SchoolWorker
{ {
while (_mailbox.Reader.TryRead(out var orphan)) while (_mailbox.Reader.TryRead(out var orphan))
{ {
if (orphan is WorkerCommand.GetPerson getPerson) CompleteOrphan(orphan);
{
getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
}
} }
return; return;
@@ -393,14 +390,23 @@ internal sealed class SchoolWorker
? new PersonCardResult(null, PersonLookupError.UnknownPerson) ? new PersonCardResult(null, PersonLookupError.UnknownPerson)
: new PersonCardResult(card, PersonLookupError.None)); : new PersonCardResult(card, PersonLookupError.None));
break; 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) catch (Exception ex)
{ {
if (command is WorkerCommand.GetPerson failed) FailCommand(command, ex);
{
failed.Result.TrySetException(ex);
}
_logger.LogError( _logger.LogError(
ex, 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<DefCatalog, Roster, ApplicantPool, StaffingOutcome> 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;
}
/// <summary> /// <summary>
/// Writes pause/speed changes, at most once per <see cref="SimulationOptions.MinSaveInterval"/>. /// Writes pause/speed changes, at most once per <see cref="SimulationOptions.MinSaveInterval"/>.
/// A single click still lands within that window; a burst collapses into one write. /// A single click still lands within that window; a burst collapses into one write.
@@ -459,7 +535,7 @@ internal sealed class SchoolWorker
/// <summary> /// <summary>
/// Writes the composition file. Not called from the 30-second clock save — the roster and /// 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.
/// </summary> /// </summary>
private void PersistPeople() private void PersistPeople()
{ {
+16
View File
@@ -1,3 +1,4 @@
using HSchool.People;
using HSchool.Server.Api; using HSchool.Server.Api;
using HSchool.Server.Net; using HSchool.Server.Net;
@@ -21,4 +22,19 @@ internal abstract record WorkerCommand
string PersonId, string PersonId,
string Locale, string Locale,
TaskCompletionSource<PersonCardResult> Result) : WorkerCommand; TaskCompletionSource<PersonCardResult> Result) : WorkerCommand;
internal sealed record HireStaff(
string PersonId,
string Position,
TaskCompletionSource<StaffingOutcome> Result) : WorkerCommand;
internal sealed record AssignSubject(
string PersonId,
string Subject,
TaskCompletionSource<StaffingOutcome> Result) : WorkerCommand;
internal sealed record UnassignSubject(
string PersonId,
string Subject,
TaskCompletionSource<StaffingOutcome> Result) : WorkerCommand;
} }
+1
View File
@@ -21,6 +21,7 @@ builder.Services
.Validate(options => !string.IsNullOrWhiteSpace(options.SavesDirectory), "Simulation:SavesDirectory must be set.") .Validate(options => !string.IsNullOrWhiteSpace(options.SavesDirectory), "Simulation:SavesDirectory must be set.")
.Validate(options => !string.IsNullOrWhiteSpace(options.ModsDirectory), "Simulation:ModsDirectory 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.SaveIntervalSeconds is > 0 and <= 3600, "Simulation:SaveIntervalSeconds must be between 1 and 3600.")
.Validate(options => options.MonthlyPayrollCap > 0, "Simulation:MonthlyPayrollCap must be positive.")
.ValidateOnStart(); .ValidateOnStart();
builder.Services.AddSingleton<GameCommandQueue>(); builder.Services.AddSingleton<GameCommandQueue>();
+2 -1
View File
@@ -13,6 +13,7 @@
"DefaultStartDate": "2012-03-31T06:00:00", "DefaultStartDate": "2012-03-31T06:00:00",
"SavesDirectory": "saves", "SavesDirectory": "saves",
"ModsDirectory": "mods", "ModsDirectory": "mods",
"SaveIntervalSeconds": 30 "SaveIntervalSeconds": 30,
"MonthlyPayrollCap": 10000
} }
} }
@@ -5,4 +5,7 @@
"parentChance": 0.35, "parentChance": 0.35,
"hourlyWageBase": 30, "hourlyWageBase": 30,
"hourlyWagePerSkill": 0.6, "hourlyWagePerSkill": 0.6,
"baseWeeklyHours": 20,
"weeksPerMonth": 4,
"extraSubjectSurcharge": 0.25,
} }
+15
View File
@@ -91,6 +91,21 @@ public sealed class School : IDisposable
RosterSpawner.Spawn(World, roster); RosterSpawner.Spawn(World, roster);
} }
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, then need decay.</summary> /// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, then need decay.</summary>
/// <returns><see langword="true"/> when the roster or the applicant pool changed this step.</returns> /// <returns><see langword="true"/> when the roster or the applicant pool changed this step.</returns>
public bool Tick(double deltaTime, double gameMinutesPerRealSecond) public bool Tick(double deltaTime, double gameMinutesPerRealSecond)
@@ -49,6 +49,12 @@ public sealed class SimulationOptions
/// </summary> /// </summary>
public int MinSaveIntervalMilliseconds { get; set; } = 1000; public int MinSaveIntervalMilliseconds { get; set; } = 1000;
/// <summary>
/// Monthly payroll the player may commit. Hire and subject assignment that would exceed it
/// are rejected immediately; money itself does not move.
/// </summary>
public float MonthlyPayrollCap { get; set; } = 10_000f;
/// <summary>Length of one fixed step.</summary> /// <summary>Length of one fixed step.</summary>
public double FixedDeltaTime => 1d / TickRate; public double FixedDeltaTime => 1d / TickRate;
@@ -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<PeopleListResponse>(
$"/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<ProblemResponse>(TestContext.Current.CancellationToken);
break;
}
response.EnsureSuccessStatusCode();
staffing = await response.Content.ReadFromJsonAsync<StaffingResponse>(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<ProblemResponse>(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<StaffingResponse>(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<StaffingResponse>(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<StaffingResponse> GetStaffingAsync(HttpClient client, int schoolId)
{
var staffing = await client.GetFromJsonAsync<StaffingResponse>(
$"/api/schools/{schoolId}/staffing?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
return staffing;
}
private static async Task<StaffingResponse> 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<StaffingResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
return staffing;
}
private static async Task<StaffingResponse> 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<StaffingResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
return staffing;
}
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response)
{
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(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<UncoveredSubjectResponse> Uncovered,
IReadOnlyList<ApplicantResponse> Applicants,
IReadOnlyList<StaffMemberResponse> 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<DefLabelResponse> Subjects);
private sealed record DefLabelResponse(string DefName, string Label);
private sealed record PeopleListResponse(
int Total,
int Page,
int PageSize,
IReadOnlyList<PersonListItemResponse> People,
object Filters);
private sealed record PersonListItemResponse(
string Id,
string FullName,
string Surname,
string Given,
string Patronymic,
bool Female,
int Age,
IReadOnlyList<string> Roles,
int? ClassYear,
string? ClassLetter,
string? Position,
string? PositionLabel);
}
@@ -26,6 +26,9 @@ public class PeopleDefTests
Assert.True(catalog.Subjects.ContainsKey("PrimarySchool")); Assert.True(catalog.Subjects.ContainsKey("PrimarySchool"));
Assert.NotNull(catalog.StaffingRules); Assert.NotNull(catalog.StaffingRules);
Assert.Equal(12, catalog.StaffingRules.PoolSize); Assert.Equal(12, catalog.StaffingRules.PoolSize);
Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours);
Assert.Equal(4, catalog.StaffingRules.WeeksPerMonth);
Assert.Equal(0.25f, catalog.StaffingRules.ExtraSubjectSurcharge);
Assert.Equal("Начальные классы", catalog.Label("ru", catalog.Subjects["PrimarySchool"])); Assert.Equal("Начальные классы", catalog.Label("ru", catalog.Subjects["PrimarySchool"]));
Assert.Equal("Primary", catalog.Label("en", catalog.Subjects["PrimarySchool"])); Assert.Equal("Primary", catalog.Label("en", catalog.Subjects["PrimarySchool"]));
} }
@@ -43,6 +43,8 @@ public class VanillaCoreTests
Assert.True(catalog.Subjects.ContainsKey("PhysicalEducation")); Assert.True(catalog.Subjects.ContainsKey("PhysicalEducation"));
Assert.NotNull(catalog.StaffingRules); Assert.NotNull(catalog.StaffingRules);
Assert.Equal(12, catalog.StaffingRules.PoolSize); Assert.Equal(12, catalog.StaffingRules.PoolSize);
Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours);
Assert.Equal(0.25f, catalog.StaffingRules.ExtraSubjectSurcharge);
Assert.Equal(2, map.Buildings.Count); Assert.Equal(2, map.Buildings.Count);
var homerooms = map.Rooms.Where(room => room.Def == "Classroom").ToList(); var homerooms = map.Rooms.Where(room => room.Def == "Classroom").ToList();
Assert.Equal(11, homerooms.Count); Assert.Equal(11, homerooms.Count);
+212
View File
@@ -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 };
}