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
+282
View File
@@ -0,0 +1,282 @@
namespace HSchool.Content;
internal static class PeopleDefValidator
{
public static void Validate(DefCatalog catalog)
{
foreach (var body in catalog.BodyAttributes.Values)
{
ValidateBody(body);
}
foreach (var skill in catalog.Skills.Values)
{
ValidateSkill(skill, catalog);
}
foreach (var trait in catalog.Traits.Values)
{
ValidateTrait(trait, catalog);
}
foreach (var need in catalog.Needs.Values)
{
ValidateNeed(need);
}
foreach (var names in catalog.NameSets.Values)
{
ValidateNameSet(names);
}
}
private static void ValidateBody(BodyAttributeDef def)
{
if (def.DefName.Equals(BodyBuilds.Attribute, StringComparison.Ordinal))
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' is reserved for the derived build.");
}
switch (def.Kind)
{
case BodyAttributeKind.Number:
if (def.Distributions.Count == 0)
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' of kind number needs at least one distribution.");
}
foreach (var row in def.Distributions)
{
RequireSex(row.Sex, def.DefName);
RequireAgeWindow(row.AgeMin, row.AgeMax, def.DefName);
if (row.Distribution.StdDev < 0)
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' has a negative stdDev.");
}
RequireRange(row.Range, $"BodyAttributeDef '{def.DefName}'");
}
break;
case BodyAttributeKind.Choice:
if (def.Options.Count == 0)
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' of kind choice needs at least one option.");
}
var values = new HashSet<string>(StringComparer.Ordinal);
foreach (var option in def.Options)
{
if (string.IsNullOrWhiteSpace(option.Value) || !values.Add(option.Value))
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' has a missing or duplicate option.");
}
if (option.Weight < 1)
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' option '{option.Value}' needs a positive weight.");
}
RequireSex(option.Sex, def.DefName);
RequireAgeWindow(option.AgeMin, option.AgeMax, def.DefName);
}
break;
default:
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' has unknown kind '{def.Kind}'.");
}
}
private static void ValidateSkill(SkillDef skill, DefCatalog catalog)
{
RequireRange(skill.Range, $"SkillDef '{skill.DefName}'");
if (skill.Distribution is { StdDev: < 0 })
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' has a negative stdDev.");
}
foreach (var limit in skill.BodyLimits)
{
if (string.IsNullOrWhiteSpace(limit.Attribute))
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits entry is missing attribute.");
}
if (limit.Min is { } min && limit.Max is { } max && min > max)
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits min is above max.");
}
if (limit.Attribute.Equals(BodyBuilds.Attribute, StringComparison.Ordinal))
{
if (string.IsNullOrWhiteSpace(limit.Value) || !BodyBuilds.IsKnown(limit.Value))
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits references unknown build '{limit.Value}'.");
}
continue;
}
if (!catalog.BodyAttributes.TryGetValue(limit.Attribute, out var body))
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits references unknown attribute '{limit.Attribute}'.");
}
if (body.Kind != BodyAttributeKind.Choice)
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits attribute '{limit.Attribute}' is not categorical.");
}
if (string.IsNullOrWhiteSpace(limit.Value)
|| !body.Options.Any(option => option.Value.Equals(limit.Value, StringComparison.Ordinal)))
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits references unknown value '{limit.Value}' of '{limit.Attribute}'.");
}
}
}
private static void ValidateTrait(TraitDef trait, DefCatalog catalog)
{
if (trait.Weight < 1)
{
throw new ContentLoadException($"TraitDef '{trait.DefName}' weight must be at least 1.");
}
RequireRange(trait.Age, $"TraitDef '{trait.DefName}'");
foreach (var role in trait.Roles)
{
if (!PersonRoles.IsKnown(role))
{
throw new ContentLoadException($"TraitDef '{trait.DefName}' has unknown role '{role}'.");
}
}
foreach (var other in trait.Incompatible)
{
if (!catalog.Traits.ContainsKey(other))
{
throw new ContentLoadException($"TraitDef '{trait.DefName}' is incompatible with unknown TraitDef '{other}'.");
}
}
foreach (var modifier in trait.SkillModifiers)
{
if (!catalog.Skills.ContainsKey(modifier.Skill))
{
throw new ContentLoadException($"TraitDef '{trait.DefName}' skill modifier references unknown SkillDef '{modifier.Skill}'.");
}
}
}
private static void ValidateNeed(NeedDef need)
{
if (need.Min > need.Max)
{
throw new ContentLoadException($"NeedDef '{need.DefName}' min is above max.");
}
if (need.Initial < need.Min || need.Initial > need.Max)
{
throw new ContentLoadException($"NeedDef '{need.DefName}' initial is outside minmax.");
}
if (need.DecayPerHour < 0)
{
throw new ContentLoadException($"NeedDef '{need.DefName}' decayPerHour cannot be negative.");
}
}
private static void ValidateNameSet(NameSetDef names)
{
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown patronymicRule '{names.PatronymicRule}'.");
}
if (!NameGrammar.IsKnownGiven(names.DefaultGivenDeclension))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown defaultGivenDeclension.");
}
if (!NameGrammar.IsKnownSurname(names.DefaultSurnameDeclension))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown defaultSurnameDeclension.");
}
if (names.MaleGiven.Count == 0 || names.FemaleGiven.Count == 0 || names.Surnames.Count == 0)
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' needs male names, female names and surnames.");
}
foreach (var given in names.MaleGiven.Concat(names.FemaleGiven))
{
if (string.IsNullOrWhiteSpace(given.Form))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has an empty given name.");
}
if (given.Cases is null)
{
var model = string.IsNullOrWhiteSpace(given.Declension)
? names.DefaultGivenDeclension
: given.Declension;
if (!NameGrammar.IsKnownGiven(model))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' given '{given.Form}' has unknown declension '{model}'.");
}
}
}
foreach (var surname in names.Surnames)
{
if (string.IsNullOrWhiteSpace(surname.Male) || string.IsNullOrWhiteSpace(surname.Female))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has a surname missing a gendered form.");
}
if (surname.MaleCases is null && surname.FemaleCases is null)
{
var model = string.IsNullOrWhiteSpace(surname.Declension)
? names.DefaultSurnameDeclension
: surname.Declension;
if (!NameGrammar.IsKnownSurname(model))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' surname '{surname.Male}' has unknown declension '{model}'.");
}
}
}
}
private static void RequireSex(string? sex, string defName)
{
if (sex is null)
{
return;
}
if (!sex.Equals("male", StringComparison.OrdinalIgnoreCase)
&& !sex.Equals("female", StringComparison.OrdinalIgnoreCase))
{
throw new ContentLoadException($"Def '{defName}' has unknown sex '{sex}'.");
}
}
private static void RequireAgeWindow(int? min, int? max, string defName)
{
if (min is { } lo && max is { } hi && lo > hi)
{
throw new ContentLoadException($"Def '{defName}' has ageMin above ageMax.");
}
}
private static void RequireRange(IntRange? range, string where)
{
if (range is { } value && value.Min > value.Max)
{
throw new ContentLoadException($"{where} range min is above max.");
}
}
}