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);
+ }
+}