diff --git a/docs/phases/14-health/78-diseases.md b/docs/phases/14-health/78-diseases.md index 5c696f1..b1f6cdd 100644 --- a/docs/phases/14-health/78-diseases.md +++ b/docs/phases/14-health/78-diseases.md @@ -12,21 +12,21 @@ ## Задачи -- [ ] `DiseaseDef` (или согласованное имя): семейство, стадии, инкубация, эффекты на нужды / +- [x] `DiseaseDef` (или согласованное имя): семейство, стадии, инкубация, эффекты на нужды / `LessonLearning` / шанс не прийти -- [ ] Ваниль **несколько** разных болезней в `core` (не один def «Болезнь») -- [ ] Векторы: холод ниже порога, дождь/снег — поля на def и BehaviorDef -- [ ] Отсутствие по болезни пишет причину для журнала 13 (уважительно) -- [ ] Временный иммунитет после выздоровления — поле на def -- [ ] Локали подписей болезней +- [x] Ваниль **несколько** разных болезней в `core` (не один def «Болезнь») +- [x] Векторы: холод ниже порога, дождь/снег — поля на def и BehaviorDef +- [x] Отсутствие по болезни пишет причину для журнала 13 (уважительно) +- [x] Временный иммунитет после выздоровления — поле на def +- [x] Локали подписей болезней ## Тесты, без которых фаза не закрыта -- [ ] Два разных def дают разные кривые/эффекты при контрольном тике -- [ ] Холод повышает шанс респираторной ванили относительно контроля -- [ ] Больной с тяжёлой стадией чаще «вне школы» на учебный день -- [ ] Явка с причиной болезнь ≠ прогул в данных 13 -- [ ] Каталог без обязательных полей падает загрузкой, не молча +- [x] Два разных def дают разные кривые/эффекты при контрольном тике +- [x] Холод повышает шанс респираторной ванили относительно контроля +- [x] Больной с тяжёлой стадией чаще «вне школы» на учебный день +- [x] Явка с причиной болезнь ≠ прогул в данных 13 +- [x] Каталог без обязательных полей падает загрузкой, не молча ## Критерий готовности diff --git a/docs/phases/14-health/README.md b/docs/phases/14-health/README.md index 52de224..0e12814 100644 --- a/docs/phases/14-health/README.md +++ b/docs/phases/14-health/README.md @@ -18,7 +18,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [78. Болезни и векторы](78-diseases.md) | 🔄 | Несколько DiseaseDef, холод/погода, урок/явка | +| [78. Болезни и векторы](78-diseases.md) | ✅ | Несколько DiseaseDef, холод/погода, урок/явка | **Этап C — зараза.** diff --git a/src/HSchool.Ai/DayPlan.cs b/src/HSchool.Ai/DayPlan.cs index c62007b..28d1155 100644 --- a/src/HSchool.Ai/DayPlan.cs +++ b/src/HSchool.Ai/DayPlan.cs @@ -38,6 +38,11 @@ public static class DayPlans return new DayPlan(day, null, null, null); } + if (DiseaseEffects.ShouldStayHome(person, catalog, schoolSeed, utc)) + { + return new DayPlan(day, null, null, null); + } + var weekday = SchoolDay.WeekdayIndex(utc); var firstRoom = FirstRoom(person, schoolClass, timetable, catalog, weekday); if (firstRoom is null) diff --git a/src/HSchool.Content/CatalogLoader.cs b/src/HSchool.Content/CatalogLoader.cs index c6313ff..9f43648 100644 --- a/src/HSchool.Content/CatalogLoader.cs +++ b/src/HSchool.Content/CatalogLoader.cs @@ -319,6 +319,7 @@ public sealed class CatalogLoader var orientations = new Dictionary(StringComparer.Ordinal); var affinity = new Dictionary(StringComparer.Ordinal); var events = new Dictionary(StringComparer.Ordinal); + var diseases = new Dictionary(StringComparer.Ordinal); foreach (var (key, json) in resolved) { @@ -396,6 +397,9 @@ public sealed class CatalogLoader case DefKind.Event: events[key.Name] = Jsonc.Deserialize(json); break; + case DefKind.Disease: + diseases[key.Name] = Jsonc.Deserialize(json); + break; } } @@ -425,6 +429,7 @@ public sealed class CatalogLoader orientations, affinity, events, + diseases, ru, en); } @@ -547,6 +552,7 @@ public sealed class CatalogLoader PeopleDefValidator.Validate(catalog, log); EventDefValidator.Validate(catalog); + DiseaseDefValidator.Validate(catalog); } private static void WarnMissingLabels(DefCatalog catalog, IContentLog log) @@ -585,7 +591,8 @@ public sealed class CatalogLoader .Concat(Enumerate(catalog.Topics.Values)) .Concat(Enumerate(catalog.Orientations.Values)) .Concat(Enumerate(catalog.Affinity.Values)) - .Concat(Enumerate(catalog.Events.Values)); + .Concat(Enumerate(catalog.Events.Values)) + .Concat(Enumerate(catalog.Diseases.Values)); static IEnumerable Enumerate(IEnumerable defs) => defs.Where(def => !def.Abstract); } diff --git a/src/HSchool.Content/DefCatalog.cs b/src/HSchool.Content/DefCatalog.cs index 4df7ce1..d0ccf5c 100644 --- a/src/HSchool.Content/DefCatalog.cs +++ b/src/HSchool.Content/DefCatalog.cs @@ -32,6 +32,7 @@ public sealed class DefCatalog IReadOnlyDictionary orientations, IReadOnlyDictionary affinity, IReadOnlyDictionary events, + IReadOnlyDictionary diseases, IReadOnlyDictionary ru, IReadOnlyDictionary en) { @@ -60,6 +61,7 @@ public sealed class DefCatalog Orientations = orientations; Affinity = affinity; Events = events; + Diseases = diseases; _ru = ru; _en = en; AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f); @@ -125,6 +127,8 @@ public sealed class DefCatalog public IReadOnlyDictionary Events { get; } + public IReadOnlyDictionary Diseases { get; } + /// The one concrete staffing ruleset, or null when a pack has not defined it. public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract); @@ -168,6 +172,7 @@ public sealed class DefCatalog DefKind.Orientation => Orientations.GetValueOrDefault(defName), DefKind.AffinityRules => Affinity.GetValueOrDefault(defName), DefKind.Event => Events.GetValueOrDefault(defName), + DefKind.Disease => Diseases.GetValueOrDefault(defName), _ => null, }; @@ -252,6 +257,7 @@ public sealed class DefCatalog OrientationDef => DefKind.Orientation, AffinityRulesDef => DefKind.AffinityRules, EventDef => DefKind.Event, + DiseaseDef => DefKind.Disease, _ => throw new ArgumentOutOfRangeException(nameof(def)), }; diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index b9c0552..6c1f61d 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -26,6 +26,7 @@ public enum DefKind Orientation, AffinityRules, Event, + Disease, } /// Shared JSONC fields. Kind comes from the folder under defs/, not from the file. diff --git a/src/HSchool.Content/DiseaseDefValidator.cs b/src/HSchool.Content/DiseaseDefValidator.cs new file mode 100644 index 0000000..77102cf --- /dev/null +++ b/src/HSchool.Content/DiseaseDefValidator.cs @@ -0,0 +1,91 @@ +namespace HSchool.Content; + +internal static class DiseaseDefValidator +{ + private static readonly HashSet Families = new(StringComparer.Ordinal) + { + DiseaseFamilies.Respiratory, + DiseaseFamilies.Gastrointestinal, + DiseaseFamilies.Ent, + }; + + public static void Validate(DefCatalog catalog) + { + foreach (var def in catalog.Diseases.Values) + { + if (def.Abstract) + { + continue; + } + + if (string.IsNullOrWhiteSpace(def.Family)) + { + throw new ContentLoadException($"DiseaseDef '{def.DefName}' family is required."); + } + + if (!Families.Contains(def.Family)) + { + throw new ContentLoadException($"DiseaseDef '{def.DefName}' has unknown family '{def.Family}'."); + } + + if (def.Stages.Count == 0) + { + throw new ContentLoadException($"DiseaseDef '{def.DefName}' stages are required."); + } + + if (def.IncubationDays < 0f) + { + throw new ContentLoadException($"DiseaseDef '{def.DefName}' incubationDays cannot be negative."); + } + + if (def.ImmunityDays < 0f) + { + throw new ContentLoadException($"DiseaseDef '{def.DefName}' immunityDays cannot be negative."); + } + + if (def.BaseChancePerDay < 0f + || def.ColdChancePerDay < 0f + || def.RainChancePerDay < 0f + || def.SnowChancePerDay < 0f) + { + throw new ContentLoadException($"DiseaseDef '{def.DefName}' chance fields cannot be negative."); + } + + DiseaseStage? previous = null; + foreach (var stage in def.Stages) + { + if (stage.MinSeverity < 0f || stage.MinSeverity > 1f) + { + throw new ContentLoadException( + $"DiseaseDef '{def.DefName}' stage minSeverity must be 0–1."); + } + + if (previous is not null && stage.MinSeverity < previous.MinSeverity) + { + throw new ContentLoadException( + $"DiseaseDef '{def.DefName}' stages must be ascending by minSeverity."); + } + + if (stage.LessonLearningFactor < 0f || stage.LessonLearningFactor > 1f) + { + throw new ContentLoadException( + $"DiseaseDef '{def.DefName}' lessonLearningFactor must be 0–1."); + } + + if (stage.StayHomeChance < 0f || stage.StayHomeChance > 1f) + { + throw new ContentLoadException( + $"DiseaseDef '{def.DefName}' stayHomeChance must be 0–1."); + } + + if (stage.WarmthDecayFactor < 0f) + { + throw new ContentLoadException( + $"DiseaseDef '{def.DefName}' warmthDecayFactor cannot be negative."); + } + + previous = stage; + } + } + } +} diff --git a/src/HSchool.Content/DiseaseDefs.cs b/src/HSchool.Content/DiseaseDefs.cs new file mode 100644 index 0000000..91a86c4 --- /dev/null +++ b/src/HSchool.Content/DiseaseDefs.cs @@ -0,0 +1,61 @@ +namespace HSchool.Content; + +/// Stable family ids for immunity grouping and realism labels. +public static class DiseaseFamilies +{ + public const string Respiratory = "respiratory"; + + public const string Gastrointestinal = "gastrointestinal"; + + public const string Ent = "ent"; +} + +/// One severity band on a curve. +public sealed class DiseaseStage +{ + /// Inclusive lower bound of severity for this stage. + public float MinSeverity { get; init; } + + public float SeverityPerDay { get; init; } + + public float ProgressPerDay { get; init; } + + /// Multiplies lesson skill gain while this stage is active (after incubation). + public float LessonLearningFactor { get; init; } = 1f; + + /// Per-day chance the person stays off campus while in this stage (after incubation). + public float StayHomeChance { get; init; } + + /// Multiplies Warmth decay. 1 = unchanged. + public float WarmthDecayFactor { get; init; } = 1f; +} + +/// +/// A named disease: stages, weather vectors, lesson/attendance effects, temporary immunity. +/// Contagion between people is phase 79 — these defs may omit it. +/// +public sealed class DiseaseDef : Def +{ + /// id. Required on concrete defs. + public string Family { get; init; } = ""; + + /// Game days after onset before stage effects (lesson / stay-home) apply. + public float IncubationDays { get; init; } + + public IReadOnlyList Stages { get; init; } = []; + + /// Base daily onset chance before weather vectors and . + public float BaseChancePerDay { get; init; } + + /// Street °C at or below this adds . Null — no cold vector. + public float? ColdBelowC { get; init; } + + public float ColdChancePerDay { get; init; } + + public float RainChancePerDay { get; init; } + + public float SnowChancePerDay { get; init; } + + /// Days of immunity to this def after recovery. + public float ImmunityDays { get; init; } +} diff --git a/src/HSchool.Content/PackPaths.cs b/src/HSchool.Content/PackPaths.cs index 45b04a0..a32f187 100644 --- a/src/HSchool.Content/PackPaths.cs +++ b/src/HSchool.Content/PackPaths.cs @@ -141,6 +141,9 @@ internal static class PackPaths case "events": kind = DefKind.Event; return true; + case "diseases": + kind = DefKind.Disease; + return true; default: kind = default; return false; diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index ca4277c..b4bfe39 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -751,6 +751,12 @@ internal static class PeopleDefValidator throw new ContentLoadException( $"BehaviorDef '{behavior.DefName}' lessonLateMinutes cannot be negative."); } + + if (behavior.DiseaseVectorScale < 0f) + { + throw new ContentLoadException( + $"BehaviorDef '{behavior.DefName}' diseaseVectorScale cannot be negative."); + } } private static void ValidateTopic(TopicDef topic, DefCatalog catalog) diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 953f0fb..5beeb69 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -655,6 +655,11 @@ public sealed class BehaviorDef : Def /// public bool LessonMarkWhenLate { get; init; } = true; + /// + /// Scales DiseaseDef weather/base onset chances. 0 disables natural onset; missing keeps 1. + /// + public float DiseaseVectorScale { get; init; } = 1f; + public static IReadOnlyList DefaultLessonMarkThresholds { get; } = [ 0.85f, diff --git a/src/HSchool.People/Attendance.cs b/src/HSchool.People/Attendance.cs index 3b3075a..2f89f0c 100644 --- a/src/HSchool.People/Attendance.cs +++ b/src/HSchool.People/Attendance.cs @@ -19,6 +19,9 @@ public static class AttendanceStatuses public static class AbsenceReasons { public const string Truancy = "truancy"; + + /// Excused absence while a DiseaseDef keeps the pupil home (slice 14). + public const string Illness = "illness"; } /// One lesson-slot attendance row. Sparse list on the person — not a year journal. diff --git a/src/HSchool.People/DiseaseEffects.cs b/src/HSchool.People/DiseaseEffects.cs new file mode 100644 index 0000000..9d3d24d --- /dev/null +++ b/src/HSchool.People/DiseaseEffects.cs @@ -0,0 +1,163 @@ +using HSchool.Content; + +namespace HSchool.People; + +/// +/// Reads DiseaseDef stages for lesson gain, stay-home rolls and warmth decay. +/// Contagion between people is phase 79. +/// +public static class DiseaseEffects +{ + public static bool TryStage(DiseaseDef disease, float severity, out DiseaseStage stage) + { + ArgumentNullException.ThrowIfNull(disease); + DiseaseStage? best = null; + foreach (var candidate in disease.Stages) + { + if (severity + 1e-6f < candidate.MinSeverity) + { + continue; + } + + if (best is null || candidate.MinSeverity >= best.MinSeverity) + { + best = candidate; + } + } + + if (best is null) + { + stage = null!; + return false; + } + + stage = best; + return true; + } + + public static bool IsIncubated(HealthCondition condition, DiseaseDef disease, DateTime now) + { + ArgumentNullException.ThrowIfNull(condition); + ArgumentNullException.ThrowIfNull(disease); + var elapsed = (DateTime.SpecifyKind(now, DateTimeKind.Utc) - condition.StartedAt).TotalDays; + return elapsed >= disease.IncubationDays; + } + + /// Product of active post-incubation lesson factors (1 when healthy). + public static float LessonLearningFactor(Person person, DefCatalog catalog, DateTime now) + { + ArgumentNullException.ThrowIfNull(person); + ArgumentNullException.ThrowIfNull(catalog); + var list = person.Conditions; + if (list is null || list.Count == 0) + { + return 1f; + } + + var factor = 1f; + foreach (var condition in list) + { + if (!catalog.Diseases.TryGetValue(condition.DefName, out var disease) || disease.Abstract) + { + continue; + } + + if (!IsIncubated(condition, disease, now) || !TryStage(disease, condition.Severity, out var stage)) + { + continue; + } + + factor *= stage.LessonLearningFactor; + } + + return factor; + } + + /// Highest stay-home chance among incubated conditions. + public static float StayHomeChance(Person person, DefCatalog catalog, DateTime now) + { + ArgumentNullException.ThrowIfNull(person); + ArgumentNullException.ThrowIfNull(catalog); + var list = person.Conditions; + if (list is null || list.Count == 0) + { + return 0f; + } + + var chance = 0f; + foreach (var condition in list) + { + if (!catalog.Diseases.TryGetValue(condition.DefName, out var disease) || disease.Abstract) + { + continue; + } + + if (!IsIncubated(condition, disease, now) || !TryStage(disease, condition.Severity, out var stage)) + { + continue; + } + + if (stage.StayHomeChance > chance) + { + chance = stage.StayHomeChance; + } + } + + return chance; + } + + /// + /// Deterministic per person+day: whether illness keeps them off campus today. + /// + public static bool ShouldStayHome(Person person, DefCatalog catalog, int peopleSeed, DateTime now) + { + var chance = StayHomeChance(person, catalog, now); + if (chance <= 0f) + { + return false; + } + + if (chance >= 1f) + { + return true; + } + + var dayNumber = DateOnly.FromDateTime(DateTime.SpecifyKind(now, DateTimeKind.Utc)).DayNumber; + var roll = Seed.Mix(peopleSeed, person.Id, dayNumber, Seed.DiseaseSalt); + var unit = (roll & int.MaxValue) / (float)int.MaxValue; + return unit < chance; + } + + /// Max Warmth decay multiplier from incubated stages (1 when healthy). + public static float WarmthDecayFactor(Person person, DefCatalog catalog, DateTime now) + { + ArgumentNullException.ThrowIfNull(person); + ArgumentNullException.ThrowIfNull(catalog); + var list = person.Conditions; + if (list is null || list.Count == 0) + { + return 1f; + } + + var factor = 1f; + foreach (var condition in list) + { + if (!catalog.Diseases.TryGetValue(condition.DefName, out var disease) || disease.Abstract) + { + continue; + } + + if (!IsIncubated(condition, disease, now) || !TryStage(disease, condition.Severity, out var stage)) + { + continue; + } + + if (stage.WarmthDecayFactor > factor) + { + factor = stage.WarmthDecayFactor; + } + } + + return factor; + } +} diff --git a/src/HSchool.People/HealthCondition.cs b/src/HSchool.People/HealthCondition.cs index 564eeaf..96603e3 100644 --- a/src/HSchool.People/HealthCondition.cs +++ b/src/HSchool.People/HealthCondition.cs @@ -1,19 +1,20 @@ using System.Text.Json.Serialization; +using HSchool.Content; namespace HSchool.People; /// /// One medical condition on a person (hediff-like). Needs stay separate — a disease may -/// later modify decay, but hunger is still a need. +/// modify decay or lesson gain, but hunger is still a need. /// public sealed class HealthCondition { public required string DefName { get; init; } - /// 0…1. Stages and effects read this; DiseaseDef curves land in phase 78. + /// 0…1. stages read this when the catalog knows the def. public float Severity { get; set; } - /// Immunity / treatment progress 0…1. + /// Immunity / treatment progress 0…1. At 1 the condition clears. public float Progress { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -23,7 +24,7 @@ public sealed class HealthCondition /// /// Base severity change per game day before the person+day seed factor. - /// Artificial conditions carry this until DiseaseDef owns the curve (phase 78). + /// Used when no DiseaseDef is in the catalog (tests / artificial conditions). /// public float SeverityPerDay { get; init; } @@ -31,6 +32,14 @@ public sealed class HealthCondition public float ProgressPerDay { get; init; } } +/// Temporary post-recovery immunity to one DiseaseDef. +public sealed class DiseaseImmunityRecord +{ + public required string DefName { get; init; } + + public DateTime Until { get; init; } +} + /// Mutates the sparse list and ticks severity deterministically. public static class HealthConditions { @@ -49,11 +58,39 @@ public static class HealthConditions list.Add(condition); } + public static bool Has(Person person, string defName) + { + ArgumentNullException.ThrowIfNull(person); + ArgumentException.ThrowIfNullOrWhiteSpace(defName); + var list = person.Conditions; + if (list is null) + { + return false; + } + + foreach (var row in list) + { + if (row.DefName.Equals(defName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + /// /// Advances severity/progress for everyone with conditions. Same /// , person id and yield the same curve. + /// When has a DiseaseDef, stage rates replace the artificial per-day fields. /// - public static bool Tick(Person person, int peopleSeed, int dayNumber, double gameMinutes) + public static bool Tick( + Person person, + int peopleSeed, + int dayNumber, + double gameMinutes, + DefCatalog? catalog = null, + DateTime? now = null) { ArgumentNullException.ThrowIfNull(person); var list = person.Conditions; @@ -67,11 +104,24 @@ public static class HealthConditions var dayFactor = 0.5f + unit; var days = gameMinutes / (24d * 60d); var changed = false; + List? recovered = null; + var clock = now ?? DateTime.SpecifyKind(DateTime.UnixEpoch.AddDays(dayNumber), DateTimeKind.Utc); foreach (var condition in list) { - var severityDelta = (float)(condition.SeverityPerDay * dayFactor * days); - var progressDelta = (float)(condition.ProgressPerDay * dayFactor * days); + var severityPerDay = condition.SeverityPerDay; + var progressPerDay = condition.ProgressPerDay; + if (catalog is not null + && catalog.Diseases.TryGetValue(condition.DefName, out var disease) + && !disease.Abstract + && DiseaseEffects.TryStage(disease, condition.Severity, out var stage)) + { + severityPerDay = stage.SeverityPerDay; + progressPerDay = stage.ProgressPerDay; + } + + var severityDelta = (float)(severityPerDay * dayFactor * days); + var progressDelta = (float)(progressPerDay * dayFactor * days); if (severityDelta == 0f && progressDelta == 0f) { continue; @@ -87,8 +137,120 @@ public static class HealthConditions condition.Severity = nextSeverity; condition.Progress = nextProgress; changed = true; + + if (nextProgress >= 1f) + { + recovered ??= []; + recovered.Add(condition); + } } - return changed; + if (recovered is null) + { + return changed; + } + + foreach (var done in recovered) + { + list.Remove(done); + if (catalog is not null + && catalog.Diseases.TryGetValue(done.DefName, out var disease) + && !disease.Abstract + && disease.ImmunityDays > 0f) + { + DiseaseImmunities.Grant( + person, + done.DefName, + clock.AddDays(disease.ImmunityDays)); + } + } + + if (list.Count == 0) + { + person.Conditions = null; + } + + return true; + } +} + +/// Sparse post-recovery immunity list on the person. +public static class DiseaseImmunities +{ + public static void Grant(Person person, string defName, DateTime until) + { + ArgumentNullException.ThrowIfNull(person); + ArgumentException.ThrowIfNullOrWhiteSpace(defName); + + var list = person.DiseaseImmunities; + if (list is null) + { + list = []; + person.DiseaseImmunities = list; + } + + for (var i = 0; i < list.Count; i++) + { + if (list[i].DefName.Equals(defName, StringComparison.Ordinal)) + { + if (until > list[i].Until) + { + list[i] = new DiseaseImmunityRecord + { + DefName = defName, + Until = DateTime.SpecifyKind(until, DateTimeKind.Utc), + }; + } + + return; + } + } + + list.Add(new DiseaseImmunityRecord + { + DefName = defName, + Until = DateTime.SpecifyKind(until, DateTimeKind.Utc), + }); + } + + public static bool IsImmune(Person person, string defName, DateTime now) + { + ArgumentNullException.ThrowIfNull(person); + ArgumentException.ThrowIfNullOrWhiteSpace(defName); + var list = person.DiseaseImmunities; + if (list is null || list.Count == 0) + { + return false; + } + + var utc = DateTime.SpecifyKind(now, DateTimeKind.Utc); + foreach (var row in list) + { + if (row.DefName.Equals(defName, StringComparison.Ordinal) && row.Until > utc) + { + return true; + } + } + + return false; + } + + public static bool Expire(Person person, DateTime now) + { + ArgumentNullException.ThrowIfNull(person); + var list = person.DiseaseImmunities; + if (list is null || list.Count == 0) + { + return false; + } + + var utc = DateTime.SpecifyKind(now, DateTimeKind.Utc); + var removed = list.RemoveAll(row => row.Until <= utc); + if (list.Count == 0) + { + person.DiseaseImmunities = null; + } + + return removed > 0; } } diff --git a/src/HSchool.People/Roster.cs b/src/HSchool.People/Roster.cs index c2abedb..dd85709 100644 --- a/src/HSchool.People/Roster.cs +++ b/src/HSchool.People/Roster.cs @@ -93,6 +93,11 @@ public sealed record Person /// public List? Conditions { get; set; } + /// + /// Temporary disease immunities after recovery. Null when empty so people.json stays compact. + /// + public List? DiseaseImmunities { get; set; } + public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf); } diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs index 7b0d97e..3ce9ebe 100644 --- a/src/HSchool.People/Seed.cs +++ b/src/HSchool.People/Seed.cs @@ -25,6 +25,7 @@ public static class Seed public const int HealthSalt = 17; public const int MeetingSalt = 18; public const int MeetingAttendSalt = 19; + public const int DiseaseSalt = 20; /// A stream that belongs to the school rather than to one family. public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt); diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc index fc67ee3..c232479 100644 --- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -121,4 +121,6 @@ "lessonLateMinutes": 5, // Late still gets a mark when standing (stage A). False would skip the mark for late arrivals. "lessonMarkWhenLate": true, + // Scales DiseaseDef onset from weather/base (slice 14 phase 78). 0 disables natural onset. + "diseaseVectorScale": 1, } diff --git a/src/HSchool.Server/mods/core/defs/diseases/vanilla.jsonc b/src/HSchool.Server/mods/core/defs/diseases/vanilla.jsonc new file mode 100644 index 0000000..118a2b2 --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/diseases/vanilla.jsonc @@ -0,0 +1,61 @@ +[ + { + "defName": "CommonCold", + "family": "respiratory", + "incubationDays": 0.5, + "immunityDays": 14, + "baseChancePerDay": 0.01, + "coldBelowC": 5, + "coldChancePerDay": 0.08, + "rainChancePerDay": 0.03, + "snowChancePerDay": 0.04, + "stages": [ + { "minSeverity": 0, "severityPerDay": 0.25, "progressPerDay": 0.05, "lessonLearningFactor": 0.85, "stayHomeChance": 0.15, "warmthDecayFactor": 1.1 }, + { "minSeverity": 0.4, "severityPerDay": 0.1, "progressPerDay": 0.12, "lessonLearningFactor": 0.65, "stayHomeChance": 0.45, "warmthDecayFactor": 1.2 }, + { "minSeverity": 0.7, "severityPerDay": -0.05, "progressPerDay": 0.2, "lessonLearningFactor": 0.45, "stayHomeChance": 0.75, "warmthDecayFactor": 1.3 }, + ], + }, + { + "defName": "Influenza", + "family": "respiratory", + "incubationDays": 1, + "immunityDays": 30, + "baseChancePerDay": 0.004, + "coldBelowC": 0, + "coldChancePerDay": 0.06, + "rainChancePerDay": 0.02, + "snowChancePerDay": 0.05, + "stages": [ + { "minSeverity": 0, "severityPerDay": 0.35, "progressPerDay": 0.03, "lessonLearningFactor": 0.7, "stayHomeChance": 0.35, "warmthDecayFactor": 1.2 }, + { "minSeverity": 0.35, "severityPerDay": 0.15, "progressPerDay": 0.08, "lessonLearningFactor": 0.4, "stayHomeChance": 0.7, "warmthDecayFactor": 1.4 }, + { "minSeverity": 0.65, "severityPerDay": -0.08, "progressPerDay": 0.18, "lessonLearningFactor": 0.25, "stayHomeChance": 0.95, "warmthDecayFactor": 1.5 }, + ], + }, + { + "defName": "StomachBug", + "family": "gastrointestinal", + "incubationDays": 0.25, + "immunityDays": 10, + "baseChancePerDay": 0.012, + "rainChancePerDay": 0.01, + "stages": [ + { "minSeverity": 0, "severityPerDay": 0.4, "progressPerDay": 0.08, "lessonLearningFactor": 0.6, "stayHomeChance": 0.5, "warmthDecayFactor": 1 }, + { "minSeverity": 0.5, "severityPerDay": -0.1, "progressPerDay": 0.25, "lessonLearningFactor": 0.35, "stayHomeChance": 0.85, "warmthDecayFactor": 1 }, + ], + }, + { + "defName": "Otitis", + "family": "ent", + "incubationDays": 0.75, + "immunityDays": 21, + "baseChancePerDay": 0.006, + "coldBelowC": 8, + "coldChancePerDay": 0.05, + "snowChancePerDay": 0.03, + "stages": [ + { "minSeverity": 0, "severityPerDay": 0.2, "progressPerDay": 0.04, "lessonLearningFactor": 0.8, "stayHomeChance": 0.2, "warmthDecayFactor": 1.05 }, + { "minSeverity": 0.45, "severityPerDay": 0.05, "progressPerDay": 0.1, "lessonLearningFactor": 0.55, "stayHomeChance": 0.55, "warmthDecayFactor": 1.1 }, + { "minSeverity": 0.75, "severityPerDay": -0.06, "progressPerDay": 0.22, "lessonLearningFactor": 0.4, "stayHomeChance": 0.8, "warmthDecayFactor": 1.15 }, + ], + }, +] diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 7910552..cceb340 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -236,5 +236,9 @@ "GoingToPrincipal": "going to the principal", "WaitForPrincipal": "waiting at the principal's office", "GoingToParentMeeting": "going to a parent meeting", + "CommonCold": "Common cold", + "Influenza": "Influenza", + "StomachBug": "Stomach bug", + "Otitis": "Ear infection", "core": "Core", } diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index a2f673d..f00100e 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -236,5 +236,9 @@ "GoingToPrincipal": "идёт к директору", "WaitForPrincipal": "ждёт у кабинета директора", "GoingToParentMeeting": "идёт на собрание", + "CommonCold": "ОРВИ", + "Influenza": "Грипп", + "StomachBug": "Кишечная инфекция", + "Otitis": "Отит", "core": "Базовая игра", } diff --git a/src/HSchool.Simulation/AttendanceSystem.cs b/src/HSchool.Simulation/AttendanceSystem.cs index dc81974..8505f05 100644 --- a/src/HSchool.Simulation/AttendanceSystem.cs +++ b/src/HSchool.Simulation/AttendanceSystem.cs @@ -117,6 +117,15 @@ internal static class AttendanceSystem continue; } + var reason = DiseaseEffects.ShouldStayHome( + person, + school.Catalog!, + school.PeopleSeed, + school.Clock.Time) + || DiseaseEffects.StayHomeChance(person, school.Catalog!, school.Clock.Time) >= 0.5f + ? AbsenceReasons.Illness + : AbsenceReasons.Truancy; + if (AttendanceMemory.Record( person, lesson.Subject, @@ -124,7 +133,7 @@ internal static class AttendanceSystem school.Clock.Time, period, rules, - AbsenceReasons.Truancy)) + reason)) { school.RosterTalkDirty = true; } diff --git a/src/HSchool.Simulation/DiseaseSystem.cs b/src/HSchool.Simulation/DiseaseSystem.cs new file mode 100644 index 0000000..54d23bb --- /dev/null +++ b/src/HSchool.Simulation/DiseaseSystem.cs @@ -0,0 +1,163 @@ +using HSchool.Content; +using HSchool.People; + +namespace HSchool.Simulation; + +/// +/// DiseaseDef onset from weather vectors and ticks that follow stage curves. +/// Person-to-person contagion is phase 79. +/// +internal static class DiseaseSystem +{ + public static bool Apply(School school, double gameMinutes) + { + if (school.Roster is null || school.Catalog is null) + { + return false; + } + + var catalog = school.Catalog; + var changed = false; + var dayNumber = DateOnly.FromDateTime(school.Clock.Time).DayNumber; + var now = school.Clock.Time; + + foreach (var person in school.Roster.People) + { + changed |= DiseaseImmunities.Expire(person, now); + } + + if (school.LastDiseaseDay != dayNumber) + { + school.LastDiseaseDay = dayNumber; + changed |= TryOnset(school); + } + + if (gameMinutes > 0) + { + foreach (var person in school.Roster.People) + { + changed |= HealthConditions.Tick( + person, + school.PeopleSeed, + dayNumber, + gameMinutes, + catalog, + now); + } + } + + return changed; + } + + private static bool TryOnset(School school) + { + var catalog = school.Catalog!; + var rules = catalog.BehaviorRules; + if (rules is null || rules.DiseaseVectorScale <= 0f || catalog.Diseases.Count == 0) + { + return false; + } + + var weather = school.Weather; + var dayNumber = DateOnly.FromDateTime(school.Clock.Time).DayNumber; + var changed = false; + + foreach (var person in school.Roster!.People) + { + if (!person.IsStudent && !person.IsStaff) + { + continue; + } + + foreach (var disease in catalog.Diseases.Values) + { + if (disease.Abstract + || HealthConditions.Has(person, disease.DefName) + || DiseaseImmunities.IsImmune(person, disease.DefName, school.Clock.Time)) + { + continue; + } + + var chance = OnsetChance(disease, weather, rules.DiseaseVectorScale); + if (chance <= 0f) + { + continue; + } + + var salt = Seed.DiseaseSalt + Stable(disease.DefName); + var roll = Seed.Mix(school.PeopleSeed, person.Id, dayNumber, salt); + var unit = (roll & int.MaxValue) / (float)int.MaxValue; + if (unit >= chance) + { + continue; + } + + HealthConditions.Add( + person, + new HealthCondition + { + DefName = disease.DefName, + Severity = 0.05f, + Progress = 0f, + Source = SourceOf(disease, weather), + StartedAt = school.Clock.Time, + }); + changed = true; + } + } + + return changed; + } + + internal static float OnsetChance(DiseaseDef disease, OutdoorWeather weather, float vectorScale) + { + if (vectorScale <= 0f) + { + return 0f; + } + + var chance = disease.BaseChancePerDay; + if (disease.ColdBelowC is { } below && weather.TemperatureC <= below) + { + chance += disease.ColdChancePerDay; + } + + chance += weather.Precipitation switch + { + Precipitation.Rain => disease.RainChancePerDay, + Precipitation.Snow => disease.SnowChancePerDay, + _ => 0f, + }; + + return Math.Clamp(chance * vectorScale, 0f, 1f); + } + + private static string SourceOf(DiseaseDef disease, OutdoorWeather weather) + { + if (disease.ColdBelowC is { } below && weather.TemperatureC <= below) + { + return "cold"; + } + + return weather.Precipitation switch + { + Precipitation.Rain => "rain", + Precipitation.Snow => "snow", + _ => "idiopathic", + }; + } + + private static int Stable(string value) + { + unchecked + { + var hash = 17; + foreach (var character in value) + { + hash = (hash * 31) + character; + } + + return hash & 0xFFFF; + } + } +} diff --git a/src/HSchool.Simulation/HealthConditionSystem.cs b/src/HSchool.Simulation/HealthConditionSystem.cs index e3fe7c9..09ed6f1 100644 --- a/src/HSchool.Simulation/HealthConditionSystem.cs +++ b/src/HSchool.Simulation/HealthConditionSystem.cs @@ -3,24 +3,10 @@ using HSchool.People; namespace HSchool.Simulation; /// -/// Ticks medical condition severity on the roster. Contagion and DiseaseDef outbreaks are later phases. +/// Ticks medical conditions. DiseaseDef onset and stage curves live in . +/// Kept so older call sites and phase-77 tests still have a named entry. /// internal static class HealthConditionSystem { - public static bool Apply(School school, double gameMinutes) - { - if (gameMinutes <= 0 || school.Roster is null) - { - return false; - } - - var dayNumber = DateOnly.FromDateTime(school.Clock.Time).DayNumber; - var changed = false; - foreach (var person in school.Roster.People) - { - changed |= HealthConditions.Tick(person, school.PeopleSeed, dayNumber, gameMinutes); - } - - return changed; - } + public static bool Apply(School school, double gameMinutes) => DiseaseSystem.Apply(school, gameMinutes); } diff --git a/src/HSchool.Simulation/LessonLearningSystem.cs b/src/HSchool.Simulation/LessonLearningSystem.cs index bba899a..bcde170 100644 --- a/src/HSchool.Simulation/LessonLearningSystem.cs +++ b/src/HSchool.Simulation/LessonLearningSystem.cs @@ -110,6 +110,7 @@ internal static class LessonLearningSystem } textbookFactor *= TalkCircles.LessonWhisperFactor(activity.ActionId, rules); + textbookFactor *= DiseaseEffects.LessonLearningFactor(person, catalog, school.Clock.Time); IReadOnlyDictionary taught = teacherSkills.TryGetValue(lesson.TeacherId, out var found) ? found diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index c5fc3d6..6d82cdd 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -119,6 +119,9 @@ public sealed class School : IDisposable /// Last slot observed — detects leaving a lesson to finalize absents. internal DaySlot? LastAttendanceSlot { get; set; } + /// Calendar day of the last DiseaseDef onset pass (one roll per person per day). + internal int LastDiseaseDay { get; set; } = int.MinValue; + internal Dictionary Plans { get; } = new(StringComparer.Ordinal); internal Queue DecisionQueue { get; } = new(); @@ -227,6 +230,7 @@ public sealed class School : IDisposable PlanDay = null; LastDecisionSlot = null; LastAttendanceSlot = null; + LastDiseaseDay = int.MinValue; Plans.Clear(); DecisionQueue.Clear(); LoggedActivity.Clear(); diff --git a/tests/HSchool.Content.Tests/DiseaseDefTests.cs b/tests/HSchool.Content.Tests/DiseaseDefTests.cs new file mode 100644 index 0000000..dbcd4a0 --- /dev/null +++ b/tests/HSchool.Content.Tests/DiseaseDefTests.cs @@ -0,0 +1,77 @@ +using HSchool.Content; + +namespace HSchool.Content.Tests; + +public class DiseaseDefTests +{ + private readonly CatalogLoader _loader = new(); + + [Fact] + public void VanillaCore_LoadsSeveralDiseases() + { + var catalog = LoadVanilla(); + + Assert.True(catalog.Diseases.ContainsKey("CommonCold")); + Assert.True(catalog.Diseases.ContainsKey("Influenza")); + Assert.True(catalog.Diseases.ContainsKey("StomachBug")); + Assert.True(catalog.Diseases.ContainsKey("Otitis")); + + var cold = catalog.Diseases["CommonCold"]; + Assert.Equal(DiseaseFamilies.Respiratory, cold.Family); + Assert.Equal(5f, cold.ColdBelowC); + Assert.True(cold.Stages.Count >= 2); + Assert.Equal(0.5f, cold.IncubationDays); + Assert.Equal(14f, cold.ImmunityDays); + + Assert.Equal("ОРВИ", catalog.Label("ru", cold)); + Assert.Equal("Common cold", catalog.Label("en", cold)); + Assert.Equal(1f, catalog.BehaviorRules!.DiseaseVectorScale); + } + + [Fact] + public void MissingFamily_FailsTheCatalog() + { + var documents = PackDocuments.FromDirectory( + CatalogLoader.CorePackId, + Path.Combine(AppContext.BaseDirectory, "vanilla")) + .Append(PackDocuments.Def( + CatalogLoader.CorePackId, + "diseases", + "bad", + """ + { + "defName": "BadDisease", + "incubationDays": 1, + "immunityDays": 1, + "stages": [{ "minSeverity": 0, "severityPerDay": 0.1, "progressPerDay": 0.1 }] + } + """)) + .ToList(); + + var ex = Assert.Throws(() => _loader.Load([CatalogLoader.CorePackId], documents)); + Assert.Contains("family", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void MissingStages_FailsTheCatalog() + { + var documents = PackDocuments.FromDirectory( + CatalogLoader.CorePackId, + Path.Combine(AppContext.BaseDirectory, "vanilla")) + .Append(PackDocuments.Def( + CatalogLoader.CorePackId, + "diseases", + "bad-stages", + """{ "defName": "NoStages", "family": "respiratory", "incubationDays": 0, "immunityDays": 1 }""")) + .ToList(); + + var ex = Assert.Throws(() => _loader.Load([CatalogLoader.CorePackId], documents)); + Assert.Contains("stages", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + private DefCatalog LoadVanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + return _loader.Load([CatalogLoader.CorePackId], PackDocuments.FromDirectory(CatalogLoader.CorePackId, root)); + } +} diff --git a/tests/HSchool.People.Tests/DiseaseEffectTests.cs b/tests/HSchool.People.Tests/DiseaseEffectTests.cs new file mode 100644 index 0000000..9a55dab --- /dev/null +++ b/tests/HSchool.People.Tests/DiseaseEffectTests.cs @@ -0,0 +1,161 @@ +using HSchool.Content; + +namespace HSchool.People.Tests; + +public class DiseaseEffectTests +{ + [Fact] + public void TwoDiseaseDefs_YieldDifferentSeverityCurves() + { + var catalog = LoadVanilla(); + var cold = catalog.Diseases["CommonCold"]; + var flu = catalog.Diseases["Influenza"]; + var started = new DateTime(2012, 4, 3, 8, 0, 0, DateTimeKind.Utc); + var a = Blank("p1"); + var b = Blank("p1"); + HealthConditions.Add(a, Onset(cold.DefName, started, severity: 0.1f)); + HealthConditions.Add(b, Onset(flu.DefName, started, severity: 0.1f)); + + Assert.True(HealthConditions.Tick(a, peopleSeed: 7, dayNumber: 100, gameMinutes: 24 * 60, catalog, started)); + Assert.True(HealthConditions.Tick(b, peopleSeed: 7, dayNumber: 100, gameMinutes: 24 * 60, catalog, started)); + + Assert.NotEqual(a.Conditions![0].Severity, b.Conditions![0].Severity); + Assert.True(a.Conditions[0].Severity > 0.1f); + Assert.True(b.Conditions[0].Severity > 0.1f); + } + + [Fact] + public void HeavyStage_StaysHomeMoreOftenThanMild() + { + var catalog = LoadVanilla(); + var started = new DateTime(2012, 1, 1, 6, 0, 0, DateTimeKind.Utc); + var mild = Blank("pupil"); + var heavy = Blank("pupil"); + HealthConditions.Add(mild, Onset("Influenza", started, severity: 0.1f)); + HealthConditions.Add(heavy, Onset("Influenza", started, severity: 0.8f)); + + // Past incubation so stage effects apply. Same person, different days — the day salt varies the roll. + var day = started.AddDays(2); + var mildHome = 0; + var heavyHome = 0; + for (var i = 0; i < 200; i++) + { + var when = day.AddDays(i); + if (DiseaseEffects.ShouldStayHome(mild, catalog, peopleSeed: 11, when)) + { + mildHome++; + } + + if (DiseaseEffects.ShouldStayHome(heavy, catalog, peopleSeed: 11, when)) + { + heavyHome++; + } + } + + Assert.True(heavyHome > mildHome); + Assert.True(heavyHome > 100); + } + + [Fact] + public void Attendance_IllnessReason_IsNotTruancy() + { + var person = Blank("p1"); + var rules = new BehaviorDef { DefName = "Behavior", AttendanceMax = 10 }; + var time = new DateTime(2012, 4, 3, 9, 15, 0, DateTimeKind.Utc); + Assert.True(AttendanceMemory.Record( + person, + "Mathematics", + AttendanceStatuses.Absent, + time, + period: 1, + rules, + AbsenceReasons.Illness)); + + Assert.Equal(AbsenceReasons.Illness, person.Attendance![0].AbsenceReason); + Assert.NotEqual(AbsenceReasons.Truancy, person.Attendance[0].AbsenceReason); + + var json = RosterJson.Serialize(RosterDocument.From(1, new Roster([person], [], []))); + var loaded = RosterJson.Parse(json).ToRoster().People[0]; + Assert.Equal(AbsenceReasons.Illness, loaded.Attendance![0].AbsenceReason); + } + + [Fact] + public void Recovery_GrantsTemporaryImmunity() + { + var catalog = LoadVanilla(); + var started = new DateTime(2012, 4, 3, 8, 0, 0, DateTimeKind.Utc); + var person = Blank("p1"); + HealthConditions.Add( + person, + new HealthCondition + { + DefName = "CommonCold", + Severity = 0.2f, + Progress = 0.99f, + Source = "cold", + StartedAt = started, + }); + + Assert.True(HealthConditions.Tick( + person, + peopleSeed: 3, + dayNumber: 50, + gameMinutes: 24 * 60 * 3, + catalog, + started.AddDays(1))); + + Assert.Null(person.Conditions); + Assert.NotNull(person.DiseaseImmunities); + Assert.Equal("CommonCold", person.DiseaseImmunities![0].DefName); + Assert.True(person.DiseaseImmunities[0].Until > started); + Assert.True(DiseaseImmunities.IsImmune(person, "CommonCold", started.AddDays(2))); + } + + private static HealthCondition Onset(string defName, DateTime started, float severity) => + new() + { + DefName = defName, + Severity = severity, + Progress = 0f, + Source = "cold", + StartedAt = DateTime.SpecifyKind(started, DateTimeKind.Utc), + }; + + private static DefCatalog LoadVanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + return new CatalogLoader().Load( + [CatalogLoader.CorePackId], + PackDocuments.FromDirectory(CatalogLoader.CorePackId, root)); + } + + private static Person Blank(string id) + { + var cases = new CaseTable + { + Nom = id, + Gen = id, + Dat = id, + Acc = id, + Ins = id, + Pre = id, + }; + return new Person + { + Id = id, + FamilyId = "f", + Female = false, + BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Name = new PersonName(id, id, id, cases, cases, cases), + IsStudent = true, + IsStaff = false, + IsParent = false, + Numbers = new Dictionary(StringComparer.Ordinal), + Choices = new Dictionary(StringComparer.Ordinal), + Skills = new Dictionary(StringComparer.Ordinal), + Traits = [], + Needs = new Dictionary(StringComparer.Ordinal), + Opinions = new Dictionary(StringComparer.Ordinal), + }; + } +} diff --git a/tests/HSchool.Simulation.Tests/DiseaseSimulationTests.cs b/tests/HSchool.Simulation.Tests/DiseaseSimulationTests.cs new file mode 100644 index 0000000..cc1c2e1 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/DiseaseSimulationTests.cs @@ -0,0 +1,159 @@ +using Arch.Core; +using HSchool.Ai; +using HSchool.Content; +using HSchool.People; +using HSchool.Schedule; + +namespace HSchool.Simulation.Tests; + +public class DiseaseSimulationTests +{ + private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + private static readonly DateTime LessonStart = new(2012, 4, 3, 8, 30, 0, DateTimeKind.Utc); + + [Fact] + public void ColdWeather_RaisesRespiratoryOnsetVsWarmControl() + { + using var coldSchool = OpenStaffed(); + using var warmSchool = OpenStaffed(); + coldSchool.ForceWeather(new OutdoorWeather(-10f, Precipitation.Snow)); + warmSchool.ForceWeather(new OutdoorWeather(18f, Precipitation.None)); + + var coldHits = CountRespiratoryOnset(coldSchool); + var warmHits = CountRespiratoryOnset(warmSchool); + + Assert.True(coldHits > warmHits); + Assert.True(coldHits > 0); + } + + [Fact] + public void HeavyInfluenza_MarksAbsenceAsIllness() + { + var (school, _, pupilId, _) = StaffedMath(); + using (school) + { + var pupil = school.Roster!.People.First(row => row.Id == pupilId); + HealthConditions.Add( + pupil, + new HealthCondition + { + DefName = "Influenza", + Severity = 0.85f, + Progress = 0.1f, + Source = "cold", + StartedAt = LessonStart.AddDays(-3), + }); + + Assert.True(DiseaseEffects.StayHomeChance(pupil, school.Catalog!, LessonStart) >= 0.5f); + + school.PlanDay = null; + AdvanceTo(school, LessonStart.AddMinutes(-1)); + SetPlace(school, pupilId, null); + school.Clock.JumpTo(LessonStart); + AttendanceSystem.Apply(school); + school.Clock.JumpTo(LessonStart.AddMinutes(45)); + AttendanceSystem.Apply(school); + + var row = school.Roster.People.First(p => p.Id == pupilId).Attendance?.LastOrDefault(); + Assert.NotNull(row); + Assert.Equal(AttendanceStatuses.Absent, row!.Status); + Assert.Equal(AbsenceReasons.Illness, row.AbsenceReason); + Assert.NotEqual(AbsenceReasons.Truancy, row.AbsenceReason); + } + } + + private static int CountRespiratoryOnset(School school) + { + // Force the day-onset pass with fixed weather. + school.LastDiseaseDay = int.MinValue; + DiseaseSystem.Apply(school, gameMinutes: 0); + return school.Roster!.People.Count(person => + person.Conditions is not null + && person.Conditions.Any(row => + row.DefName is "CommonCold" or "Influenza" or "Otitis")); + } + + private static void SetPlace(School school, string personId, string? nodeId) + { + var query = new QueryDescription().WithAll(); + school.World.Query( + in query, + (ref PersonIdentity identity, ref Presence presence) => + { + if (!identity.Id.Equals(personId, StringComparison.Ordinal)) + { + return; + } + + presence = nodeId is null + ? Presence.OffCampus + : new Presence(nodeId, 0f, nodeId, false, []); + }); + } + + private static void AdvanceTo(School school, DateTime until) + { + while (school.Clock.Time < until) + { + school.Tick(0.2d, 5d); + } + } + + private static (School School, string Room, string PupilId, string TeacherId) StaffedMath() + { + var (catalog, map) = Vanilla(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", TuesdayMorning); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", TuesdayMorning); + var hired = Staffing.Hire(catalog, map, roster, pool, pool.Applicants[0].Person.Id, Staffing.TeacherPosition, 1_000_000f); + Assert.Equal(StaffingError.None, hired.Error); + roster = hired.Roster; + pool = hired.Pool; + var hiredId = roster.People.First(person => person.IsStaff).Id; + var schoolClass = roster.Classes.First(row => + row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104"); + var school = School.Create(1, "Болезни", TuesdayMorning, catalog, map); + school.InstallPeople(roster, seed: 1, "Russia", pool); + school.SetTimetable(new Timetable( + [new LessonPlacement(schoolClass.Id, "Mathematics", hiredId, schoolClass.RoomId, Day: 1, Period: 1)], + [])); + school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); + var pupil = schoolClass.PupilIds + .Select(id => school.Roster!.People.First(person => person.Id == id)) + .First(person => !person.Traits.Contains("Lazy")); + return (school, schoolClass.RoomId, pupil.Id, hiredId); + } + + private static School OpenStaffed() + { + var start = TuesdayMorning; + var (catalog, map) = Vanilla(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 42, "Russia", start); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 42, "Russia", start); + var school = School.Create(1, "Болезни", start, catalog, map); + school.InstallPeople(roster, seed: 42, "Russia", pool); + school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); + return school; + } + + private static (DefCatalog Catalog, MapLayout Map) Vanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + var documents = new List(); + foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories)) + { + if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase) + && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var relative = Path.GetRelativePath(root, path).Replace('\\', '/'); + documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path))); + } + + var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); + var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); + Assert.NotNull(map); + return (catalog, map); + } +}