Files
h-school/src/HSchool.Content/PeopleDefValidator.cs
T
Leonid PershinandCursor 1ba739dd9b Wear worn clothes on campus and replace rags at the work morning.
Condition drops from catalog hours only while the person is at school; skip through the night issues the same fresh set as a lived night so the school does not walk in in rags.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 03:49:11 +03:00

764 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace HSchool.Content;
internal static class PeopleDefValidator
{
public static void Validate(DefCatalog catalog, IContentLog? log = null)
{
log ??= NullContentLog.Instance;
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 country in catalog.Countries.Values)
{
ValidateCountry(country, catalog);
}
foreach (var subject in catalog.Subjects.Values)
{
ValidateSubject(subject, catalog);
}
foreach (var staffing in catalog.Staffing.Values)
{
ValidateStaffing(staffing);
}
foreach (var frame in catalog.DayFrames.Values)
{
ValidateDayFrame(frame);
}
foreach (var holiday in catalog.Holidays.Values)
{
ValidateHoliday(holiday);
}
foreach (var action in catalog.Actions.Values)
{
ValidateAction(action, catalog);
}
foreach (var behavior in catalog.Behavior.Values)
{
ValidateBehavior(behavior, log);
}
if (catalog.Staffing.Values.Count(def => !def.Abstract) > 1)
{
throw new ContentLoadException("A catalog may only have one concrete StaffingDef.");
}
if (catalog.DayFrames.Values.Count(def => !def.Abstract) > 1)
{
throw new ContentLoadException("A catalog may only have one concrete DayFrameDef.");
}
if (catalog.Behavior.Values.Count(def => !def.Abstract) > 1)
{
throw new ContentLoadException("A catalog may only have one concrete BehaviorDef.");
}
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)
{
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.");
}
if (skill.AdultChance is < 0 or > 1)
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' adultChance must be 01.");
}
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 ValidateSubject(SubjectDef subject, DefCatalog catalog)
{
if (subject.HoursPerWeek < 0)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' hoursPerWeek cannot be negative.");
}
if (subject.Grades.Min < 1 || subject.Grades.Max > 11 || subject.Grades.Min > subject.Grades.Max)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' grades must be 111 with min ≤ max.");
}
if (subject.Skills.Count == 0)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' needs at least one skill.");
}
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var share in subject.Skills)
{
if (string.IsNullOrWhiteSpace(share.Skill) || !seen.Add(share.Skill))
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' has a missing or duplicate skill.");
}
if (!catalog.Skills.TryGetValue(share.Skill, out var skill) || skill.Abstract)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' references unknown SkillDef '{share.Skill}'.");
}
if (share.Share < 0)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' skill '{share.Skill}' share cannot be negative.");
}
}
if (string.IsNullOrWhiteSpace(subject.Room))
{
return;
}
if (!catalog.Rooms.TryGetValue(subject.Room, out var room) || room.Abstract)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' references unknown RoomDef '{subject.Room}'.");
}
}
private static void ValidateStaffing(StaffingDef staffing)
{
if (staffing.Abstract)
{
return;
}
if (staffing.PoolSize < 1 || staffing.PoolSize > 64)
{
throw new ContentLoadException($"StaffingDef '{staffing.DefName}' poolSize must be 164.");
}
if (staffing.StayChance <= 0f || staffing.StayChance >= 1f)
{
throw new ContentLoadException($"StaffingDef '{staffing.DefName}' stayChance must be between 0 and 1 exclusive.");
}
if (staffing.ParentChance < 0f || staffing.ParentChance > 1f)
{
throw new ContentLoadException($"StaffingDef '{staffing.DefName}' parentChance must be 01.");
}
if (staffing.HourlyWageBase < 0f || staffing.HourlyWagePerSkill < 0f)
{
throw new ContentLoadException($"StaffingDef '{staffing.DefName}' wage scale cannot be negative.");
}
if (staffing.BaseWeeklyHours <= 0f || staffing.WeeksPerMonth <= 0f)
{
throw new ContentLoadException($"StaffingDef '{staffing.DefName}' monthly hours must be positive.");
}
if (staffing.MaxWeeklyHours < staffing.BaseWeeklyHours)
{
throw new ContentLoadException(
$"StaffingDef '{staffing.DefName}' maxWeeklyHours must be at least baseWeeklyHours: "
+ "nobody can be paid for a full rate they are not allowed to work.");
}
}
private static void ValidateDayFrame(DayFrameDef frame)
{
if (frame.Abstract)
{
return;
}
if (!SchoolDay.TryParseTime(frame.FirstLesson, out _))
{
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' firstLesson '{frame.FirstLesson}' is not a time.");
}
if (frame.LessonCount is < 1 or > 12)
{
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' lessonCount must be 112.");
}
if (frame.LessonMinutes is < 1 or > 180)
{
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' lessonMinutes must be 1180.");
}
if (frame.BreakMinutes is < 0 or > 60)
{
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' breakMinutes must be 060.");
}
if (frame.LongBreakMinutes is < 0 or > 120)
{
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' longBreakMinutes must be 0120.");
}
if (frame.LongBreakAfter is < 0 || frame.LongBreakAfter >= frame.LessonCount)
{
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' longBreakAfter must be 0 or a lesson before the last.");
}
var fed = new HashSet<int>();
foreach (var sitting in frame.LunchBreaks)
{
if (sitting.AfterLesson < 1 || sitting.AfterLesson >= frame.LessonCount)
{
throw new ContentLoadException(
$"DayFrameDef '{frame.DefName}' has a lunch break after lesson {sitting.AfterLesson}; it must be a lesson before the last.");
}
if (sitting.GradeMin < 1 || sitting.GradeMax < sitting.GradeMin)
{
throw new ContentLoadException(
$"DayFrameDef '{frame.DefName}' has a lunch break with grades {sitting.GradeMin}{sitting.GradeMax}.");
}
for (var year = sitting.GradeMin; year <= sitting.GradeMax; year++)
{
if (!fed.Add(year))
{
throw new ContentLoadException(
$"DayFrameDef '{frame.DefName}' feeds grade {year} at two lunch breaks.");
}
}
}
}
private static void ValidateAction(ActionDef action, DefCatalog catalog)
{
if (action.Abstract)
{
return;
}
if (string.IsNullOrWhiteSpace(action.Room))
{
throw new ContentLoadException($"ActionDef '{action.DefName}' needs a room.");
}
var roomKnown = catalog.Rooms.TryGetValue(action.Room, out var room) && !room.Abstract;
var territoryKnown = catalog.Territories.TryGetValue(action.Room, out var territory) && !territory.Abstract;
if (!roomKnown && !territoryKnown)
{
throw new ContentLoadException($"ActionDef '{action.DefName}' references unknown room '{action.Room}'.");
}
if (action.Minutes <= 0)
{
throw new ContentLoadException($"ActionDef '{action.DefName}' minutes must be positive.");
}
if (action.Weight < 0)
{
throw new ContentLoadException($"ActionDef '{action.DefName}' weight cannot be negative.");
}
if (!string.IsNullOrWhiteSpace(action.Thing))
{
if (!catalog.Things.TryGetValue(action.Thing, out var thing) || thing.Abstract)
{
throw new ContentLoadException($"ActionDef '{action.DefName}' references unknown ThingDef '{action.Thing}'.");
}
}
if (string.IsNullOrWhiteSpace(action.Need))
{
if (action.NeedGain != 0)
{
throw new ContentLoadException($"ActionDef '{action.DefName}' has needGain without a need.");
}
}
else if (!catalog.Needs.TryGetValue(action.Need, out var need) || need.Abstract)
{
throw new ContentLoadException($"ActionDef '{action.DefName}' references unknown NeedDef '{action.Need}'.");
}
foreach (var role in action.Roles)
{
if (!PersonRoles.IsKnown(role))
{
throw new ContentLoadException($"ActionDef '{action.DefName}' has unknown role '{role}'.");
}
}
}
private static void ValidateBehavior(BehaviorDef behavior, IContentLog log)
{
if (behavior.Abstract)
{
return;
}
if (behavior.NeedThreshold < 0f || behavior.NeedThreshold > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' needThreshold must be 01.");
}
if (behavior.LessonSkillPerHour < 0f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' lessonSkillPerHour cannot be negative.");
}
if (behavior.SwitchMargin < 0f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' switchMargin cannot be negative.");
}
if (behavior.CommuteSlackMin < 0 || behavior.CommuteSlackMax < behavior.CommuteSlackMin)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' commute slack must be a non-negative range with min ≤ max.");
}
if (behavior.DutyLessonWeight < 0f
|| behavior.DutyTravelWeight < 0f
|| behavior.NeedWeightAtZero < 0f
|| behavior.LunchWeight < 0f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' goal weights cannot be negative.");
}
if (behavior.CarryMassBase < 0f
|| behavior.CarryMassPerStrength < 0f
|| behavior.CarryMassPerEndurance < 0f
|| behavior.CarryMassPerHauling < 0f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' carry mass cannot be negative.");
}
if (behavior.OptionalApparelChance is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' optionalApparelChance must be 01.");
}
if (behavior.ApparelWearPerHour < 0f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' apparelWearPerHour cannot be negative.");
}
if (behavior.ApparelReplaceBelow is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' apparelReplaceBelow must be 01.");
}
ValidateConditionBands(behavior);
// A pack may invert this on purpose — lunch then pulls the class out of the lesson.
// The warning is the catch; refusing to load would make the number unmoddable.
if (behavior.LunchWeight > behavior.DutyLessonWeight)
{
log.Warning(
$"BehaviorDef '{behavior.DefName}' lunchWeight {behavior.LunchWeight} is above dutyLessonWeight {behavior.DutyLessonWeight}; pupils will leave class for lunch.");
}
}
private static void ValidateConditionBands(BehaviorDef behavior)
{
var bands = behavior.ApparelConditionBands;
if (bands.Count == 0)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' apparelConditionBands cannot be empty.");
}
var ids = new HashSet<string>(StringComparer.Ordinal);
var sawZero = false;
foreach (var band in bands)
{
if (string.IsNullOrWhiteSpace(band.Id))
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' apparel condition band is missing an id.");
}
if (band.Min is < 0f or > 1f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' apparel condition '{band.Id}' min must be 01.");
}
if (!ids.Add(band.Id))
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' repeats apparel condition '{band.Id}'.");
}
if (band.Min == 0f)
{
sawZero = true;
}
}
if (!sawZero)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' apparelConditionBands must include a min of 0 so rags have a caption.");
}
}
private static void ValidateHoliday(HolidayDef holiday)
{
if (holiday.Abstract)
{
return;
}
ValidateMonthDay(holiday.Start, holiday.DefName, "start");
ValidateMonthDay(holiday.End, holiday.DefName, "end");
}
private static void ValidateMonthDay(MonthDay stamp, string defName, string field)
{
if (stamp.Month is < 1 or > 12)
{
throw new ContentLoadException($"HolidayDef '{defName}' {field} month must be 112.");
}
var days = DateTime.DaysInMonth(2000, stamp.Month);
if (stamp.Day < 1 || stamp.Day > days)
{
throw new ContentLoadException($"HolidayDef '{defName}' {field} day is not valid for that month.");
}
}
private static void ValidateCountry(CountryDef country, DefCatalog catalog)
{
if (country.Abstract)
{
return;
}
if (country.ClimatePresets.Count == 0)
{
throw new ContentLoadException($"CountryDef '{country.DefName}' needs at least one climate preset.");
}
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var presetId in country.ClimatePresets)
{
if (string.IsNullOrWhiteSpace(presetId) || !seen.Add(presetId))
{
throw new ContentLoadException($"CountryDef '{country.DefName}' has a missing or duplicate climate preset.");
}
if (!catalog.ClimatePresets.TryGetValue(presetId, out var preset) || preset.Abstract)
{
throw new ContentLoadException($"CountryDef '{country.DefName}' references unknown ClimatePresetDef '{presetId}'.");
}
}
ValidateNameSet(country.Names ?? new NameSetDef(), catalog, country.DefName);
}
private static void ValidateNameSet(NameSetDef names, DefCatalog catalog, string countryDefName)
{
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
{
throw new ContentLoadException($"CountryDef '{countryDefName}' has unknown patronymicRule '{names.PatronymicRule}'.");
}
foreach (var native in names.Spoken)
{
if (!catalog.Skills.TryGetValue(native, out var language) || language.Abstract)
{
throw new ContentLoadException($"CountryDef '{countryDefName}' native language '{native}' is not a SkillDef.");
}
}
if (names.RelatedLanguageChance is < 0 or > 1)
{
throw new ContentLoadException($"CountryDef '{countryDefName}' relatedLanguageChance must be 01.");
}
if (names.RelatedLanguageStdDev < 0)
{
throw new ContentLoadException($"CountryDef '{countryDefName}' relatedLanguageStdDev must not be negative.");
}
if (names.RelatedLanguageMean < 0)
{
throw new ContentLoadException($"CountryDef '{countryDefName}' relatedLanguageMean must not be negative.");
}
if (names.RelatedLanguageMax < 0)
{
throw new ContentLoadException($"CountryDef '{countryDefName}' relatedLanguageMax must not be negative.");
}
if (!NameGrammar.IsKnownGiven(names.DefaultGivenDeclension))
{
throw new ContentLoadException($"CountryDef '{countryDefName}' has unknown defaultGivenDeclension.");
}
if (!NameGrammar.IsKnownSurname(names.DefaultSurnameDeclension))
{
throw new ContentLoadException($"CountryDef '{countryDefName}' has unknown defaultSurnameDeclension.");
}
if (names.MaleGiven.Count == 0 || names.FemaleGiven.Count == 0 || names.Surnames.Count == 0)
{
throw new ContentLoadException($"CountryDef '{countryDefName}' needs male names, female names and surnames.");
}
foreach (var given in names.MaleGiven.Concat(names.FemaleGiven))
{
if (string.IsNullOrWhiteSpace(given.Form))
{
throw new ContentLoadException($"CountryDef '{countryDefName}' 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($"CountryDef '{countryDefName}' 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($"CountryDef '{countryDefName}' 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($"CountryDef '{countryDefName}' 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.");
}
}
}