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.
ci / server (push) Failing after 3m45s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 18:57:01 +03:00
parent 38afbcad36
commit e6182e0e45
56 changed files with 3706 additions and 9 deletions
+354
View File
@@ -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);
}
}