473 lines
15 KiB
C#
473 lines
15 KiB
C#
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 1–12. 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, 0–1. 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;
|
||
}
|