Files
h-school/src/HSchool.People/RosterGenerator.cs
T
Leonid Pershin d2911c3daf Complete phase 47 romance pack on generic orientation and affinity hooks.
Vanilla catalogs stay without crushes; a content pack ships the overlay so later mods can reuse it.
2026-08-20 10:42:42 +03:00

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