Files
h-school/src/HSchool.Server/Api/PeopleModels.cs
T
Leonid Pershin b135a9caad Enhance protocol and simulation features with activity tracking and behavior definitions
- Updated protocol documentation to include new `activity` and `activityLabel` fields in the person card response, reflecting real-time activity status.
- Introduced `BehaviorDef` to define behavior rules, including need thresholds and lesson skill gains, enhancing AI decision-making.
- Revised the `DefCatalog` to incorporate behavior definitions and updated validation logic to ensure proper behavior handling.
- Enhanced the simulation to manage presence and activity states, allowing for more dynamic interactions within the school environment.
- Updated tests to validate the new activity tracking and behavior functionalities, ensuring robust performance and reliability.
- Improved localization strings to support new activity and behavior features, enhancing user experience.
2026-08-19 19:49:44 +03:00

342 lines
11 KiB
C#

using HSchool.Content;
using HSchool.People;
namespace HSchool.Server.Api;
internal enum PersonLookupError
{
None,
UnknownSchool,
UnknownPerson,
}
internal sealed record PersonCardResult(PersonCardResponse? Card, PersonLookupError Error);
internal sealed record PeopleListResponse(
int Total,
int Page,
int PageSize,
IReadOnlyList<PersonListItemResponse> People,
PeopleFilterOptionsResponse Filters);
internal sealed record PeopleFilterOptionsResponse(
IReadOnlyList<int> Years,
IReadOnlyList<string> Letters,
IReadOnlyList<DefLabelResponse> Positions);
internal sealed record DefLabelResponse(string DefName, string Label);
internal 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);
internal sealed record PersonCardResponse(
string Id,
string FullName,
string Surname,
string Given,
string Patronymic,
bool Female,
int Age,
DateTime BirthDate,
IReadOnlyList<string> Roles,
int? ClassYear,
string? ClassLetter,
string? ClassId,
string? Position,
string? PositionLabel,
IReadOnlyList<LabeledStatResponse> Body,
IReadOnlyList<LabeledStatResponse> Skills,
IReadOnlyList<DefLabelResponse> Traits,
IReadOnlyList<NeedStatResponse> Needs,
string? Activity,
string? ActivityLabel,
PersonFamilyResponse Family);
internal sealed record LabeledStatResponse(string Id, string Label, string Value);
internal sealed record NeedStatResponse(string Id, string Label, float Value);
internal sealed record PersonFamilyResponse(
IReadOnlyList<PersonRelResponse> Parents,
IReadOnlyList<PersonRelResponse> Children,
IReadOnlyList<PersonRelResponse> Siblings,
IReadOnlyList<PersonRelResponse> Partners);
internal sealed record PersonRelResponse(string Id, string FullName, bool Female);
internal sealed record DirectoryResponse(IReadOnlyList<DirectoryPersonResponse> People);
internal sealed record DirectoryPersonResponse(string Id, string FullName);
internal static class PeopleListMapper
{
public static PeopleListResponse From(
Roster roster,
RosterPage page,
DateTime asOf,
DefCatalog? catalog,
string locale,
RosterQuery query)
{
var classes = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal);
var people = page.People
.Select(person => Item(person, classes, asOf, catalog, locale))
.ToArray();
return new PeopleListResponse(page.Total, query.Page, query.PageSize, people, FilterOptions(roster, catalog, locale));
}
private static PersonListItemResponse Item(
Person person,
IReadOnlyDictionary<string, SchoolClass> classes,
DateTime asOf,
DefCatalog? catalog,
string locale)
{
int? year = null;
string? letter = null;
if (person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass))
{
year = schoolClass.Year;
letter = schoolClass.Letter;
}
return new PersonListItemResponse(
person.Id,
person.Name.Full,
person.Name.Surname,
person.Name.Given,
person.Name.Patronymic,
person.Female,
person.AgeOn(asOf),
RolesOf(person),
year,
letter,
person.Position,
PositionLabel(catalog, locale, person.Position));
}
internal static string[] RolesOf(Person person)
{
var roles = new List<string>(3);
if (person.IsStudent)
{
roles.Add(PersonRoles.Student);
}
if (person.IsStaff)
{
roles.Add(PersonRoles.Staff);
}
if (person.IsParent)
{
roles.Add(PersonRoles.Parent);
}
return [.. roles];
}
internal static string? PositionLabel(DefCatalog? catalog, string locale, string? position)
{
if (position is null)
{
return null;
}
if (catalog is not null && catalog.Positions.TryGetValue(position, out var def))
{
return catalog.Label(locale, def);
}
return position;
}
private static PeopleFilterOptionsResponse FilterOptions(Roster roster, DefCatalog? catalog, string locale)
{
var years = roster.Classes.Select(schoolClass => schoolClass.Year).Distinct().OrderBy(year => year).ToArray();
var letters = roster.Classes.Select(schoolClass => schoolClass.Letter).Distinct().OrderBy(letter => letter, StringComparer.Ordinal).ToArray();
var positions = roster.People
.Select(person => person.Position)
.Where(position => position is not null)
.Distinct(StringComparer.Ordinal)
.OrderBy(position => PositionLabel(catalog, locale, position) ?? position, StringComparer.Ordinal)
.Select(position => new DefLabelResponse(position!, PositionLabel(catalog, locale, position) ?? position!))
.ToArray();
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,
IReadOnlyList<DefLabelResponse> Positions,
IReadOnlyList<UncoveredSubjectResponse> Subjects);
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,
IReadOnlyList<LabeledStatResponse> Skills);
internal sealed record StaffMemberResponse(
string Id,
string FullName,
bool Female,
int Age,
bool IsParent,
string Position,
string PositionLabel,
float HourlyWageAsk,
float WeeklyHours,
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 || catalog is null ? 0f : Staffing.Payroll(catalog, rules, roster);
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),
StrongSkills(applicant.Person, catalog, locale)))
.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, roster, catalog, rules, locale, asOf))
.ToArray();
var positions = catalog is null
? Array.Empty<DefLabelResponse>()
: catalog.Positions.Values
.Where(def => !def.Abstract)
.Select(def => new DefLabelResponse(def.DefName, catalog.Label(locale, def)))
.OrderBy(row => row.Label, StringComparer.Ordinal)
.ToArray();
var subjects = catalog is null
? Array.Empty<UncoveredSubjectResponse>()
: catalog.Subjects.Values
.Where(def => !def.Abstract)
.Select(def => new UncoveredSubjectResponse(
def.DefName,
catalog.Label(locale, def),
def.Grades.Min,
def.Grades.Max,
def.HoursPerWeek))
.ToArray();
return new StaffingResponse(allocated, payroll, remaining, uncovered, applicants, staff, positions, subjects);
}
private static IReadOnlyList<LabeledStatResponse> StrongSkills(Person person, DefCatalog? catalog, string locale)
{
return person.Skills
.OrderByDescending(pair => pair.Value)
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
.Take(3)
.Select(pair => new LabeledStatResponse(
pair.Key,
catalog is not null && catalog.Skills.TryGetValue(pair.Key, out var def)
? catalog.Label(locale, def)
: pair.Key,
pair.Value.ToString()))
.ToArray();
}
private static StaffMemberResponse Member(
Person person,
Roster roster,
DefCatalog? catalog,
StaffingDef? rules,
string locale,
DateTime asOf)
{
var ask = person.HourlyWageAsk ?? 0f;
var hours = catalog is null ? 0f : Staffing.WeeklyHours(catalog, roster, person);
var pay = rules is null ? 0f : Staffing.MonthlyPay(rules, ask, hours);
var subjects = person.Subjects
.Select(name => new DefLabelResponse(
name,
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,
MathF.Round(hours, 1),
pay,
subjects);
}
}