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; }
}
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; }
}
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; }
/// Inclusive range of extra commute minutes rolled per person per day.
public int CommuteSlackMin { get; init; }
public int CommuteSlackMax { 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;
}
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;
}