namespace HSchool.People;
///
/// Builds a roster from a catalog, a map and a school seed. Deterministic: same inputs, same people.
///
public static class RosterGenerator
{
///
/// Matches SimulationOptions.DefaultStartDate so tests without a clock still sit in
/// the default school year. Callers with a live clock must pass .
///
public static readonly DateTime DefaultAsOf = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
public static Roster Generate(
DefCatalog catalog,
MapLayout map,
int schoolSeed,
string nameSetId,
DateTime? asOf = null)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(map);
ArgumentException.ThrowIfNullOrWhiteSpace(nameSetId);
if (!catalog.NameSets.TryGetValue(nameSetId, out var names))
{
throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId));
}
var when = DateTime.SpecifyKind(asOf ?? DefaultAsOf, DateTimeKind.Utc);
var yearStart = SchoolYears.StartOn(when);
var demand = SchoolDemand.From(catalog, map);
var plans = FamilyPlanner.Plan(schoolSeed, ShuffleSeats(demand.Seats, schoolSeed));
var people = new List();
var families = new List(plans.Count);
foreach (var plan in plans)
{
var (family, members) = FamilyFactory.Create(catalog, names, schoolSeed, plan, yearStart, when);
families.Add(family);
people.AddRange(members);
}
var nextFamily = plans.Count;
var adults = people.Count(person => !person.IsStudent);
while (adults < demand.Staff.Count)
{
var (family, member) = FamilyFactory.CreateStaffOnly(catalog, names, schoolSeed, nextFamily, when);
families.Add(family);
people.Add(member);
nextFamily++;
adults++;
}
var staffed = AssignStaff(people, demand.Staff);
var classes = FillClasses(demand.Classes, staffed);
return new Roster(staffed, families, classes);
}
///
/// Seats leave grouped by classroom, and a family takes a run of
/// consecutive seats — so without this every pair of siblings landed in the same class, the
/// same year and the same twelve-month birth window. Shuffling is seeded, so the roster stays
/// reproducible.
///
private static IReadOnlyList ShuffleSeats(IReadOnlyList seats, int schoolSeed)
{
var shuffled = seats.ToArray();
var rng = new Random(Seed.ForSchool(schoolSeed, Seed.SeatShuffleSalt));
for (var i = shuffled.Length - 1; i > 0; i--)
{
var j = rng.Next(i + 1);
(shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]);
}
return shuffled;
}
private static IReadOnlyList AssignStaff(List people, IReadOnlyList openings)
{
if (openings.Count == 0)
{
return people;
}
var jobs = new Dictionary(StringComparer.Ordinal);
var index = 0;
foreach (var person in people)
{
if (person.IsStudent || index >= openings.Count)
{
continue;
}
jobs[person.Id] = openings[index];
index++;
}
if (jobs.Count == 0)
{
return people;
}
var result = new Person[people.Count];
for (var i = 0; i < people.Count; i++)
{
var person = people[i];
result[i] = jobs.TryGetValue(person.Id, out var job)
? person with
{
IsStaff = true,
Position = job.Position,
WorkplaceRoomId = job.RoomId,
}
: person;
}
return result;
}
private static IReadOnlyList FillClasses(
IReadOnlyList classes,
IReadOnlyList people)
{
var pupils = new Dictionary>(StringComparer.Ordinal);
foreach (var schoolClass in classes)
{
pupils[schoolClass.Id] = new List(schoolClass.Capacity);
}
foreach (var person in people)
{
if (person.ClassId is { } classId && pupils.TryGetValue(classId, out var list))
{
list.Add(person.Id);
}
}
var filled = new SchoolClass[classes.Count];
for (var i = 0; i < classes.Count; i++)
{
var schoolClass = classes[i];
filled[i] = schoolClass with { PupilIds = pupils[schoolClass.Id] };
}
return filled;
}
}