Enhance school creation functionality by introducing support for name sets in the API and UI. Update the catalog to include skills, traits, body attributes, needs, and name sets, improving character generation capabilities. Revise localization strings for better user guidance and update tests to validate the new name set functionality and ensure robustness in school creation processes.
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
namespace HSchool.People;
|
||||
|
||||
internal static class FamilyFactory
|
||||
{
|
||||
public static (Family Family, List<Person> Members) Create(
|
||||
DefCatalog catalog,
|
||||
NameSetDef names,
|
||||
int schoolSeed,
|
||||
FamilyPlan plan,
|
||||
DateTime yearStart,
|
||||
DateTime asOf)
|
||||
{
|
||||
var rng = new Random(Seed.Mix(schoolSeed, plan.FamilyIndex, Seed.AppearanceSalt));
|
||||
var familyId = $"f{plan.FamilyIndex}";
|
||||
var surname = names.Surnames[rng.Next(names.Surnames.Count)];
|
||||
|
||||
var childDrafts = new ChildDraft[plan.Seats.Count];
|
||||
var usedGiven = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < plan.Seats.Count; i++)
|
||||
{
|
||||
var seat = plan.Seats[i];
|
||||
var female = rng.Next(2) == 0;
|
||||
var given = PickGiven(female ? names.FemaleGiven : names.MaleGiven, rng, usedGiven);
|
||||
var (first, last) = SchoolYears.BirthWindow(yearStart, seat.Year);
|
||||
childDrafts[i] = new ChildDraft(seat, female, given, SchoolYears.RandomInRange(rng, first, last));
|
||||
}
|
||||
|
||||
var fatherGiven = PickGiven(names.MaleGiven, rng);
|
||||
var motherGiven = PickGiven(names.FemaleGiven, rng);
|
||||
var fatherPatronymicSource = PickGiven(names.MaleGiven, rng);
|
||||
var motherPatronymicSource = PickGiven(names.MaleGiven, rng);
|
||||
|
||||
DateTime motherBirth;
|
||||
DateTime fatherBirth;
|
||||
if (childDrafts.Length == 0)
|
||||
{
|
||||
motherBirth = asOf.AddYears(-(28 + rng.Next(28))).AddDays(-rng.Next(365));
|
||||
fatherBirth = motherBirth.AddDays(rng.Next(-5 * 365, (5 * 365) + 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
var oldestBirth = childDrafts[0].Birth;
|
||||
foreach (var child in childDrafts)
|
||||
{
|
||||
if (child.Birth < oldestBirth)
|
||||
{
|
||||
oldestBirth = child.Birth;
|
||||
}
|
||||
}
|
||||
|
||||
var motherYearsOlder = 22 + rng.Next(17);
|
||||
motherBirth = oldestBirth.AddYears(-motherYearsOlder).AddDays(-rng.Next(30));
|
||||
fatherBirth = motherBirth.AddDays(rng.Next(-5 * 365, (5 * 365) + 1));
|
||||
}
|
||||
|
||||
var members = new List<Person>();
|
||||
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));
|
||||
|
||||
var childIds = new List<string>(childDrafts.Length);
|
||||
for (var i = 0; i < childDrafts.Length; i++)
|
||||
{
|
||||
var draft = childDrafts[i];
|
||||
var id = $"{familyId}.c{i}";
|
||||
childIds.Add(id);
|
||||
members.Add(
|
||||
RollChild(
|
||||
catalog,
|
||||
names,
|
||||
rng,
|
||||
id,
|
||||
familyId,
|
||||
draft,
|
||||
asOf,
|
||||
surname,
|
||||
fatherGiven.Form));
|
||||
}
|
||||
|
||||
var family = new Family(familyId, [fatherId, motherId], childIds);
|
||||
return (family, members);
|
||||
}
|
||||
|
||||
public static (Family Family, List<Person> Members) 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);
|
||||
}
|
||||
|
||||
private static Person RollAdult(
|
||||
DefCatalog catalog,
|
||||
NameSetDef names,
|
||||
Random rng,
|
||||
string id,
|
||||
string familyId,
|
||||
bool female,
|
||||
DateTime birth,
|
||||
DateTime asOf,
|
||||
SurnameEntry surname,
|
||||
GivenNameEntry given,
|
||||
string patronymic,
|
||||
bool isParent)
|
||||
{
|
||||
var age = SchoolYears.AgeYears(birth, asOf);
|
||||
var roles = isParent ? new[] { PersonRoles.Parent } : new[] { PersonRoles.Staff };
|
||||
return FinishPerson(
|
||||
catalog,
|
||||
names,
|
||||
rng,
|
||||
id,
|
||||
familyId,
|
||||
female,
|
||||
birth,
|
||||
asOf,
|
||||
age,
|
||||
surname,
|
||||
given,
|
||||
patronymic,
|
||||
roles,
|
||||
isStudent: false,
|
||||
isParent,
|
||||
classId: null);
|
||||
}
|
||||
|
||||
private static Person RollChild(
|
||||
DefCatalog catalog,
|
||||
NameSetDef names,
|
||||
Random rng,
|
||||
string id,
|
||||
string familyId,
|
||||
ChildDraft draft,
|
||||
DateTime asOf,
|
||||
SurnameEntry surname,
|
||||
string fatherGiven)
|
||||
{
|
||||
var age = SchoolYears.AgeYears(draft.Birth, asOf);
|
||||
var patronymic = NameGrammar.Patronymic(fatherGiven, draft.Female, names.PatronymicRule);
|
||||
return FinishPerson(
|
||||
catalog,
|
||||
names,
|
||||
rng,
|
||||
id,
|
||||
familyId,
|
||||
draft.Female,
|
||||
draft.Birth,
|
||||
asOf,
|
||||
age,
|
||||
surname,
|
||||
draft.Given,
|
||||
patronymic,
|
||||
[PersonRoles.Student],
|
||||
isStudent: true,
|
||||
isParent: false,
|
||||
draft.Seat.ClassId);
|
||||
}
|
||||
|
||||
private static Person FinishPerson(
|
||||
DefCatalog catalog,
|
||||
NameSetDef names,
|
||||
Random rng,
|
||||
string id,
|
||||
string familyId,
|
||||
bool female,
|
||||
DateTime birth,
|
||||
DateTime asOf,
|
||||
int age,
|
||||
SurnameEntry surname,
|
||||
GivenNameEntry given,
|
||||
string patronymic,
|
||||
IReadOnlyList<string> roles,
|
||||
bool isStudent,
|
||||
bool isParent,
|
||||
string? classId)
|
||||
{
|
||||
var (numbers, choices) = PersonSampler.Body(catalog, rng, female, age);
|
||||
var traits = PersonSampler.Traits(catalog, rng, roles, age);
|
||||
var skills = PersonSampler.Skills(catalog, rng, age, choices, traits);
|
||||
var needs = PersonSampler.Needs(catalog);
|
||||
var name = new PersonName(
|
||||
given.Form,
|
||||
female ? surname.Female : surname.Male,
|
||||
patronymic,
|
||||
GivenTable(given, names.DefaultGivenDeclension),
|
||||
SurnameTable(surname, female, names.DefaultSurnameDeclension),
|
||||
PatronymicTable(patronymic, female));
|
||||
|
||||
return new Person
|
||||
{
|
||||
Id = id,
|
||||
FamilyId = familyId,
|
||||
Female = female,
|
||||
BirthDate = DateTime.SpecifyKind(birth, DateTimeKind.Utc),
|
||||
Name = name,
|
||||
IsStudent = isStudent,
|
||||
IsStaff = false,
|
||||
IsParent = isParent,
|
||||
ClassId = classId,
|
||||
Numbers = numbers,
|
||||
Choices = choices,
|
||||
Skills = skills,
|
||||
Traits = traits,
|
||||
Needs = needs,
|
||||
};
|
||||
}
|
||||
|
||||
private static GivenNameEntry PickGiven(
|
||||
IReadOnlyList<GivenNameEntry> pool,
|
||||
Random rng,
|
||||
HashSet<string>? used = null)
|
||||
{
|
||||
if (pool.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Name set has no given names for this sex.");
|
||||
}
|
||||
|
||||
for (var attempt = 0; attempt < 16; attempt++)
|
||||
{
|
||||
var pick = pool[rng.Next(pool.Count)];
|
||||
if (used is null || used.Add(pick.Form) || used.Count >= pool.Count)
|
||||
{
|
||||
return pick;
|
||||
}
|
||||
}
|
||||
|
||||
return pool[rng.Next(pool.Count)];
|
||||
}
|
||||
|
||||
private static CaseTable GivenTable(GivenNameEntry entry, string defaultModel) =>
|
||||
new()
|
||||
{
|
||||
Nom = NameGrammar.InflectGiven(entry, GrammaticalCase.Nominative, defaultModel),
|
||||
Gen = NameGrammar.InflectGiven(entry, GrammaticalCase.Genitive, defaultModel),
|
||||
Dat = NameGrammar.InflectGiven(entry, GrammaticalCase.Dative, defaultModel),
|
||||
Acc = NameGrammar.InflectGiven(entry, GrammaticalCase.Accusative, defaultModel),
|
||||
Ins = NameGrammar.InflectGiven(entry, GrammaticalCase.Instrumental, defaultModel),
|
||||
Pre = NameGrammar.InflectGiven(entry, GrammaticalCase.Prepositional, defaultModel),
|
||||
};
|
||||
|
||||
private static CaseTable SurnameTable(SurnameEntry entry, bool female, string defaultModel) =>
|
||||
new()
|
||||
{
|
||||
Nom = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Nominative, defaultModel),
|
||||
Gen = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Genitive, defaultModel),
|
||||
Dat = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Dative, defaultModel),
|
||||
Acc = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Accusative, defaultModel),
|
||||
Ins = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Instrumental, defaultModel),
|
||||
Pre = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Prepositional, defaultModel),
|
||||
};
|
||||
|
||||
private static CaseTable PatronymicTable(string nominative, bool female) =>
|
||||
new()
|
||||
{
|
||||
Nom = nominative,
|
||||
Gen = NameGrammar.InflectPatronymic(nominative, female, GrammaticalCase.Genitive),
|
||||
Dat = NameGrammar.InflectPatronymic(nominative, female, GrammaticalCase.Dative),
|
||||
Acc = NameGrammar.InflectPatronymic(nominative, female, GrammaticalCase.Accusative),
|
||||
Ins = NameGrammar.InflectPatronymic(nominative, female, GrammaticalCase.Instrumental),
|
||||
Pre = NameGrammar.InflectPatronymic(nominative, female, GrammaticalCase.Prepositional),
|
||||
};
|
||||
|
||||
private readonly record struct ChildDraft(
|
||||
PupilSeat Seat,
|
||||
bool Female,
|
||||
GivenNameEntry Given,
|
||||
DateTime Birth);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace HSchool.People;
|
||||
|
||||
internal readonly record struct FamilyPlan(int FamilyIndex, IReadOnlyList<PupilSeat> Seats);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class FamilyPlanner
|
||||
{
|
||||
public static IReadOnlyList<FamilyPlan> Plan(int schoolSeed, IReadOnlyList<PupilSeat> seats)
|
||||
{
|
||||
var plans = new List<FamilyPlan>();
|
||||
var offset = 0;
|
||||
var index = 0;
|
||||
while (offset < seats.Count)
|
||||
{
|
||||
var remaining = seats.Count - offset;
|
||||
var want = ChildCount(schoolSeed, index);
|
||||
var take = want <= remaining ? want : 1;
|
||||
var slice = new PupilSeat[take];
|
||||
for (var i = 0; i < take; i++)
|
||||
{
|
||||
slice[i] = seats[offset + i];
|
||||
}
|
||||
|
||||
plans.Add(new FamilyPlan(index, slice));
|
||||
offset += take;
|
||||
index++;
|
||||
}
|
||||
|
||||
return plans;
|
||||
}
|
||||
|
||||
public static int ChildCount(int schoolSeed, int familyIndex)
|
||||
{
|
||||
var rng = new Random(Seed.Mix(schoolSeed, familyIndex, Seed.ChildCountSalt));
|
||||
var roll = rng.Next(100);
|
||||
if (roll < 50)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return roll < 85 ? 2 : 3;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using HSchool.Content;
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>HSchool.People</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="HSchool.People.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,354 @@
|
||||
namespace HSchool.People;
|
||||
|
||||
internal static class PersonSampler
|
||||
{
|
||||
public static (Dictionary<string, int> Numbers, Dictionary<string, string> Choices) Body(
|
||||
DefCatalog catalog,
|
||||
Random rng,
|
||||
bool female,
|
||||
int age)
|
||||
{
|
||||
var numbers = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var choices = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var def in catalog.BodyAttributes.Values.OrderBy(candidate => candidate.DefName, StringComparer.Ordinal))
|
||||
{
|
||||
if (def.Abstract)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (def.Kind == BodyAttributeKind.Number)
|
||||
{
|
||||
numbers[def.DefName] = SampleNumber(def, rng, female, age);
|
||||
}
|
||||
else
|
||||
{
|
||||
choices[def.DefName] = SampleChoice(def, rng, female, age);
|
||||
}
|
||||
}
|
||||
|
||||
var height = numbers.GetValueOrDefault("Height", 170);
|
||||
var weight = numbers.GetValueOrDefault("Weight", 65);
|
||||
choices[BodyBuilds.Attribute] = BodyBuilds.FromHeightAndWeight(height, weight);
|
||||
return (numbers, choices);
|
||||
}
|
||||
|
||||
public static Dictionary<string, int> Skills(
|
||||
DefCatalog catalog,
|
||||
Random rng,
|
||||
int age,
|
||||
IReadOnlyDictionary<string, string> choices,
|
||||
IReadOnlyList<string> traits)
|
||||
{
|
||||
var values = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var skill in catalog.Skills.Values.OrderBy(candidate => candidate.DefName, StringComparer.Ordinal))
|
||||
{
|
||||
if (skill.Abstract)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var mean = MeanForAge(skill, age);
|
||||
var stdDev = skill.Distribution?.StdDev ?? 10f;
|
||||
var rolled = (int)Math.Round(mean + (stdDev * NextGaussian(rng)));
|
||||
rolled = Clamp(rolled, skill.Range);
|
||||
rolled = ApplyBodyLimits(rolled, skill, choices);
|
||||
rolled = Clamp(rolled, skill.Range);
|
||||
values[skill.DefName] = rolled;
|
||||
}
|
||||
|
||||
foreach (var traitName in traits)
|
||||
{
|
||||
if (!catalog.Traits.TryGetValue(traitName, out var trait))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var modifier in trait.SkillModifiers)
|
||||
{
|
||||
if (!values.TryGetValue(modifier.Skill, out var current)
|
||||
|| !catalog.Skills.TryGetValue(modifier.Skill, out var skill))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
values[modifier.Skill] = Clamp(current + modifier.Offset, skill.Range);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
public static List<string> Traits(DefCatalog catalog, Random rng, IReadOnlyList<string> roles, int age)
|
||||
{
|
||||
var picked = new List<string>();
|
||||
var count = rng.Next(3);
|
||||
if (count == 0 || catalog.Traits.Count == 0)
|
||||
{
|
||||
return picked;
|
||||
}
|
||||
|
||||
var pool = catalog.Traits.Values
|
||||
.Where(trait => !trait.Abstract && TraitFits(trait, roles, age))
|
||||
.OrderBy(trait => trait.DefName, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
for (var n = 0; n < count && pool.Count > 0; n++)
|
||||
{
|
||||
var chosen = WeightedTrait(pool, rng);
|
||||
if (chosen is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
picked.Add(chosen.DefName);
|
||||
var banned = catalog.TraitIncompatibilities(chosen.DefName);
|
||||
pool.RemoveAll(trait =>
|
||||
trait.DefName == chosen.DefName
|
||||
|| banned.Contains(trait.DefName));
|
||||
}
|
||||
|
||||
picked.Sort(StringComparer.Ordinal);
|
||||
return picked;
|
||||
}
|
||||
|
||||
public static Dictionary<string, float> Needs(DefCatalog catalog)
|
||||
{
|
||||
var values = new Dictionary<string, float>(StringComparer.Ordinal);
|
||||
foreach (var need in catalog.Needs.Values.OrderBy(candidate => candidate.DefName, StringComparer.Ordinal))
|
||||
{
|
||||
if (!need.Abstract)
|
||||
{
|
||||
values[need.DefName] = need.Initial;
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
internal static int ApplyBodyLimits(int value, SkillDef skill, IReadOnlyDictionary<string, string> choices)
|
||||
{
|
||||
foreach (var limit in skill.BodyLimits)
|
||||
{
|
||||
if (!choices.TryGetValue(limit.Attribute, out var actual))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (limit.Value is not null && !actual.Equals(limit.Value, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (limit.Min is { } min)
|
||||
{
|
||||
value = Math.Max(value, min);
|
||||
}
|
||||
|
||||
if (limit.Max is { } max)
|
||||
{
|
||||
value = Math.Min(value, max);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static bool TraitFits(TraitDef trait, IReadOnlyList<string> roles, int age)
|
||||
{
|
||||
if (trait.Age is { } range && (age < range.Min || age > range.Max))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trait.Roles.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var role in trait.Roles)
|
||||
{
|
||||
foreach (var have in roles)
|
||||
{
|
||||
if (role.Equals(have, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static TraitDef? WeightedTrait(List<TraitDef> pool, Random rng)
|
||||
{
|
||||
var total = 0;
|
||||
foreach (var trait in pool)
|
||||
{
|
||||
total += Math.Max(trait.Weight, 1);
|
||||
}
|
||||
|
||||
if (total <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var pick = rng.Next(total);
|
||||
foreach (var trait in pool)
|
||||
{
|
||||
pick -= Math.Max(trait.Weight, 1);
|
||||
if (pick < 0)
|
||||
{
|
||||
return trait;
|
||||
}
|
||||
}
|
||||
|
||||
return pool[^1];
|
||||
}
|
||||
|
||||
private static int SampleNumber(BodyAttributeDef def, Random rng, bool female, int age)
|
||||
{
|
||||
var row = MatchDistribution(def.Distributions, female, age);
|
||||
var mean = row?.Distribution.Mean ?? 0;
|
||||
var stdDev = row?.Distribution.StdDev ?? 1;
|
||||
var value = (int)Math.Round(mean + (stdDev * NextGaussian(rng)));
|
||||
if (row?.Range is { } range)
|
||||
{
|
||||
return Clamp(value, range);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string SampleChoice(BodyAttributeDef def, Random rng, bool female, int age)
|
||||
{
|
||||
var options = def.Options.Where(option => OptionFits(option, female, age)).ToList();
|
||||
if (options.Count == 0)
|
||||
{
|
||||
options = [.. def.Options];
|
||||
}
|
||||
|
||||
var total = 0;
|
||||
foreach (var option in options)
|
||||
{
|
||||
total += Math.Max(option.Weight, 1);
|
||||
}
|
||||
|
||||
var pick = rng.Next(Math.Max(total, 1));
|
||||
foreach (var option in options)
|
||||
{
|
||||
pick -= Math.Max(option.Weight, 1);
|
||||
if (pick < 0)
|
||||
{
|
||||
return option.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return options[^1].Value;
|
||||
}
|
||||
|
||||
private static bool OptionFits(WeightedOption option, bool female, int age)
|
||||
{
|
||||
if (option.AgeMin is { } min && age < min)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (option.AgeMax is { } max && age > max)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (option.Sex is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var want = female ? "female" : "male";
|
||||
return option.Sex.Equals(want, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static SexAgeDistribution? MatchDistribution(
|
||||
IReadOnlyList<SexAgeDistribution> rows,
|
||||
bool female,
|
||||
int age)
|
||||
{
|
||||
SexAgeDistribution? unisex = null;
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (row.AgeMin is { } min && age < min)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.AgeMax is { } max && age > max)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.Sex is null)
|
||||
{
|
||||
unisex ??= row;
|
||||
continue;
|
||||
}
|
||||
|
||||
var want = female ? "female" : "male";
|
||||
if (row.Sex.Equals(want, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
return unisex ?? (rows.Count == 0 ? null : rows[0]);
|
||||
}
|
||||
|
||||
private static float MeanForAge(SkillDef skill, int age)
|
||||
{
|
||||
if (skill.AgeMeans.Count == 0)
|
||||
{
|
||||
return skill.Distribution?.Mean ?? (skill.Range.Min + skill.Range.Max) / 2f;
|
||||
}
|
||||
|
||||
var points = skill.AgeMeans.OrderBy(point => point.Age).ToList();
|
||||
if (age <= points[0].Age)
|
||||
{
|
||||
return points[0].Mean;
|
||||
}
|
||||
|
||||
if (age >= points[^1].Age)
|
||||
{
|
||||
return points[^1].Mean;
|
||||
}
|
||||
|
||||
for (var i = 1; i < points.Count; i++)
|
||||
{
|
||||
if (age > points[i].Age)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var a = points[i - 1];
|
||||
var b = points[i];
|
||||
var span = b.Age - a.Age;
|
||||
var t = span == 0 ? 0f : (age - a.Age) / (float)span;
|
||||
return a.Mean + (t * (b.Mean - a.Mean));
|
||||
}
|
||||
|
||||
return points[^1].Mean;
|
||||
}
|
||||
|
||||
private static int Clamp(int value, IntRange range) => Math.Clamp(value, range.Min, range.Max);
|
||||
|
||||
private static double NextGaussian(Random rng)
|
||||
{
|
||||
double u1;
|
||||
do
|
||||
{
|
||||
u1 = rng.NextDouble();
|
||||
}
|
||||
while (u1 <= double.Epsilon);
|
||||
|
||||
var u2 = rng.NextDouble();
|
||||
return Math.Sqrt(-2d * Math.Log(u1)) * Math.Cos(2d * Math.PI * u2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace HSchool.People;
|
||||
|
||||
/// <summary>Plain roster data. Simulation turns these into entities; this project does not.</summary>
|
||||
public sealed record Roster(
|
||||
IReadOnlyList<Person> People,
|
||||
IReadOnlyList<Family> Families,
|
||||
IReadOnlyList<SchoolClass> Classes);
|
||||
|
||||
public sealed record Person
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
|
||||
public required string FamilyId { get; init; }
|
||||
|
||||
public required bool Female { get; init; }
|
||||
|
||||
public required DateTime BirthDate { get; init; }
|
||||
|
||||
public required PersonName Name { get; init; }
|
||||
|
||||
public required bool IsStudent { get; init; }
|
||||
|
||||
public required bool IsStaff { get; init; }
|
||||
|
||||
public required bool IsParent { get; init; }
|
||||
|
||||
public string? ClassId { get; init; }
|
||||
|
||||
public string? Position { get; init; }
|
||||
|
||||
public string? WorkplaceRoomId { get; init; }
|
||||
|
||||
public required IReadOnlyDictionary<string, int> Numbers { get; init; }
|
||||
|
||||
public required IReadOnlyDictionary<string, string> Choices { get; init; }
|
||||
|
||||
public required IReadOnlyDictionary<string, int> Skills { get; init; }
|
||||
|
||||
public required IReadOnlyList<string> Traits { get; init; }
|
||||
|
||||
public required IReadOnlyDictionary<string, float> Needs { get; init; }
|
||||
|
||||
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
|
||||
}
|
||||
|
||||
public sealed record PersonName(
|
||||
string Given,
|
||||
string Surname,
|
||||
string Patronymic,
|
||||
CaseTable GivenCases,
|
||||
CaseTable SurnameCases,
|
||||
CaseTable PatronymicCases);
|
||||
|
||||
public sealed record Family(
|
||||
string Id,
|
||||
IReadOnlyList<string> ParentIds,
|
||||
IReadOnlyList<string> ChildIds);
|
||||
|
||||
public sealed record SchoolClass(
|
||||
string Id,
|
||||
int Year,
|
||||
string Letter,
|
||||
string RoomId,
|
||||
int Capacity,
|
||||
IReadOnlyList<string> PupilIds);
|
||||
@@ -0,0 +1,127 @@
|
||||
namespace HSchool.People;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a roster from a catalog, a map and a school seed. Deterministic: same inputs, same people.
|
||||
/// </summary>
|
||||
public static class RosterGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Matches <c>SimulationOptions.DefaultStartDate</c> so tests without a clock still sit in
|
||||
/// the default school year. Callers with a live clock must pass <paramref name="asOf"/>.
|
||||
/// </summary>
|
||||
public static readonly DateTime DefaultAsOf = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public static Roster Generate(
|
||||
DefCatalog catalog,
|
||||
MapLayout map,
|
||||
int schoolSeed,
|
||||
string nameSetId,
|
||||
DateTime? asOf = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
ArgumentNullException.ThrowIfNull(map);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nameSetId);
|
||||
|
||||
if (!catalog.NameSets.TryGetValue(nameSetId, out var names))
|
||||
{
|
||||
throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId));
|
||||
}
|
||||
|
||||
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 people = new List<Person>();
|
||||
var families = new List<Family>(plans.Count);
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
var (family, members) = FamilyFactory.Create(catalog, names, schoolSeed, plan, yearStart, when);
|
||||
families.Add(family);
|
||||
people.AddRange(members);
|
||||
}
|
||||
|
||||
var nextFamily = plans.Count;
|
||||
while (people.Count(person => !person.IsStudent) < demand.Staff.Count)
|
||||
{
|
||||
var (family, members) = FamilyFactory.CreateStaffOnly(catalog, names, schoolSeed, nextFamily, when);
|
||||
families.Add(family);
|
||||
people.AddRange(members);
|
||||
nextFamily++;
|
||||
}
|
||||
|
||||
var staffed = AssignStaff(people, demand.Staff);
|
||||
var classes = FillClasses(demand.Classes, staffed);
|
||||
return new Roster(staffed, families, classes);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Person> AssignStaff(List<Person> people, IReadOnlyList<StaffOpening> openings)
|
||||
{
|
||||
if (openings.Count == 0)
|
||||
{
|
||||
return people;
|
||||
}
|
||||
|
||||
var jobs = new Dictionary<string, StaffOpening>(StringComparer.Ordinal);
|
||||
var index = 0;
|
||||
foreach (var person in people)
|
||||
{
|
||||
if (person.IsStudent || index >= openings.Count)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
jobs[person.Id] = openings[index];
|
||||
index++;
|
||||
}
|
||||
|
||||
if (jobs.Count == 0)
|
||||
{
|
||||
return people;
|
||||
}
|
||||
|
||||
var result = new Person[people.Count];
|
||||
for (var i = 0; i < people.Count; i++)
|
||||
{
|
||||
var person = people[i];
|
||||
result[i] = jobs.TryGetValue(person.Id, out var job)
|
||||
? person with
|
||||
{
|
||||
IsStaff = true,
|
||||
Position = job.Position,
|
||||
WorkplaceRoomId = job.RoomId,
|
||||
}
|
||||
: person;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SchoolClass> FillClasses(
|
||||
IReadOnlyList<SchoolClass> classes,
|
||||
IReadOnlyList<Person> people)
|
||||
{
|
||||
var pupils = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var schoolClass in classes)
|
||||
{
|
||||
pupils[schoolClass.Id] = new List<string>(schoolClass.Capacity);
|
||||
}
|
||||
|
||||
foreach (var person in people)
|
||||
{
|
||||
if (person.ClassId is { } classId && pupils.TryGetValue(classId, out var list))
|
||||
{
|
||||
list.Add(person.Id);
|
||||
}
|
||||
}
|
||||
|
||||
var filled = new SchoolClass[classes.Count];
|
||||
for (var i = 0; i < classes.Count; i++)
|
||||
{
|
||||
var schoolClass = classes[i];
|
||||
filled[i] = schoolClass with { PupilIds = pupils[schoolClass.Id] };
|
||||
}
|
||||
|
||||
return filled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
namespace HSchool.People;
|
||||
|
||||
public readonly record struct StaffOpening(string RoomId, string Position);
|
||||
|
||||
public readonly record struct PupilSeat(string ClassId, string RoomId, int Year, string Letter);
|
||||
|
||||
/// <summary>
|
||||
/// How many pupils and staff a map asks for. Classrooms are rooms with pupil slots; each one
|
||||
/// becomes a roster class. Positions come from <see cref="RoomDef.Positions"/>, not the wire labels.
|
||||
/// </summary>
|
||||
public sealed class SchoolDemand
|
||||
{
|
||||
internal SchoolDemand(
|
||||
IReadOnlyList<SchoolClass> classes,
|
||||
IReadOnlyList<PupilSeat> seats,
|
||||
IReadOnlyList<StaffOpening> staff)
|
||||
{
|
||||
Classes = classes;
|
||||
Seats = seats;
|
||||
Staff = staff;
|
||||
}
|
||||
|
||||
public IReadOnlyList<SchoolClass> Classes { get; }
|
||||
|
||||
public IReadOnlyList<PupilSeat> Seats { get; }
|
||||
|
||||
public IReadOnlyList<StaffOpening> Staff { get; }
|
||||
|
||||
public static SchoolDemand From(DefCatalog catalog, MapLayout map)
|
||||
{
|
||||
var classrooms = new List<(RoomNode Room, int Slots)>();
|
||||
var staff = new List<StaffOpening>();
|
||||
|
||||
foreach (var room in map.Rooms)
|
||||
{
|
||||
var slots = PupilSlotsOf(catalog, room);
|
||||
if (slots > 0)
|
||||
{
|
||||
classrooms.Add((room, slots));
|
||||
}
|
||||
|
||||
if (catalog.Rooms.TryGetValue(room.Def, out var def))
|
||||
{
|
||||
foreach (var position in def.Positions)
|
||||
{
|
||||
staff.Add(new StaffOpening(room.Id, position));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const string letters = "АБВГДЕЖЗИКЛМНОПРСТУФХЦЧШЩЭЮЯ";
|
||||
var classes = new SchoolClass[classrooms.Count];
|
||||
var seats = new List<PupilSeat>();
|
||||
for (var i = 0; i < classrooms.Count; i++)
|
||||
{
|
||||
var (room, capacity) = classrooms[i];
|
||||
var year = (i % 11) + 1;
|
||||
var letterIndex = i / 11;
|
||||
var letter = letterIndex < letters.Length ? letters[letterIndex].ToString() : "?";
|
||||
var classId = $"class-{room.Id}";
|
||||
classes[i] = new SchoolClass(classId, year, letter, room.Id, capacity, []);
|
||||
for (var seat = 0; seat < capacity; seat++)
|
||||
{
|
||||
seats.Add(new PupilSeat(classId, room.Id, year, letter));
|
||||
}
|
||||
}
|
||||
|
||||
return new SchoolDemand(classes, seats, staff);
|
||||
}
|
||||
|
||||
private static int PupilSlotsOf(DefCatalog catalog, RoomNode room)
|
||||
{
|
||||
var pupilSlots = 0L;
|
||||
foreach (var fill in room.Slots)
|
||||
{
|
||||
var count = fill.Count < 1 ? 1 : Math.Min(fill.Count, byte.MaxValue);
|
||||
if (catalog.Things.TryGetValue(fill.Thing, out var thing) && thing.PupilSlots > 0)
|
||||
{
|
||||
pupilSlots += (long)thing.PupilSlots * count;
|
||||
}
|
||||
}
|
||||
|
||||
return (int)Math.Clamp(pupilSlots, 0, ushort.MaxValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace HSchool.People;
|
||||
|
||||
/// <summary>
|
||||
/// Game-calendar helpers. Takes the caller's date so this library never reads the wall clock.
|
||||
/// </summary>
|
||||
internal static class SchoolYears
|
||||
{
|
||||
public static DateTime StartOn(DateTime asOf)
|
||||
{
|
||||
var utc = DateTime.SpecifyKind(asOf, DateTimeKind.Utc);
|
||||
var year = utc.Month >= 9 ? utc.Year : utc.Year - 1;
|
||||
return new DateTime(year, 9, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grade N pupils turned N+6 on or before 1 September of the current school year.
|
||||
/// </summary>
|
||||
public static (DateTime First, DateTime Last) BirthWindow(DateTime yearStart, int grade)
|
||||
{
|
||||
var ageOnStart = grade + 6;
|
||||
var last = yearStart.AddYears(-ageOnStart);
|
||||
var first = last.AddYears(-1).AddDays(1);
|
||||
return (first, last);
|
||||
}
|
||||
|
||||
public static int AgeYears(DateTime birth, DateTime asOf)
|
||||
{
|
||||
var age = asOf.Year - birth.Year;
|
||||
if (birth.Date.AddYears(age) > asOf.Date)
|
||||
{
|
||||
age--;
|
||||
}
|
||||
|
||||
return Math.Max(age, 0);
|
||||
}
|
||||
|
||||
public static DateTime RandomInRange(Random rng, DateTime first, DateTime last)
|
||||
{
|
||||
var days = (last.Date - first.Date).Days;
|
||||
var offset = days <= 0 ? 0 : rng.Next(days + 1);
|
||||
return DateTime.SpecifyKind(first.Date.AddDays(offset), DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace HSchool.People;
|
||||
|
||||
/// <summary>
|
||||
/// Per-family streams derived from the school seed. Family N never consumes family N-1's rolls,
|
||||
/// so appending a thirteenth family leaves the first twelve unchanged.
|
||||
/// </summary>
|
||||
internal static class Seed
|
||||
{
|
||||
public const int ChildCountSalt = 1;
|
||||
public const int AppearanceSalt = 2;
|
||||
|
||||
public static int Mix(int schoolSeed, int familyIndex, int salt)
|
||||
{
|
||||
var z = Mix64((uint)schoolSeed);
|
||||
z = Mix64(z ^ (uint)(familyIndex + 1));
|
||||
z = Mix64(z ^ (uint)(salt + 1));
|
||||
return (int)z;
|
||||
}
|
||||
|
||||
private static ulong Mix64(ulong z)
|
||||
{
|
||||
z += 0x9E3779B97F4A7C15UL;
|
||||
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||
z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL;
|
||||
return z ^ (z >> 31);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user