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