From 7f6852548d383585ddbcf121f88a16d1ae0a6af0 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 18 Aug 2026 19:57:59 +0300 Subject: [PATCH] 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. --- AGENTS.md | 2 +- docs/phases/09-yearly-intake.md | 12 +- docs/phases/README.md | 2 +- src/HSchool.People/FamilyFactory.cs | 58 ++++ src/HSchool.People/FamilyPlanner.cs | 4 +- src/HSchool.People/Seed.cs | 1 + src/HSchool.People/YearlyIntake.cs | 260 ++++++++++++++++++ src/HSchool.Server/Game/SchoolWorker.cs | 11 +- src/HSchool.Simulation/RosterSpawner.cs | 21 ++ src/HSchool.Simulation/School.cs | 45 ++- .../HSchool.People.Tests/YearlyIntakeTests.cs | 122 ++++++++ .../PeopleInSchoolTests.cs | 79 ++++++ 12 files changed, 600 insertions(+), 17 deletions(-) create mode 100644 src/HSchool.People/YearlyIntake.cs create mode 100644 tests/HSchool.People.Tests/YearlyIntakeTests.cs diff --git a/AGENTS.md b/AGENTS.md index 1b7a83f..8f01f8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ way; this file is *how to work in them*. | schools, the game clock, game rules | `src/HSchool.Simulation` | | defs, JSONC catalog, map validation | `src/HSchool.Content` | | skills, traits, body, needs, name sets | `src/HSchool.Content` | -| people generation, families, roster records | `src/HSchool.People` | +| people generation, families, roster records, yearly intake | `src/HSchool.People` | | people in a school's World, need decay | `src/HSchool.Simulation` | | the menu API (list, create, delete, mods, catalog) and the people list/card | `src/HSchool.Server/Api` **and** `docs/protocol.md` | | what the socket carries | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` | diff --git a/docs/phases/09-yearly-intake.md b/docs/phases/09-yearly-intake.md index 1444758..be14e75 100644 --- a/docs/phases/09-yearly-intake.md +++ b/docs/phases/09-yearly-intake.md @@ -11,16 +11,16 @@ ## Задачи -- [ ] Переход по игровому календарю на 1 сентября: у каждого класса параллель +1 -- [ ] Выпуск **старшей имеющейся** параллели — школа из четырёх кабинетов не ждёт одиннадцатого +- [x] Переход по игровому календарю на 1 сентября: у каждого класса параллель +1 +- [x] Выпуск **старшей имеющейся** параллели — школа из четырёх кабинетов не ждёт одиннадцатого класса, чтобы кого-то выпустить -- [ ] Освободившийся кабинет достаётся новому первому классу; вместимость школы не меняется — +- [x] Освободившийся кабинет достаётся новому первому классу; вместимость школы не меняется — её меняет только стройка -- [ ] Набор первого класса тем же генератором и теми же семьями: у нового ученика может +- [x] Набор первого класса тем же генератором и теми же семьями: у нового ученика может оказаться брат или сестра в старшей параллели -- [ ] Выпускники уходят из ростера; их родители уходят тоже, если не осталось других детей в +- [x] Выпускники уходят из ростера; их родители уходят тоже, если не осталось других детей в школе и они в ней не работают -- [ ] Ростер переписывается один раз за переход, снимок публикуется заново — не по тику +- [x] Ростер переписывается один раз за переход, снимок публикуется заново — не по тику ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index a988032..05bd517 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -35,4 +35,4 @@ | [6. Библиотека генерации](06-people-generator.md) | ✅ | `HSchool.People`: семьи из карты и сида | | [7. Люди в школе](07-people-in-school.md) | ✅ | Сущности в `World`, ростер на диске, 11 кабинетов в `core` | | [8. Просмотр людей](08-people-browser.md) | ✅ | Панель со списком, фильтрами и карточкой | -| [9. Годовой набор](09-yearly-intake.md) | ⬜ | Первое сентября: переход, выпуск, набор | +| [9. Годовой набор](09-yearly-intake.md) | ✅ | Первое сентября: переход, выпуск, набор | diff --git a/src/HSchool.People/FamilyFactory.cs b/src/HSchool.People/FamilyFactory.cs index 6f439ac..101ccdc 100644 --- a/src/HSchool.People/FamilyFactory.cs +++ b/src/HSchool.People/FamilyFactory.cs @@ -108,6 +108,64 @@ internal static class FamilyFactory return (family, members); } + /// + /// A younger sibling entering year 1, sharing the parents' surname and the father's patronymic. + /// + public static Person AddChild( + DefCatalog catalog, + NameSetDef names, + Random rng, + Family family, + IReadOnlyList 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( + 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 Members) CreateStaffOnly( DefCatalog catalog, NameSetDef names, diff --git a/src/HSchool.People/FamilyPlanner.cs b/src/HSchool.People/FamilyPlanner.cs index bffbbce..6ab6d8c 100644 --- a/src/HSchool.People/FamilyPlanner.cs +++ b/src/HSchool.People/FamilyPlanner.cs @@ -9,11 +9,11 @@ internal readonly record struct FamilyPlan(int FamilyIndex, IReadOnlyList internal static class FamilyPlanner { - public static IReadOnlyList Plan(int schoolSeed, IReadOnlyList seats) + public static IReadOnlyList Plan(int schoolSeed, IReadOnlyList seats, int startIndex = 0) { var plans = new List(); var offset = 0; - var index = 0; + var index = startIndex; while (offset < seats.Count) { var remaining = seats.Count - offset; diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs index 627dfcf..365238b 100644 --- a/src/HSchool.People/Seed.cs +++ b/src/HSchool.People/Seed.cs @@ -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) { diff --git a/src/HSchool.People/YearlyIntake.cs b/src/HSchool.People/YearlyIntake.cs new file mode 100644 index 0000000..2e945a4 --- /dev/null +++ b/src/HSchool.People/YearlyIntake.cs @@ -0,0 +1,260 @@ +namespace HSchool.People; + +/// +/// 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. +/// +public static class YearlyIntake +{ + public const int MaxChildrenInFamily = 3; + + /// + /// September 1sts strictly after and at or before . + /// A school created on 1 September does not intake that morning — its roster is already that year. + /// + public static IEnumerable 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(); + 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(); + 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 families, + Dictionary people, + List 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 AttachPupils(SchoolClass[] classes, List people) + { + var pupils = new Dictionary>(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; +} diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 55aa9b0..31246e1 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -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; } diff --git a/src/HSchool.Simulation/RosterSpawner.cs b/src/HSchool.Simulation/RosterSpawner.cs index ca2fbe9..71dfbcd 100644 --- a/src/HSchool.Simulation/RosterSpawner.cs +++ b/src/HSchool.Simulation/RosterSpawner.cs @@ -6,6 +6,9 @@ namespace HSchool.Simulation; /// Turns roster records into Arch entities. Parents have no map location — by design. public static class RosterSpawner { + private static readonly QueryDescription People = new QueryDescription().WithAll(); + private static readonly QueryDescription Classes = new QueryDescription().WithAll(); + public static void Spawn(World world, Roster roster) { foreach (var schoolClass in roster.Classes) @@ -36,4 +39,22 @@ public static class RosterSpawner person.WorkplaceRoomId)); } } + + /// Drops the previous composition and spawns . Called on yearly intake. + 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(); + world.Query(in query, (Entity entity) => entities.Add(entity)); + foreach (var entity in entities) + { + world.Destroy(entity); + } + } } diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 465bbd7..8e449af 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -70,29 +70,64 @@ public sealed class School : IDisposable public int PeopleSeed { get; private set; } + /// Name pack used to generate this school's people. Needed again on 1 September. + public string? NameSetId { get; private set; } + /// /// Installs a roster that already matches the map. Spawns entities; does not write to disk. /// - 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); } - /// Runs one fixed step of the school: calendar, then need decay. - public void Tick(double deltaTime, double gameMinutesPerRealSecond) + /// Runs one fixed step of the school: calendar, yearly intake if 1 September passed, then need decay. + /// when the roster changed this step. + 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() diff --git a/tests/HSchool.People.Tests/YearlyIntakeTests.cs b/tests/HSchool.People.Tests/YearlyIntakeTests.cs new file mode 100644 index 0000000..19e229b --- /dev/null +++ b/tests/HSchool.People.Tests/YearlyIntakeTests.cs @@ -0,0 +1,122 @@ +namespace HSchool.People.Tests; + +public class YearlyIntakeTests +{ + private static readonly DateTime September = new(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc); + + [Fact] + public void DatesBetween_SkipsAStartAlreadyOnFirstSeptember() + { + var start = new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Empty(YearlyIntake.DatesBetween(start, start.AddMinutes(1))); + Assert.Equal( + [new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc)], + YearlyIntake.DatesBetween(new DateTime(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc), start)); + } + + [Fact] + public void FourClassrooms_GraduatesTheOldestYearNotEleventh() + { + var map = Fixtures.Classrooms(4); + var catalog = Fixtures.Catalog(); + var before = Fixtures.Generate(map); + Assert.Equal(4, before.Classes.Max(schoolClass => schoolClass.Year)); + Assert.DoesNotContain(before.Classes, schoolClass => schoolClass.Year == 11); + + var after = YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Slavic", September); + var demand = SchoolDemand.From(catalog, map); + + Assert.True(RosterFit.Matches(after, demand)); + Assert.Equal(before.People.Count(person => person.IsStudent), after.People.Count(person => person.IsStudent)); + Assert.Equal(before.Classes.Sum(schoolClass => schoolClass.Capacity), after.Classes.Sum(schoolClass => schoolClass.Capacity)); + Assert.Equal(4, after.Classes.Max(schoolClass => schoolClass.Year)); + Assert.Contains(after.Classes, schoolClass => schoolClass.Year == 1); + Assert.All(after.Classes, schoolClass => Assert.Equal(schoolClass.Capacity, schoolClass.PupilIds.Count)); + + var graduated = before.Classes + .Where(schoolClass => schoolClass.Year == 4) + .SelectMany(schoolClass => schoolClass.PupilIds) + .ToHashSet(StringComparer.Ordinal); + Assert.All(graduated, id => Assert.DoesNotContain(after.People, person => person.Id == id)); + } + + [Fact] + public void SameInputs_YieldTheSameIntake() + { + var map = Fixtures.Classrooms(4); + var catalog = Fixtures.Catalog(); + var before = Fixtures.Generate(map); + var a = Snapshot(YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Slavic", September)); + var b = Snapshot(YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Slavic", September)); + Assert.Equal(a, b); + } + + [Fact] + public void NewFirstYear_CanShareAFamilyWithAnOlderPupil() + { + var map = Fixtures.Classrooms(4); + var catalog = Fixtures.Catalog(); + var before = Fixtures.Generate(map); + var after = YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Slavic", September); + var people = after.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + var yearOf = after.Classes.ToDictionary(schoolClass => schoolClass.Id, schoolClass => schoolClass.Year, StringComparer.Ordinal); + + Assert.Contains(after.Families, family => + { + var years = family.ChildIds + .Select(id => people[id]) + .Where(person => person.IsStudent && person.ClassId is not null) + .Select(person => yearOf[person.ClassId!]) + .ToHashSet(); + return years.Contains(1) && years.Any(year => year > 1); + }); + } + + [Fact] + public void ParentsOfOnlyGraduates_LeaveUnlessTheyWorkHere() + { + var map = Fixtures.Classrooms(4); + var catalog = Fixtures.Catalog(); + var before = Fixtures.Generate(map); + var people = before.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + var graduating = before.Classes + .Where(schoolClass => schoolClass.Year == 4) + .SelectMany(schoolClass => schoolClass.PupilIds) + .ToHashSet(StringComparer.Ordinal); + + var shouldLeave = new List(); + var staffWhoStay = new List(); + foreach (var family in before.Families) + { + if (family.ChildIds.Count == 0 || family.ChildIds.Any(id => !graduating.Contains(id))) + { + continue; + } + + foreach (var parentId in family.ParentIds) + { + if (people[parentId].IsStaff) + { + staffWhoStay.Add(parentId); + } + else + { + shouldLeave.Add(parentId); + } + } + } + + var after = YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Slavic", September); + var afterIds = after.People.Select(person => person.Id).ToHashSet(StringComparer.Ordinal); + Assert.NotEmpty(shouldLeave); + Assert.All(shouldLeave, id => Assert.DoesNotContain(id, afterIds)); + Assert.All(staffWhoStay, id => Assert.Contains(id, afterIds)); + Assert.All( + after.People.Where(person => staffWhoStay.Contains(person.Id)), + person => Assert.False(person.IsParent)); + } + + private static string Snapshot(Roster roster) => + string.Join('\n', roster.People.Select(person => + $"{person.Id}|{person.FamilyId}|{person.IsStudent}|{person.IsStaff}|{person.IsParent}|{person.ClassId}|{person.Name.Full}")); +} diff --git a/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs b/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs index 11c6ead..8a8295a 100644 --- a/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs +++ b/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs @@ -94,6 +94,85 @@ public class PeopleInSchoolTests second.People.Select(person => $"{person.Name.Surname} {person.Name.Given} {person.Name.Patronymic}")); } + [Fact] + public void TickPastFirstSeptember_GraduatesTheOldestYearAndKeepsCapacity() + { + var catalog = Catalog(); + var map = Classrooms(4); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", Start); + var demand = SchoolDemand.From(catalog, map); + var oldest = roster.Classes.Max(schoolClass => schoolClass.Year); + var graduated = roster.Classes + .Where(schoolClass => schoolClass.Year == oldest) + .SelectMany(schoolClass => schoolClass.PupilIds) + .ToHashSet(StringComparer.Ordinal); + + using var school = School.Create(1, "Четыре кабинета", Start, catalog, map); + school.InstallPeople(roster, seed: 1, "Slavic"); + + var sept = new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc); + var changed = school.Tick((sept - Start).TotalMinutes / 5d, 5d); + + Assert.True(changed); + Assert.True(school.Clock.Time >= sept); + Assert.NotNull(school.Roster); + Assert.True(RosterFit.Matches(school.Roster, demand)); + Assert.Equal(roster.People.Count(person => person.IsStudent), school.Roster.People.Count(person => person.IsStudent)); + Assert.DoesNotContain(school.Roster.People, person => graduated.Contains(person.Id)); + Assert.Contains(school.Roster.Classes, schoolClass => schoolClass.Year == 1); + + var peopleQuery = new QueryDescription().WithAll(); + var classesQuery = new QueryDescription().WithAll(); + Assert.Equal(school.Roster.People.Count, school.World.CountEntities(in peopleQuery)); + Assert.Equal(school.Roster.Classes.Count, school.World.CountEntities(in classesQuery)); + } + + [Fact] + public void TickOnCreateDateOfFirstSeptember_DoesNotIntake() + { + var start = new DateTime(2012, 9, 1, 6, 0, 0, DateTimeKind.Utc); + var catalog = Catalog(); + var map = Classrooms(4); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 3, "Slavic", start); + using var school = School.Create(3, "Первое сентября", start, catalog, map); + school.InstallPeople(roster, seed: 3, "Slavic"); + + var years = roster.Classes.Select(schoolClass => schoolClass.Year).OrderBy(year => year).ToArray(); + var changed = school.Tick(1d / 20d, 5d); + + Assert.False(changed); + Assert.Same(roster, school.Roster); + Assert.Equal(years, school.Roster!.Classes.Select(schoolClass => schoolClass.Year).OrderBy(year => year)); + } + + private static DefCatalog Catalog() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + return new CatalogLoader().Load([CatalogLoader.CorePackId], PackDocumentsFrom(root)); + } + + private static MapLayout Classrooms(int count, int desks = 16) + { + var rooms = new RoomNode[count]; + for (var i = 0; i < count; i++) + { + rooms[i] = new RoomNode + { + Id = $"classroom-{i:00}", + Def = "Classroom", + Building = "main", + Floor = "floor-1", + Label = $"{101 + i}", + Slots = + [ + new SlotFill { Key = "studentDesks", Thing = "StudentDesk", Count = desks }, + ], + }; + } + + return new MapLayout { Rooms = rooms }; + } + private static (DefCatalog Catalog, MapLayout Map) Vanilla() { var root = Path.Combine(AppContext.BaseDirectory, "vanilla");