namespace HSchool.People; /// Plain roster data. Simulation turns these into entities; this project does not. public sealed record Roster( IReadOnlyList People, IReadOnlyList Families, IReadOnlyList Classes); public sealed record Person { public required string Id { get; init; } public required string FamilyId { get; init; } public required bool Female { get; init; } public required DateTime BirthDate { get; init; } public required PersonName Name { get; init; } public required bool IsStudent { get; init; } public required bool IsStaff { get; init; } public required bool IsParent { get; init; } public string? ClassId { get; init; } public string? Position { get; init; } public string? WorkplaceRoomId { get; init; } public required IReadOnlyDictionary Numbers { get; init; } public required IReadOnlyDictionary Choices { get; init; } public required IReadOnlyDictionary Skills { get; init; } public required IReadOnlyList Traits { get; init; } 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; } /// Worn, bag, locker and home. Empty on rosters written before clothes existed. public IReadOnlyList Items { get; init; } = []; /// Changing-room node when this pupil won a locker slot. Null for staff and the rest. public string? LockerRoomId { get; init; } /// What this person thinks of others, A→B. Sparse; zero is not stored. public IReadOnlyDictionary Opinions { get; init; } = new Dictionary(StringComparer.Ordinal); /// /// OrientationDef name. Null when the catalog has no orientations (vanilla). Omitted from JSON. /// public string? Orientation { get; init; } /// Crushes and the one pair. Null without an orientation pack. Mutable overlay. public PersonBonds? Bonds { get; set; } /// /// Victim a bully keeps aiming at. Cleared when they leave the roster or opinion hits the floor. /// Null on everyone without a remembers-victim trait. /// public string? BullyVictimId { get; set; } /// /// Recent misconduct (quarrel / fight / reprimand). Null when empty so people.json stays compact. /// Ceiling and which kinds write live on BehaviorDef. /// public List? Offenses { get; set; } /// /// Recent lesson marks (2–5). Null when empty so people.json stays compact. /// Ceiling and thresholds live on BehaviorDef. /// public List? LessonMarks { get; set; } public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf); } /// /// One-way crushes and at most one pair. Lives on the person when a pack shipped orientations. /// Mutated in place the same way opinions are. /// public sealed class PersonBonds { public List Crushes { get; set; } = []; public string? PartnerId { get; set; } public DateTime? PairEndedOn { get; set; } } public sealed record PersonName( string Given, string Surname, string Patronymic, CaseTable GivenCases, CaseTable SurnameCases, CaseTable PatronymicCases) { /// Nominative ФИО as the list and cards show it: surname, given, patronymic. public string Full => string.Join(' ', new[] { Surname, Given, Patronymic }.Where(part => part.Length > 0)); } /// /// A household. holds one adult or two — a child keeps the father's /// surname and patronymic whether or not the father lives with them, so both are recorded here /// rather than read off a parent who may not be in the roster. /// public sealed record Family( string Id, IReadOnlyList ParentIds, IReadOnlyList ChildIds, int NextChild = 0, string FatherGiven = "", string Surname = "") { /// /// Id suffix for the next child of this family. It is a counter and not /// ChildIds.Count, because a graduate leaves the roster: deriving the suffix from the /// children still present would hand a newcomer the id of the person who just left, and every /// family link and open card pointing at that id would silently follow the wrong person. /// /// Rosters written before the counter existed fall back to the highest suffix still present. /// public int NextChildIndex => Math.Max(NextChild, HighestChildSuffix() + 1); private int HighestChildSuffix() { var highest = -1; foreach (var id in ChildIds) { var marker = id.LastIndexOf(".c", StringComparison.Ordinal); if (marker >= 0 && int.TryParse(id.AsSpan(marker + 2), out var index) && index > highest) { highest = index; } } return highest; } } public sealed record SchoolClass( string Id, int Year, string Letter, string RoomId, int Capacity, IReadOnlyList PupilIds, /// Hired teacher who is the class teacher, or null when the slot is empty. string? ClassTeacherId = null);