Files
h-school/src/HSchool.Content/PeopleDefValidator.cs
T
Leonid PershinandCursor 4a52107bd2 Add person-to-person disease contagion with outbreak notices.
Node/class contact rolls use pair+day seeds; vanilla respiratory diseases spread, and a threshold raises an info toast instead of per-sneeze noise.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 13:55:24 +03:00

1120 lines
39 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 preset in catalog.ClimatePresets.Values)
{
ValidateClimatePreset(preset);
}
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);
}
foreach (var topic in catalog.Topics.Values)
{
ValidateTopic(topic, catalog);
}
foreach (var orientation in catalog.Orientations.Values)
{
ValidateOrientation(orientation);
}
foreach (var affinity in catalog.Affinity.Values)
{
ValidateAffinityRules(affinity);
}
RequireTalkTopics(catalog);
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.");
}
if (catalog.Affinity.Values.Count(def => !def.Abstract) > 1)
{
throw new ContentLoadException("A catalog may only have one concrete AffinityRulesDef.");
}
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}'.");
}
}
if (trait.WhisperCatchMultiplier < 0f)
{
throw new ContentLoadException($"TraitDef '{trait.DefName}' whisperCatchMultiplier cannot be negative.");
}
if (trait.ConflictChance < 0f)
{
throw new ContentLoadException($"TraitDef '{trait.DefName}' conflictChance cannot be negative.");
}
if (trait.ApologyChance < 0f)
{
throw new ContentLoadException($"TraitDef '{trait.DefName}' apologyChance cannot be negative.");
}
}
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.LessonNoTextbookFactor is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' lessonNoTextbookFactor must be 01.");
}
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.CommuteRainMinutes < 0 || behavior.CommuteSnowMinutes < 0)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' commute rain and snow minutes cannot be negative.");
}
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.");
}
if (behavior.TalkCircleMin < 2 || behavior.TalkCircleMax < behavior.TalkCircleMin)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' talk circle size must be at least 2 with max ≥ min.");
}
if (behavior.LessonWhisperSkillFactor is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' lessonWhisperSkillFactor must be 01.");
}
if (behavior.WhisperCatchChance is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' whisperCatchChance must be 01.");
}
if (behavior.WhisperCatchMax is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' whisperCatchMax must be 01.");
}
if (behavior.WhisperStartChance is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' whisperStartChance must be 01.");
}
if (behavior.TalkPedagogyOpinionScale < 0f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' talkPedagogyOpinionScale cannot be negative.");
}
if (behavior.TeacherAfterLessonWeight < 0f
|| behavior.TeacherQuestionWeight < 0f
|| behavior.TeacherPraiseWeight < 0f
|| behavior.TeacherReprimandWeight < 0f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' teacher-talk weights cannot be negative.");
}
if (behavior.QuarrelChance is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' quarrelChance must be 01.");
}
if (behavior.FightChance is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' fightChance must be 01.");
}
if (behavior.ApologyRestoreFraction is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' apologyRestoreFraction must be 01.");
}
if (behavior.OffenseMemoryMax < 0)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' offenseMemoryMax cannot be negative.");
}
foreach (var kind in behavior.OffenseMemoryKinds)
{
if (string.IsNullOrWhiteSpace(kind))
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' offenseMemoryKinds cannot contain empty ids.");
}
}
if (behavior.DirectorSummonFightBreakChance is < 0f or > 1f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' directorSummonFightBreakChance must be 01.");
}
if (behavior.DirectorSummonReprimandChance is < 0f or > 1f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' directorSummonReprimandChance must be 01.");
}
if (behavior.DirectorHearingMinutes <= 0f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' directorHearingMinutes must be positive.");
}
if (behavior.ParentMeetingChance is < 0f or > 1f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' parentMeetingChance must be 01.");
}
if (behavior.ParentMeetingAttendanceFraction is < 0f or > 1f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' parentMeetingAttendanceFraction must be 01.");
}
if (behavior.ParentMeetingMinutes <= 0f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' parentMeetingMinutes must be positive.");
}
if (behavior.LessonMarkMax < 0)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' lessonMarkMax cannot be negative.");
}
if (behavior.LessonMarkWhenNoTeacher is { } absent
&& absent is < 2 or > 5)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' lessonMarkWhenNoTeacher must be 25 or null.");
}
if (behavior.LessonMarkThresholds.Count > 0)
{
if (behavior.LessonMarkThresholds.Count != 3)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' lessonMarkThresholds must list three floors (5, 4, 3).");
}
float? previous = null;
foreach (var floor in behavior.LessonMarkThresholds)
{
if (floor is < 0f or > 1f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' lessonMarkThresholds must be 01.");
}
if (previous is { } prior && floor > prior)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' lessonMarkThresholds must be descending.");
}
previous = floor;
}
}
if (behavior.AttendanceMax < 0)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' attendanceMax cannot be negative.");
}
if (behavior.LessonLateMinutes < 0f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' lessonLateMinutes cannot be negative.");
}
if (behavior.DiseaseVectorScale < 0f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' diseaseVectorScale cannot be negative.");
}
if (behavior.DiseaseOutbreakMinNewCases < 0)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' diseaseOutbreakMinNewCases cannot be negative.");
}
}
private static void ValidateTopic(TopicDef topic, DefCatalog catalog)
{
if (topic.Abstract)
{
return;
}
if (topic.Tags.Count == 0)
{
throw new ContentLoadException($"TopicDef '{topic.DefName}' needs at least one tag.");
}
if (topic.Age is { Min: var min, Max: var max } && min > max)
{
throw new ContentLoadException($"TopicDef '{topic.DefName}' age min cannot exceed max.");
}
foreach (var role in topic.Roles)
{
if (!PersonRoles.IsKnown(role))
{
throw new ContentLoadException($"TopicDef '{topic.DefName}' has unknown role '{role}'.");
}
}
if (topic.Language is not null && !catalog.Skills.ContainsKey(topic.Language))
{
throw new ContentLoadException($"TopicDef '{topic.DefName}' references unknown SkillDef '{topic.Language}'.");
}
}
private static void ValidateOrientation(OrientationDef orientation)
{
if (orientation.Abstract)
{
return;
}
if (orientation.Weight < 1)
{
throw new ContentLoadException($"OrientationDef '{orientation.DefName}' weight must be at least 1.");
}
if (!orientation.SameGender && !orientation.OppositeGender)
{
throw new ContentLoadException(
$"OrientationDef '{orientation.DefName}' must allow same gender, opposite gender, or both.");
}
}
private static void ValidateAffinityRules(AffinityRulesDef rules)
{
if (rules.Abstract)
{
return;
}
if (rules.SympathyThreshold < OpinionLabelsMin || rules.PairThreshold < OpinionLabelsMin
|| rules.PairBreakThreshold < OpinionLabelsMin)
{
throw new ContentLoadException($"AffinityRulesDef '{rules.DefName}' opinion thresholds are out of range.");
}
if (rules.PairMinAge < 0)
{
throw new ContentLoadException($"AffinityRulesDef '{rules.DefName}' pairMinAge cannot be negative.");
}
if (rules.StudentAgeDeltaYears < 0)
{
throw new ContentLoadException($"AffinityRulesDef '{rules.DefName}' studentAgeDeltaYears cannot be negative.");
}
foreach (var pair in rules.RolePairs)
{
if (!PersonRoles.IsKnown(pair.From) || !PersonRoles.IsKnown(pair.To))
{
throw new ContentLoadException(
$"AffinityRulesDef '{rules.DefName}' role pair '{pair.From}'→'{pair.To}' uses an unknown role.");
}
}
}
private const int OpinionLabelsMin = -100;
/// <summary>A catalog with circle actions but no topics would silently solo-chat — refuse load.</summary>
private static void RequireTalkTopics(DefCatalog catalog)
{
var hasTalkAction = catalog.Actions.Values.Any(action =>
!action.Abstract && TalkActions.IsTalk(action.DefName));
if (!hasTalkAction)
{
return;
}
if (!catalog.Topics.Values.Any(topic => !topic.Abstract))
{
throw new ContentLoadException(
"A catalog with talk actions requires at least one concrete TopicDef.");
}
}
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 ValidateClimatePreset(ClimatePresetDef preset)
{
if (preset.Abstract)
{
return;
}
if (preset.MonthlyNorms.Count != 12)
{
throw new ContentLoadException(
$"ClimatePresetDef '{preset.DefName}' monthlyNorms must list 12 months.");
}
if (preset.DaySpread < 0 || preset.HourSpread < 0)
{
throw new ContentLoadException($"ClimatePresetDef '{preset.DefName}' spreads cannot be negative.");
}
if (preset.PrecipitationChance < 0 || preset.PrecipitationChance > 1)
{
throw new ContentLoadException($"ClimatePresetDef '{preset.DefName}' precipitationChance must be 01.");
}
if (preset.ComfortHalfWidthC < 0)
{
throw new ContentLoadException($"ClimatePresetDef '{preset.DefName}' comfortHalfWidthC cannot be negative.");
}
if (preset.InsulationPerC < 0)
{
throw new ContentLoadException($"ClimatePresetDef '{preset.DefName}' insulationPerC cannot be negative.");
}
PromptContributionValidator.ValidateClimate($"ClimatePresetDef '{preset.DefName}'", preset.Prompt);
}
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.");
}
}
}