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;
|
||||
}
|
||||
@@ -239,6 +239,7 @@ internal sealed class SchoolWorker
|
||||
var lastTimestamp = Stopwatch.GetTimestamp();
|
||||
var accumulator = 0d;
|
||||
var lastSave = lastTimestamp;
|
||||
var peopleChanged = false;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -256,10 +257,11 @@ internal sealed class SchoolWorker
|
||||
lastTimestamp = now;
|
||||
|
||||
var steps = 0;
|
||||
peopleChanged = false;
|
||||
while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
|
||||
{
|
||||
var stepStarted = Stopwatch.GetTimestamp();
|
||||
school.Tick(fixedDelta, _options.GameMinutesPerRealSecond);
|
||||
peopleChanged |= school.Tick(fixedDelta, _options.GameMinutesPerRealSecond);
|
||||
_metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds);
|
||||
|
||||
accumulator -= fixedDelta;
|
||||
@@ -275,6 +277,11 @@ internal sealed class SchoolWorker
|
||||
accumulator = 0d;
|
||||
}
|
||||
|
||||
if (peopleChanged)
|
||||
{
|
||||
PersistPeople();
|
||||
}
|
||||
|
||||
if (steps > 0)
|
||||
{
|
||||
PublishSnapshot();
|
||||
@@ -508,7 +515,7 @@ internal sealed class SchoolWorker
|
||||
$"School {_id} roster does not match its map; the people file was left untouched.");
|
||||
}
|
||||
|
||||
school.InstallPeople(roster, seed);
|
||||
school.InstallPeople(roster, seed, nameSetId);
|
||||
return generated;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ namespace HSchool.Simulation;
|
||||
/// <summary>Turns roster records into Arch entities. Parents have no map location — by design.</summary>
|
||||
public static class RosterSpawner
|
||||
{
|
||||
private static readonly QueryDescription People = new QueryDescription().WithAll<PersonIdentity>();
|
||||
private static readonly QueryDescription Classes = new QueryDescription().WithAll<ClassIdentity>();
|
||||
|
||||
public static void Spawn(World world, Roster roster)
|
||||
{
|
||||
foreach (var schoolClass in roster.Classes)
|
||||
@@ -36,4 +39,22 @@ public static class RosterSpawner
|
||||
person.WorkplaceRoomId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drops the previous composition and spawns <paramref name="roster"/>. Called on yearly intake.</summary>
|
||||
public static void Replace(World world, Roster roster)
|
||||
{
|
||||
DestroyAll(world, People);
|
||||
DestroyAll(world, Classes);
|
||||
Spawn(world, roster);
|
||||
}
|
||||
|
||||
private static void DestroyAll(World world, QueryDescription query)
|
||||
{
|
||||
var entities = new List<Entity>();
|
||||
world.Query(in query, (Entity entity) => entities.Add(entity));
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
world.Destroy(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,29 +70,64 @@ public sealed class School : IDisposable
|
||||
|
||||
public int PeopleSeed { get; private set; }
|
||||
|
||||
/// <summary>Name pack used to generate this school's people. Needed again on 1 September.</summary>
|
||||
public string? NameSetId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Installs a roster that already matches the map. Spawns entities; does not write to disk.
|
||||
/// </summary>
|
||||
public void InstallPeople(Roster roster, int seed)
|
||||
public void InstallPeople(Roster roster, int seed, string? nameSetId = null)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(roster);
|
||||
|
||||
Roster = roster;
|
||||
PeopleSeed = seed;
|
||||
NameSetId = nameSetId;
|
||||
RosterSpawner.Spawn(World, roster);
|
||||
}
|
||||
|
||||
/// <summary>Runs one fixed step of the school: calendar, then need decay.</summary>
|
||||
public void Tick(double deltaTime, double gameMinutesPerRealSecond)
|
||||
/// <summary>Runs one fixed step of the school: calendar, yearly intake if 1 September passed, then need decay.</summary>
|
||||
/// <returns><see langword="true"/> when the roster changed this step.</returns>
|
||||
public bool Tick(double deltaTime, double gameMinutesPerRealSecond)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var before = Clock.Time;
|
||||
var gameMinutes = Clock.Advance(deltaTime, gameMinutesPerRealSecond);
|
||||
if (gameMinutes > 0 && Catalog is not null)
|
||||
var peopleChanged = false;
|
||||
if (gameMinutes > 0)
|
||||
{
|
||||
NeedDecay.Apply(World, Catalog, gameMinutes);
|
||||
peopleChanged = TryYearlyIntake(before, Clock.Time);
|
||||
if (Catalog is not null)
|
||||
{
|
||||
NeedDecay.Apply(World, Catalog, gameMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
return peopleChanged;
|
||||
}
|
||||
|
||||
private bool TryYearlyIntake(DateTime before, DateTime after)
|
||||
{
|
||||
if (Roster is null || Catalog is null || NameSetId is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
foreach (var date in YearlyIntake.DatesBetween(before, after))
|
||||
{
|
||||
Roster = YearlyIntake.Apply(Catalog, Roster, PeopleSeed, NameSetId, date);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
RosterSpawner.Replace(World, Roster);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
Reference in New Issue
Block a user