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