diff --git a/docs/design/people.md b/docs/design/people.md index a292684..d8426b9 100644 --- a/docs/design/people.md +++ b/docs/design/people.md @@ -102,6 +102,37 @@ по классам, а взрослых — по должностям. Обратный порядок («сгенерировать 400 учеников, потом приписать им родителей») не даёт ни братьев, ни учителей-родителей. +**Места раздаются вперемешку по всей школе, а не подряд.** Карта отдаёт ученические места +сгруппированными по кабинетам, и семья, берущая подряд идущие места, получала бы всех детей в один +класс — то есть ровесниками, с одним и тем же окном рождения. Порядок мест перемешивается от +школьного сида до раздачи. + +Цена решения названа прямо: набор мест перестаёт быть «дописываемым с конца». Более крупная карта +даёт другую раскладку по классам с самого начала, а не только в хвосте. Что остаётся неизменным — +**как разыгрывается сама семья**: её сид зависит только от номера, поэтому фамилия, состав и имена +семьи №5 одинаковы в школе на четыре кабинета и в школе на пять. Меняется то, в каком классе сидят +её дети, и вместе с классом — год рождения. + +### Неполные семьи + +Примерно **8%** семей с детьми живут с одним родителем. Кто именно остался — отец или мать — +решается броском, без перевеса в чью-либо сторону. + +**Причина не моделируется.** Ростер записывает, кто живёт в доме, а не почему. Развод, вдовство и +прочее — это события, а событий в этом срезе нет; появится лента — появится и повод завести признак. + +Ребёнок **в любом случае носит отцовскую фамилию и отчество**. Значит, отсутствующему отцу всё +равно разыгрывается имя — оно записывается на семью вместе с фамилией. Читать их с присутствующего +родителя нельзя: у матери-одиночки нет мужской формы фамилии, а сыну нужна именно она. + +Решение «кто остался» берётся из **отдельного потока**, а не из общего с внешностью. В общем потоке +два соседних броска оказались связаны, и все неполные семьи до единой вышли материнскими. + +**Номер ребёнка в идентификаторе — счётчик, а не длина списка.** Выпускник уходит из ростера; +если следующий ребёнок семьи получит номер по числу оставшихся детей, он унаследует идентификатор +ушедшего, и все ссылки на него — семейные связи, открытая карточка — молча начнут показывать +другого человека. + ## Дефы Пять новых видов. Ссылки — строками `defName`, как везде. @@ -295,6 +326,7 @@ Protocol ← Server → Simulation → People → Content - Смеси наборов имён с весами. - Новые роли из модов: ученик, работник, родитель — код, не данные. - Портреты и любой арт. +- Причина неполной семьи: развод, вдовство и прочее — это события, а ленты событий ещё нет. ## Зафиксировано этим разговором @@ -313,6 +345,13 @@ Protocol ← Server → Simulation → People → Content | Родители | Сущности в `World`, без места на карте; задел на вызов в школу | | Совмещение ролей | Один человек может быть работником и родителем ученика | | Единица генерации | Семья, а не человек | +| Неполные семьи | Около 8% семей с детьми; остаётся случайный родитель; причина не моделируется | +| Фамилия и отчество | Всегда отцовские; имя отца и фамилия записаны на семье, а не на человеке | +| Раздача мест | Вперемешку по школе от сида: иначе братья и сёстры — всегда одноклассники | +| Стабильность сида | Гарантируется по семье (фамилия, состав, имена), не по месту в классе | +| Идентификатор ребёнка | Счётчик семьи; номер выпустившегося не переиспользуется | +| Набор в первый класс | Новые семьи приходят по одному ребёнку — иначе это тройняшки-ровесники | +| Порядок ограничений | Черта не может пробить потолок тела: зажим повторяется после модификаторов | | Слои человека | Личность, тело, навыки, черты, нужды, связи | | Новые defs | `SkillDef`, `TraitDef`, `BodyAttributeDef`, `NeedDef`, `NameSetDef` | | Телосложение | Производное от роста и веса, не def | diff --git a/docs/phases/06-people-generator.md b/docs/phases/06-people-generator.md index 6e33114..4bf7455 100644 --- a/docs/phases/06-people-generator.md +++ b/docs/phases/06-people-generator.md @@ -26,7 +26,9 @@ ## Тесты, без которых фаза не закрыта - [x] Тот же сид, карта и набор имён — ровно тот же ростер -- [x] Тринадцатая семья не меняет первые двенадцать +- [x] Тринадцатая семья не меняет, как разыграны первые двенадцать (фамилия, состав, имена). + Класс ребёнка при этом меняется — места раздаются вперемешку по школе, иначе братья и + сёстры всегда оказывались одноклассниками; см. [`../design/people.md`](../design/people.md) - [x] Фамилии, родовые формы и отчества внутри семьи согласованы - [x] Ученик с телосложением «полное» не получает высокую ловкость - [x] Все ученические места заполнены, все должности закрыты diff --git a/src/HSchool.Content/DefCatalog.cs b/src/HSchool.Content/DefCatalog.cs index 5525aed..4ed6d41 100644 --- a/src/HSchool.Content/DefCatalog.cs +++ b/src/HSchool.Content/DefCatalog.cs @@ -40,8 +40,16 @@ public sealed class DefCatalog NameSets = nameSets; _ru = ru; _en = en; + AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f); } + /// + /// Whether any need in these packs actually drains. Core ships every rate at zero until + /// something can refill them, and walking every person twenty times a second to subtract + /// nothing is the kind of work that multiplies by six schools. + /// + public bool AnyNeedDecays { get; } + public IReadOnlyList PackIds { get; } public IReadOnlyDictionary Actions { get; } diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index c1b3792..8b49719 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -28,6 +28,35 @@ internal static class PeopleDefValidator { ValidateNameSet(names); } + + RequireBuildInputs(catalog); + } + + /// + /// The derived build reads these two by name. A pack without them would leave every person + /// on the fallback height and weight, and the whole build column would read "Average". + /// + private static void RequireBuildInputs(DefCatalog catalog) + { + if (catalog.BodyAttributes.Count == 0) + { + return; + } + + foreach (var required in new[] { BodyBuilds.HeightAttribute, BodyBuilds.WeightAttribute }) + { + if (!catalog.BodyAttributes.TryGetValue(required, out var def) || def.Abstract) + { + throw new ContentLoadException( + $"BodyAttributeDef '{required}' is required: the derived build is computed from it."); + } + + if (def.Kind != BodyAttributeKind.Number) + { + throw new ContentLoadException( + $"BodyAttributeDef '{required}' must be a number: the derived build is computed from it."); + } + } } private static void ValidateBody(BodyAttributeDef def) diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 4a3af34..65118dd 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -21,6 +21,15 @@ public static class BodyBuilds { public const string Attribute = "Build"; + /// + /// The two numeric attributes the build is computed from. They are named in code, so a pack + /// that renames or drops them is rejected at load rather than quietly producing an average + /// build for everybody. + /// + public const string HeightAttribute = "Height"; + + public const string WeightAttribute = "Weight"; + public const string Skinny = "Skinny"; public const string Average = "Average"; public const string Athletic = "Athletic"; diff --git a/src/HSchool.People/FamilyFactory.cs b/src/HSchool.People/FamilyFactory.cs index 101ccdc..36e60db 100644 --- a/src/HSchool.People/FamilyFactory.cs +++ b/src/HSchool.People/FamilyFactory.cs @@ -2,6 +2,13 @@ namespace HSchool.People; internal static class FamilyFactory { + /// + /// Share of families with children where one parent is simply absent. No reason is modelled — + /// the roster records who lives there, not why. The child keeps the father's surname and + /// patronymic either way, so an absent father still gets his name rolled. + /// + public const int IncompleteFamilyPercent = 8; + public static (Family Family, List Members) Create( DefCatalog catalog, NameSetDef names, @@ -53,37 +60,56 @@ internal static class FamilyFactory fatherBirth = motherBirth.AddDays(rng.Next(-5 * 365, (5 * 365) + 1)); } + // Its own stream: who lives in the household is a separate decision from what they look + // like, and drawing both from one stream tied the two together — every incomplete family + // came out mother-only. + var household = new Random(Seed.Mix(schoolSeed, plan.FamilyIndex, Seed.HouseholdSalt)); + var singleParent = childDrafts.Length > 0 && household.Next(100) < IncompleteFamilyPercent; + var motherStays = household.Next(2) == 0; + var hasFather = !singleParent || !motherStays; + var hasMother = !singleParent || motherStays; + var members = new List(); + var parentIds = new List(2); var fatherId = $"{familyId}.p0"; var motherId = $"{familyId}.p1"; - members.Add( - RollAdult( - catalog, - names, - rng, - fatherId, - familyId, - female: false, - fatherBirth, - asOf, - surname, - fatherGiven, - NameGrammar.Patronymic(fatherPatronymicSource.Form, female: false, names.PatronymicRule), - isParent: childDrafts.Length > 0)); - members.Add( - RollAdult( - catalog, - names, - rng, - motherId, - familyId, - female: true, - motherBirth, - asOf, - surname, - motherGiven, - NameGrammar.Patronymic(motherPatronymicSource.Form, female: true, names.PatronymicRule), - isParent: childDrafts.Length > 0)); + if (hasFather) + { + parentIds.Add(fatherId); + members.Add( + RollAdult( + catalog, + names, + rng, + fatherId, + familyId, + female: false, + fatherBirth, + asOf, + surname, + fatherGiven, + NameGrammar.Patronymic(fatherPatronymicSource.Form, female: false, names.PatronymicRule), + isParent: childDrafts.Length > 0)); + } + + if (hasMother) + { + parentIds.Add(motherId); + members.Add( + RollAdult( + catalog, + names, + rng, + motherId, + familyId, + female: true, + motherBirth, + asOf, + surname, + motherGiven, + NameGrammar.Patronymic(motherPatronymicSource.Form, female: true, names.PatronymicRule), + isParent: childDrafts.Length > 0)); + } var childIds = new List(childDrafts.Length); for (var i = 0; i < childDrafts.Length; i++) @@ -104,7 +130,7 @@ internal static class FamilyFactory fatherGiven.Form)); } - var family = new Family(familyId, [fatherId, motherId], childIds); + var family = new Family(familyId, parentIds, childIds, childIds.Count, fatherGiven.Form, surname.Male); return (family, members); } @@ -122,8 +148,12 @@ internal static class FamilyFactory DateTime asOf, int childIndex) { - var father = members.First(person => !person.IsStudent && !person.Female); - var mother = members.FirstOrDefault(person => !person.IsStudent && person.Female); + var fatherGiven = FatherGivenOf(family, members); + if (fatherGiven is null) + { + throw new InvalidOperationException($"Family '{family.Id}' has no father's name to build a patronymic from."); + } + var usedGiven = new HashSet( members.Where(person => person.IsStudent).Select(person => person.Name.Given), StringComparer.Ordinal); @@ -132,9 +162,11 @@ internal static class FamilyFactory 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 patronymic = NameGrammar.Patronymic(fatherGiven, female, names.PatronymicRule); + + // The surname is the father's whether or not he lives here, and it is gendered — so it + // comes from the name set, not from whichever parent happens to be present. + var (surnameNom, surnameCases) = SurnameFor(family, names, members, female); 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); @@ -166,15 +198,42 @@ internal static class FamilyFactory }; } - public static (Family Family, List Members) CreateStaffOnly( + /// + /// One adult who lives alone and works at the school. Deliberately not a couple: the top-up + /// loop counts openings one at a time, so a two-adult household overshot an odd deficit and + /// left behind an adult who was neither staff, parent nor pupil — invisible to every filter. + /// + public static (Family Family, Person Member) CreateStaffOnly( DefCatalog catalog, NameSetDef names, int schoolSeed, int familyIndex, DateTime asOf) { - var empty = new FamilyPlan(familyIndex, []); - return Create(catalog, names, schoolSeed, empty, SchoolYears.StartOn(asOf), asOf); + var rng = new Random(Seed.Mix(schoolSeed, familyIndex, Seed.AppearanceSalt)); + var familyId = $"f{familyIndex}"; + var surname = names.Surnames[rng.Next(names.Surnames.Count)]; + var female = rng.Next(2) == 0; + var given = PickGiven(female ? names.FemaleGiven : names.MaleGiven, rng); + var patronymicSource = PickGiven(names.MaleGiven, rng); + var birth = asOf.AddYears(-(24 + rng.Next(38))).AddDays(-rng.Next(365)); + + var id = $"{familyId}.p0"; + var person = RollAdult( + catalog, + names, + rng, + id, + familyId, + female, + birth, + asOf, + surname, + given, + NameGrammar.Patronymic(patronymicSource.Form, female, names.PatronymicRule), + isParent: false); + + return (new Family(familyId, [id], [], NextChild: 0, FatherGiven: string.Empty, Surname: surname.Male), person); } private static Person RollAdult( @@ -293,6 +352,39 @@ internal static class FamilyFactory }; } + /// + /// The father's given name: recorded on the family, or read off him when a roster written + /// before that field is loaded. Null only for such a roster with no father present. + /// + internal static string? FatherGivenOf(Family family, IReadOnlyList members) => + family.FatherGiven.Length > 0 + ? family.FatherGiven + : members.FirstOrDefault(person => !person.IsStudent && !person.Female)?.Name.Given; + + private static (string Nominative, CaseTable Cases) SurnameFor( + Family family, + NameSetDef names, + IReadOnlyList members, + bool female) + { + var entry = family.Surname.Length > 0 + ? names.Surnames.FirstOrDefault(candidate => candidate.Male.Equals(family.Surname, StringComparison.Ordinal)) + : null; + + if (entry is not null) + { + return ( + female ? entry.Female : entry.Male, + SurnameTable(entry, female, names.DefaultSurnameDeclension)); + } + + // Older roster, or a surname whose pack is gone: borrow from a parent of the same sex, + // then from any parent at all. + var parents = members.Where(person => !person.IsStudent).ToArray(); + var source = parents.FirstOrDefault(person => person.Female == female) ?? parents[0]; + return (source.Name.Surname, source.Name.SurnameCases); + } + private static GivenNameEntry PickGiven( IReadOnlyList pool, Random rng, diff --git a/src/HSchool.People/FamilyPlanner.cs b/src/HSchool.People/FamilyPlanner.cs index 6ab6d8c..012aa67 100644 --- a/src/HSchool.People/FamilyPlanner.cs +++ b/src/HSchool.People/FamilyPlanner.cs @@ -3,12 +3,31 @@ namespace HSchool.People; internal readonly record struct FamilyPlan(int FamilyIndex, IReadOnlyList Seats); /// -/// Walks seats in order and groups them into families of 1–3. A short remainder becomes -/// singleton families rather than shrinking an earlier family's intended size, so a longer -/// seat list only appends families. +/// Walks seats in order and groups them into families of 1–3, taking a run of consecutive seats +/// per family. Callers hand in a shuffled seat list — a run of *adjacent* seats is one classroom, +/// which would put every sibling in the same class. +/// +/// A family whose intended size does not fit the remainder takes one seat instead of shrinking, +/// so growing the seat list only disturbs the tail. /// internal static class FamilyPlanner { + /// + /// One child per family. Used by the yearly intake: every seat there is a first-year seat, so + /// grouping would hand a family two or three same-age children at once. An arriving pupil who + /// does have an older sibling gets attached to that family instead. + /// + public static IReadOnlyList Singletons(IReadOnlyList seats, int startIndex) + { + var plans = new FamilyPlan[seats.Count]; + for (var i = 0; i < seats.Count; i++) + { + plans[i] = new FamilyPlan(startIndex + i, [seats[i]]); + } + + return plans; + } + public static IReadOnlyList Plan(int schoolSeed, IReadOnlyList seats, int startIndex = 0) { var plans = new List(); diff --git a/src/HSchool.People/PersonSampler.cs b/src/HSchool.People/PersonSampler.cs index 81a97ce..4725861 100644 --- a/src/HSchool.People/PersonSampler.cs +++ b/src/HSchool.People/PersonSampler.cs @@ -57,6 +57,7 @@ internal static class PersonSampler values[skill.DefName] = rolled; } + var touched = new HashSet(StringComparer.Ordinal); foreach (var traitName in traits) { if (!catalog.Traits.TryGetValue(traitName, out var trait)) @@ -73,9 +74,19 @@ internal static class PersonSampler } values[modifier.Skill] = Clamp(current + modifier.Offset, skill.Range); + touched.Add(modifier.Skill); } } + // The body has the last word. Without this pass a trait modifier reopened what the body + // closed: a skinny Bully rolled Strength 45, the trait added 8, and the "Skinny caps + // Strength at 45" limit was silently gone. + foreach (var skillName in touched) + { + var skill = catalog.Skills[skillName]; + values[skillName] = Clamp(ApplyBodyLimits(values[skillName], skill, choices), skill.Range); + } + return values; } diff --git a/src/HSchool.People/Roster.cs b/src/HSchool.People/Roster.cs index feb6373..738d78a 100644 --- a/src/HSchool.People/Roster.cs +++ b/src/HSchool.People/Roster.cs @@ -55,10 +55,44 @@ public sealed record PersonName( public string Full => string.Join(' ', new[] { Surname, Given, Patronymic }.Where(part => part.Length > 0)); } +/// +/// A household. holds one adult or two — a child keeps the father's +/// surname and patronymic whether or not the father lives with them, so both are recorded here +/// rather than read off a parent who may not be in the roster. +/// public sealed record Family( string Id, IReadOnlyList ParentIds, - IReadOnlyList ChildIds); + IReadOnlyList ChildIds, + int NextChild = 0, + string FatherGiven = "", + string Surname = "") +{ + /// + /// Id suffix for the next child of this family. It is a counter and not + /// ChildIds.Count, because a graduate leaves the roster: deriving the suffix from the + /// children still present would hand a newcomer the id of the person who just left, and every + /// family link and open card pointing at that id would silently follow the wrong person. + /// + /// Rosters written before the counter existed fall back to the highest suffix still present. + /// + public int NextChildIndex => Math.Max(NextChild, HighestChildSuffix() + 1); + + private int HighestChildSuffix() + { + var highest = -1; + foreach (var id in ChildIds) + { + var marker = id.LastIndexOf(".c", StringComparison.Ordinal); + if (marker >= 0 && int.TryParse(id.AsSpan(marker + 2), out var index) && index > highest) + { + highest = index; + } + } + + return highest; + } +} public sealed record SchoolClass( string Id, diff --git a/src/HSchool.People/RosterBrowser.cs b/src/HSchool.People/RosterBrowser.cs index dc7033e..ac5cae9 100644 --- a/src/HSchool.People/RosterBrowser.cs +++ b/src/HSchool.People/RosterBrowser.cs @@ -30,6 +30,17 @@ public static class RosterBrowser public const int MaxPageSize = 100; + /// + /// A list a person reads has to follow the alphabet, not code points: ordinal comparison puts + /// «Ёлкина» (U+0401) ahead of «Абрамова» (U+0410), because Ё sits outside the А–Я block. + /// + /// A culture-aware comparer is not an option here — the repository builds with + /// InvariantGlobalization, which quietly turns every linguistic comparison back into an + /// ordinal one. So the one anomaly that matters is folded away by hand. The id tiebreak in + /// stays ordinal, so paging is stable regardless. + /// + private static readonly IComparer Names = NameOrder.Instance; + public static RosterPage Apply(Roster roster, DateTime asOf, RosterQuery query) { var classes = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal); @@ -47,21 +58,21 @@ public static class RosterBrowser var ordered = query.Sort switch { PersonSort.Age => query.Descending - ? filtered.OrderByDescending(person => person.AgeOn(asOf)).ThenByDescending(SurnameKey, StringComparer.Ordinal) - : filtered.OrderBy(person => person.AgeOn(asOf)).ThenBy(SurnameKey, StringComparer.Ordinal), + ? filtered.OrderByDescending(person => person.AgeOn(asOf)).ThenByDescending(SurnameKey, Names) + : filtered.OrderBy(person => person.AgeOn(asOf)).ThenBy(SurnameKey, Names), PersonSort.Year => SortByYear(filtered, classes, query.Descending), PersonSort.Position => query.Descending ? filtered .OrderBy(person => person.Position is null) .ThenByDescending(person => person.Position ?? string.Empty, StringComparer.Ordinal) - .ThenByDescending(SurnameKey, StringComparer.Ordinal) + .ThenByDescending(SurnameKey, Names) : filtered .OrderBy(person => person.Position is null) .ThenBy(person => person.Position ?? string.Empty, StringComparer.Ordinal) - .ThenBy(SurnameKey, StringComparer.Ordinal), + .ThenBy(SurnameKey, Names), _ => query.Descending - ? filtered.OrderByDescending(SurnameKey, StringComparer.Ordinal).ThenByDescending(GivenKey, StringComparer.Ordinal) - : filtered.OrderBy(SurnameKey, StringComparer.Ordinal).ThenBy(GivenKey, StringComparer.Ordinal), + ? filtered.OrderByDescending(SurnameKey, Names).ThenByDescending(GivenKey, Names) + : filtered.OrderBy(SurnameKey, Names).ThenBy(GivenKey, Names), }; var sorted = ordered.ThenBy(person => person.Id, StringComparer.Ordinal).ToArray(); @@ -111,12 +122,12 @@ public static class RosterBrowser return descending ? studentsFirst .ThenByDescending(person => YearOf(person, classes) ?? 0) - .ThenByDescending(person => LetterOf(person, classes) ?? string.Empty, StringComparer.Ordinal) - .ThenByDescending(SurnameKey, StringComparer.Ordinal) + .ThenByDescending(person => LetterOf(person, classes) ?? string.Empty, Names) + .ThenByDescending(SurnameKey, Names) : studentsFirst .ThenBy(person => YearOf(person, classes) ?? 0) - .ThenBy(person => LetterOf(person, classes) ?? string.Empty, StringComparer.Ordinal) - .ThenBy(SurnameKey, StringComparer.Ordinal); + .ThenBy(person => LetterOf(person, classes) ?? string.Empty, Names) + .ThenBy(SurnameKey, Names); } private static bool Matches( @@ -199,4 +210,57 @@ public static class RosterBrowser private static string SurnameKey(Person person) => person.Name.Surname; private static string GivenKey(Person person) => person.Name.Given; + + /// + /// Ordinal order with Ё alphabetised as Е, the way a Russian list is expected to read. + /// Compares in place rather than building folded keys: this runs on every row of every page. + /// + private sealed class NameOrder : IComparer + { + public static readonly NameOrder Instance = new(); + + public int Compare(string? x, string? y) + { + if (ReferenceEquals(x, y)) + { + return 0; + } + + if (x is null) + { + return -1; + } + + if (y is null) + { + return 1; + } + + var shared = Math.Min(x.Length, y.Length); + for (var i = 0; i < shared; i++) + { + var left = Fold(x[i]); + var right = Fold(y[i]); + if (left != right) + { + return left.CompareTo(right); + } + } + + if (x.Length != y.Length) + { + return x.Length - y.Length; + } + + // Identical once folded — «Артёмов» and «Артемов» still need a stable order between them. + return string.CompareOrdinal(x, y); + } + + private static char Fold(char value) => value switch + { + 'Ё' => 'Е', + 'ё' => 'е', + _ => value, + }; + } } diff --git a/src/HSchool.People/RosterGenerator.cs b/src/HSchool.People/RosterGenerator.cs index 0b12afe..87aa25b 100644 --- a/src/HSchool.People/RosterGenerator.cs +++ b/src/HSchool.People/RosterGenerator.cs @@ -30,7 +30,7 @@ public static class RosterGenerator var when = DateTime.SpecifyKind(asOf ?? DefaultAsOf, DateTimeKind.Utc); var yearStart = SchoolYears.StartOn(when); var demand = SchoolDemand.From(catalog, map); - var plans = FamilyPlanner.Plan(schoolSeed, demand.Seats); + var plans = FamilyPlanner.Plan(schoolSeed, ShuffleSeats(demand.Seats, schoolSeed)); var people = new List(); var families = new List(plans.Count); @@ -42,12 +42,14 @@ public static class RosterGenerator } var nextFamily = plans.Count; - while (people.Count(person => !person.IsStudent) < demand.Staff.Count) + var adults = people.Count(person => !person.IsStudent); + while (adults < demand.Staff.Count) { - var (family, members) = FamilyFactory.CreateStaffOnly(catalog, names, schoolSeed, nextFamily, when); + var (family, member) = FamilyFactory.CreateStaffOnly(catalog, names, schoolSeed, nextFamily, when); families.Add(family); - people.AddRange(members); + people.Add(member); nextFamily++; + adults++; } var staffed = AssignStaff(people, demand.Staff); @@ -55,6 +57,25 @@ public static class RosterGenerator return new Roster(staffed, families, classes); } + /// + /// Seats leave grouped by classroom, and a family takes a run of + /// consecutive seats — so without this every pair of siblings landed in the same class, the + /// same year and the same twelve-month birth window. Shuffling is seeded, so the roster stays + /// reproducible. + /// + private static IReadOnlyList ShuffleSeats(IReadOnlyList seats, int schoolSeed) + { + var shuffled = seats.ToArray(); + var rng = new Random(Seed.ForSchool(schoolSeed, Seed.SeatShuffleSalt)); + for (var i = shuffled.Length - 1; i > 0; i--) + { + var j = rng.Next(i + 1); + (shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]); + } + + return shuffled; + } + private static IReadOnlyList AssignStaff(List people, IReadOnlyList openings) { if (openings.Count == 0) diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs index 365238b..9da4ad3 100644 --- a/src/HSchool.People/Seed.cs +++ b/src/HSchool.People/Seed.cs @@ -9,6 +9,11 @@ internal static class Seed public const int ChildCountSalt = 1; public const int AppearanceSalt = 2; public const int IntakeSalt = 3; + public const int SeatShuffleSalt = 4; + public const int HouseholdSalt = 5; + + /// A stream that belongs to the school rather than to one family. + public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt); public static int Mix(int schoolSeed, int familyIndex, int salt) { diff --git a/src/HSchool.People/YearlyIntake.cs b/src/HSchool.People/YearlyIntake.cs index 2e945a4..878b4e4 100644 --- a/src/HSchool.People/YearlyIntake.cs +++ b/src/HSchool.People/YearlyIntake.cs @@ -89,7 +89,15 @@ public static class YearlyIntake continue; } - families.Add(new Family(family.Id, keepParents, childIds)); + // The counter travels with the family: a graduate's id must never come back. So do the + // father's name and the surname — a single-mother household still names its children + // after the father, and he is not in the roster to be asked. + families.Add(family with + { + ParentIds = keepParents, + ChildIds = childIds, + NextChild = family.NextChildIndex, + }); foreach (var parentId in keepParents) { var parent = remainingPeople[parentId]; @@ -166,17 +174,25 @@ public static class YearlyIntake var members = family.ParentIds.Concat(family.ChildIds) .Select(id => people[id]) .ToArray(); - if (!members.Any(person => !person.IsStudent && !person.Female)) + + // A household with no adult left cannot take in a first-year; nor can one whose + // father's name is unknown, since the newcomer's patronymic comes from it. + if (!members.Any(person => !person.IsStudent) + || FamilyFactory.FatherGivenOf(family, members) is null) { continue; } - var childIndex = NextChildIndex(family); + var childIndex = family.NextChildIndex; 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] }; + families[familyAt] = family with + { + ChildIds = [.. family.ChildIds, child.Id], + NextChild = childIndex + 1, + }; foreach (var parentId in family.ParentIds) { people[parentId] = people[parentId] with { IsParent = true }; @@ -191,7 +207,7 @@ public static class YearlyIntake } var leftover = seats.Skip(cursor).ToArray(); - foreach (var plan in FamilyPlanner.Plan(schoolSeed, leftover, Math.Max(nextFamilyIndex, 0))) + foreach (var plan in FamilyPlanner.Singletons(leftover, Math.Max(nextFamilyIndex, 0))) { var (created, members) = FamilyFactory.Create(catalog, names, schoolSeed, plan, yearStart, asOf); families.Add(created); @@ -238,21 +254,6 @@ public static class YearlyIntake 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 diff --git a/src/HSchool.Simulation/NeedDecay.cs b/src/HSchool.Simulation/NeedDecay.cs index 5a8c9fb..6dbc012 100644 --- a/src/HSchool.Simulation/NeedDecay.cs +++ b/src/HSchool.Simulation/NeedDecay.cs @@ -13,7 +13,7 @@ public static class NeedDecay public static void Apply(World world, DefCatalog catalog, double gameMinutes) { - if (gameMinutes <= 0 || catalog.Needs.Count == 0) + if (gameMinutes <= 0 || !catalog.AnyNeedDecays) { return; } diff --git a/tests/HSchool.Content.Tests/PeopleDefTests.cs b/tests/HSchool.Content.Tests/PeopleDefTests.cs index ae162aa..eeaccf9 100644 --- a/tests/HSchool.Content.Tests/PeopleDefTests.cs +++ b/tests/HSchool.Content.Tests/PeopleDefTests.cs @@ -94,6 +94,27 @@ public class PeopleDefTests Assert.Contains("Missing", ex.Message); } + /// + /// The derived build is computed from these two by name. A pack that renames them used to + /// load fine and hand every single person the fallback height and weight — and therefore the + /// same build, which then drives every body limit on every skill. + /// + [Fact] + public void BodyAttributesWithoutHeightOrWeight_FailTheCatalog() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "bodies", + "stature", + """{ "defName": "Stature", "kind": "number", "distributions": [ { "distribution": { "mean": 170, "stdDev": 8 } } ] }"""), + ])); + + Assert.Contains("Height", ex.Message); + } + [Fact] public void UnknownBuildValue_FailsTheCatalog() { diff --git a/tests/HSchool.People.Tests/Fixtures.cs b/tests/HSchool.People.Tests/Fixtures.cs index 5a9ad71..bb36b5b 100644 --- a/tests/HSchool.People.Tests/Fixtures.cs +++ b/tests/HSchool.People.Tests/Fixtures.cs @@ -57,6 +57,33 @@ internal static class Fixtures return new MapLayout { Rooms = rooms }; } + /// + /// One tiny class and five posts: the pupils' parents cannot fill them, so the generator has + /// to top the staff up, and the deficit is odd on purpose. + /// + public static MapLayout PostHeavyMap() + { + var rooms = new List + { + new() + { + Id = "classroom-00", + Def = "Classroom", + Building = "main", + Floor = "floor-1", + Label = "101", + Slots = [new SlotFill { Key = "studentDesks", Thing = "StudentDesk", Count = 1 }], + }, + }; + + foreach (var def in new[] { "PrincipalsOffice", "SecretaryOffice", "Library", "MedicalOffice" }) + { + rooms.Add(new RoomNode { Id = def, Def = def, Building = "main", Floor = "floor-1" }); + } + + return new MapLayout { Rooms = rooms }; + } + public static Roster Generate(MapLayout map, int seed = SchoolSeed) => RosterGenerator.Generate(Catalog(), map, seed, "Slavic", AsOf); diff --git a/tests/HSchool.People.Tests/ReviewFixTests.cs b/tests/HSchool.People.Tests/ReviewFixTests.cs new file mode 100644 index 0000000..fa75a44 --- /dev/null +++ b/tests/HSchool.People.Tests/ReviewFixTests.cs @@ -0,0 +1,253 @@ +namespace HSchool.People.Tests; + +/// +/// Regressions for the defects the slice review turned up. Each one was measured on the vanilla +/// catalog before it was fixed, so the numbers below are thresholds, not guesses. +/// +public class ReviewFixTests +{ + [Fact] + public void Siblings_AreNotAllInTheSameClass() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(11)); + var classOf = roster.People + .Where(person => person.IsStudent && person.ClassId is not null) + .ToDictionary(person => person.Id, person => person.ClassId!, StringComparer.Ordinal); + + var multi = roster.Families.Where(family => family.ChildIds.Count > 1).ToArray(); + Assert.NotEmpty(multi); + + // Seats used to leave SchoolDemand grouped by classroom and families took a consecutive + // run of them, so 44 of 47 sibling groups shared one class, one year and one birth window. + var spread = multi.Count(family => + family.ChildIds.Select(id => classOf[id]).Distinct(StringComparer.Ordinal).Count() > 1); + + Assert.True( + spread * 2 > multi.Length, + $"only {spread} of {multi.Length} sibling groups span more than one class"); + } + + [Fact] + public void TraitModifiers_CannotReopenWhatTheBodyClosed() + { + var catalog = Fixtures.Catalog(); + var roster = Fixtures.Generate(Fixtures.Classrooms(11)); + + foreach (var person in roster.People) + { + foreach (var (skillName, value) in person.Skills) + { + var skill = catalog.Skills[skillName]; + var capped = PersonSampler.ApplyBodyLimits(value, skill, person.Choices); + Assert.Equal(capped, value); + } + } + } + + [Fact] + public void StaffTopUp_LeavesNobodyWithoutARole() + { + // More posts than the pupils' parents can fill, and an odd deficit: the old top-up added + // two adults per opening and the spare one ended up neither staff, parent nor pupil. + var roster = Fixtures.Generate(Fixtures.PostHeavyMap()); + + Assert.All( + roster.People, + person => Assert.True( + person.IsStudent || person.IsStaff || person.IsParent, + $"{person.Id} ({person.Name.Full}) has no role at all")); + } + + [Fact] + public void Graduation_DoesNotHandTheGraduatesIdToANewcomer() + { + var catalog = Fixtures.Catalog(); + var map = Fixtures.Classrooms(4); + var roster = Fixtures.Generate(map); + var seen = new HashSet(roster.People.Select(person => person.Id), StringComparer.Ordinal); + + var date = new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc); + for (var year = 0; year < 4; year++) + { + var before = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + roster = YearlyIntake.Apply(catalog, roster, Fixtures.SchoolSeed, "Slavic", date); + date = date.AddYears(1); + + foreach (var person in roster.People) + { + if (before.TryGetValue(person.Id, out var earlier)) + { + // Same id must still be the same human being. + Assert.Equal(earlier.Name.Full, person.Name.Full); + Assert.Equal(earlier.BirthDate, person.BirthDate); + continue; + } + + Assert.True(seen.Add(person.Id), $"{person.Id} was reused for a different person"); + } + } + } + + [Fact] + public void IntakeFamilies_DoNotArriveAsTriplets() + { + var catalog = Fixtures.Catalog(); + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var known = roster.Families.Select(family => family.Id).ToHashSet(StringComparer.Ordinal); + + var after = YearlyIntake.Apply( + catalog, + roster, + Fixtures.SchoolSeed, + "Slavic", + new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc)); + + // Every seat an intake fills is a first-year seat, so a brand-new family that took two or + // three of them would be handing the school same-age triplets. + var arrived = after.Families.Where(family => !known.Contains(family.Id)).ToArray(); + Assert.NotEmpty(arrived); + Assert.All(arrived, family => Assert.Single(family.ChildIds)); + } + + /// + /// Twelve schools rather than one: a single roster gives around forty incomplete families, + /// and a share measured on forty samples swings far enough to make the test a coin toss. + /// + [Fact] + public void IncompleteFamilies_AreAroundTheIntendedShareAndSplitBetweenBothParents() + { + var withChildren = 0; + var single = 0; + var motherOnly = 0; + + for (var seed = 1; seed <= 12; seed++) + { + var roster = Fixtures.Generate(Fixtures.Classrooms(11), seed); + var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + + foreach (var family in roster.Families.Where(family => family.ChildIds.Count > 0)) + { + withChildren++; + Assert.InRange(family.ParentIds.Count, 1, 2); + if (family.ParentIds.Count != 1) + { + continue; + } + + single++; + if (people[family.ParentIds[0]].Female) + { + motherOnly++; + } + } + } + + var share = single * 100d / withChildren; + Assert.InRange(share, 5d, 10d); + + // Which parent stays is drawn from its own stream; sharing one with the appearance rolls + // produced mother-only households every single time. + Assert.InRange(motherOnly * 100d / single, 30d, 70d); + } + + [Fact] + public void AChildOfASingleMother_StillCarriesTheFathersSurnameAndPatronymic() + { + var catalog = Fixtures.Catalog(); + var names = catalog.NameSets["Slavic"]; + var roster = Fixtures.Generate(Fixtures.Classrooms(11)); + var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + + var motherOnly = roster.Families + .Where(family => family.ChildIds.Count > 0 && family.ParentIds.Count == 1) + .Where(family => people[family.ParentIds[0]].Female) + .ToArray(); + + Assert.NotEmpty(motherOnly); + foreach (var family in motherOnly) + { + var entry = names.Surnames.Single(candidate => candidate.Male == family.Surname); + foreach (var child in family.ChildIds.Select(id => people[id])) + { + Assert.Equal(child.Female ? entry.Female : entry.Male, child.Name.Surname); + Assert.Equal( + NameGrammar.Patronymic(family.FatherGiven, child.Female, NameGrammar.SlavicPatronymic), + child.Name.Patronymic); + } + } + } + + [Fact] + public void ASingleMotherFamily_CanStillTakeInAYoungerSibling() + { + var catalog = Fixtures.Catalog(); + var names = catalog.NameSets["Slavic"]; + var roster = Fixtures.Generate(Fixtures.Classrooms(11)); + var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + var motherOnly = roster.Families + .Where(family => family.ChildIds.Count > 0 && family.ParentIds.Count == 1) + .First(family => people[family.ParentIds[0]].Female); + + var members = motherOnly.ParentIds.Concat(motherOnly.ChildIds).Select(id => people[id]).ToArray(); + var seat = new PupilSeat("class-classroom-00", "classroom-00", Year: 1, "А"); + var child = FamilyFactory.AddChild( + catalog, + names, + new Random(1), + motherOnly, + members, + seat, + new DateTime(2011, 9, 1, 0, 0, 0, DateTimeKind.Utc), + Fixtures.AsOf, + motherOnly.NextChildIndex); + + // Boys need the male form of a surname no living member of this household carries. + var entry = names.Surnames.Single(candidate => candidate.Male == motherOnly.Surname); + Assert.Equal(child.Female ? entry.Female : entry.Male, child.Name.Surname); + Assert.Equal(child.Name.Surname, child.Name.SurnameCases.Nom); + Assert.Equal( + NameGrammar.Patronymic(motherOnly.FatherGiven, child.Female, NameGrammar.SlavicPatronymic), + child.Name.Patronymic); + } + + [Fact] + public void SurnameSort_FollowsTheAlphabetNotCodePoints() + { + var roster = new Roster( + [Pupil("a", "Ёлкина"), Pupil("b", "Абрамова"), Pupil("c", "Яковлева"), Pupil("d", "Егорова")], + [], + []); + + var page = RosterBrowser.Apply( + roster, + Fixtures.AsOf, + new RosterQuery(null, null, null, null, null, null, null, PersonSort.Surname, false, 1, 10)); + + // Ordinal put «Ёлкина» (U+0401) ahead of «Абрамова» (U+0410); the alphabet puts it after «Егорова». + Assert.Equal( + ["Абрамова", "Егорова", "Ёлкина", "Яковлева"], + page.People.Select(person => person.Name.Surname)); + } + + /// Sorting only reads the nominative, so the other five cases can be the same word. + private static CaseTable Flat(string word) => + new() { Nom = word, Gen = word, Dat = word, Acc = word, Ins = word, Pre = word }; + + private static Person Pupil(string id, string surname) => + new() + { + Id = id, + FamilyId = id, + Female = true, + BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Name = new PersonName("Мария", surname, "Петровна", Flat("Мария"), Flat(surname), Flat("Петровна")), + IsStudent = true, + IsStaff = false, + IsParent = false, + Numbers = new Dictionary(), + Choices = new Dictionary(), + Skills = new Dictionary(), + Traits = [], + Needs = new Dictionary(), + }; +} diff --git a/tests/HSchool.People.Tests/RosterBrowserTests.cs b/tests/HSchool.People.Tests/RosterBrowserTests.cs index 95db9e3..edb9c15 100644 --- a/tests/HSchool.People.Tests/RosterBrowserTests.cs +++ b/tests/HSchool.People.Tests/RosterBrowserTests.cs @@ -21,14 +21,21 @@ public class RosterBrowserTests }); } + /// + /// Alphabetical, with Ё counted as Е — see RosterBrowser.NameOrder. Ordinal order would + /// agree on this sample (no vanilla surname starts with Ё), which is exactly why the rule is + /// pinned down separately in ReviewFixTests. + /// [Fact] - public void SortBySurname_IsAscendingOrdinal() + public void SortBySurname_IsAlphabetical() { var roster = Fixtures.Generate(Fixtures.Classrooms(4)); var page = RosterBrowser.Apply(roster, Fixtures.AsOf, Query(pageSize: 20)); var surnames = page.People.Select(person => person.Name.Surname).ToArray(); - var expected = surnames.OrderBy(name => name, StringComparer.Ordinal).ToArray(); + var expected = surnames + .OrderBy(name => name.Replace('Ё', 'Е').Replace('ё', 'е'), StringComparer.Ordinal) + .ToArray(); Assert.Equal(expected, surnames); } diff --git a/tests/HSchool.People.Tests/RosterGeneratorTests.cs b/tests/HSchool.People.Tests/RosterGeneratorTests.cs index e9719b8..059d563 100644 --- a/tests/HSchool.People.Tests/RosterGeneratorTests.cs +++ b/tests/HSchool.People.Tests/RosterGeneratorTests.cs @@ -12,11 +12,18 @@ public class RosterGeneratorTests Assert.Equal(Snapshot(a), Snapshot(b)); } + /// + /// Each family draws from its own seeded stream, so a bigger map does not reshuffle the + /// families already planned: the first twelve keep their surname, their size and every given + /// name. What a bigger map does move is which classroom a child sits in — seats are dealt + /// across the whole school so siblings are not automatically classmates — and with the + /// classroom comes the year, hence the birth year. That is the one thing not asserted here. + /// [Fact] - public void ThirteenthFamily_DoesNotChangeTheFirstTwelve() + public void ThirteenthFamily_DoesNotChangeHowTheFirstTwelveAreDrawn() { - var twelve = FamilySnapshots(Fixtures.Generate(Fixtures.Classrooms(4)), take: 12); - var thirteen = FamilySnapshots(Fixtures.Generate(Fixtures.Classrooms(5)), take: 12); + var twelve = FamilyIdentities(Fixtures.Generate(Fixtures.Classrooms(4)), take: 12); + var thirteen = FamilyIdentities(Fixtures.Generate(Fixtures.Classrooms(5)), take: 12); Assert.Equal(12, twelve.Count); Assert.Equal(twelve, thirteen); @@ -35,20 +42,31 @@ public class RosterGeneratorTests continue; } - var father = people[family.ParentIds[0]]; - var mother = people[family.ParentIds[1]]; - Assert.False(father.Female); - Assert.True(mother.Female); + // One parent may be absent, so the father's name comes off the family, not off a person. + var parents = family.ParentIds.Select(id => people[id]).ToArray(); + Assert.NotEmpty(parents); + Assert.Equal(parents.Length, parents.DistinctBy(parent => parent.Female).Count()); + Assert.NotEmpty(family.FatherGiven); + + foreach (var parent in parents) + { + Assert.Equal(parent.Name.SurnameCases.Nom, parent.Name.Surname); + } foreach (var childId in family.ChildIds) { var child = people[childId]; Assert.Equal( - NameGrammar.Patronymic(father.Name.Given, child.Female, NameGrammar.SlavicPatronymic), + NameGrammar.Patronymic(family.FatherGiven, child.Female, NameGrammar.SlavicPatronymic), child.Name.Patronymic); - Assert.Equal(child.Female ? mother.Name.Surname : father.Name.Surname, child.Name.Surname); - Assert.Equal(father.Name.SurnameCases.Nom, father.Name.Surname); - Assert.Equal(mother.Name.SurnameCases.Nom, mother.Name.Surname); + + var sameSexParent = parents.FirstOrDefault(parent => parent.Female == child.Female); + if (sameSexParent is not null) + { + Assert.Equal(sameSexParent.Name.Surname, child.Name.Surname); + } + + Assert.Equal(child.Name.SurnameCases.Nom, child.Name.Surname); } } } @@ -167,6 +185,22 @@ public class RosterGeneratorTests private static string Skills(Person person) => string.Join(',', person.Skills.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}={pair.Value}")); + /// Who a family is, without where its children ended up sitting. + private static List FamilyIdentities(Roster roster, int take) + { + var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + return roster.Families + .OrderBy(family => family.Id, StringComparer.Ordinal) + .Take(take) + .Select(family => + { + var members = family.ParentIds.Concat(family.ChildIds).Select(id => people[id]); + return string.Join(';', members.Select(person => + $"{person.Id}:{person.Name.Surname} {person.Name.Given} {person.Name.Patronymic}:{person.Female}")); + }) + .ToList(); + } + private static List FamilySnapshots(Roster roster, int take) { var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);