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, 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); } 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); } /// /// 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". /// 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(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 0–1."); } 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 min–max."); } 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 1–11 with min ≤ max."); } if (subject.Skills.Count == 0) { throw new ContentLoadException($"SubjectDef '{subject.DefName}' needs at least one skill."); } var seen = new HashSet(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 1–64."); } 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 0–1."); } 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 1–12."); } if (frame.LessonMinutes is < 1 or > 180) { throw new ContentLoadException($"DayFrameDef '{frame.DefName}' lessonMinutes must be 1–180."); } if (frame.BreakMinutes is < 0 or > 60) { throw new ContentLoadException($"DayFrameDef '{frame.DefName}' breakMinutes must be 0–60."); } if (frame.LongBreakMinutes is < 0 or > 120) { throw new ContentLoadException($"DayFrameDef '{frame.DefName}' longBreakMinutes must be 0–120."); } 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(); 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) { if (behavior.Abstract) { return; } if (behavior.NeedThreshold < 0f || behavior.NeedThreshold > 1f) { throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' needThreshold must be 0–1."); } 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."); } } 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 1–12."); } 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 ValidateNameSet(NameSetDef names, DefCatalog catalog) { if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule)) { throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown patronymicRule '{names.PatronymicRule}'."); } foreach (var native in names.Spoken) { if (!catalog.Skills.TryGetValue(native, out var language) || language.Abstract) { throw new ContentLoadException($"NameSetDef '{names.DefName}' native language '{native}' is not a SkillDef."); } } if (names.RelatedLanguageChance is < 0 or > 1) { throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageChance must be 0–1."); } if (names.RelatedLanguageStdDev < 0) { throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageStdDev must not be negative."); } if (names.RelatedLanguageMean < 0) { throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageMean must not be negative."); } if (names.RelatedLanguageMax < 0) { throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageMax must not be negative."); } 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."); } } }