namespace HSchool.Content; /// Roles the generator and traits talk about. Code, not a def — new roles are a rebuild. public static class PersonRoles { public const string Student = "student"; public const string Staff = "staff"; public const string Parent = "parent"; public static bool IsKnown(string role) => role.Equals(Student, StringComparison.OrdinalIgnoreCase) || role.Equals(Staff, StringComparison.OrdinalIgnoreCase) || role.Equals(Parent, StringComparison.OrdinalIgnoreCase); } /// /// Derived body type, not a . Skill limits may name it the same /// way they name hair colour; the generator computes it from height and weight. /// public static class BodyBuilds { public const string Attribute = "Build"; /// /// The two numeric attributes the build is computed from. They are named in code, so a pack /// that renames or drops them is rejected at load rather than quietly producing an average /// build for everybody. /// public const string HeightAttribute = "Height"; public const string WeightAttribute = "Weight"; public const string Skinny = "Skinny"; public const string Average = "Average"; public const string Athletic = "Athletic"; public const string Heavy = "Heavy"; public const string Obese = "Obese"; public static readonly IReadOnlyList Values = [Skinny, Average, Athletic, Heavy, Obese]; public static bool IsKnown(string value) => Values.Any(candidate => candidate.Equals(value, StringComparison.Ordinal)); /// /// WHO-ish bands with a fit slice in the healthy range so Athletic is derived, not rolled. /// Height in centimetres, weight in kilograms. /// public static string FromHeightAndWeight(int heightCm, int weightKg) { if (heightCm <= 0) { return Average; } var metres = heightCm / 100d; var bmi = weightKg / (metres * metres); if (bmi < 18.5) { return Skinny; } if (bmi < 22.0) { return Average; } if (bmi < 25.0) { return Athletic; } if (bmi < 30.0) { return Heavy; } return Obese; } } public sealed class IntRange { public int Min { get; init; } public int Max { get; init; } = 100; } public sealed class StatDistribution { public float Mean { get; init; } public float StdDev { get; init; } = 1; } public sealed class AgeMeanPoint { public int Age { get; init; } public float Mean { get; init; } } public sealed class BodySkillLimit { public required string Attribute { get; init; } public string? Value { get; init; } public int? Min { get; init; } public int? Max { get; init; } } public sealed class SkillDef : Def { public IntRange Range { get; init; } = new(); public StatDistribution? Distribution { get; init; } public IReadOnlyList AgeMeans { get; init; } = []; public IReadOnlyList BodyLimits { get; init; } = []; /// Everyone has this: body, speech, the floor under a profession. public bool Always { get; init; } /// Adult extras: cook, nurse, secretary. Not rolled for pupils. public bool Work { get; init; } /// /// Probability that an adult also has this, on top of the native language. 0 means never /// by chance — pupils still get it when a subject of their year points here. /// public float AdultChance { get; init; } } public sealed class SubjectSkillShare { public required string Skill { get; init; } public float Share { get; init; } } public sealed class SubjectDef : Def { public IntRange Grades { get; init; } = new() { Min = 1, Max = 11 }; public int HoursPerWeek { get; init; } public IReadOnlyList Skills { get; init; } = []; /// /// RoomDef the lesson needs. Null means the class's homeroom. PE uses a gym, informatics a lab. /// public string? Room { get; init; } } public sealed class TraitSkillModifier { public required string Skill { get; init; } public int Offset { get; init; } } /// Biases topic pick toward a tag. Used on gossip and similar traits. public sealed class TopicTagWeight { public required string Tag { get; init; } public float Weight { get; init; } = 1f; } public sealed class TraitDef : Def { public int Weight { get; init; } = 1; public IReadOnlyList Incompatible { get; init; } = []; /// Empty means every role. Values are ids. public IReadOnlyList Roles { get; init; } = []; public IntRange? Age { get; init; } public IReadOnlyList SkillModifiers { get; init; } = []; /// /// Added to the hourly wage ask. Positive means the person wants more at the same skills. /// public float WageAsk { get; init; } /// /// Extra minutes of commute slack. Positive arrives earlier; negative cuts it closer. /// public int CommuteMinutes { get; init; } /// /// Shifts the warmth comfort band, in °C. Heat-loving is positive (suffers cold earlier); /// cold-loving is negative. /// public float ComfortTemperatureOffset { get; init; } /// How often this person initiates talk. Quiet below 1, leader above. public float TalkInitiative { get; init; } = 1f; /// Extra seats preferred in a circle. Social trait raises it. public int TalkCircleBonus { get; init; } /// Multiplies opinion shift from talk. Gossip above 1. public float TalkOpinionMultiplier { get; init; } = 1f; /// Pull toward topics carrying these tags when this person picks the subject. public IReadOnlyList TalkTagWeights { get; init; } = []; /// /// Multiplies the chance a whisper is caught. Quiet below 1, outgoing above. Missing is 1. /// public float WhisperCatchMultiplier { get; init; } = 1f; /// /// Added to the catalog's sympathy opinion threshold. Negative — crush-prone; a pack without /// affinity rules ignores the field. /// public int AffinityThresholdOffset { get; init; } /// /// Added to the pair-break threshold. Positive — the pair dissolves while opinion is still /// higher (jealous). Ignored without affinity rules. /// public int AffinityBreakOffset { get; init; } /// /// Multiplies quarrel and fight chance. Hot-tempered and bully above 1. Missing is 1. /// public float ConflictChance { get; init; } = 1f; /// /// This person keeps one victim id until they leave or opinion hits the floor. /// A field, not a defName == "Bully" branch. /// public bool RemembersVictim { get; init; } /// Multiplies the chance to start an apology. Bully below 1. Missing is 1. public float ApologyChance { get; init; } = 1f; } /// /// Who this person is drawn to. Present only when a pack ships orientations; vanilla catalogs /// have none, and the generator then writes no orientation field. /// public sealed class OrientationDef : Def { public int Weight { get; init; } = 1; /// Attracted to people of the same gender. public bool SameGender { get; init; } /// Attracted to people of the opposite gender. public bool OppositeGender { get; init; } } /// /// One directed role pairing the affinity overlay may use. Empty /// means the overlay never fires, even if orientations exist. /// public sealed class AffinityRolePair { /// id of the person who holds the feeling. public required string From { get; init; } /// id of the person it is about. public required string To { get; init; } public bool Sympathy { get; init; } public bool Pair { get; init; } } /// /// Optional singleton, like . Vanilla core does not ship one; a content /// pack that wants crushes and pairs adds the numbers. Simulation reads the def if present and /// no-ops when it is missing — packs are not named in code. /// public sealed class AffinityRulesDef : Def { /// Opinion at or above this (plus trait offset) becomes a one-way crush. public int SympathyThreshold { get; init; } = 50; /// Mutual crushes at or above this may become a pair, if the role pair allows it. public int PairThreshold { get; init; } = 70; /// A pair ends when either opinion falls below this (plus jealous offset). public int PairBreakThreshold { get; init; } = 20; /// Both people must be at least this old to pair. Sympathy has its own age filter. public int PairMinAge { get; init; } = 18; /// Student–student sympathy: age gap no larger than this, or adjacent year. public int StudentAgeDeltaYears { get; init; } = 2; public bool StudentAdjacentYear { get; init; } = true; /// Family members never get a crush or pair. Kinship is not this overlay. public bool ExcludeFamily { get; init; } = true; /// Opinion the rebuffed person loses when a one-way crush is turned down in talk. public int RebuffOpinionShift { get; init; } = -8; public IReadOnlyList RolePairs { get; init; } = []; } /// Conversation subject. Tags gate appropriateness; language is optional skill help. public sealed class TopicDef : Def { /// Vanilla tags: study, games, food, family, sport, gossip, rude, appearance. public IReadOnlyList Tags { get; init; } = []; public IntRange? Age { get; init; } /// Empty means every role. public IReadOnlyList Roles { get; init; } = []; /// Allowed as whisper on lesson — phase 43 uses this; ordinary Chat does not. public bool WhisperOnLesson { get; init; } /// Skill that helps when shared above threshold. Null — any language works equally. public string? Language { get; init; } /// Base opinion shift toward each other participant when talk succeeds. public int OpinionShift { get; init; } = 2; } public sealed class StaffingDef : Def { public int PoolSize { get; init; } public float StayChance { get; init; } public float ParentChance { get; init; } public float HourlyWageBase { get; init; } public float HourlyWagePerSkill { get; init; } /// /// One full rate. A hired person is paid for at least this many hours even with nothing /// assigned; hours beyond it are paid on top. /// public float BaseWeeklyHours { get; init; } /// /// Most hours one person can carry. A subject whose curriculum needs more than this needs a /// second teacher — the hours are then shared between them. /// public float MaxWeeklyHours { get; init; } public float WeeksPerMonth { get; init; } } /// /// One school's behaviour numbers: when a need is urgent, how fast lessons teach, commute slack, /// how much a new goal must beat the current one before a person switches, and the four goal /// weights the decision planner compares. A catalog may have only one concrete ruleset. Missing /// weights keep today's numbers so a pack without them does not empty the classrooms. /// public sealed class BehaviorDef : Def { /// A need at or below this value is urgent. The planner turns that into a goal weight. public float NeedThreshold { get; init; } /// Skill points a lesson adds per game hour, before traits and need state. public float LessonSkillPerHour { get; init; } /// /// Multiplier when the bag has no textbook for this lesson. Locker and home do not count. /// A pack without the field keeps vanilla half-gain so the catalog still loads. /// public float LessonNoTextbookFactor { get; init; } = 0.5f; /// Inclusive range of extra commute minutes rolled per person per day. public int CommuteSlackMin { get; init; } public int CommuteSlackMax { get; init; } /// /// Extra minutes on a rainy morning. Added to slack when the street is wet. A pack without /// the field adds nothing so the catalog still loads. /// public int CommuteRainMinutes { get; init; } /// Same as when the street is snow. public int CommuteSnowMinutes { get; init; } /// A new goal must beat the current one by this much before the person switches. public float SwitchMargin { get; init; } /// Lesson or posted work. Beats leisure and a need that only just crossed the threshold. public float DutyLessonWeight { get; init; } = 10f; /// Walk to the next room on a break. Beats chatting in the corridor you are standing in. public float DutyTravelWeight { get; init; } = 5f; /// Need at zero. Beats a lesson so a desperate toilet trip leaves class. public float NeedWeightAtZero { get; init; } = 20f; /// /// A sitting during this parallel's own lunch break. Above so /// lunch beats walking on to the next room, below so it never /// pulls anybody out of a lesson. /// public float LunchWeight { get; init; } = 6f; /// Kilograms a person can carry at strength/endurance/hauling 0, before the per-skill terms. public float CarryMassBase { get; init; } = 5f; public float CarryMassPerStrength { get; init; } = 0.08f; public float CarryMassPerEndurance { get; init; } = 0.04f; public float CarryMassPerHauling { get; init; } = 0.08f; /// Chance each optional layer (sweater, coat, hat, accessory) is worn at generation. public float OptionalApparelChance { get; init; } = 0.4f; /// /// Condition lost per game hour while the thing is worn on campus. Bag, locker and home do /// not wear. The number lives here so a pack can make clothes last a term or a week. /// public float ApparelWearPerHour { get; init; } = 0.01f; /// /// Worn apparel below this stays home: morning replacement issues a fresh instance. /// At the threshold they still go out, even when the caption already says torn. /// public float ApparelReplaceBelow { get; init; } = 0.15f; /// /// Caption bands for a 0–1 condition bar. Highest /// the value still meets wins. Empty falls back to . /// public IReadOnlyList ApparelConditionBands { get; init; } = DefaultConditionBands; /// Younger pupils keep regular hemlines even when the school chose short form. public int ShortFormMinAge { get; init; } = 13; /// Everyday and short-form minimum formality on worn layers. public int FormalityRegularMin { get; init; } = 20; /// Strict-form minimum formality on worn layers. public int FormalityStrictMin { get; init; } = 60; /// Street below this °C expects an layer. public float OuterBelowC { get; init; } = 10f; /// Above this street °C a fur coat is inappropriate. public float HeavyOuterAboveC { get; init; } = 15f; /// Walk to the locker room and change. Just above duty travel. public float ApparelGoalWeight { get; init; } = 6f; /// Game minutes for ChangeClothes* actions. public float ChangeClothesMinutes { get; init; } = 5f; /// Parent → child start. Child → parent uses . public int OpinionParentToChildStart { get; init; } = 85; public int OpinionChildToParentStart { get; init; } = 75; public int OpinionSiblingStart { get; init; } = 45; public int OpinionPartnerStart { get; init; } = 65; /// Points each work morning moves an opinion toward family basis or zero. public int OpinionDriftPerMorning { get; init; } = 3; /// /// Family-morning opinion uses this fraction of the topic's school-talk shift. Below 1 so /// breakfast is quieter than a corridor circle. /// public float HomeTalkOpinionScale { get; init; } = 0.5f; /// How many non-family friends or enemies the card lists at the top. public int OpinionTopCount { get; init; } = 5; /// /// Caption bands for −100…100. Highest the value still meets wins. /// Empty falls back to . /// public IReadOnlyList OpinionBands { get; init; } = DefaultOpinionBands; /// Skill points Communication gains per game hour of talk — less than a lesson. public float TalkSkillPerHour { get; init; } = 0.02f; /// Language used in talk, when not already at skill max. public float TalkLanguageSkillPerHour { get; init; } = 0.005f; /// Minimum shared language skill for full opinion shift. public int TalkLanguageThreshold { get; init; } = 25; /// Opinion multiplier when participants share no language above threshold. public float TalkNoLanguageOpinionMultiplier { get; init; } = 0.15f; /// Opinion multiplier for phone chat vs live circle. public float TalkPhoneOpinionMultiplier { get; init; } = 0.5f; /// How much Communication scales opinion shift (per 100 points). public float TalkCommunicationOpinionScale { get; init; } = 0.5f; /// Minimum circle size. Below this Chat does not start. public int TalkCircleMin { get; init; } = 2; /// Maximum people in one live circle. public int TalkCircleMax { get; init; } = 4; /// Opinion at or above — friend for invites and node pull. public int TalkFriendThreshold { get; init; } = 40; /// Opinion at or below — enemy; never invited, lunch nodes avoided. public int TalkEnemyThreshold { get; init; } = -40; /// /// Lesson skill multiplier while the pupil is in a whisper circle. Missing keeps a cut, not /// a skipped lesson — whispering is costly, not a free skip. /// public float LessonWhisperSkillFactor { get; init; } = 0.4f; /// Base chance the teacher notices one whisper circle. Traits scale it; never 1. public float WhisperCatchChance { get; init; } = 0.4f; /// Ceiling after traits. A pack cannot make every whisper a sure catch. public float WhisperCatchMax { get; init; } = 0.85f; /// /// How far pedagogy 0…100 bends a pupil's opinion of the teacher. 50 is identity; above it /// praise grows and reprimand softens, below it the reverse. /// public float TalkPedagogyOpinionScale { get; init; } = 0.4f; /// Leisure weight for a teacher taking pupils after the bell. Below lesson, above chat. public float TeacherAfterLessonWeight { get; init; } = 4f; /// Relative weights when the teacher picks question / praise / reprimand. public float TeacherQuestionWeight { get; init; } = 2f; public float TeacherPraiseWeight { get; init; } = 2f; public float TeacherReprimandWeight { get; init; } = 1f; /// Per-decision chance a pupil in class tries to start a whisper, before initiative. public float WhisperStartChance { get; init; } = 0.12f; /// Rivals always roll this before traits. 1 means every enemy pair can start a quarrel. public float QuarrelChance { get; init; } = 1f; /// Base chance a yard or gym clash becomes a fight. Traits scale it; kept rare. public float FightChance { get; init; } = 0.1f; /// Opinion shift per other participant when a quarrel ends. Stronger than rude talk (−3). public int QuarrelOpinionShift { get; init; } = -8; /// Opinion shift when a fight ends. No health need is touched. public int FightOpinionShift { get; init; } = -16; /// Fraction of lost opinion an apology returns. Never climbs past the pre-quarrel value. public float ApologyRestoreFraction { get; init; } = 0.5f; /// Opinion of the victim at or above this — a third person may join the quarrel. public int DefendOpinionMin { get; init; } = 40; public static IReadOnlyList DefaultOpinionBands { get; } = [ new() { Min = 70, Id = "OpinionCloseFriend" }, new() { Min = 40, Id = "OpinionFriend" }, new() { Min = 20, Id = "OpinionPleasant" }, new() { Min = 1, Id = "OpinionAcquaintance" }, new() { Min = -1, Id = "OpinionStrained" }, new() { Min = -40, Id = "OpinionDislike" }, new() { Min = -100, Id = "OpinionEnemy" }, ]; public static IReadOnlyList DefaultConditionBands { get; } = [ new() { Min = 0.75f, Id = "ApparelConditionIntact" }, new() { Min = 0.4f, Id = "ApparelConditionWorn" }, new() { Min = 0.15f, Id = "ApparelConditionTorn" }, new() { Min = 0f, Id = "ApparelConditionRags" }, ]; } /// One caption on the condition bar. is a locale key, not a Def. public sealed class ApparelConditionBand { public float Min { get; init; } public required string Id { get; init; } } /// One caption on an opinion value. is a locale key, not a Def. public sealed class OpinionBand { public int Min { get; init; } public required string Id { get; init; } } public enum BodyAttributeKind { Number, Choice, } public sealed class SexAgeDistribution { /// male, female, or omit for both. public string? Sex { get; init; } public int? AgeMin { get; init; } public int? AgeMax { get; init; } public required StatDistribution Distribution { get; init; } public IntRange? Range { get; init; } } public sealed class WeightedOption { public required string Value { get; init; } public int Weight { get; init; } = 1; public string? Sex { get; init; } public int? AgeMin { get; init; } public int? AgeMax { get; init; } } public sealed class BodyAttributeDef : Def { public BodyAttributeKind Kind { get; init; } public IReadOnlyList Distributions { get; init; } = []; public IReadOnlyList Options { get; init; } = []; } public sealed class NeedDef : Def { public float Initial { get; init; } = 1; public float DecayPerHour { get; init; } public float Min { get; init; } public float Max { get; init; } = 1; /// /// When true, this need snaps to off campus instead of draining. Sleep /// restores overnight; hunger does not keep falling at home. /// public bool RestoredOffCampus { get; init; } /// /// When true, campus drain is not per hour. Warmth uses it as the /// drop per °C of mismatch against the place temperature. /// public bool Environmental { get; init; } } public sealed class CaseTable { public required string Nom { get; init; } public required string Gen { get; init; } public required string Dat { get; init; } public required string Acc { get; init; } public required string Ins { get; init; } public required string Pre { get; init; } public string this[GrammaticalCase grammaticalCase] => grammaticalCase switch { GrammaticalCase.Nominative => Nom, GrammaticalCase.Genitive => Gen, GrammaticalCase.Dative => Dat, GrammaticalCase.Accusative => Acc, GrammaticalCase.Instrumental => Ins, GrammaticalCase.Prepositional => Pre, _ => Nom, }; } public sealed class GivenNameEntry { public required string Form { get; init; } public string? Declension { get; init; } public CaseTable? Cases { get; init; } } public sealed class SurnameEntry { public required string Male { get; init; } public required string Female { get; init; } public string? Declension { get; init; } public CaseTable? MaleCases { get; init; } public CaseTable? FemaleCases { get; init; } } /// /// Nested name grammar of a . Not a selectable catalog kind of its own. /// public sealed class NameSetDef { public string PatronymicRule { get; init; } = NameGrammar.SlavicPatronymic; public string DefaultGivenDeclension { get; init; } = NameGrammar.Hard; public string DefaultSurnameDeclension { get; init; } = NameGrammar.Ov; /// /// Languages this country can speak natively. Slavic names cover Russian, Belarusian and /// Ukrainian; the school picks one at create. Singular is still /// accepted in JSONC for a one-language pack. /// public IReadOnlyList NativeLanguages { get; init; } = []; /// One-language form. Folded into when the list is empty. public string? NativeLanguage { get; init; } public IReadOnlyList Spoken => NativeLanguages.Count > 0 ? NativeLanguages : string.IsNullOrWhiteSpace(NativeLanguage) ? [] : [NativeLanguage]; /// /// Chance each other language in is present at a low level — a Russian /// speaker who understands Belarusian. 0 leaves relatives off the card. /// public float RelatedLanguageChance { get; init; } public float RelatedLanguageMean { get; init; } = 22f; public float RelatedLanguageStdDev { get; init; } = 8f; /// Cap so a related roll cannot look like a native speaker. public int RelatedLanguageMax { get; init; } = 40; public IReadOnlyList MaleGiven { get; init; } = []; public IReadOnlyList FemaleGiven { get; init; } = []; public IReadOnlyList Surnames { get; init; } = []; } /// /// What the player picks at create: nested names plus climate-preset ids. Weather numbers live /// on . /// public sealed class CountryDef : Def { public IReadOnlyList ClimatePresets { get; init; } = []; public NameSetDef Names { get; init; } = new(); } /// /// Outdoor climate a country may roll. Monthly norms, day/hour spread and precipitation chance /// are the numbers the school uses to sample the street; indoor offset is walls without a technician. /// public sealed class ClimatePresetDef : Def { /// Mean outdoor °C for months 1–12. Concrete presets must list all twelve. public IReadOnlyList MonthlyNorms { get; init; } = []; /// How far a day's mean may wander from the monthly norm, °C. public float DaySpread { get; init; } /// How far the hour wanders from that day's mean, °C. Coldest around 03:00, warmest 15:00. public float HourSpread { get; init; } /// Chance of precipitation this hour, 0–1. Below 0 °C the same roll is snow. public float PrecipitationChance { get; init; } /// Added to indoor temperature vs the street. Walls hold heat; this is not comfort. public float IndoorOffset { get; init; } = 8f; /// Centre of the clothing comfort band, °C, before trait offsets. public float ComfortC { get; init; } = 21f; /// Half-width of the comfort band, °C. Inside it warmth barely drops. public float ComfortHalfWidthC { get; init; } = 3f; /// How many °C of protection one insulation point is worth. public float InsulationPerC { get; init; } = 1f; }