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