Implement yearly intake functionality in school simulation, allowing for the graduation of students and the addition of new first-year pupils. Update the roster management to reflect these changes, ensuring proper entity handling in the simulation. Enhance the API to support name sets during roster installation and revise related tests to validate the new intake process and roster updates.
This commit is contained in:
@@ -108,6 +108,64 @@ internal static class FamilyFactory
|
||||
return (family, members);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A younger sibling entering year 1, sharing the parents' surname and the father's patronymic.
|
||||
/// </summary>
|
||||
public static Person AddChild(
|
||||
DefCatalog catalog,
|
||||
NameSetDef names,
|
||||
Random rng,
|
||||
Family family,
|
||||
IReadOnlyList<Person> members,
|
||||
PupilSeat seat,
|
||||
DateTime yearStart,
|
||||
DateTime asOf,
|
||||
int childIndex)
|
||||
{
|
||||
var father = members.First(person => !person.IsStudent && !person.Female);
|
||||
var mother = members.FirstOrDefault(person => !person.IsStudent && person.Female);
|
||||
var usedGiven = new HashSet<string>(
|
||||
members.Where(person => person.IsStudent).Select(person => person.Name.Given),
|
||||
StringComparer.Ordinal);
|
||||
var female = rng.Next(2) == 0;
|
||||
var given = PickGiven(female ? names.FemaleGiven : names.MaleGiven, rng, usedGiven);
|
||||
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 (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);
|
||||
var needs = PersonSampler.Needs(catalog);
|
||||
var name = new PersonName(
|
||||
given.Form,
|
||||
surnameNom,
|
||||
patronymic,
|
||||
GivenTable(given, names.DefaultGivenDeclension),
|
||||
surnameCases,
|
||||
PatronymicTable(patronymic, female));
|
||||
|
||||
return new Person
|
||||
{
|
||||
Id = $"{family.Id}.c{childIndex}",
|
||||
FamilyId = family.Id,
|
||||
Female = female,
|
||||
BirthDate = DateTime.SpecifyKind(birth, DateTimeKind.Utc),
|
||||
Name = name,
|
||||
IsStudent = true,
|
||||
IsStaff = false,
|
||||
IsParent = false,
|
||||
ClassId = seat.ClassId,
|
||||
Numbers = numbers,
|
||||
Choices = choices,
|
||||
Skills = skills,
|
||||
Traits = traits,
|
||||
Needs = needs,
|
||||
};
|
||||
}
|
||||
|
||||
public static (Family Family, List<Person> Members) CreateStaffOnly(
|
||||
DefCatalog catalog,
|
||||
NameSetDef names,
|
||||
|
||||
@@ -9,11 +9,11 @@ internal readonly record struct FamilyPlan(int FamilyIndex, IReadOnlyList<PupilS
|
||||
/// </summary>
|
||||
internal static class FamilyPlanner
|
||||
{
|
||||
public static IReadOnlyList<FamilyPlan> Plan(int schoolSeed, IReadOnlyList<PupilSeat> seats)
|
||||
public static IReadOnlyList<FamilyPlan> Plan(int schoolSeed, IReadOnlyList<PupilSeat> seats, int startIndex = 0)
|
||||
{
|
||||
var plans = new List<FamilyPlan>();
|
||||
var offset = 0;
|
||||
var index = 0;
|
||||
var index = startIndex;
|
||||
while (offset < seats.Count)
|
||||
{
|
||||
var remaining = seats.Count - offset;
|
||||
|
||||
@@ -8,6 +8,7 @@ internal static class Seed
|
||||
{
|
||||
public const int ChildCountSalt = 1;
|
||||
public const int AppearanceSalt = 2;
|
||||
public const int IntakeSalt = 3;
|
||||
|
||||
public static int Mix(int schoolSeed, int familyIndex, int salt)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
namespace HSchool.People;
|
||||
|
||||
/// <summary>
|
||||
/// 1 September: promote every class, graduate the oldest year present, fill the vacated rooms
|
||||
/// with a new first year. Capacity is the map — intake replaces graduates, it does not grow.
|
||||
/// </summary>
|
||||
public static class YearlyIntake
|
||||
{
|
||||
public const int MaxChildrenInFamily = 3;
|
||||
|
||||
/// <summary>
|
||||
/// September 1sts strictly after <paramref name="before"/> and at or before <paramref name="after"/>.
|
||||
/// A school created on 1 September does not intake that morning — its roster is already that year.
|
||||
/// </summary>
|
||||
public static IEnumerable<DateTime> DatesBetween(DateTime before, DateTime after)
|
||||
{
|
||||
var utcBefore = DateTime.SpecifyKind(before, DateTimeKind.Utc);
|
||||
var utcAfter = DateTime.SpecifyKind(after, DateTimeKind.Utc);
|
||||
if (utcAfter <= utcBefore)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var date = new DateTime(utcBefore.Year, 9, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
if (date <= utcBefore)
|
||||
{
|
||||
date = date.AddYears(1);
|
||||
}
|
||||
|
||||
while (date <= utcAfter)
|
||||
{
|
||||
yield return date;
|
||||
date = date.AddYears(1);
|
||||
}
|
||||
}
|
||||
|
||||
public static Roster Apply(
|
||||
DefCatalog catalog,
|
||||
Roster roster,
|
||||
int schoolSeed,
|
||||
string nameSetId,
|
||||
DateTime asOf)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
ArgumentNullException.ThrowIfNull(roster);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nameSetId);
|
||||
|
||||
if (!catalog.NameSets.TryGetValue(nameSetId, out var names))
|
||||
{
|
||||
throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId));
|
||||
}
|
||||
|
||||
if (roster.Classes.Count == 0)
|
||||
{
|
||||
return roster;
|
||||
}
|
||||
|
||||
var when = DateTime.SpecifyKind(asOf, DateTimeKind.Utc);
|
||||
var yearStart = SchoolYears.StartOn(when);
|
||||
var maxYear = roster.Classes.Max(schoolClass => schoolClass.Year);
|
||||
var graduatingIds = roster.Classes
|
||||
.Where(schoolClass => schoolClass.Year == maxYear)
|
||||
.SelectMany(schoolClass => schoolClass.PupilIds)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var remainingPeople = roster.People
|
||||
.Where(person => !graduatingIds.Contains(person.Id))
|
||||
.ToDictionary(person => person.Id, StringComparer.Ordinal);
|
||||
|
||||
var families = new List<Family>();
|
||||
foreach (var family in roster.Families.OrderBy(candidate => IndexOf(candidate.Id)))
|
||||
{
|
||||
var childIds = family.ChildIds.Where(id => !graduatingIds.Contains(id)).ToArray();
|
||||
var parentIds = family.ParentIds.Where(id => remainingPeople.ContainsKey(id)).ToArray();
|
||||
var keepParents = childIds.Length > 0
|
||||
? parentIds
|
||||
: parentIds.Where(id => remainingPeople[id].IsStaff).ToArray();
|
||||
|
||||
foreach (var parentId in parentIds)
|
||||
{
|
||||
if (!keepParents.Contains(parentId, StringComparer.Ordinal))
|
||||
{
|
||||
remainingPeople.Remove(parentId);
|
||||
}
|
||||
}
|
||||
|
||||
if (keepParents.Length == 0 && childIds.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
families.Add(new Family(family.Id, keepParents, childIds));
|
||||
foreach (var parentId in keepParents)
|
||||
{
|
||||
var parent = remainingPeople[parentId];
|
||||
remainingPeople[parentId] = parent with { IsParent = childIds.Length > 0 };
|
||||
}
|
||||
}
|
||||
|
||||
var classes = new SchoolClass[roster.Classes.Count];
|
||||
var newSeats = new List<PupilSeat>();
|
||||
for (var i = 0; i < roster.Classes.Count; i++)
|
||||
{
|
||||
var schoolClass = roster.Classes[i];
|
||||
if (schoolClass.Year == maxYear)
|
||||
{
|
||||
classes[i] = schoolClass with { Year = 1, PupilIds = [] };
|
||||
for (var seat = 0; seat < schoolClass.Capacity; seat++)
|
||||
{
|
||||
newSeats.Add(new PupilSeat(schoolClass.Id, schoolClass.RoomId, Year: 1, schoolClass.Letter));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var kept = schoolClass.PupilIds.Where(id => remainingPeople.ContainsKey(id)).ToArray();
|
||||
classes[i] = schoolClass with { Year = schoolClass.Year + 1, PupilIds = kept };
|
||||
}
|
||||
}
|
||||
|
||||
FillFirstYear(
|
||||
catalog,
|
||||
names,
|
||||
schoolSeed,
|
||||
yearStart,
|
||||
when,
|
||||
families,
|
||||
remainingPeople,
|
||||
newSeats,
|
||||
nextFamilyIndex: roster.Families.Select(family => IndexOf(family.Id)).DefaultIfEmpty(-1).Max() + 1);
|
||||
|
||||
var people = remainingPeople.Values.ToList();
|
||||
var filled = AttachPupils(classes, people);
|
||||
return new Roster(people, families, filled);
|
||||
}
|
||||
|
||||
private static void FillFirstYear(
|
||||
DefCatalog catalog,
|
||||
NameSetDef names,
|
||||
int schoolSeed,
|
||||
DateTime yearStart,
|
||||
DateTime asOf,
|
||||
List<Family> families,
|
||||
Dictionary<string, Person> people,
|
||||
List<PupilSeat> seats,
|
||||
int nextFamilyIndex)
|
||||
{
|
||||
var cursor = 0;
|
||||
foreach (var family in families.ToArray())
|
||||
{
|
||||
if (cursor >= seats.Count)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var index = IndexOf(family.Id);
|
||||
if (index < 0 || family.ChildIds.Count == 0 || family.ChildIds.Count >= MaxChildrenInFamily)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!WantsYoungerSibling(schoolSeed, index, yearStart.Year))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var members = family.ParentIds.Concat(family.ChildIds)
|
||||
.Select(id => people[id])
|
||||
.ToArray();
|
||||
if (!members.Any(person => !person.IsStudent && !person.Female))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var childIndex = NextChildIndex(family);
|
||||
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] };
|
||||
foreach (var parentId in family.ParentIds)
|
||||
{
|
||||
people[parentId] = people[parentId] with { IsParent = true };
|
||||
}
|
||||
|
||||
cursor++;
|
||||
}
|
||||
|
||||
if (cursor >= seats.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var leftover = seats.Skip(cursor).ToArray();
|
||||
foreach (var plan in FamilyPlanner.Plan(schoolSeed, leftover, Math.Max(nextFamilyIndex, 0)))
|
||||
{
|
||||
var (created, members) = FamilyFactory.Create(catalog, names, schoolSeed, plan, yearStart, asOf);
|
||||
families.Add(created);
|
||||
foreach (var member in members)
|
||||
{
|
||||
people[member.Id] = member;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SchoolClass> AttachPupils(SchoolClass[] classes, List<Person> people)
|
||||
{
|
||||
var pupils = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var schoolClass in classes)
|
||||
{
|
||||
pupils[schoolClass.Id] = [.. schoolClass.PupilIds];
|
||||
}
|
||||
|
||||
foreach (var person in people)
|
||||
{
|
||||
if (!person.IsStudent || person.ClassId is not { } classId || !pupils.TryGetValue(classId, out var list))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!list.Contains(person.Id, StringComparer.Ordinal))
|
||||
{
|
||||
list.Add(person.Id);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < classes.Length; i++)
|
||||
{
|
||||
var schoolClass = classes[i];
|
||||
classes[i] = schoolClass with { PupilIds = pupils[schoolClass.Id] };
|
||||
}
|
||||
|
||||
return classes;
|
||||
}
|
||||
|
||||
private static bool WantsYoungerSibling(int schoolSeed, int familyIndex, int intakeYear)
|
||||
{
|
||||
var rng = new Random(Seed.Mix(schoolSeed, familyIndex, Seed.IntakeSalt ^ intakeYear));
|
||||
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
|
||||
: -1;
|
||||
}
|
||||
Reference in New Issue
Block a user