867 lines
30 KiB
C#
867 lines
30 KiB
C#
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);
|
||
}
|
||
|
||
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.");
|
||
}
|
||
|
||
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 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<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 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<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 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.");
|
||
}
|
||
|
||
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.");
|
||
}
|
||
}
|
||
|
||
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}'.");
|
||
}
|
||
}
|
||
|
||
/// <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
|
||
&& (action.DefName.Equals(TalkActions.Chat, StringComparison.Ordinal)
|
||
|| action.DefName.Equals(TalkActions.StaffChat, StringComparison.Ordinal)
|
||
|| action.DefName.Equals(TalkActions.PhoneChat, StringComparison.Ordinal)));
|
||
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 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<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 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.");
|
||
}
|
||
}
|
||
|
||
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.");
|
||
}
|
||
}
|
||
}
|