From b3e52e5faa2c8793838f0db432a81fb992c0c4a7 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 20:22:10 +0300 Subject: [PATCH] Add wet condition and disease aftereffect (phase 84). --- docs/phases/15-fallout/84-wet-aftereffect.md | 20 +-- src/HSchool.Content/DiseaseDefValidator.cs | 7 +- src/HSchool.Content/DiseaseDefs.cs | 22 +++ src/HSchool.Content/PeopleDefValidator.cs | 7 + src/HSchool.Content/PeopleDefs.cs | 8 ++ src/HSchool.People/HealthCondition.cs | 34 ++++- src/HSchool.People/Seed.cs | 1 + .../mods/core/defs/behavior/rules.jsonc | 3 + .../mods/core/defs/diseases/vanilla.jsonc | 27 ++++ .../mods/core/localizations/en.jsonc | 2 + .../mods/core/localizations/ru.jsonc | 2 + src/HSchool.Simulation/DiseaseSystem.cs | 91 +++++++++++- .../HSchool.Content.Tests/DiseaseDefTests.cs | 9 ++ .../WetAftereffectTests.cs | 132 ++++++++++++++++++ .../WetAftereffectTests.cs | 109 +++++++++++++++ 15 files changed, 455 insertions(+), 19 deletions(-) create mode 100644 tests/HSchool.People.Tests/WetAftereffectTests.cs create mode 100644 tests/HSchool.Simulation.Tests/WetAftereffectTests.cs diff --git a/docs/phases/15-fallout/84-wet-aftereffect.md b/docs/phases/15-fallout/84-wet-aftereffect.md index f061002..b9092f9 100644 --- a/docs/phases/15-fallout/84-wet-aftereffect.md +++ b/docs/phases/15-fallout/84-wet-aftereffect.md @@ -12,20 +12,20 @@ ## Задачи -- [ ] Короткое состояние «мокрый» после улицы под дождём/снегом (или эквивалент на слое 14) -- [ ] Мокрый повышает вектор респираторных болезней (поля def) -- [ ] Aftereffect после тяжёлой болезни — короткий ослабленный хвост (урок/нужды), данные -- [ ] Сход сами / у медсестры не обязателен для мокрого (высох), aftereffect — по кривой -- [ ] Без новой вкладки, если здоровье (80) уже показывает условия -- [ ] Опционально: один info «средний след за период» без аттестации — только если не раздувает +- [x] Короткое состояние «мокрый» после улицы под дождём/снегом (или эквивалент на слое 14) +- [x] Мокрый повышает вектор респираторных болезней (поля def) +- [x] Aftereffect после тяжёлой болезни — короткий ослабленный хвост (урок/нужды), данные +- [x] Сход сами / у медсестры не обязателен для мокрого (высох), aftereffect — по кривой +- [x] Без новой вкладки, если здоровье (80) уже показывает условия +- [x] Опционально: один info «средний след за период» без аттестации — только если не раздувает фазу; иначе не делать ## Тесты, без которых фаза не закрыта -- [ ] Приход под дождём чаще даёт «мокрый», чем ясная погода -- [ ] Мокрый повышает шанс ванильной респираторной относительно сухого контроля -- [ ] После тяжёлой болезни aftereffect появляется и сходит за ограниченное время -- [ ] Сейв сохраняет оба вида условий +- [x] Приход под дождём чаще даёт «мокрый», чем ясная погода +- [x] Мокрый повышает шанс ванильной респираторной относительно сухого контроля +- [x] После тяжёлой болезни aftereffect появляется и сходит за ограниченное время +- [x] Сейв сохраняет оба вида условий ## Критерий готовности diff --git a/src/HSchool.Content/DiseaseDefValidator.cs b/src/HSchool.Content/DiseaseDefValidator.cs index d2004e5..d9b2357 100644 --- a/src/HSchool.Content/DiseaseDefValidator.cs +++ b/src/HSchool.Content/DiseaseDefValidator.cs @@ -7,6 +7,7 @@ internal static class DiseaseDefValidator DiseaseFamilies.Respiratory, DiseaseFamilies.Gastrointestinal, DiseaseFamilies.Ent, + DiseaseFamilies.Condition, }; public static void Validate(DefCatalog catalog) @@ -47,10 +48,14 @@ internal static class DiseaseDefValidator || def.ColdChancePerDay < 0f || def.RainChancePerDay < 0f || def.SnowChancePerDay < 0f + || def.WetChancePerDay < 0f + || def.AftereffectMinSeverity < 0f + || def.AftereffectMinSeverity > 1f || def.NodeContagionChancePerDay < 0f || def.ClassContagionChancePerDay < 0f) { - throw new ContentLoadException($"DiseaseDef '{def.DefName}' chance fields cannot be negative."); + throw new ContentLoadException( + $"DiseaseDef '{def.DefName}' chance/aftereffect fields must be non-negative (aftereffect 0–1)."); } if (def.NodeContagionChancePerDay > 1f || def.ClassContagionChancePerDay > 1f) diff --git a/src/HSchool.Content/DiseaseDefs.cs b/src/HSchool.Content/DiseaseDefs.cs index 6f282f1..8c1a22b 100644 --- a/src/HSchool.Content/DiseaseDefs.cs +++ b/src/HSchool.Content/DiseaseDefs.cs @@ -8,6 +8,16 @@ public static class DiseaseFamilies public const string Gastrointestinal = "gastrointestinal"; public const string Ent = "ent"; + + /// + /// Non-infectious short conditions (wet, post-disease aftereffect). Same HealthCondition list, + /// but onset/contagion/immunity skips this family. + /// + public const string Condition = "condition"; + + public const string WetDefName = "Wet"; + + public const string AftereffectDefName = "DiseaseAftereffect"; } /// One severity band on a curve. @@ -56,6 +66,18 @@ public sealed class DiseaseDef : Def public float SnowChancePerDay { get; init; } + /// + /// Extra daily onset chance while the person has the Wet condition. Packs without the field + /// add nothing. + /// + public float WetChancePerDay { get; init; } + + /// + /// On recovery at or above this severity, grant . + /// 0 — no aftereffect. + /// + public float AftereffectMinSeverity { get; init; } + /// Days of immunity to this def after recovery. public float ImmunityDays { get; init; } diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 5363bdd..bf9e782 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -788,6 +788,13 @@ internal static class PeopleDefValidator $"BehaviorDef '{behavior.DefName}' diseaseVectorScale cannot be negative."); } + if (behavior.WetRainChance < 0f || behavior.WetRainChance > 1f + || behavior.WetSnowChance < 0f || behavior.WetSnowChance > 1f) + { + throw new ContentLoadException( + $"BehaviorDef '{behavior.DefName}' wetRainChance and wetSnowChance must be 0–1."); + } + if (behavior.DiseaseOutbreakMinNewCases < 0) { throw new ContentLoadException( diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index ebdb702..33c6fc8 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -688,6 +688,14 @@ public sealed class BehaviorDef : Def /// public float DiseaseVectorScale { get; init; } = 1f; + /// + /// Per-person chance to pick up Wet when coming to school under rain. 0 disables. + /// + public float WetRainChance { get; init; } + + /// Same as under snow. + public float WetSnowChance { get; init; } + /// /// New contagion cases in one day at or above this raise a diseaseOutbreak world event. /// 0 disables the toast. diff --git a/src/HSchool.People/HealthCondition.cs b/src/HSchool.People/HealthCondition.cs index 96603e3..2d14175 100644 --- a/src/HSchool.People/HealthCondition.cs +++ b/src/HSchool.People/HealthCondition.cs @@ -153,16 +153,42 @@ public static class HealthConditions 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) + if (catalog is null + || !catalog.Diseases.TryGetValue(done.DefName, out var disease) + || disease.Abstract) + { + continue; + } + + // Condition-family rows (wet / aftereffect) dry off without immunity or a second tail. + if (disease.Family.Equals(DiseaseFamilies.Condition, StringComparison.Ordinal)) + { + continue; + } + + if (disease.ImmunityDays > 0f) { DiseaseImmunities.Grant( person, done.DefName, clock.AddDays(disease.ImmunityDays)); } + + if (disease.AftereffectMinSeverity > 0f + && done.Severity + 1e-6f >= disease.AftereffectMinSeverity + && !Has(person, DiseaseFamilies.AftereffectDefName) + && catalog.Diseases.ContainsKey(DiseaseFamilies.AftereffectDefName)) + { + list.Add(new HealthCondition + { + DefName = DiseaseFamilies.AftereffectDefName, + Severity = 0.45f, + Progress = 0f, + Source = done.DefName, + StartedAt = clock, + }); + changed = true; + } } if (list.Count == 0) diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs index 1348a85..76ae971 100644 --- a/src/HSchool.People/Seed.cs +++ b/src/HSchool.People/Seed.cs @@ -28,6 +28,7 @@ public static class Seed public const int DiseaseSalt = 20; public const int ContagionSalt = 21; public const int GradeTrailSalt = 22; + public const int WetSalt = 23; /// 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 2651dd7..b0aa84d 100644 --- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -130,6 +130,9 @@ "gradeTrailSummonChance": 0.35, // Scales DiseaseDef onset from weather/base (slice 14 phase 78). 0 disables natural onset. "diseaseVectorScale": 1, + // Commute under rain/snow → short Wet condition (slice 15 phase 84). Clear street stays dry. + "wetRainChance": 0.75, + "wetSnowChance": 0.85, // Contagion cases in one day at or above this raise diseaseOutbreak (slice 14 phase 79). "diseaseOutbreakMinNewCases": 3, } diff --git a/src/HSchool.Server/mods/core/defs/diseases/vanilla.jsonc b/src/HSchool.Server/mods/core/defs/diseases/vanilla.jsonc index e63bf35..0b82857 100644 --- a/src/HSchool.Server/mods/core/defs/diseases/vanilla.jsonc +++ b/src/HSchool.Server/mods/core/defs/diseases/vanilla.jsonc @@ -1,4 +1,24 @@ [ + { + "defName": "Wet", + "family": "condition", + "incubationDays": 0, + "immunityDays": 0, + // Dries in under a day on its own — nurse is not required. + "stages": [ + { "minSeverity": 0, "severityPerDay": -0.4, "progressPerDay": 2.5, "lessonLearningFactor": 1, "stayHomeChance": 0, "warmthDecayFactor": 1.05 }, + ], + }, + { + "defName": "DiseaseAftereffect", + "family": "condition", + "incubationDays": 0, + "immunityDays": 0, + // Short post-illness weakness: lighter lessons, slightly hungrier warmth. + "stages": [ + { "minSeverity": 0, "severityPerDay": -0.15, "progressPerDay": 0.55, "lessonLearningFactor": 0.8, "stayHomeChance": 0, "warmthDecayFactor": 1.12 }, + ], + }, { "defName": "CommonCold", "family": "respiratory", @@ -9,6 +29,8 @@ "coldChancePerDay": 0.08, "rainChancePerDay": 0.03, "snowChancePerDay": 0.04, + "wetChancePerDay": 0.07, + "aftereffectMinSeverity": 0.9, "nodeContagionChancePerDay": 0.55, "classContagionChancePerDay": 0.25, "contagiousDuringIncubation": true, @@ -28,6 +50,8 @@ "coldChancePerDay": 0.06, "rainChancePerDay": 0.02, "snowChancePerDay": 0.05, + "wetChancePerDay": 0.05, + "aftereffectMinSeverity": 0.55, "nodeContagionChancePerDay": 0.65, "classContagionChancePerDay": 0.35, "contagiousDuringIncubation": true, @@ -44,6 +68,7 @@ "immunityDays": 10, "baseChancePerDay": 0.012, "rainChancePerDay": 0.01, + "aftereffectMinSeverity": 0.75, "nodeContagionChancePerDay": 0.3, "classContagionChancePerDay": 0, "contagiousDuringIncubation": false, @@ -61,6 +86,8 @@ "coldBelowC": 8, "coldChancePerDay": 0.05, "snowChancePerDay": 0.03, + "wetChancePerDay": 0.04, + "aftereffectMinSeverity": 0.85, "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 }, diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index bc178fa..fbbf2fc 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -249,5 +249,7 @@ "Influenza": "Influenza", "StomachBug": "Stomach bug", "Otitis": "Ear infection", + "Wet": "Wet", + "DiseaseAftereffect": "Recovering", "core": "Core", } diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index dfb6365..73829c0 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -249,5 +249,7 @@ "Influenza": "Грипп", "StomachBug": "Кишечная инфекция", "Otitis": "Отит", + "Wet": "Мокрый", + "DiseaseAftereffect": "Ослабление после болезни", "core": "Базовая игра", } diff --git a/src/HSchool.Simulation/DiseaseSystem.cs b/src/HSchool.Simulation/DiseaseSystem.cs index afcc1be..742289c 100644 --- a/src/HSchool.Simulation/DiseaseSystem.cs +++ b/src/HSchool.Simulation/DiseaseSystem.cs @@ -37,6 +37,7 @@ internal static class DiseaseSystem if (school.LastDiseaseDay != dayNumber) { school.LastDiseaseDay = dayNumber; + changed |= TryWet(school); changed |= TryOnset(school); changed |= TryContagion(school); } @@ -58,6 +59,70 @@ internal static class DiseaseSystem return changed; } + private static bool TryWet(School school) + { + var catalog = school.Catalog!; + var rules = catalog.BehaviorRules; + if (rules is null || !catalog.Diseases.ContainsKey(DiseaseFamilies.WetDefName)) + { + return false; + } + + var chance = school.Weather.Precipitation switch + { + Precipitation.Rain => rules.WetRainChance, + Precipitation.Snow => rules.WetSnowChance, + _ => 0f, + }; + if (chance <= 0f) + { + return false; + } + + var dayNumber = DateOnly.FromDateTime(school.Clock.Time).DayNumber; + var source = school.Weather.Precipitation == Precipitation.Snow ? "snow" : "rain"; + var changed = false; + + foreach (var person in school.Roster!.People) + { + if (!person.IsStudent && !person.IsStaff) + { + continue; + } + + if (school.Plans.TryGetValue(person.Id, out var plan) && !plan.Comes) + { + continue; + } + + if (HealthConditions.Has(person, DiseaseFamilies.WetDefName)) + { + continue; + } + + var roll = Seed.Mix(school.PeopleSeed, person.Id, dayNumber, Seed.WetSalt); + var unit = (roll & int.MaxValue) / (float)int.MaxValue; + if (unit >= chance) + { + continue; + } + + HealthConditions.Add( + person, + new HealthCondition + { + DefName = DiseaseFamilies.WetDefName, + Severity = 0.65f, + Progress = 0f, + Source = source, + StartedAt = school.Clock.Time, + }); + changed = true; + } + + return changed; + } + private static bool TryOnset(School school) { var catalog = school.Catalog!; @@ -78,16 +143,19 @@ internal static class DiseaseSystem continue; } + var wet = HealthConditions.Has(person, DiseaseFamilies.WetDefName); + foreach (var disease in catalog.Diseases.Values) { if (disease.Abstract + || disease.Family.Equals(DiseaseFamilies.Condition, StringComparison.Ordinal) || HealthConditions.Has(person, disease.DefName) || DiseaseImmunities.IsImmune(person, disease.DefName, school.Clock.Time)) { continue; } - var chance = OnsetChance(disease, weather, rules.DiseaseVectorScale); + var chance = OnsetChance(disease, weather, rules.DiseaseVectorScale, wet); if (chance <= 0f) { continue; @@ -108,7 +176,7 @@ internal static class DiseaseSystem DefName = disease.DefName, Severity = 0.05f, Progress = 0f, - Source = SourceOf(disease, weather), + Source = SourceOf(disease, weather, wet), StartedAt = school.Clock.Time, }); changed = true; @@ -145,6 +213,7 @@ internal static class DiseaseSystem { if (!catalog.Diseases.TryGetValue(condition.DefName, out var disease) || disease.Abstract + || disease.Family.Equals(DiseaseFamilies.Condition, StringComparison.Ordinal) || !CanTransmit(condition, disease, now)) { continue; @@ -348,7 +417,11 @@ internal static class DiseaseSystem return map; } - internal static float OnsetChance(DiseaseDef disease, OutdoorWeather weather, float vectorScale) + internal static float OnsetChance( + DiseaseDef disease, + OutdoorWeather weather, + float vectorScale, + bool wet = false) { if (vectorScale <= 0f) { @@ -368,11 +441,21 @@ internal static class DiseaseSystem _ => 0f, }; + if (wet) + { + chance += disease.WetChancePerDay; + } + return Math.Clamp(chance * vectorScale, 0f, 1f); } - private static string SourceOf(DiseaseDef disease, OutdoorWeather weather) + private static string SourceOf(DiseaseDef disease, OutdoorWeather weather, bool wet) { + if (wet && disease.WetChancePerDay > 0f) + { + return "wet"; + } + if (disease.ColdBelowC is { } below && weather.TemperatureC <= below) { return "cold"; diff --git a/tests/HSchool.Content.Tests/DiseaseDefTests.cs b/tests/HSchool.Content.Tests/DiseaseDefTests.cs index 161f531..4f76f5b 100644 --- a/tests/HSchool.Content.Tests/DiseaseDefTests.cs +++ b/tests/HSchool.Content.Tests/DiseaseDefTests.cs @@ -15,6 +15,8 @@ public class DiseaseDefTests Assert.True(catalog.Diseases.ContainsKey("Influenza")); Assert.True(catalog.Diseases.ContainsKey("StomachBug")); Assert.True(catalog.Diseases.ContainsKey("Otitis")); + Assert.True(catalog.Diseases.ContainsKey(DiseaseFamilies.WetDefName)); + Assert.True(catalog.Diseases.ContainsKey(DiseaseFamilies.AftereffectDefName)); var cold = catalog.Diseases["CommonCold"]; Assert.Equal(DiseaseFamilies.Respiratory, cold.Family); @@ -22,6 +24,9 @@ public class DiseaseDefTests Assert.True(cold.Stages.Count >= 2); Assert.Equal(0.5f, cold.IncubationDays); Assert.Equal(14f, cold.ImmunityDays); + Assert.Equal(0.07f, cold.WetChancePerDay); + Assert.Equal(0.55f, catalog.Diseases["Influenza"].AftereffectMinSeverity); + Assert.Equal(DiseaseFamilies.Condition, catalog.Diseases[DiseaseFamilies.WetDefName].Family); Assert.Equal(0.55f, cold.NodeContagionChancePerDay); Assert.Equal(0.25f, cold.ClassContagionChancePerDay); Assert.True(cold.ContagiousDuringIncubation); @@ -30,7 +35,11 @@ public class DiseaseDefTests Assert.Equal("ОРВИ", catalog.Label("ru", cold)); Assert.Equal("Common cold", catalog.Label("en", cold)); + Assert.Equal("Мокрый", catalog.Label("ru", catalog.Diseases[DiseaseFamilies.WetDefName])); + Assert.Equal("Wet", catalog.Label("en", catalog.Diseases[DiseaseFamilies.WetDefName])); Assert.Equal(1f, catalog.BehaviorRules!.DiseaseVectorScale); + Assert.Equal(0.75f, catalog.BehaviorRules.WetRainChance); + Assert.Equal(0.85f, catalog.BehaviorRules.WetSnowChance); Assert.Equal(3, catalog.BehaviorRules.DiseaseOutbreakMinNewCases); Assert.True(catalog.Events.ContainsKey("DiseaseOutbreak")); Assert.Equal(EventTriggers.DiseaseOutbreak, catalog.Events["DiseaseOutbreak"].Trigger); diff --git a/tests/HSchool.People.Tests/WetAftereffectTests.cs b/tests/HSchool.People.Tests/WetAftereffectTests.cs new file mode 100644 index 0000000..eaf400e --- /dev/null +++ b/tests/HSchool.People.Tests/WetAftereffectTests.cs @@ -0,0 +1,132 @@ +using HSchool.Content; + +namespace HSchool.People.Tests; + +public class WetAftereffectTests +{ + [Fact] + public void HeavyDisease_LeavesAftereffectThatClears() + { + 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 = "Influenza", + Severity = 0.85f, + Progress = 0.99f, + Source = "cold", + StartedAt = started, + }); + + Assert.True(HealthConditions.Tick( + person, + peopleSeed: 3, + dayNumber: 50, + gameMinutes: 24 * 60, + catalog, + started.AddDays(3))); + + Assert.NotNull(person.Conditions); + Assert.Contains(person.Conditions!, row => row.DefName == DiseaseFamilies.AftereffectDefName); + Assert.DoesNotContain(person.Conditions!, row => row.DefName == "Influenza"); + Assert.True(DiseaseImmunities.IsImmune(person, "Influenza", started.AddDays(4))); + + // Aftereffect clears on its stage curve within a few game days — not a year-long chronicle. + for (var day = 0; day < 14 && person.Conditions is { Count: > 0 }; day++) + { + HealthConditions.Tick( + person, + peopleSeed: 3, + dayNumber: 51 + day, + gameMinutes: 24 * 60, + catalog, + started.AddDays(4 + day)); + } + + Assert.True( + person.Conditions is null + || person.Conditions.All(row => row.DefName != DiseaseFamilies.AftereffectDefName)); + } + + [Fact] + public void RosterJson_RoundTripsWetAndAftereffect() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var pupil = roster.People.First(person => person.IsStudent && !person.IsParent); + var started = new DateTime(2012, 4, 3, 9, 0, 0, DateTimeKind.Utc); + HealthConditions.Add( + pupil, + new HealthCondition + { + DefName = DiseaseFamilies.WetDefName, + Severity = 0.6f, + Progress = 0.1f, + Source = "rain", + StartedAt = started, + }); + HealthConditions.Add( + pupil, + new HealthCondition + { + DefName = DiseaseFamilies.AftereffectDefName, + Severity = 0.4f, + Progress = 0.2f, + Source = "Influenza", + StartedAt = started.AddDays(-1), + }); + + var json = RosterJson.Serialize(RosterDocument.From(1, roster)); + Assert.Contains($"\"defName\": \"{DiseaseFamilies.WetDefName}\"", json, StringComparison.Ordinal); + Assert.Contains($"\"defName\": \"{DiseaseFamilies.AftereffectDefName}\"", json, StringComparison.Ordinal); + + var loaded = RosterJson.Parse(json).ToRoster(); + var loadedPupil = loaded.People.First(person => person.Id.Equals(pupil.Id, StringComparison.Ordinal)); + Assert.NotNull(loadedPupil.Conditions); + Assert.Equal(2, loadedPupil.Conditions!.Count); + Assert.Contains(loadedPupil.Conditions, row => row.DefName == DiseaseFamilies.WetDefName && row.Source == "rain"); + Assert.Contains( + loadedPupil.Conditions, + row => row.DefName == DiseaseFamilies.AftereffectDefName && row.Source == "Influenza"); + } + + 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/WetAftereffectTests.cs b/tests/HSchool.Simulation.Tests/WetAftereffectTests.cs new file mode 100644 index 0000000..9833ce2 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/WetAftereffectTests.cs @@ -0,0 +1,109 @@ +using HSchool.Ai; +using HSchool.Content; +using HSchool.People; + +namespace HSchool.Simulation.Tests; + +public class WetAftereffectTests +{ + private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + + [Fact] + public void RainArrival_AssignsWetMoreThanClear() + { + using var rainSchool = OpenStaffed(seed: 42); + using var clearSchool = OpenStaffed(seed: 42); + rainSchool.ForceWeather(new OutdoorWeather(12f, Precipitation.Rain)); + clearSchool.ForceWeather(new OutdoorWeather(12f, Precipitation.None)); + + var rainWet = CountWet(rainSchool); + var clearWet = CountWet(clearSchool); + + Assert.True(rainWet > clearWet); + Assert.True(rainWet > 0); + } + + [Fact] + public void Wet_RaisesRespiratoryOnsetVsDryControl() + { + using var wetSchool = OpenStaffed(seed: 7); + using var drySchool = OpenStaffed(seed: 7); + wetSchool.ForceWeather(new OutdoorWeather(14f, Precipitation.None)); + drySchool.ForceWeather(new OutdoorWeather(14f, Precipitation.None)); + + foreach (var person in wetSchool.Roster!.People.Where(row => row.IsStudent || row.IsStaff)) + { + HealthConditions.Add( + person, + new HealthCondition + { + DefName = DiseaseFamilies.WetDefName, + Severity = 0.7f, + Progress = 0f, + Source = "rain", + StartedAt = TuesdayMorning, + }); + } + + var wetHits = CountRespiratoryOnset(wetSchool); + var dryHits = CountRespiratoryOnset(drySchool); + + Assert.True(wetHits > dryHits); + Assert.True(wetHits > 0); + } + + private static int CountWet(School school) + { + 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 == DiseaseFamilies.WetDefName)); + } + + private static int CountRespiratoryOnset(School school) + { + 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 School OpenStaffed(int seed) + { + var (catalog, map) = Vanilla(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: seed, "Russia", TuesdayMorning); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: seed, "Russia", TuesdayMorning); + var school = School.Create(1, "Мокрый", TuesdayMorning, catalog, map); + school.InstallPeople(roster, seed: seed, "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); + } +}