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

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