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 countryId, DateTime? asOf = null, string? nativeLanguage = null) { ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(map); ArgumentException.ThrowIfNullOrWhiteSpace(countryId); if (!catalog.TryGetCountryNames(countryId, out var names)) { throw new ArgumentException($"Unknown country '{countryId}'.", nameof(countryId)); } var native = NativeLanguages.Pick(names, schoolSeed, nativeLanguage, rollIfOmitted: true); 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, native); families.Add(family); people.AddRange(members); } var classes = FillClasses(demand.Classes, people); var roster = LockerAssigner.Apply(catalog, map, new Roster(people, families, classes)); roster = OrientationGenerator.Assign(catalog, OpinionGenerator.SeedFamily(catalog, roster), schoolSeed); Affinity.Refresh(catalog, roster, when); return roster; } /// /// 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 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; } }