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);