Add DiseaseDef diseases with weather vectors and excused absence.

Vanilla ships several diseases whose stages drive lesson gain, stay-home
rolls, and illness attendance; cold/rain/snow raise onset via BehaviorDef scale.
This commit is contained in:
Leonid Pershin
2026-08-21 13:28:57 +03:00
parent 564776883a
commit 2d633411e4
27 changed files with 1188 additions and 38 deletions
@@ -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<ContentLoadException>(() => _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<ContentLoadException>(() => _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));
}
}
@@ -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<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,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<PersonIdentity, Presence>();
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<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);
}
}