805 lines
29 KiB
C#
805 lines
29 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; }
|
||
}
|
||
|
||
/// <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>Student–student 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 0–1 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 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;
|
||
}
|