Vanilla catalogs stay without crushes; a content pack ships the overlay so later mods can reuse it.
100 lines
3.7 KiB
C#
100 lines
3.7 KiB
C#
namespace HSchool.People;
|
|
|
|
/// <summary>
|
|
/// Builds a roster from a catalog, a map and a school seed. Deterministic: same inputs, same people.
|
|
/// </summary>
|
|
public static class RosterGenerator
|
|
{
|
|
/// <summary>
|
|
/// Matches <c>SimulationOptions.DefaultStartDate</c> so tests without a clock still sit in
|
|
/// the default school year. Callers with a live clock must pass <paramref name="asOf"/>.
|
|
/// </summary>
|
|
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<Person>();
|
|
var families = new List<Family>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Seats leave <see cref="SchoolDemand"/> 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.
|
|
/// </summary>
|
|
private static IReadOnlyList<PupilSeat> ShuffleSeats(IReadOnlyList<PupilSeat> 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<SchoolClass> FillClasses(
|
|
IReadOnlyList<SchoolClass> classes,
|
|
IReadOnlyList<Person> people)
|
|
{
|
|
var pupils = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
|
foreach (var schoolClass in classes)
|
|
{
|
|
pupils[schoolClass.Id] = new List<string>(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;
|
|
}
|
|
}
|