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);
}
///
/// 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}'.");
}
}
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 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, IContentLog log)
{
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.LessonNoTextbookFactor is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' lessonNoTextbookFactor must be 0–1.");
}
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 0–1.");
}
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 0–1.");
}
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 0–1.");
}
if (behavior.WhisperCatchChance is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' whisperCatchChance must be 0–1.");
}
if (behavior.WhisperCatchMax is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' whisperCatchMax must be 0–1.");
}
if (behavior.WhisperStartChance is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' whisperStartChance must be 0–1.");
}
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 0–1.");
}
if (behavior.FightChance is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' fightChance must be 0–1.");
}
if (behavior.ApologyRestoreFraction is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' apologyRestoreFraction must be 0–1.");
}
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 0–1.");
}
if (behavior.DirectorSummonReprimandChance is < 0f or > 1f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' directorSummonReprimandChance must be 0–1.");
}
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 0–1.");
}
if (behavior.ParentMeetingAttendanceFraction is < 0f or > 1f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' parentMeetingAttendanceFraction must be 0–1.");
}
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 2–5 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 0–1.");
}
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;
/// A catalog with circle actions but no topics would silently solo-chat — refuse load.
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(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 0–1.");
}
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 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 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(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 0–1.");
}
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 0–1.");
}
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.");
}
}
}