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;
}