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
+27
View File
@@ -302,6 +302,11 @@ public sealed class CatalogLoader
var buildings = new Dictionary<string, BuildingDef>(StringComparer.Ordinal);
var floors = new Dictionary<string, FloorDef>(StringComparer.Ordinal);
var territories = new Dictionary<string, TerritoryDef>(StringComparer.Ordinal);
var skills = new Dictionary<string, SkillDef>(StringComparer.Ordinal);
var traits = new Dictionary<string, TraitDef>(StringComparer.Ordinal);
var bodyAttributes = new Dictionary<string, BodyAttributeDef>(StringComparer.Ordinal);
var needs = new Dictionary<string, NeedDef>(StringComparer.Ordinal);
var nameSets = new Dictionary<string, NameSetDef>(StringComparer.Ordinal);
foreach (var (key, json) in resolved)
{
@@ -331,6 +336,21 @@ public sealed class CatalogLoader
case DefKind.Territory:
territories[key.Name] = Jsonc.Deserialize<TerritoryDef>(json);
break;
case DefKind.Skill:
skills[key.Name] = Jsonc.Deserialize<SkillDef>(json);
break;
case DefKind.Trait:
traits[key.Name] = Jsonc.Deserialize<TraitDef>(json);
break;
case DefKind.BodyAttribute:
bodyAttributes[key.Name] = Jsonc.Deserialize<BodyAttributeDef>(json);
break;
case DefKind.Need:
needs[key.Name] = Jsonc.Deserialize<NeedDef>(json);
break;
case DefKind.NameSet:
nameSets[key.Name] = Jsonc.Deserialize<NameSetDef>(json);
break;
}
}
@@ -344,6 +364,11 @@ public sealed class CatalogLoader
buildings,
floors,
territories,
skills,
traits,
bodyAttributes,
needs,
nameSets,
ru,
en);
}
@@ -397,6 +422,8 @@ public sealed class CatalogLoader
}
}
}
PeopleDefValidator.Validate(catalog);
}
private sealed record RawDef(string PackId, DefKind Kind, string DefName, JsonObject Json, string Source);
+54
View File
@@ -16,6 +16,11 @@ public sealed class DefCatalog
IReadOnlyDictionary<string, BuildingDef> buildings,
IReadOnlyDictionary<string, FloorDef> floors,
IReadOnlyDictionary<string, TerritoryDef> territories,
IReadOnlyDictionary<string, SkillDef> skills,
IReadOnlyDictionary<string, TraitDef> traits,
IReadOnlyDictionary<string, BodyAttributeDef> bodyAttributes,
IReadOnlyDictionary<string, NeedDef> needs,
IReadOnlyDictionary<string, NameSetDef> nameSets,
IReadOnlyDictionary<string, string> ru,
IReadOnlyDictionary<string, string> en)
{
@@ -28,6 +33,11 @@ public sealed class DefCatalog
Buildings = buildings;
Floors = floors;
Territories = territories;
Skills = skills;
Traits = traits;
BodyAttributes = bodyAttributes;
Needs = needs;
NameSets = nameSets;
_ru = ru;
_en = en;
}
@@ -50,6 +60,16 @@ public sealed class DefCatalog
public IReadOnlyDictionary<string, TerritoryDef> Territories { get; }
public IReadOnlyDictionary<string, SkillDef> Skills { get; }
public IReadOnlyDictionary<string, TraitDef> Traits { get; }
public IReadOnlyDictionary<string, BodyAttributeDef> BodyAttributes { get; }
public IReadOnlyDictionary<string, NeedDef> Needs { get; }
public IReadOnlyDictionary<string, NameSetDef> NameSets { get; }
private readonly IReadOnlyDictionary<string, string> _ru;
private readonly IReadOnlyDictionary<string, string> _en;
@@ -65,6 +85,11 @@ public sealed class DefCatalog
DefKind.Building => Buildings.GetValueOrDefault(defName),
DefKind.Floor => Floors.GetValueOrDefault(defName),
DefKind.Territory => Territories.GetValueOrDefault(defName),
DefKind.Skill => Skills.GetValueOrDefault(defName),
DefKind.Trait => Traits.GetValueOrDefault(defName),
DefKind.BodyAttribute => BodyAttributes.GetValueOrDefault(defName),
DefKind.Need => Needs.GetValueOrDefault(defName),
DefKind.NameSet => NameSets.GetValueOrDefault(defName),
_ => null,
};
@@ -115,6 +140,35 @@ public sealed class DefCatalog
BuildingDef => DefKind.Building,
FloorDef => DefKind.Floor,
TerritoryDef => DefKind.Territory,
SkillDef => DefKind.Skill,
TraitDef => DefKind.Trait,
BodyAttributeDef => DefKind.BodyAttribute,
NeedDef => DefKind.Need,
NameSetDef => DefKind.NameSet,
_ => throw new ArgumentOutOfRangeException(nameof(def)),
};
/// <summary>Incompatibilities written on either trait — a one-way list is mutual.</summary>
public IReadOnlySet<string> TraitIncompatibilities(string defName)
{
var set = new HashSet<string>(StringComparer.Ordinal);
if (Traits.TryGetValue(defName, out var trait))
{
foreach (var other in trait.Incompatible)
{
set.Add(other);
}
}
foreach (var candidate in Traits.Values)
{
if (candidate.Incompatible.Contains(defName, StringComparer.Ordinal))
{
set.Add(candidate.DefName);
}
}
set.Remove(defName);
return set;
}
}
+5
View File
@@ -10,6 +10,11 @@ public enum DefKind
Building,
Floor,
Territory,
Skill,
Trait,
BodyAttribute,
Need,
NameSet,
}
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
+2
View File
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace HSchool.Content;
@@ -12,6 +13,7 @@ public static class Jsonc
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
WriteIndented = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
public static JsonDocumentOptions DocumentOptions { get; } = new()
+302
View File
@@ -0,0 +1,302 @@
namespace HSchool.Content;
public enum GrammaticalCase
{
Nominative,
Genitive,
Dative,
Accusative,
Instrumental,
Prepositional,
}
/// <summary>
/// How a written name becomes six cases. Models live here; a <see cref="CaseTable"/> on the
/// entry wins when the rule is wrong. People generation calls this; Content tests pin it.
/// </summary>
public static class NameGrammar
{
public const string SlavicPatronymic = "slavic";
public const string Hard = "hard";
public const string Soft = "soft";
public const string A = "a";
public const string Ya = "ya";
public const string Iya = "iya";
public const string Ov = "ov";
public const string In = "in";
public const string Sky = "sky";
public const string Indeclinable = "indeclinable";
public static readonly IReadOnlyList<string> GivenModels = [Hard, Soft, A, Ya, Iya, Indeclinable];
public static readonly IReadOnlyList<string> SurnameModels = [Ov, In, Sky, Indeclinable];
public static readonly IReadOnlyList<string> PatronymicRules = [SlavicPatronymic];
public static bool IsKnownGiven(string model) =>
GivenModels.Any(candidate => candidate.Equals(model, StringComparison.OrdinalIgnoreCase));
public static bool IsKnownSurname(string model) =>
SurnameModels.Any(candidate => candidate.Equals(model, StringComparison.OrdinalIgnoreCase));
public static bool IsKnownPatronymic(string rule) =>
PatronymicRules.Any(candidate => candidate.Equals(rule, StringComparison.OrdinalIgnoreCase));
public static string InflectGiven(GivenNameEntry entry, GrammaticalCase grammaticalCase, string defaultModel)
{
if (entry.Cases is { } table)
{
return table[grammaticalCase];
}
var model = string.IsNullOrWhiteSpace(entry.Declension) ? defaultModel : entry.Declension;
return InflectGivenForm(entry.Form, model, grammaticalCase);
}
public static string InflectSurname(
SurnameEntry entry,
bool female,
GrammaticalCase grammaticalCase,
string defaultModel)
{
var table = female ? entry.FemaleCases : entry.MaleCases;
if (table is not null)
{
return table[grammaticalCase];
}
var nominative = female ? entry.Female : entry.Male;
if (grammaticalCase == GrammaticalCase.Nominative)
{
return nominative;
}
var model = string.IsNullOrWhiteSpace(entry.Declension) ? defaultModel : entry.Declension;
return InflectSurnameForm(nominative, model, female, grammaticalCase);
}
public static string InflectPatronymic(string nominative, bool female, GrammaticalCase grammaticalCase)
{
if (string.IsNullOrWhiteSpace(nominative) || grammaticalCase == GrammaticalCase.Nominative)
{
return nominative;
}
return female
? InflectGivenForm(nominative, A, grammaticalCase)
: InflectGivenForm(nominative, Hard, grammaticalCase);
}
public static string Patronymic(string fatherNominative, bool female, string rule)
{
if (!rule.Equals(SlavicPatronymic, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentOutOfRangeException(nameof(rule), rule, "Unknown patronymic rule.");
}
return SlavicFromFather(fatherNominative, female);
}
public static string InflectGivenForm(string nominative, string model, GrammaticalCase grammaticalCase)
{
if (grammaticalCase == GrammaticalCase.Nominative)
{
return nominative;
}
return model.ToLowerInvariant() switch
{
Hard => HardGiven(nominative, grammaticalCase),
Soft => SoftGiven(nominative, grammaticalCase),
A => AGiven(nominative, grammaticalCase),
Ya => YaGiven(nominative, grammaticalCase),
Iya => IyaGiven(nominative, grammaticalCase),
Indeclinable => nominative,
_ => throw new ArgumentOutOfRangeException(nameof(model), model, "Unknown given-name declension."),
};
}
public static string InflectSurnameForm(string nominative, string model, bool female, GrammaticalCase grammaticalCase)
{
if (grammaticalCase == GrammaticalCase.Nominative)
{
return nominative;
}
return model.ToLowerInvariant() switch
{
Ov => OvSurname(nominative, female, grammaticalCase),
In => InSurname(nominative, female, grammaticalCase),
Sky => SkySurname(nominative, female, grammaticalCase),
Indeclinable => nominative,
_ => throw new ArgumentOutOfRangeException(nameof(model), model, "Unknown surname declension."),
};
}
private static string HardGiven(string nom, GrammaticalCase grammaticalCase)
{
// Иван → Ивана, Ивану, Ивана, Иваном, Иване. Пётр → Петра.
var stem = nom.EndsWith('ь') ? nom[..^1] : nom;
stem = stem.Replace('ё', 'е').Replace('Ё', 'Е');
if (stem.EndsWith('й'))
{
stem = stem[..^1];
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "я",
GrammaticalCase.Dative => stem + "ю",
GrammaticalCase.Accusative => stem + "я",
GrammaticalCase.Instrumental => stem + "ем",
GrammaticalCase.Prepositional => stem + "е",
_ => nom,
};
}
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "а",
GrammaticalCase.Dative => stem + "у",
GrammaticalCase.Accusative => stem + "а",
GrammaticalCase.Instrumental => stem + "ом",
GrammaticalCase.Prepositional => stem + "е",
_ => nom,
};
}
private static string SoftGiven(string nom, GrammaticalCase grammaticalCase)
{
// Игорь → Игоря; Любовь needs a table.
var stem = nom.EndsWith('ь') ? nom[..^1] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "я",
GrammaticalCase.Dative => stem + "ю",
GrammaticalCase.Accusative => stem + "я",
GrammaticalCase.Instrumental => stem + "ем",
GrammaticalCase.Prepositional => stem + "е",
_ => nom,
};
}
private static string AGiven(string nom, GrammaticalCase grammaticalCase)
{
var stem = nom.EndsWith('а') ? nom[..^1] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "ы",
GrammaticalCase.Dative => stem + "е",
GrammaticalCase.Accusative => stem + "у",
GrammaticalCase.Instrumental => stem + "ой",
GrammaticalCase.Prepositional => stem + "е",
_ => nom,
};
}
private static string YaGiven(string nom, GrammaticalCase grammaticalCase)
{
var stem = nom.EndsWith('я') ? nom[..^1] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "и",
GrammaticalCase.Dative => stem + "е",
GrammaticalCase.Accusative => stem + "ю",
GrammaticalCase.Instrumental => stem + "ей",
GrammaticalCase.Prepositional => stem + "е",
_ => nom,
};
}
private static string IyaGiven(string nom, GrammaticalCase grammaticalCase)
{
var stem = nom.EndsWith("ия", StringComparison.Ordinal) ? nom[..^2] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "ии",
GrammaticalCase.Dative => stem + "ии",
GrammaticalCase.Accusative => stem + "ию",
GrammaticalCase.Instrumental => stem + "ией",
GrammaticalCase.Prepositional => stem + "ии",
_ => nom,
};
}
private static string OvSurname(string nom, bool female, GrammaticalCase grammaticalCase)
{
if (female)
{
var stem = nom.EndsWith('а') ? nom[..^1] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "ой",
GrammaticalCase.Dative => stem + "ой",
GrammaticalCase.Accusative => stem + "у",
GrammaticalCase.Instrumental => stem + "ой",
GrammaticalCase.Prepositional => stem + "ой",
_ => nom,
};
}
return grammaticalCase switch
{
GrammaticalCase.Genitive => nom + "а",
GrammaticalCase.Dative => nom + "у",
GrammaticalCase.Accusative => nom + "а",
GrammaticalCase.Instrumental => nom + "ым",
GrammaticalCase.Prepositional => nom + "е",
_ => nom,
};
}
private static string InSurname(string nom, bool female, GrammaticalCase grammaticalCase) =>
OvSurname(nom, female, grammaticalCase);
private static string SkySurname(string nom, bool female, GrammaticalCase grammaticalCase)
{
if (female)
{
var stem = nom.EndsWith("ая", StringComparison.Ordinal) ? nom[..^2] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "ой",
GrammaticalCase.Dative => stem + "ой",
GrammaticalCase.Accusative => stem + "ую",
GrammaticalCase.Instrumental => stem + "ой",
GrammaticalCase.Prepositional => stem + "ой",
_ => nom,
};
}
var maleStem = nom.EndsWith("ий", StringComparison.Ordinal) ? nom[..^2] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => maleStem + "ого",
GrammaticalCase.Dative => maleStem + "ому",
GrammaticalCase.Accusative => maleStem + "ого",
GrammaticalCase.Instrumental => maleStem + "им",
GrammaticalCase.Prepositional => maleStem + "ом",
_ => nom,
};
}
private static string SlavicFromFather(string father, bool female)
{
var name = father.Replace('ё', 'е').Replace('Ё', 'Е');
if (name.Equals("Илья", StringComparison.Ordinal))
{
return female ? "Ильинична" : "Ильич";
}
if (name.EndsWith('й') || name.EndsWith('ь'))
{
var stem = name[..^1];
return female ? stem + "евна" : stem + "евич";
}
if (name.EndsWith('а') || name.EndsWith('я'))
{
var stem = name[..^1];
return female ? stem + "ична" : stem + "ич";
}
return female ? name + "овна" : name + "ович";
}
}
+15
View File
@@ -86,6 +86,21 @@ internal static class PackPaths
case "territories":
kind = DefKind.Territory;
return true;
case "skills":
kind = DefKind.Skill;
return true;
case "traits":
kind = DefKind.Trait;
return true;
case "bodies":
kind = DefKind.BodyAttribute;
return true;
case "needs":
kind = DefKind.Need;
return true;
case "namesets":
kind = DefKind.NameSet;
return true;
default:
kind = default;
return false;
+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.");
}
}
}
+251
View File
@@ -0,0 +1,251 @@
namespace HSchool.Content;
/// <summary>Roles the generator and traits talk about. Code, not a def — new roles are a rebuild.</summary>
public static class PersonRoles
{
public const string Student = "student";
public const string Staff = "staff";
public const string Parent = "parent";
public static bool IsKnown(string role) =>
role.Equals(Student, StringComparison.OrdinalIgnoreCase)
|| role.Equals(Staff, StringComparison.OrdinalIgnoreCase)
|| role.Equals(Parent, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Derived body type, not a <see cref="BodyAttributeDef"/>. Skill limits may name it the same
/// way they name hair colour; the generator computes it from height and weight.
/// </summary>
public static class BodyBuilds
{
public const string Attribute = "Build";
public const string Skinny = "Skinny";
public const string Average = "Average";
public const string Athletic = "Athletic";
public const string Heavy = "Heavy";
public const string Obese = "Obese";
public static readonly IReadOnlyList<string> Values =
[Skinny, Average, Athletic, Heavy, Obese];
public static bool IsKnown(string value) =>
Values.Any(candidate => candidate.Equals(value, StringComparison.Ordinal));
/// <summary>
/// WHO-ish bands with a fit slice in the healthy range so <c>Athletic</c> is derived, not rolled.
/// Height in centimetres, weight in kilograms.
/// </summary>
public static string FromHeightAndWeight(int heightCm, int weightKg)
{
if (heightCm <= 0)
{
return Average;
}
var metres = heightCm / 100d;
var bmi = weightKg / (metres * metres);
if (bmi < 18.5)
{
return Skinny;
}
if (bmi < 22.0)
{
return Average;
}
if (bmi < 25.0)
{
return Athletic;
}
if (bmi < 30.0)
{
return Heavy;
}
return Obese;
}
}
public sealed class IntRange
{
public int Min { get; init; }
public int Max { get; init; } = 100;
}
public sealed class StatDistribution
{
public float Mean { get; init; }
public float StdDev { get; init; } = 1;
}
public sealed class AgeMeanPoint
{
public int Age { get; init; }
public float Mean { get; init; }
}
public sealed class BodySkillLimit
{
public required string Attribute { get; init; }
public string? Value { get; init; }
public int? Min { get; init; }
public int? Max { get; init; }
}
public sealed class SkillDef : Def
{
public IntRange Range { get; init; } = new();
public StatDistribution? Distribution { get; init; }
public IReadOnlyList<AgeMeanPoint> AgeMeans { get; init; } = [];
public IReadOnlyList<BodySkillLimit> BodyLimits { get; init; } = [];
}
public sealed class TraitSkillModifier
{
public required string Skill { get; init; }
public int Offset { get; init; }
}
public sealed class TraitDef : Def
{
public int Weight { get; init; } = 1;
public IReadOnlyList<string> Incompatible { get; init; } = [];
/// <summary>Empty means every role. Values are <see cref="PersonRoles"/> ids.</summary>
public IReadOnlyList<string> Roles { get; init; } = [];
public IntRange? Age { get; init; }
public IReadOnlyList<TraitSkillModifier> SkillModifiers { get; init; } = [];
}
public enum BodyAttributeKind
{
Number,
Choice,
}
public sealed class SexAgeDistribution
{
/// <summary><c>male</c>, <c>female</c>, or omit for both.</summary>
public string? Sex { get; init; }
public int? AgeMin { get; init; }
public int? AgeMax { get; init; }
public required StatDistribution Distribution { get; init; }
public IntRange? Range { get; init; }
}
public sealed class WeightedOption
{
public required string Value { get; init; }
public int Weight { get; init; } = 1;
public string? Sex { get; init; }
public int? AgeMin { get; init; }
public int? AgeMax { get; init; }
}
public sealed class BodyAttributeDef : Def
{
public BodyAttributeKind Kind { get; init; }
public IReadOnlyList<SexAgeDistribution> Distributions { get; init; } = [];
public IReadOnlyList<WeightedOption> Options { get; init; } = [];
}
public sealed class NeedDef : Def
{
public float Initial { get; init; } = 1;
public float DecayPerHour { get; init; }
public float Min { get; init; }
public float Max { get; init; } = 1;
}
public sealed class CaseTable
{
public required string Nom { get; init; }
public required string Gen { get; init; }
public required string Dat { get; init; }
public required string Acc { get; init; }
public required string Ins { get; init; }
public required string Pre { get; init; }
public string this[GrammaticalCase grammaticalCase] => grammaticalCase switch
{
GrammaticalCase.Nominative => Nom,
GrammaticalCase.Genitive => Gen,
GrammaticalCase.Dative => Dat,
GrammaticalCase.Accusative => Acc,
GrammaticalCase.Instrumental => Ins,
GrammaticalCase.Prepositional => Pre,
_ => Nom,
};
}
public sealed class GivenNameEntry
{
public required string Form { get; init; }
public string? Declension { get; init; }
public CaseTable? Cases { get; init; }
}
public sealed class SurnameEntry
{
public required string Male { get; init; }
public required string Female { get; init; }
public string? Declension { get; init; }
public CaseTable? MaleCases { get; init; }
public CaseTable? FemaleCases { get; init; }
}
public sealed class NameSetDef : Def
{
public string PatronymicRule { get; init; } = NameGrammar.SlavicPatronymic;
public string DefaultGivenDeclension { get; init; } = NameGrammar.Hard;
public string DefaultSurnameDeclension { get; init; } = NameGrammar.Ov;
public IReadOnlyList<GivenNameEntry> MaleGiven { get; init; } = [];
public IReadOnlyList<GivenNameEntry> FemaleGiven { get; init; } = [];
public IReadOnlyList<SurnameEntry> Surnames { get; init; } = [];
}