Files
h-school/src/HSchool.Content/PeopleDefs.cs
T

805 lines
29 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace HSchool.Content;
/// <summary>Roles the generator and traits talk about. Code, not a def — new roles are a rebuild.</summary>
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);
}
/// <summary>
/// Derived body type, not a <see cref="BodyAttributeDef"/>. Skill limits may name it the same
/// way they name hair colour; the generator computes it from height and weight.
/// </summary>
public static class BodyBuilds
{
public const string Attribute = "Build";
/// <summary>
/// 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.
/// </summary>
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<string> Values =
[Skinny, Average, Athletic, Heavy, Obese];
public static bool IsKnown(string value) =>
Values.Any(candidate => candidate.Equals(value, StringComparison.Ordinal));
/// <summary>
/// WHO-ish bands with a fit slice in the healthy range so <c>Athletic</c> is derived, not rolled.
/// Height in centimetres, weight in kilograms.
/// </summary>
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<AgeMeanPoint> AgeMeans { get; init; } = [];
public IReadOnlyList<BodySkillLimit> BodyLimits { get; init; } = [];
/// <summary>Everyone has this: body, speech, the floor under a profession.</summary>
public bool Always { get; init; }
/// <summary>Adult extras: cook, nurse, secretary. Not rolled for pupils.</summary>
public bool Work { get; init; }
/// <summary>
/// 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.
/// </summary>
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<SubjectSkillShare> Skills { get; init; } = [];
/// <summary>
/// RoomDef the lesson needs. Null means the class's homeroom. PE uses a gym, informatics a lab.
/// </summary>
public string? Room { get; init; }
}
public sealed class TraitSkillModifier
{
public required string Skill { get; init; }
public int Offset { get; init; }
}
/// <summary>Biases topic pick toward a tag. Used on gossip and similar traits.</summary>
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<string> Incompatible { get; init; } = [];
/// <summary>Empty means every role. Values are <see cref="PersonRoles"/> ids.</summary>
public IReadOnlyList<string> Roles { get; init; } = [];
public IntRange? Age { get; init; }
public IReadOnlyList<TraitSkillModifier> SkillModifiers { get; init; } = [];
/// <summary>
/// Added to the hourly wage ask. Positive means the person wants more at the same skills.
/// </summary>
public float WageAsk { get; init; }
/// <summary>
/// Extra minutes of commute slack. Positive arrives earlier; negative cuts it closer.
/// </summary>
public int CommuteMinutes { get; init; }
/// <summary>
/// Shifts the warmth comfort band, in °C. Heat-loving is positive (suffers cold earlier);
/// cold-loving is negative.
/// </summary>
public float ComfortTemperatureOffset { get; init; }
/// <summary>How often this person initiates talk. Quiet below 1, leader above.</summary>
public float TalkInitiative { get; init; } = 1f;
/// <summary>Extra seats preferred in a circle. Social trait raises it.</summary>
public int TalkCircleBonus { get; init; }
/// <summary>Multiplies opinion shift from talk. Gossip above 1.</summary>
public float TalkOpinionMultiplier { get; init; } = 1f;
/// <summary>Pull toward topics carrying these tags when this person picks the subject.</summary>
public IReadOnlyList<TopicTagWeight> TalkTagWeights { get; init; } = [];
/// <summary>
/// Multiplies the chance a whisper is caught. Quiet below 1, outgoing above. Missing is 1.
/// </summary>
public float WhisperCatchMultiplier { get; init; } = 1f;
/// <summary>
/// Added to the catalog's sympathy opinion threshold. Negative — crush-prone; a pack without
/// affinity rules ignores the field.
/// </summary>
public int AffinityThresholdOffset { get; init; }
/// <summary>
/// Added to the pair-break threshold. Positive — the pair dissolves while opinion is still
/// higher (jealous). Ignored without affinity rules.
/// </summary>
public int AffinityBreakOffset { get; init; }
/// <summary>
/// Multiplies quarrel and fight chance. Hot-tempered and bully above 1. Missing is 1.
/// </summary>
public float ConflictChance { get; init; } = 1f;
/// <summary>
/// This person keeps one victim id until they leave or opinion hits the floor.
/// A field, not a <c>defName == "Bully"</c> branch.
/// </summary>
public bool RemembersVictim { get; init; }
/// <summary>Multiplies the chance to start an apology. Bully below 1. Missing is 1.</summary>
public float ApologyChance { get; init; } = 1f;
}
/// <summary>
/// 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.
/// </summary>
public sealed class OrientationDef : Def
{
public int Weight { get; init; } = 1;
/// <summary>Attracted to people of the same gender.</summary>
public bool SameGender { get; init; }
/// <summary>Attracted to people of the opposite gender.</summary>
public bool OppositeGender { get; init; }
}
/// <summary>
/// One directed role pairing the affinity overlay may use. Empty <see cref="AffinityRulesDef.RolePairs"/>
/// means the overlay never fires, even if orientations exist.
/// </summary>
public sealed class AffinityRolePair
{
/// <summary><see cref="PersonRoles"/> id of the person who holds the feeling.</summary>
public required string From { get; init; }
/// <summary><see cref="PersonRoles"/> id of the person it is about.</summary>
public required string To { get; init; }
public bool Sympathy { get; init; }
public bool Pair { get; init; }
}
/// <summary>
/// Optional singleton, like <see cref="BehaviorDef"/>. 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.
/// </summary>
public sealed class AffinityRulesDef : Def
{
/// <summary>Opinion at or above this (plus trait offset) becomes a one-way crush.</summary>
public int SympathyThreshold { get; init; } = 50;
/// <summary>Mutual crushes at or above this may become a pair, if the role pair allows it.</summary>
public int PairThreshold { get; init; } = 70;
/// <summary>A pair ends when either opinion falls below this (plus jealous offset).</summary>
public int PairBreakThreshold { get; init; } = 20;
/// <summary>Both people must be at least this old to pair. Sympathy has its own age filter.</summary>
public int PairMinAge { get; init; } = 18;
/// <summary>Studentstudent sympathy: age gap no larger than this, or adjacent year.</summary>
public int StudentAgeDeltaYears { get; init; } = 2;
public bool StudentAdjacentYear { get; init; } = true;
/// <summary>Family members never get a crush or pair. Kinship is not this overlay.</summary>
public bool ExcludeFamily { get; init; } = true;
/// <summary>Opinion the rebuffed person loses when a one-way crush is turned down in talk.</summary>
public int RebuffOpinionShift { get; init; } = -8;
public IReadOnlyList<AffinityRolePair> RolePairs { get; init; } = [];
}
/// <summary>Conversation subject. Tags gate appropriateness; language is optional skill help.</summary>
public sealed class TopicDef : Def
{
/// <summary>Vanilla tags: study, games, food, family, sport, gossip, rude, appearance.</summary>
public IReadOnlyList<string> Tags { get; init; } = [];
public IntRange? Age { get; init; }
/// <summary>Empty means every role.</summary>
public IReadOnlyList<string> Roles { get; init; } = [];
/// <summary>Allowed as whisper on lesson — phase 43 uses this; ordinary Chat does not.</summary>
public bool WhisperOnLesson { get; init; }
/// <summary>Skill that helps when shared above threshold. Null — any language works equally.</summary>
public string? Language { get; init; }
/// <summary>Base opinion shift toward each other participant when talk succeeds.</summary>
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; }
/// <summary>
/// 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.
/// </summary>
public float BaseWeeklyHours { get; init; }
/// <summary>
/// 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.
/// </summary>
public float MaxWeeklyHours { get; init; }
public float WeeksPerMonth { get; init; }
}
/// <summary>
/// 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.
/// </summary>
public sealed class BehaviorDef : Def
{
/// <summary>A need at or below this value is urgent. The planner turns that into a goal weight.</summary>
public float NeedThreshold { get; init; }
/// <summary>Skill points a lesson adds per game hour, before traits and need state.</summary>
public float LessonSkillPerHour { get; init; }
/// <summary>
/// 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.
/// </summary>
public float LessonNoTextbookFactor { get; init; } = 0.5f;
/// <summary>Inclusive range of extra commute minutes rolled per person per day.</summary>
public int CommuteSlackMin { get; init; }
public int CommuteSlackMax { get; init; }
/// <summary>
/// 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.
/// </summary>
public int CommuteRainMinutes { get; init; }
/// <summary>Same as <see cref="CommuteRainMinutes"/> when the street is snow.</summary>
public int CommuteSnowMinutes { get; init; }
/// <summary>A new goal must beat the current one by this much before the person switches.</summary>
public float SwitchMargin { get; init; }
/// <summary>Lesson or posted work. Beats leisure and a need that only just crossed the threshold.</summary>
public float DutyLessonWeight { get; init; } = 10f;
/// <summary>Walk to the next room on a break. Beats chatting in the corridor you are standing in.</summary>
public float DutyTravelWeight { get; init; } = 5f;
/// <summary>Need at zero. Beats a lesson so a desperate toilet trip leaves class.</summary>
public float NeedWeightAtZero { get; init; } = 20f;
/// <summary>
/// A sitting during this parallel's own lunch break. Above <see cref="DutyTravelWeight"/> so
/// lunch beats walking on to the next room, below <see cref="DutyLessonWeight"/> so it never
/// pulls anybody out of a lesson.
/// </summary>
public float LunchWeight { get; init; } = 6f;
/// <summary>Kilograms a person can carry at strength/endurance/hauling 0, before the per-skill terms.</summary>
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;
/// <summary>Chance each optional layer (sweater, coat, hat, accessory) is worn at generation.</summary>
public float OptionalApparelChance { get; init; } = 0.4f;
/// <summary>
/// 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.
/// </summary>
public float ApparelWearPerHour { get; init; } = 0.01f;
/// <summary>
/// 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.
/// </summary>
public float ApparelReplaceBelow { get; init; } = 0.15f;
/// <summary>
/// Caption bands for a 01 condition bar. Highest <see cref="ApparelConditionBand.Min"/>
/// the value still meets wins. Empty falls back to <see cref="DefaultConditionBands"/>.
/// </summary>
public IReadOnlyList<ApparelConditionBand> ApparelConditionBands { get; init; } = DefaultConditionBands;
/// <summary>Younger pupils keep regular hemlines even when the school chose short form.</summary>
public int ShortFormMinAge { get; init; } = 13;
/// <summary>Everyday and short-form minimum formality on worn layers.</summary>
public int FormalityRegularMin { get; init; } = 20;
/// <summary>Strict-form minimum formality on worn layers.</summary>
public int FormalityStrictMin { get; init; } = 60;
/// <summary>Street below this °C expects an <see cref="ApparelLayers.Outer"/> layer.</summary>
public float OuterBelowC { get; init; } = 10f;
/// <summary>Above this street °C a fur coat is inappropriate.</summary>
public float HeavyOuterAboveC { get; init; } = 15f;
/// <summary>Walk to the locker room and change. Just above duty travel.</summary>
public float ApparelGoalWeight { get; init; } = 6f;
/// <summary>Game minutes for <c>ChangeClothes*</c> actions.</summary>
public float ChangeClothesMinutes { get; init; } = 5f;
/// <summary>Parent → child start. Child → parent uses <see cref="OpinionChildToParentStart"/>.</summary>
public int OpinionParentToChildStart { get; init; } = 85;
public int OpinionChildToParentStart { get; init; } = 75;
public int OpinionSiblingStart { get; init; } = 45;
public int OpinionPartnerStart { get; init; } = 65;
/// <summary>Points each work morning moves an opinion toward family basis or zero.</summary>
public int OpinionDriftPerMorning { get; init; } = 3;
/// <summary>
/// Family-morning opinion uses this fraction of the topic's school-talk shift. Below 1 so
/// breakfast is quieter than a corridor circle.
/// </summary>
public float HomeTalkOpinionScale { get; init; } = 0.5f;
/// <summary>How many non-family friends or enemies the card lists at the top.</summary>
public int OpinionTopCount { get; init; } = 5;
/// <summary>
/// Caption bands for 100…100. Highest <see cref="OpinionBand.Min"/> the value still meets wins.
/// Empty falls back to <see cref="DefaultOpinionBands"/>.
/// </summary>
public IReadOnlyList<OpinionBand> OpinionBands { get; init; } = DefaultOpinionBands;
/// <summary>Skill points Communication gains per game hour of talk — less than a lesson.</summary>
public float TalkSkillPerHour { get; init; } = 0.02f;
/// <summary>Language used in talk, when not already at skill max.</summary>
public float TalkLanguageSkillPerHour { get; init; } = 0.005f;
/// <summary>Minimum shared language skill for full opinion shift.</summary>
public int TalkLanguageThreshold { get; init; } = 25;
/// <summary>Opinion multiplier when participants share no language above threshold.</summary>
public float TalkNoLanguageOpinionMultiplier { get; init; } = 0.15f;
/// <summary>Opinion multiplier for phone chat vs live circle.</summary>
public float TalkPhoneOpinionMultiplier { get; init; } = 0.5f;
/// <summary>How much Communication scales opinion shift (per 100 points).</summary>
public float TalkCommunicationOpinionScale { get; init; } = 0.5f;
/// <summary>Minimum circle size. Below this Chat does not start.</summary>
public int TalkCircleMin { get; init; } = 2;
/// <summary>Maximum people in one live circle.</summary>
public int TalkCircleMax { get; init; } = 4;
/// <summary>Opinion at or above — friend for invites and node pull.</summary>
public int TalkFriendThreshold { get; init; } = 40;
/// <summary>Opinion at or below — enemy; never invited, lunch nodes avoided.</summary>
public int TalkEnemyThreshold { get; init; } = -40;
/// <summary>
/// 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.
/// </summary>
public float LessonWhisperSkillFactor { get; init; } = 0.4f;
/// <summary>Base chance the teacher notices one whisper circle. Traits scale it; never 1.</summary>
public float WhisperCatchChance { get; init; } = 0.4f;
/// <summary>Ceiling after traits. A pack cannot make every whisper a sure catch.</summary>
public float WhisperCatchMax { get; init; } = 0.85f;
/// <summary>
/// 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.
/// </summary>
public float TalkPedagogyOpinionScale { get; init; } = 0.4f;
/// <summary>Leisure weight for a teacher taking pupils after the bell. Below lesson, above chat.</summary>
public float TeacherAfterLessonWeight { get; init; } = 4f;
/// <summary>Relative weights when the teacher picks question / praise / reprimand.</summary>
public float TeacherQuestionWeight { get; init; } = 2f;
public float TeacherPraiseWeight { get; init; } = 2f;
public float TeacherReprimandWeight { get; init; } = 1f;
/// <summary>Per-decision chance a pupil in class tries to start a whisper, before initiative.</summary>
public float WhisperStartChance { get; init; } = 0.12f;
/// <summary>Rivals always roll this before traits. 1 means every enemy pair can start a quarrel.</summary>
public float QuarrelChance { get; init; } = 1f;
/// <summary>Base chance a yard or gym clash becomes a fight. Traits scale it; kept rare.</summary>
public float FightChance { get; init; } = 0.1f;
/// <summary>Opinion shift per other participant when a quarrel ends. Stronger than rude talk (3).</summary>
public int QuarrelOpinionShift { get; init; } = -8;
/// <summary>Opinion shift when a fight ends. No health need is touched.</summary>
public int FightOpinionShift { get; init; } = -16;
/// <summary>Fraction of lost opinion an apology returns. Never climbs past the pre-quarrel value.</summary>
public float ApologyRestoreFraction { get; init; } = 0.5f;
/// <summary>Opinion of the victim at or above this — a third person may join the quarrel.</summary>
public int DefendOpinionMin { get; init; } = 40;
public static IReadOnlyList<OpinionBand> 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<ApparelConditionBand> 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" },
];
}
/// <summary>One caption on the condition bar. <see cref="Id"/> is a locale key, not a Def.</summary>
public sealed class ApparelConditionBand
{
public float Min { get; init; }
public required string Id { get; init; }
}
/// <summary>One caption on an opinion value. <see cref="Id"/> is a locale key, not a Def.</summary>
public sealed class OpinionBand
{
public int Min { get; init; }
public required string Id { get; init; }
}
public enum BodyAttributeKind
{
Number,
Choice,
}
public sealed class SexAgeDistribution
{
/// <summary><c>male</c>, <c>female</c>, or omit for both.</summary>
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<SexAgeDistribution> Distributions { get; init; } = [];
public IReadOnlyList<WeightedOption> 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;
/// <summary>
/// When true, this need snaps to <see cref="Max"/> off campus instead of draining. Sleep
/// restores overnight; hunger does not keep falling at home.
/// </summary>
public bool RestoredOffCampus { get; init; }
/// <summary>
/// When true, campus drain is not <see cref="DecayPerHour"/> per hour. Warmth uses it as the
/// drop per °C of mismatch against the place temperature.
/// </summary>
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; }
}
/// <summary>
/// Nested name grammar of a <see cref="CountryDef"/>. Not a selectable catalog kind of its own.
/// </summary>
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;
/// <summary>
/// Languages this country can speak natively. Slavic names cover Russian, Belarusian and
/// Ukrainian; the school picks one at create. Singular <see cref="NativeLanguage"/> is still
/// accepted in JSONC for a one-language pack.
/// </summary>
public IReadOnlyList<string> NativeLanguages { get; init; } = [];
/// <summary>One-language form. Folded into <see cref="Spoken"/> when the list is empty.</summary>
public string? NativeLanguage { get; init; }
public IReadOnlyList<string> Spoken =>
NativeLanguages.Count > 0
? NativeLanguages
: string.IsNullOrWhiteSpace(NativeLanguage) ? [] : [NativeLanguage];
/// <summary>
/// Chance each other language in <see cref="Spoken"/> is present at a low level — a Russian
/// speaker who understands Belarusian. 0 leaves relatives off the card.
/// </summary>
public float RelatedLanguageChance { get; init; }
public float RelatedLanguageMean { get; init; } = 22f;
public float RelatedLanguageStdDev { get; init; } = 8f;
/// <summary>Cap so a related roll cannot look like a native speaker.</summary>
public int RelatedLanguageMax { get; init; } = 40;
public IReadOnlyList<GivenNameEntry> MaleGiven { get; init; } = [];
public IReadOnlyList<GivenNameEntry> FemaleGiven { get; init; } = [];
public IReadOnlyList<SurnameEntry> Surnames { get; init; } = [];
}
/// <summary>
/// What the player picks at create: nested names plus climate-preset ids. Weather numbers live
/// on <see cref="ClimatePresetDef"/>.
/// </summary>
public sealed class CountryDef : Def
{
public IReadOnlyList<string> ClimatePresets { get; init; } = [];
public NameSetDef Names { get; init; } = new();
}
/// <summary>
/// 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.
/// </summary>
public sealed class ClimatePresetDef : Def
{
/// <summary>Mean outdoor °C for months 112. Concrete presets must list all twelve.</summary>
public IReadOnlyList<float> MonthlyNorms { get; init; } = [];
/// <summary>How far a day's mean may wander from the monthly norm, °C.</summary>
public float DaySpread { get; init; }
/// <summary>How far the hour wanders from that day's mean, °C. Coldest around 03:00, warmest 15:00.</summary>
public float HourSpread { get; init; }
/// <summary>Chance of precipitation this hour, 01. Below 0 °C the same roll is snow.</summary>
public float PrecipitationChance { get; init; }
/// <summary>Added to indoor temperature vs the street. Walls hold heat; this is not comfort.</summary>
public float IndoorOffset { get; init; } = 8f;
/// <summary>Centre of the clothing comfort band, °C, before trait offsets.</summary>
public float ComfortC { get; init; } = 21f;
/// <summary>Half-width of the comfort band, °C. Inside it warmth barely drops.</summary>
public float ComfortHalfWidthC { get; init; } = 3f;
/// <summary>How many °C of protection one insulation point is worth.</summary>
public float InsulationPerC { get; init; } = 1f;
}