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");