Enhance family and roster management in school simulation by implementing support for incomplete families, ensuring proper handling of absent parents while maintaining surname consistency. Update family creation logic to account for single-parent households and revise child identification to prevent ID conflicts. Introduce seat shuffling to prevent siblings from being placed in the same class, improving the realism of student distribution. Update related tests to validate these new functionalities and ensure robustness in family and roster handling.
ci / server (push) Failing after 3m49s
ci / client (push) Successful in 24s

This commit is contained in:
Leonid Pershin
2026-08-18 21:13:16 +03:00
parent 7f6852548d
commit 3366e8e44b
19 changed files with 766 additions and 90 deletions
+39
View File
@@ -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 |
+3 -1
View File
@@ -26,7 +26,9 @@
## Тесты, без которых фаза не закрыта
- [x] Тот же сид, карта и набор имён — ровно тот же ростер
- [x] Тринадцатая семья не меняет первые двенадцать
- [x] Тринадцатая семья не меняет, как разыграны первые двенадцать (фамилия, состав, имена).
Класс ребёнка при этом меняется — места раздаются вперемешку по школе, иначе братья и
сёстры всегда оказывались одноклассниками; см. [`../design/people.md`](../design/people.md)
- [x] Фамилии, родовые формы и отчества внутри семьи согласованы
- [x] Ученик с телосложением «полное» не получает высокую ловкость
- [x] Все ученические места заполнены, все должности закрыты
+8
View File
@@ -40,8 +40,16 @@ public sealed class DefCatalog
NameSets = nameSets;
_ru = ru;
_en = en;
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
}
/// <summary>
/// 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.
/// </summary>
public bool AnyNeedDecays { get; }
public IReadOnlyList<string> PackIds { get; }
public IReadOnlyDictionary<string, ActionDef> Actions { get; }
+29
View File
@@ -28,6 +28,35 @@ internal static class PeopleDefValidator
{
ValidateNameSet(names);
}
RequireBuildInputs(catalog);
}
/// <summary>
/// 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".
/// </summary>
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)
+9
View File
@@ -21,6 +21,15 @@ public static class BodyBuilds
{
public const string Attribute = "Build";
/// <summary>
/// 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.
/// </summary>
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";
+101 -9
View File
@@ -2,6 +2,13 @@ namespace HSchool.People;
internal static class FamilyFactory
{
/// <summary>
/// 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.
/// </summary>
public const int IncompleteFamilyPercent = 8;
public static (Family Family, List<Person> Members) Create(
DefCatalog catalog,
NameSetDef names,
@@ -53,9 +60,22 @@ 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<Person>();
var parentIds = new List<string>(2);
var fatherId = $"{familyId}.p0";
var motherId = $"{familyId}.p1";
if (hasFather)
{
parentIds.Add(fatherId);
members.Add(
RollAdult(
catalog,
@@ -70,6 +90,11 @@ internal static class FamilyFactory
fatherGiven,
NameGrammar.Patronymic(fatherPatronymicSource.Form, female: false, names.PatronymicRule),
isParent: childDrafts.Length > 0));
}
if (hasMother)
{
parentIds.Add(motherId);
members.Add(
RollAdult(
catalog,
@@ -84,6 +109,7 @@ internal static class FamilyFactory
motherGiven,
NameGrammar.Patronymic(motherPatronymicSource.Form, female: true, names.PatronymicRule),
isParent: childDrafts.Length > 0));
}
var childIds = new List<string>(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<string>(
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<Person> Members) CreateStaffOnly(
/// <summary>
/// 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.
/// </summary>
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
};
}
/// <summary>
/// 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.
/// </summary>
internal static string? FatherGivenOf(Family family, IReadOnlyList<Person> 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<Person> 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<GivenNameEntry> pool,
Random rng,
+22 -3
View File
@@ -3,12 +3,31 @@ namespace HSchool.People;
internal readonly record struct FamilyPlan(int FamilyIndex, IReadOnlyList<PupilSeat> Seats);
/// <summary>
/// Walks seats in order and groups them into families of 13. 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 13, 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.
/// </summary>
internal static class FamilyPlanner
{
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<FamilyPlan> Singletons(IReadOnlyList<PupilSeat> 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<FamilyPlan> Plan(int schoolSeed, IReadOnlyList<PupilSeat> seats, int startIndex = 0)
{
var plans = new List<FamilyPlan>();
+11
View File
@@ -57,6 +57,7 @@ internal static class PersonSampler
values[skill.DefName] = rolled;
}
var touched = new HashSet<string>(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;
}
+35 -1
View File
@@ -55,10 +55,44 @@ public sealed record PersonName(
public string Full => string.Join(' ', new[] { Surname, Given, Patronymic }.Where(part => part.Length > 0));
}
/// <summary>
/// A household. <see cref="ParentIds"/> 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.
/// </summary>
public sealed record Family(
string Id,
IReadOnlyList<string> ParentIds,
IReadOnlyList<string> ChildIds);
IReadOnlyList<string> ChildIds,
int NextChild = 0,
string FatherGiven = "",
string Surname = "")
{
/// <summary>
/// Id suffix for the next child of this family. It is a counter and not
/// <c>ChildIds.Count</c>, 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.
/// </summary>
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,
+74 -10
View File
@@ -30,6 +30,17 @@ public static class RosterBrowser
public const int MaxPageSize = 100;
/// <summary>
/// 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
/// <c>InvariantGlobalization</c>, 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
/// <see cref="Apply"/> stays ordinal, so paging is stable regardless.
/// </summary>
private static readonly IComparer<string> 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;
/// <summary>
/// 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.
/// </summary>
private sealed class NameOrder : IComparer<string>
{
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,
};
}
}
+25 -4
View File
@@ -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<Person>();
var families = new List<Family>(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);
}
/// <summary>
/// Seats leave <see cref="SchoolDemand"/> 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.
/// </summary>
private static IReadOnlyList<PupilSeat> ShuffleSeats(IReadOnlyList<PupilSeat> 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<Person> AssignStaff(List<Person> people, IReadOnlyList<StaffOpening> openings)
{
if (openings.Count == 0)
+5
View File
@@ -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;
/// <summary>A stream that belongs to the school rather than to one family.</summary>
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
public static int Mix(int schoolSeed, int familyIndex, int salt)
{
+21 -20
View File
@@ -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
+1 -1
View File
@@ -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;
}
@@ -94,6 +94,27 @@ public class PeopleDefTests
Assert.Contains("Missing", ex.Message);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void BodyAttributesWithoutHeightOrWeight_FailTheCatalog()
{
var ex = Assert.Throws<ContentLoadException>(() => _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()
{
+27
View File
@@ -57,6 +57,33 @@ internal static class Fixtures
return new MapLayout { Rooms = rooms };
}
/// <summary>
/// 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.
/// </summary>
public static MapLayout PostHeavyMap()
{
var rooms = new List<RoomNode>
{
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);
@@ -0,0 +1,253 @@
namespace HSchool.People.Tests;
/// <summary>
/// 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.
/// </summary>
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<string>(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));
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>Sorting only reads the nominative, so the other five cases can be the same word.</summary>
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<string, int>(),
Choices = new Dictionary<string, string>(),
Skills = new Dictionary<string, int>(),
Traits = [],
Needs = new Dictionary<string, float>(),
};
}
@@ -21,14 +21,21 @@ public class RosterBrowserTests
});
}
/// <summary>
/// Alphabetical, with Ё counted as Е — see <c>RosterBrowser.NameOrder</c>. Ordinal order would
/// agree on this sample (no vanilla surname starts with Ё), which is exactly why the rule is
/// pinned down separately in <c>ReviewFixTests</c>.
/// </summary>
[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);
}
@@ -12,11 +12,18 @@ public class RosterGeneratorTests
Assert.Equal(Snapshot(a), Snapshot(b));
}
/// <summary>
/// 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.
/// </summary>
[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}"));
/// <summary>Who a family is, without where its children ended up sitting.</summary>
private static List<string> 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<string> FamilySnapshots(Roster roster, int take)
{
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);