Merge branch 'phase/84-wet-aftereffect'

This commit is contained in:
Leonid Pershin
2026-08-21 20:35:51 +03:00
16 changed files with 456 additions and 20 deletions
+10 -10
View File
@@ -12,20 +12,20 @@
## Задачи ## Задачи
- [ ] Короткое состояние «мокрый» после улицы под дождём/снегом (или эквивалент на слое 14) - [x] Короткое состояние «мокрый» после улицы под дождём/снегом (или эквивалент на слое 14)
- [ ] Мокрый повышает вектор респираторных болезней (поля def) - [x] Мокрый повышает вектор респираторных болезней (поля def)
- [ ] Aftereffect после тяжёлой болезни — короткий ослабленный хвост (урок/нужды), данные - [x] Aftereffect после тяжёлой болезни — короткий ослабленный хвост (урок/нужды), данные
- [ ] Сход сами / у медсестры не обязателен для мокрого (высох), aftereffect — по кривой - [x] Сход сами / у медсестры не обязателен для мокрого (высох), aftereffect — по кривой
- [ ] Без новой вкладки, если здоровье (80) уже показывает условия - [x] Без новой вкладки, если здоровье (80) уже показывает условия
- [ ] Опционально: один info «средний след за период» без аттестации — только если не раздувает - [x] Опционально: один info «средний след за период» без аттестации — только если не раздувает
фазу; иначе не делать фазу; иначе не делать
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Приход под дождём чаще даёт «мокрый», чем ясная погода - [x] Приход под дождём чаще даёт «мокрый», чем ясная погода
- [ ] Мокрый повышает шанс ванильной респираторной относительно сухого контроля - [x] Мокрый повышает шанс ванильной респираторной относительно сухого контроля
- [ ] После тяжёлой болезни aftereffect появляется и сходит за ограниченное время - [x] После тяжёлой болезни aftereffect появляется и сходит за ограниченное время
- [ ] Сейв сохраняет оба вида условий - [x] Сейв сохраняет оба вида условий
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -30,6 +30,6 @@
| Фаза | Статус | Зачем | | Фаза | Статус | Зачем |
| --- | --- | --- | | --- | --- | --- |
| [84. Мокрый и aftereffect](84-wet-aftereffect.md) | 🔄 | Дождь → мокрый; хвост болезни | | [84. Мокрый и aftereffect](84-wet-aftereffect.md) | | Дождь → мокрый; хвост болезни |
81 стоит на 44 и 77; 82 — на 78 и 73/48; 83 — на 68/70/76; 84 — на 78 и 32. 81 стоит на 44 и 77; 82 — на 78 и 73/48; 83 — на 68/70/76; 84 — на 78 и 32.
+6 -1
View File
@@ -7,6 +7,7 @@ internal static class DiseaseDefValidator
DiseaseFamilies.Respiratory, DiseaseFamilies.Respiratory,
DiseaseFamilies.Gastrointestinal, DiseaseFamilies.Gastrointestinal,
DiseaseFamilies.Ent, DiseaseFamilies.Ent,
DiseaseFamilies.Condition,
}; };
public static void Validate(DefCatalog catalog) public static void Validate(DefCatalog catalog)
@@ -47,10 +48,14 @@ internal static class DiseaseDefValidator
|| def.ColdChancePerDay < 0f || def.ColdChancePerDay < 0f
|| def.RainChancePerDay < 0f || def.RainChancePerDay < 0f
|| def.SnowChancePerDay < 0f || def.SnowChancePerDay < 0f
|| def.WetChancePerDay < 0f
|| def.AftereffectMinSeverity < 0f
|| def.AftereffectMinSeverity > 1f
|| def.NodeContagionChancePerDay < 0f || def.NodeContagionChancePerDay < 0f
|| def.ClassContagionChancePerDay < 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 01).");
} }
if (def.NodeContagionChancePerDay > 1f || def.ClassContagionChancePerDay > 1f) if (def.NodeContagionChancePerDay > 1f || def.ClassContagionChancePerDay > 1f)
+22
View File
@@ -8,6 +8,16 @@ public static class DiseaseFamilies
public const string Gastrointestinal = "gastrointestinal"; public const string Gastrointestinal = "gastrointestinal";
public const string Ent = "ent"; public const string Ent = "ent";
/// <summary>
/// Non-infectious short conditions (wet, post-disease aftereffect). Same HealthCondition list,
/// but onset/contagion/immunity skips this family.
/// </summary>
public const string Condition = "condition";
public const string WetDefName = "Wet";
public const string AftereffectDefName = "DiseaseAftereffect";
} }
/// <summary>One severity band on a <see cref="DiseaseDef"/> curve.</summary> /// <summary>One severity band on a <see cref="DiseaseDef"/> curve.</summary>
@@ -65,6 +75,18 @@ public sealed class DiseaseDef : Def
public float SnowChancePerDay { get; init; } public float SnowChancePerDay { get; init; }
/// <summary>
/// Extra daily onset chance while the person has the Wet condition. Packs without the field
/// add nothing.
/// </summary>
public float WetChancePerDay { get; init; }
/// <summary>
/// On recovery at or above this severity, grant <see cref="DiseaseFamilies.AftereffectDefName"/>.
/// 0 — no aftereffect.
/// </summary>
public float AftereffectMinSeverity { get; init; }
/// <summary>Days of immunity to this def after recovery.</summary> /// <summary>Days of immunity to this def after recovery.</summary>
public float ImmunityDays { get; init; } public float ImmunityDays { get; init; }
@@ -788,6 +788,13 @@ internal static class PeopleDefValidator
$"BehaviorDef '{behavior.DefName}' diseaseVectorScale cannot be negative."); $"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 01.");
}
if (behavior.DiseaseOutbreakMinNewCases < 0) if (behavior.DiseaseOutbreakMinNewCases < 0)
{ {
throw new ContentLoadException( throw new ContentLoadException(
+8
View File
@@ -688,6 +688,14 @@ public sealed class BehaviorDef : Def
/// </summary> /// </summary>
public float DiseaseVectorScale { get; init; } = 1f; public float DiseaseVectorScale { get; init; } = 1f;
/// <summary>
/// Per-person chance to pick up Wet when coming to school under rain. 0 disables.
/// </summary>
public float WetRainChance { get; init; }
/// <summary>Same as <see cref="WetRainChance"/> under snow.</summary>
public float WetSnowChance { get; init; }
/// <summary> /// <summary>
/// New contagion cases in one day at or above this raise a diseaseOutbreak world event. /// New contagion cases in one day at or above this raise a diseaseOutbreak world event.
/// 0 disables the toast. /// 0 disables the toast.
+30 -4
View File
@@ -153,16 +153,42 @@ public static class HealthConditions
foreach (var done in recovered) foreach (var done in recovered)
{ {
list.Remove(done); list.Remove(done);
if (catalog is not null if (catalog is null
&& catalog.Diseases.TryGetValue(done.DefName, out var disease) || !catalog.Diseases.TryGetValue(done.DefName, out var disease)
&& !disease.Abstract || disease.Abstract)
&& disease.ImmunityDays > 0f) {
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( DiseaseImmunities.Grant(
person, person,
done.DefName, done.DefName,
clock.AddDays(disease.ImmunityDays)); 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) if (list.Count == 0)
+1
View File
@@ -29,6 +29,7 @@ public static class Seed
public const int ContagionSalt = 21; public const int ContagionSalt = 21;
public const int GradeTrailSalt = 22; public const int GradeTrailSalt = 22;
public const int NurseSalt = 23; public const int NurseSalt = 23;
public const int WetSalt = 24;
/// <summary>A stream that belongs to the school rather than to one family.</summary> /// <summary>A stream that belongs to the school rather than to one family.</summary>
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt); public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
@@ -130,6 +130,9 @@
"gradeTrailSummonChance": 0.35, "gradeTrailSummonChance": 0.35,
// Scales DiseaseDef onset from weather/base (slice 14 phase 78). 0 disables natural onset. // Scales DiseaseDef onset from weather/base (slice 14 phase 78). 0 disables natural onset.
"diseaseVectorScale": 1, "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). // Contagion cases in one day at or above this raise diseaseOutbreak (slice 14 phase 79).
"diseaseOutbreakMinNewCases": 3, "diseaseOutbreakMinNewCases": 3,
// Nurse tend scales with Medicine skill (slice 14 phase 80). // Nurse tend scales with Medicine skill (slice 14 phase 80).
@@ -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", "defName": "CommonCold",
"family": "respiratory", "family": "respiratory",
@@ -9,6 +29,8 @@
"coldChancePerDay": 0.08, "coldChancePerDay": 0.08,
"rainChancePerDay": 0.03, "rainChancePerDay": 0.03,
"snowChancePerDay": 0.04, "snowChancePerDay": 0.04,
"wetChancePerDay": 0.07,
"aftereffectMinSeverity": 0.9,
"nodeContagionChancePerDay": 0.55, "nodeContagionChancePerDay": 0.55,
"classContagionChancePerDay": 0.25, "classContagionChancePerDay": 0.25,
"contagiousDuringIncubation": true, "contagiousDuringIncubation": true,
@@ -31,6 +53,8 @@
"coldChancePerDay": 0.06, "coldChancePerDay": 0.06,
"rainChancePerDay": 0.02, "rainChancePerDay": 0.02,
"snowChancePerDay": 0.05, "snowChancePerDay": 0.05,
"wetChancePerDay": 0.05,
"aftereffectMinSeverity": 0.55,
"nodeContagionChancePerDay": 0.65, "nodeContagionChancePerDay": 0.65,
"classContagionChancePerDay": 0.35, "classContagionChancePerDay": 0.35,
"contagiousDuringIncubation": true, "contagiousDuringIncubation": true,
@@ -50,6 +74,7 @@
"immunityDays": 10, "immunityDays": 10,
"baseChancePerDay": 0.012, "baseChancePerDay": 0.012,
"rainChancePerDay": 0.01, "rainChancePerDay": 0.01,
"aftereffectMinSeverity": 0.75,
"nodeContagionChancePerDay": 0.3, "nodeContagionChancePerDay": 0.3,
"classContagionChancePerDay": 0, "classContagionChancePerDay": 0,
"contagiousDuringIncubation": false, "contagiousDuringIncubation": false,
@@ -73,6 +98,8 @@
"nurseSeekSeverity": 0.4, "nurseSeekSeverity": 0.4,
"tendSeverityPerDay": -0.25, "tendSeverityPerDay": -0.25,
"tendProgressPerDay": 0.35, "tendProgressPerDay": 0.35,
"wetChancePerDay": 0.04,
"aftereffectMinSeverity": 0.85,
"stages": [ "stages": [
{ "minSeverity": 0, "severityPerDay": 0.2, "progressPerDay": 0.04, "lessonLearningFactor": 0.8, "stayHomeChance": 0.2, "warmthDecayFactor": 1.05, "talkWeightFactor": 0.75, "effect": "OtitisEffectMild" }, { "minSeverity": 0, "severityPerDay": 0.2, "progressPerDay": 0.04, "lessonLearningFactor": 0.8, "stayHomeChance": 0.2, "warmthDecayFactor": 1.05, "talkWeightFactor": 0.75, "effect": "OtitisEffectMild" },
{ "minSeverity": 0.45, "severityPerDay": 0.05, "progressPerDay": 0.1, "lessonLearningFactor": 0.55, "stayHomeChance": 0.55, "warmthDecayFactor": 1.1, "talkWeightFactor": 0.45, "effect": "OtitisEffectModerate" }, { "minSeverity": 0.45, "severityPerDay": 0.05, "progressPerDay": 0.1, "lessonLearningFactor": 0.55, "stayHomeChance": 0.55, "warmthDecayFactor": 1.1, "talkWeightFactor": 0.45, "effect": "OtitisEffectModerate" },
@@ -263,5 +263,7 @@
"OtitisEffectMild": "Earache, hears worse", "OtitisEffectMild": "Earache, hears worse",
"OtitisEffectModerate": "Pain hurts lessons and talk", "OtitisEffectModerate": "Pain hurts lessons and talk",
"OtitisEffectHeavy": "Severe pain, barely talks", "OtitisEffectHeavy": "Severe pain, barely talks",
"Wet": "Wet",
"DiseaseAftereffect": "Recovering",
"core": "Core", "core": "Core",
} }
@@ -263,5 +263,7 @@
"OtitisEffectMild": "Боль в ухе, хуже слышит", "OtitisEffectMild": "Боль в ухе, хуже слышит",
"OtitisEffectModerate": "Боль мешает уроку и разговору", "OtitisEffectModerate": "Боль мешает уроку и разговору",
"OtitisEffectHeavy": "Сильная боль, почти не общается", "OtitisEffectHeavy": "Сильная боль, почти не общается",
"Wet": "Мокрый",
"DiseaseAftereffect": "Ослабление после болезни",
"core": "Базовая игра", "core": "Базовая игра",
} }
+87 -4
View File
@@ -37,6 +37,7 @@ internal static class DiseaseSystem
if (school.LastDiseaseDay != dayNumber) if (school.LastDiseaseDay != dayNumber)
{ {
school.LastDiseaseDay = dayNumber; school.LastDiseaseDay = dayNumber;
changed |= TryWet(school);
changed |= TryOnset(school); changed |= TryOnset(school);
changed |= TryContagion(school); changed |= TryContagion(school);
} }
@@ -58,6 +59,70 @@ internal static class DiseaseSystem
return changed; 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) private static bool TryOnset(School school)
{ {
var catalog = school.Catalog!; var catalog = school.Catalog!;
@@ -78,16 +143,19 @@ internal static class DiseaseSystem
continue; continue;
} }
var wet = HealthConditions.Has(person, DiseaseFamilies.WetDefName);
foreach (var disease in catalog.Diseases.Values) foreach (var disease in catalog.Diseases.Values)
{ {
if (disease.Abstract if (disease.Abstract
|| disease.Family.Equals(DiseaseFamilies.Condition, StringComparison.Ordinal)
|| HealthConditions.Has(person, disease.DefName) || HealthConditions.Has(person, disease.DefName)
|| DiseaseImmunities.IsImmune(person, disease.DefName, school.Clock.Time)) || DiseaseImmunities.IsImmune(person, disease.DefName, school.Clock.Time))
{ {
continue; continue;
} }
var chance = OnsetChance(disease, weather, rules.DiseaseVectorScale); var chance = OnsetChance(disease, weather, rules.DiseaseVectorScale, wet);
if (chance <= 0f) if (chance <= 0f)
{ {
continue; continue;
@@ -108,7 +176,7 @@ internal static class DiseaseSystem
DefName = disease.DefName, DefName = disease.DefName,
Severity = 0.05f, Severity = 0.05f,
Progress = 0f, Progress = 0f,
Source = SourceOf(disease, weather), Source = SourceOf(disease, weather, wet),
StartedAt = school.Clock.Time, StartedAt = school.Clock.Time,
}); });
changed = true; changed = true;
@@ -145,6 +213,7 @@ internal static class DiseaseSystem
{ {
if (!catalog.Diseases.TryGetValue(condition.DefName, out var disease) if (!catalog.Diseases.TryGetValue(condition.DefName, out var disease)
|| disease.Abstract || disease.Abstract
|| disease.Family.Equals(DiseaseFamilies.Condition, StringComparison.Ordinal)
|| !CanTransmit(condition, disease, now)) || !CanTransmit(condition, disease, now))
{ {
continue; continue;
@@ -348,7 +417,11 @@ internal static class DiseaseSystem
return map; 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) if (vectorScale <= 0f)
{ {
@@ -368,11 +441,21 @@ internal static class DiseaseSystem
_ => 0f, _ => 0f,
}; };
if (wet)
{
chance += disease.WetChancePerDay;
}
return Math.Clamp(chance * vectorScale, 0f, 1f); 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) if (disease.ColdBelowC is { } below && weather.TemperatureC <= below)
{ {
return "cold"; return "cold";
@@ -15,6 +15,8 @@ public class DiseaseDefTests
Assert.True(catalog.Diseases.ContainsKey("Influenza")); Assert.True(catalog.Diseases.ContainsKey("Influenza"));
Assert.True(catalog.Diseases.ContainsKey("StomachBug")); Assert.True(catalog.Diseases.ContainsKey("StomachBug"));
Assert.True(catalog.Diseases.ContainsKey("Otitis")); 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"]; var cold = catalog.Diseases["CommonCold"];
Assert.Equal(DiseaseFamilies.Respiratory, cold.Family); Assert.Equal(DiseaseFamilies.Respiratory, cold.Family);
@@ -22,6 +24,9 @@ public class DiseaseDefTests
Assert.True(cold.Stages.Count >= 2); Assert.True(cold.Stages.Count >= 2);
Assert.Equal(0.5f, cold.IncubationDays); Assert.Equal(0.5f, cold.IncubationDays);
Assert.Equal(14f, cold.ImmunityDays); 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.55f, cold.NodeContagionChancePerDay);
Assert.Equal(0.25f, cold.ClassContagionChancePerDay); Assert.Equal(0.25f, cold.ClassContagionChancePerDay);
Assert.True(cold.ContagiousDuringIncubation); Assert.True(cold.ContagiousDuringIncubation);
@@ -30,7 +35,11 @@ public class DiseaseDefTests
Assert.Equal("ОРВИ", catalog.Label("ru", cold)); Assert.Equal("ОРВИ", catalog.Label("ru", cold));
Assert.Equal("Common cold", catalog.Label("en", 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(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.Equal(3, catalog.BehaviorRules.DiseaseOutbreakMinNewCases);
Assert.True(catalog.Events.ContainsKey("DiseaseOutbreak")); Assert.True(catalog.Events.ContainsKey("DiseaseOutbreak"));
Assert.Equal(EventTriggers.DiseaseOutbreak, catalog.Events["DiseaseOutbreak"].Trigger); Assert.Equal(EventTriggers.DiseaseOutbreak, catalog.Events["DiseaseOutbreak"].Trigger);
@@ -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<string, int>(StringComparer.Ordinal),
Choices = new Dictionary<string, string>(StringComparer.Ordinal),
Skills = new Dictionary<string, int>(StringComparer.Ordinal),
Traits = [],
Needs = new Dictionary<string, float>(StringComparer.Ordinal),
Opinions = new Dictionary<string, int>(StringComparer.Ordinal),
};
}
}
@@ -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<ContentDocument>();
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);
}
}