Enhance family and roster management in school simulation by implementing support for incomplete families, ensuring proper handling of absent parents while maintaining surname consistency. Update family creation logic to account for single-parent households and revise child identification to prevent ID conflicts. Introduce seat shuffling to prevent siblings from being placed in the same class, improving the realism of student distribution. Update related tests to validate these new functionalities and ensure robustness in family and roster handling.
This commit is contained in:
@@ -2,6 +2,13 @@ namespace HSchool.People;
|
||||
|
||||
internal static class FamilyFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Share of families with children where one parent is simply absent. No reason is modelled —
|
||||
/// the roster records who lives there, not why. The child keeps the father's surname and
|
||||
/// patronymic either way, so an absent father still gets his name rolled.
|
||||
/// </summary>
|
||||
public const int IncompleteFamilyPercent = 8;
|
||||
|
||||
public static (Family Family, List<Person> Members) Create(
|
||||
DefCatalog catalog,
|
||||
NameSetDef names,
|
||||
@@ -53,37 +60,56 @@ internal static class FamilyFactory
|
||||
fatherBirth = motherBirth.AddDays(rng.Next(-5 * 365, (5 * 365) + 1));
|
||||
}
|
||||
|
||||
// Its own stream: who lives in the household is a separate decision from what they look
|
||||
// like, and drawing both from one stream tied the two together — every incomplete family
|
||||
// came out mother-only.
|
||||
var household = new Random(Seed.Mix(schoolSeed, plan.FamilyIndex, Seed.HouseholdSalt));
|
||||
var singleParent = childDrafts.Length > 0 && household.Next(100) < IncompleteFamilyPercent;
|
||||
var motherStays = household.Next(2) == 0;
|
||||
var hasFather = !singleParent || !motherStays;
|
||||
var hasMother = !singleParent || motherStays;
|
||||
|
||||
var members = new List<Person>();
|
||||
var parentIds = new List<string>(2);
|
||||
var fatherId = $"{familyId}.p0";
|
||||
var motherId = $"{familyId}.p1";
|
||||
members.Add(
|
||||
RollAdult(
|
||||
catalog,
|
||||
names,
|
||||
rng,
|
||||
fatherId,
|
||||
familyId,
|
||||
female: false,
|
||||
fatherBirth,
|
||||
asOf,
|
||||
surname,
|
||||
fatherGiven,
|
||||
NameGrammar.Patronymic(fatherPatronymicSource.Form, female: false, names.PatronymicRule),
|
||||
isParent: childDrafts.Length > 0));
|
||||
members.Add(
|
||||
RollAdult(
|
||||
catalog,
|
||||
names,
|
||||
rng,
|
||||
motherId,
|
||||
familyId,
|
||||
female: true,
|
||||
motherBirth,
|
||||
asOf,
|
||||
surname,
|
||||
motherGiven,
|
||||
NameGrammar.Patronymic(motherPatronymicSource.Form, female: true, names.PatronymicRule),
|
||||
isParent: childDrafts.Length > 0));
|
||||
if (hasFather)
|
||||
{
|
||||
parentIds.Add(fatherId);
|
||||
members.Add(
|
||||
RollAdult(
|
||||
catalog,
|
||||
names,
|
||||
rng,
|
||||
fatherId,
|
||||
familyId,
|
||||
female: false,
|
||||
fatherBirth,
|
||||
asOf,
|
||||
surname,
|
||||
fatherGiven,
|
||||
NameGrammar.Patronymic(fatherPatronymicSource.Form, female: false, names.PatronymicRule),
|
||||
isParent: childDrafts.Length > 0));
|
||||
}
|
||||
|
||||
if (hasMother)
|
||||
{
|
||||
parentIds.Add(motherId);
|
||||
members.Add(
|
||||
RollAdult(
|
||||
catalog,
|
||||
names,
|
||||
rng,
|
||||
motherId,
|
||||
familyId,
|
||||
female: true,
|
||||
motherBirth,
|
||||
asOf,
|
||||
surname,
|
||||
motherGiven,
|
||||
NameGrammar.Patronymic(motherPatronymicSource.Form, female: true, names.PatronymicRule),
|
||||
isParent: childDrafts.Length > 0));
|
||||
}
|
||||
|
||||
var childIds = new List<string>(childDrafts.Length);
|
||||
for (var i = 0; i < childDrafts.Length; i++)
|
||||
@@ -104,7 +130,7 @@ internal static class FamilyFactory
|
||||
fatherGiven.Form));
|
||||
}
|
||||
|
||||
var family = new Family(familyId, [fatherId, motherId], childIds);
|
||||
var family = new Family(familyId, parentIds, childIds, childIds.Count, fatherGiven.Form, surname.Male);
|
||||
return (family, members);
|
||||
}
|
||||
|
||||
@@ -122,8 +148,12 @@ internal static class FamilyFactory
|
||||
DateTime asOf,
|
||||
int childIndex)
|
||||
{
|
||||
var father = members.First(person => !person.IsStudent && !person.Female);
|
||||
var mother = members.FirstOrDefault(person => !person.IsStudent && person.Female);
|
||||
var fatherGiven = FatherGivenOf(family, members);
|
||||
if (fatherGiven is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Family '{family.Id}' has no father's name to build a patronymic from.");
|
||||
}
|
||||
|
||||
var usedGiven = new HashSet<string>(
|
||||
members.Where(person => person.IsStudent).Select(person => person.Name.Given),
|
||||
StringComparer.Ordinal);
|
||||
@@ -132,9 +162,11 @@ internal static class FamilyFactory
|
||||
var (first, last) = SchoolYears.BirthWindow(yearStart, seat.Year);
|
||||
var birth = SchoolYears.RandomInRange(rng, first, last);
|
||||
var age = SchoolYears.AgeYears(birth, asOf);
|
||||
var patronymic = NameGrammar.Patronymic(father.Name.Given, female, names.PatronymicRule);
|
||||
var surnameNom = female ? (mother ?? father).Name.Surname : father.Name.Surname;
|
||||
var surnameCases = female ? (mother ?? father).Name.SurnameCases : father.Name.SurnameCases;
|
||||
var patronymic = NameGrammar.Patronymic(fatherGiven, female, names.PatronymicRule);
|
||||
|
||||
// The surname is the father's whether or not he lives here, and it is gendered — so it
|
||||
// comes from the name set, not from whichever parent happens to be present.
|
||||
var (surnameNom, surnameCases) = SurnameFor(family, names, members, female);
|
||||
var (numbers, choices) = PersonSampler.Body(catalog, rng, female, age);
|
||||
var traits = PersonSampler.Traits(catalog, rng, [PersonRoles.Student], age);
|
||||
var skills = PersonSampler.Skills(catalog, rng, age, choices, traits);
|
||||
@@ -166,15 +198,42 @@ internal static class FamilyFactory
|
||||
};
|
||||
}
|
||||
|
||||
public static (Family Family, List<Person> Members) CreateStaffOnly(
|
||||
/// <summary>
|
||||
/// One adult who lives alone and works at the school. Deliberately not a couple: the top-up
|
||||
/// loop counts openings one at a time, so a two-adult household overshot an odd deficit and
|
||||
/// left behind an adult who was neither staff, parent nor pupil — invisible to every filter.
|
||||
/// </summary>
|
||||
public static (Family Family, Person Member) CreateStaffOnly(
|
||||
DefCatalog catalog,
|
||||
NameSetDef names,
|
||||
int schoolSeed,
|
||||
int familyIndex,
|
||||
DateTime asOf)
|
||||
{
|
||||
var empty = new FamilyPlan(familyIndex, []);
|
||||
return Create(catalog, names, schoolSeed, empty, SchoolYears.StartOn(asOf), asOf);
|
||||
var rng = new Random(Seed.Mix(schoolSeed, familyIndex, Seed.AppearanceSalt));
|
||||
var familyId = $"f{familyIndex}";
|
||||
var surname = names.Surnames[rng.Next(names.Surnames.Count)];
|
||||
var female = rng.Next(2) == 0;
|
||||
var given = PickGiven(female ? names.FemaleGiven : names.MaleGiven, rng);
|
||||
var patronymicSource = PickGiven(names.MaleGiven, rng);
|
||||
var birth = asOf.AddYears(-(24 + rng.Next(38))).AddDays(-rng.Next(365));
|
||||
|
||||
var id = $"{familyId}.p0";
|
||||
var person = RollAdult(
|
||||
catalog,
|
||||
names,
|
||||
rng,
|
||||
id,
|
||||
familyId,
|
||||
female,
|
||||
birth,
|
||||
asOf,
|
||||
surname,
|
||||
given,
|
||||
NameGrammar.Patronymic(patronymicSource.Form, female, names.PatronymicRule),
|
||||
isParent: false);
|
||||
|
||||
return (new Family(familyId, [id], [], NextChild: 0, FatherGiven: string.Empty, Surname: surname.Male), person);
|
||||
}
|
||||
|
||||
private static Person RollAdult(
|
||||
@@ -293,6 +352,39 @@ internal static class FamilyFactory
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The father's given name: recorded on the family, or read off him when a roster written
|
||||
/// before that field is loaded. Null only for such a roster with no father present.
|
||||
/// </summary>
|
||||
internal static string? FatherGivenOf(Family family, IReadOnlyList<Person> members) =>
|
||||
family.FatherGiven.Length > 0
|
||||
? family.FatherGiven
|
||||
: members.FirstOrDefault(person => !person.IsStudent && !person.Female)?.Name.Given;
|
||||
|
||||
private static (string Nominative, CaseTable Cases) SurnameFor(
|
||||
Family family,
|
||||
NameSetDef names,
|
||||
IReadOnlyList<Person> members,
|
||||
bool female)
|
||||
{
|
||||
var entry = family.Surname.Length > 0
|
||||
? names.Surnames.FirstOrDefault(candidate => candidate.Male.Equals(family.Surname, StringComparison.Ordinal))
|
||||
: null;
|
||||
|
||||
if (entry is not null)
|
||||
{
|
||||
return (
|
||||
female ? entry.Female : entry.Male,
|
||||
SurnameTable(entry, female, names.DefaultSurnameDeclension));
|
||||
}
|
||||
|
||||
// Older roster, or a surname whose pack is gone: borrow from a parent of the same sex,
|
||||
// then from any parent at all.
|
||||
var parents = members.Where(person => !person.IsStudent).ToArray();
|
||||
var source = parents.FirstOrDefault(person => person.Female == female) ?? parents[0];
|
||||
return (source.Name.Surname, source.Name.SurnameCases);
|
||||
}
|
||||
|
||||
private static GivenNameEntry PickGiven(
|
||||
IReadOnlyList<GivenNameEntry> pool,
|
||||
Random rng,
|
||||
|
||||
@@ -3,12 +3,31 @@ namespace HSchool.People;
|
||||
internal readonly record struct FamilyPlan(int FamilyIndex, IReadOnlyList<PupilSeat> Seats);
|
||||
|
||||
/// <summary>
|
||||
/// Walks seats in order and groups them into families of 1–3. A short remainder becomes
|
||||
/// singleton families rather than shrinking an earlier family's intended size, so a longer
|
||||
/// seat list only appends families.
|
||||
/// Walks seats in order and groups them into families of 1–3, taking a run of consecutive seats
|
||||
/// per family. Callers hand in a shuffled seat list — a run of *adjacent* seats is one classroom,
|
||||
/// which would put every sibling in the same class.
|
||||
///
|
||||
/// A family whose intended size does not fit the remainder takes one seat instead of shrinking,
|
||||
/// so growing the seat list only disturbs the tail.
|
||||
/// </summary>
|
||||
internal static class FamilyPlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// One child per family. Used by the yearly intake: every seat there is a first-year seat, so
|
||||
/// grouping would hand a family two or three same-age children at once. An arriving pupil who
|
||||
/// does have an older sibling gets attached to that family instead.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<FamilyPlan> Singletons(IReadOnlyList<PupilSeat> seats, int startIndex)
|
||||
{
|
||||
var plans = new FamilyPlan[seats.Count];
|
||||
for (var i = 0; i < seats.Count; i++)
|
||||
{
|
||||
plans[i] = new FamilyPlan(startIndex + i, [seats[i]]);
|
||||
}
|
||||
|
||||
return plans;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<FamilyPlan> Plan(int schoolSeed, IReadOnlyList<PupilSeat> seats, int startIndex = 0)
|
||||
{
|
||||
var plans = new List<FamilyPlan>();
|
||||
|
||||
@@ -57,6 +57,7 @@ internal static class PersonSampler
|
||||
values[skill.DefName] = rolled;
|
||||
}
|
||||
|
||||
var touched = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var traitName in traits)
|
||||
{
|
||||
if (!catalog.Traits.TryGetValue(traitName, out var trait))
|
||||
@@ -73,9 +74,19 @@ internal static class PersonSampler
|
||||
}
|
||||
|
||||
values[modifier.Skill] = Clamp(current + modifier.Offset, skill.Range);
|
||||
touched.Add(modifier.Skill);
|
||||
}
|
||||
}
|
||||
|
||||
// The body has the last word. Without this pass a trait modifier reopened what the body
|
||||
// closed: a skinny Bully rolled Strength 45, the trait added 8, and the "Skinny caps
|
||||
// Strength at 45" limit was silently gone.
|
||||
foreach (var skillName in touched)
|
||||
{
|
||||
var skill = catalog.Skills[skillName];
|
||||
values[skillName] = Clamp(ApplyBodyLimits(values[skillName], skill, choices), skill.Range);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,10 +55,44 @@ public sealed record PersonName(
|
||||
public string Full => string.Join(' ', new[] { Surname, Given, Patronymic }.Where(part => part.Length > 0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A household. <see cref="ParentIds"/> holds one adult or two — a child keeps the father's
|
||||
/// surname and patronymic whether or not the father lives with them, so both are recorded here
|
||||
/// rather than read off a parent who may not be in the roster.
|
||||
/// </summary>
|
||||
public sealed record Family(
|
||||
string Id,
|
||||
IReadOnlyList<string> ParentIds,
|
||||
IReadOnlyList<string> ChildIds);
|
||||
IReadOnlyList<string> ChildIds,
|
||||
int NextChild = 0,
|
||||
string FatherGiven = "",
|
||||
string Surname = "")
|
||||
{
|
||||
/// <summary>
|
||||
/// Id suffix for the next child of this family. It is a counter and not
|
||||
/// <c>ChildIds.Count</c>, because a graduate leaves the roster: deriving the suffix from the
|
||||
/// children still present would hand a newcomer the id of the person who just left, and every
|
||||
/// family link and open card pointing at that id would silently follow the wrong person.
|
||||
///
|
||||
/// Rosters written before the counter existed fall back to the highest suffix still present.
|
||||
/// </summary>
|
||||
public int NextChildIndex => Math.Max(NextChild, HighestChildSuffix() + 1);
|
||||
|
||||
private int HighestChildSuffix()
|
||||
{
|
||||
var highest = -1;
|
||||
foreach (var id in ChildIds)
|
||||
{
|
||||
var marker = id.LastIndexOf(".c", StringComparison.Ordinal);
|
||||
if (marker >= 0 && int.TryParse(id.AsSpan(marker + 2), out var index) && index > highest)
|
||||
{
|
||||
highest = index;
|
||||
}
|
||||
}
|
||||
|
||||
return highest;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SchoolClass(
|
||||
string Id,
|
||||
|
||||
@@ -30,6 +30,17 @@ public static class RosterBrowser
|
||||
|
||||
public const int MaxPageSize = 100;
|
||||
|
||||
/// <summary>
|
||||
/// A list a person reads has to follow the alphabet, not code points: ordinal comparison puts
|
||||
/// «Ёлкина» (U+0401) ahead of «Абрамова» (U+0410), because Ё sits outside the А–Я block.
|
||||
///
|
||||
/// A culture-aware comparer is not an option here — the repository builds with
|
||||
/// <c>InvariantGlobalization</c>, which quietly turns every linguistic comparison back into an
|
||||
/// ordinal one. So the one anomaly that matters is folded away by hand. The id tiebreak in
|
||||
/// <see cref="Apply"/> stays ordinal, so paging is stable regardless.
|
||||
/// </summary>
|
||||
private static readonly IComparer<string> Names = NameOrder.Instance;
|
||||
|
||||
public static RosterPage Apply(Roster roster, DateTime asOf, RosterQuery query)
|
||||
{
|
||||
var classes = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal);
|
||||
@@ -47,21 +58,21 @@ public static class RosterBrowser
|
||||
var ordered = query.Sort switch
|
||||
{
|
||||
PersonSort.Age => query.Descending
|
||||
? filtered.OrderByDescending(person => person.AgeOn(asOf)).ThenByDescending(SurnameKey, StringComparer.Ordinal)
|
||||
: filtered.OrderBy(person => person.AgeOn(asOf)).ThenBy(SurnameKey, StringComparer.Ordinal),
|
||||
? filtered.OrderByDescending(person => person.AgeOn(asOf)).ThenByDescending(SurnameKey, Names)
|
||||
: filtered.OrderBy(person => person.AgeOn(asOf)).ThenBy(SurnameKey, Names),
|
||||
PersonSort.Year => SortByYear(filtered, classes, query.Descending),
|
||||
PersonSort.Position => query.Descending
|
||||
? filtered
|
||||
.OrderBy(person => person.Position is null)
|
||||
.ThenByDescending(person => person.Position ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenByDescending(SurnameKey, StringComparer.Ordinal)
|
||||
.ThenByDescending(SurnameKey, Names)
|
||||
: filtered
|
||||
.OrderBy(person => person.Position is null)
|
||||
.ThenBy(person => person.Position ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(SurnameKey, StringComparer.Ordinal),
|
||||
.ThenBy(SurnameKey, Names),
|
||||
_ => query.Descending
|
||||
? filtered.OrderByDescending(SurnameKey, StringComparer.Ordinal).ThenByDescending(GivenKey, StringComparer.Ordinal)
|
||||
: filtered.OrderBy(SurnameKey, StringComparer.Ordinal).ThenBy(GivenKey, StringComparer.Ordinal),
|
||||
? filtered.OrderByDescending(SurnameKey, Names).ThenByDescending(GivenKey, Names)
|
||||
: filtered.OrderBy(SurnameKey, Names).ThenBy(GivenKey, Names),
|
||||
};
|
||||
|
||||
var sorted = ordered.ThenBy(person => person.Id, StringComparer.Ordinal).ToArray();
|
||||
@@ -111,12 +122,12 @@ public static class RosterBrowser
|
||||
return descending
|
||||
? studentsFirst
|
||||
.ThenByDescending(person => YearOf(person, classes) ?? 0)
|
||||
.ThenByDescending(person => LetterOf(person, classes) ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenByDescending(SurnameKey, StringComparer.Ordinal)
|
||||
.ThenByDescending(person => LetterOf(person, classes) ?? string.Empty, Names)
|
||||
.ThenByDescending(SurnameKey, Names)
|
||||
: studentsFirst
|
||||
.ThenBy(person => YearOf(person, classes) ?? 0)
|
||||
.ThenBy(person => LetterOf(person, classes) ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(SurnameKey, StringComparer.Ordinal);
|
||||
.ThenBy(person => LetterOf(person, classes) ?? string.Empty, Names)
|
||||
.ThenBy(SurnameKey, Names);
|
||||
}
|
||||
|
||||
private static bool Matches(
|
||||
@@ -199,4 +210,57 @@ public static class RosterBrowser
|
||||
private static string SurnameKey(Person person) => person.Name.Surname;
|
||||
|
||||
private static string GivenKey(Person person) => person.Name.Given;
|
||||
|
||||
/// <summary>
|
||||
/// Ordinal order with Ё alphabetised as Е, the way a Russian list is expected to read.
|
||||
/// Compares in place rather than building folded keys: this runs on every row of every page.
|
||||
/// </summary>
|
||||
private sealed class NameOrder : IComparer<string>
|
||||
{
|
||||
public static readonly NameOrder Instance = new();
|
||||
|
||||
public int Compare(string? x, string? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (y is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var shared = Math.Min(x.Length, y.Length);
|
||||
for (var i = 0; i < shared; i++)
|
||||
{
|
||||
var left = Fold(x[i]);
|
||||
var right = Fold(y[i]);
|
||||
if (left != right)
|
||||
{
|
||||
return left.CompareTo(right);
|
||||
}
|
||||
}
|
||||
|
||||
if (x.Length != y.Length)
|
||||
{
|
||||
return x.Length - y.Length;
|
||||
}
|
||||
|
||||
// Identical once folded — «Артёмов» and «Артемов» still need a stable order between them.
|
||||
return string.CompareOrdinal(x, y);
|
||||
}
|
||||
|
||||
private static char Fold(char value) => value switch
|
||||
{
|
||||
'Ё' => 'Е',
|
||||
'ё' => 'е',
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ public static class RosterGenerator
|
||||
var when = DateTime.SpecifyKind(asOf ?? DefaultAsOf, DateTimeKind.Utc);
|
||||
var yearStart = SchoolYears.StartOn(when);
|
||||
var demand = SchoolDemand.From(catalog, map);
|
||||
var plans = FamilyPlanner.Plan(schoolSeed, demand.Seats);
|
||||
var plans = FamilyPlanner.Plan(schoolSeed, ShuffleSeats(demand.Seats, schoolSeed));
|
||||
|
||||
var people = new List<Person>();
|
||||
var families = new List<Family>(plans.Count);
|
||||
@@ -42,12 +42,14 @@ public static class RosterGenerator
|
||||
}
|
||||
|
||||
var nextFamily = plans.Count;
|
||||
while (people.Count(person => !person.IsStudent) < demand.Staff.Count)
|
||||
var adults = people.Count(person => !person.IsStudent);
|
||||
while (adults < demand.Staff.Count)
|
||||
{
|
||||
var (family, members) = FamilyFactory.CreateStaffOnly(catalog, names, schoolSeed, nextFamily, when);
|
||||
var (family, member) = FamilyFactory.CreateStaffOnly(catalog, names, schoolSeed, nextFamily, when);
|
||||
families.Add(family);
|
||||
people.AddRange(members);
|
||||
people.Add(member);
|
||||
nextFamily++;
|
||||
adults++;
|
||||
}
|
||||
|
||||
var staffed = AssignStaff(people, demand.Staff);
|
||||
@@ -55,6 +57,25 @@ public static class RosterGenerator
|
||||
return new Roster(staffed, families, classes);
|
||||
}
|
||||
|
||||
/// <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<Person> AssignStaff(List<Person> people, IReadOnlyList<StaffOpening> openings)
|
||||
{
|
||||
if (openings.Count == 0)
|
||||
|
||||
@@ -9,6 +9,11 @@ internal static class Seed
|
||||
public const int ChildCountSalt = 1;
|
||||
public const int AppearanceSalt = 2;
|
||||
public const int IntakeSalt = 3;
|
||||
public const int SeatShuffleSalt = 4;
|
||||
public const int HouseholdSalt = 5;
|
||||
|
||||
/// <summary>A stream that belongs to the school rather than to one family.</summary>
|
||||
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
|
||||
|
||||
public static int Mix(int schoolSeed, int familyIndex, int salt)
|
||||
{
|
||||
|
||||
@@ -89,7 +89,15 @@ public static class YearlyIntake
|
||||
continue;
|
||||
}
|
||||
|
||||
families.Add(new Family(family.Id, keepParents, childIds));
|
||||
// The counter travels with the family: a graduate's id must never come back. So do the
|
||||
// father's name and the surname — a single-mother household still names its children
|
||||
// after the father, and he is not in the roster to be asked.
|
||||
families.Add(family with
|
||||
{
|
||||
ParentIds = keepParents,
|
||||
ChildIds = childIds,
|
||||
NextChild = family.NextChildIndex,
|
||||
});
|
||||
foreach (var parentId in keepParents)
|
||||
{
|
||||
var parent = remainingPeople[parentId];
|
||||
@@ -166,17 +174,25 @@ public static class YearlyIntake
|
||||
var members = family.ParentIds.Concat(family.ChildIds)
|
||||
.Select(id => people[id])
|
||||
.ToArray();
|
||||
if (!members.Any(person => !person.IsStudent && !person.Female))
|
||||
|
||||
// A household with no adult left cannot take in a first-year; nor can one whose
|
||||
// father's name is unknown, since the newcomer's patronymic comes from it.
|
||||
if (!members.Any(person => !person.IsStudent)
|
||||
|| FamilyFactory.FatherGivenOf(family, members) is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var childIndex = NextChildIndex(family);
|
||||
var childIndex = family.NextChildIndex;
|
||||
var rng = new Random(Seed.Mix(schoolSeed, index, Seed.IntakeSalt + yearStart.Year * 10 + childIndex));
|
||||
var child = FamilyFactory.AddChild(catalog, names, rng, family, members, seats[cursor], yearStart, asOf, childIndex);
|
||||
people[child.Id] = child;
|
||||
var familyAt = families.FindIndex(candidate => candidate.Id.Equals(family.Id, StringComparison.Ordinal));
|
||||
families[familyAt] = family with { ChildIds = [.. family.ChildIds, child.Id] };
|
||||
families[familyAt] = family with
|
||||
{
|
||||
ChildIds = [.. family.ChildIds, child.Id],
|
||||
NextChild = childIndex + 1,
|
||||
};
|
||||
foreach (var parentId in family.ParentIds)
|
||||
{
|
||||
people[parentId] = people[parentId] with { IsParent = true };
|
||||
@@ -191,7 +207,7 @@ public static class YearlyIntake
|
||||
}
|
||||
|
||||
var leftover = seats.Skip(cursor).ToArray();
|
||||
foreach (var plan in FamilyPlanner.Plan(schoolSeed, leftover, Math.Max(nextFamilyIndex, 0)))
|
||||
foreach (var plan in FamilyPlanner.Singletons(leftover, Math.Max(nextFamilyIndex, 0)))
|
||||
{
|
||||
var (created, members) = FamilyFactory.Create(catalog, names, schoolSeed, plan, yearStart, asOf);
|
||||
families.Add(created);
|
||||
@@ -238,21 +254,6 @@ public static class YearlyIntake
|
||||
return rng.Next(100) < 40;
|
||||
}
|
||||
|
||||
private static int NextChildIndex(Family family)
|
||||
{
|
||||
var max = -1;
|
||||
foreach (var id in family.ChildIds)
|
||||
{
|
||||
var marker = id.LastIndexOf(".c", StringComparison.Ordinal);
|
||||
if (marker >= 0 && int.TryParse(id.AsSpan(marker + 2), out var index) && index > max)
|
||||
{
|
||||
max = index;
|
||||
}
|
||||
}
|
||||
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
private static int IndexOf(string familyId) =>
|
||||
familyId.Length > 1 && familyId[0] == 'f' && int.TryParse(familyId.AsSpan(1), out var index)
|
||||
? index
|
||||
|
||||
Reference in New Issue
Block a user