Merge branch 'phase/78-diseases'

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-21 13:39:49 +03:00
co-authored by Cursor
28 changed files with 1189 additions and 39 deletions
+10 -1
View File
@@ -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;
}
+163
View File
@@ -0,0 +1,163 @@
using HSchool.Content;
using HSchool.People;
namespace HSchool.Simulation;
/// <summary>
/// DiseaseDef onset from weather vectors and ticks that follow stage curves.
/// Person-to-person contagion is phase 79.
/// </summary>
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;
}
}
}
@@ -3,24 +3,10 @@ using HSchool.People;
namespace HSchool.Simulation;
/// <summary>
/// Ticks medical condition severity on the roster. Contagion and DiseaseDef outbreaks are later phases.
/// Ticks medical conditions. DiseaseDef onset and stage curves live in <see cref="DiseaseSystem"/>.
/// Kept so older call sites and phase-77 tests still have a named entry.
/// </summary>
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);
}
@@ -110,6 +110,7 @@ internal static class LessonLearningSystem
}
textbookFactor *= TalkCircles.LessonWhisperFactor(activity.ActionId, rules);
textbookFactor *= DiseaseEffects.LessonLearningFactor(person, catalog, school.Clock.Time);
IReadOnlyDictionary<string, float> taught = teacherSkills.TryGetValue(lesson.TeacherId, out var found)
? found
+4
View File
@@ -119,6 +119,9 @@ public sealed class School : IDisposable
/// <summary>Last slot <see cref="AttendanceSystem"/> observed — detects leaving a lesson to finalize absents.</summary>
internal DaySlot? LastAttendanceSlot { get; set; }
/// <summary>Calendar day of the last DiseaseDef onset pass (one roll per person per day).</summary>
internal int LastDiseaseDay { get; set; } = int.MinValue;
internal Dictionary<string, DayPlan> Plans { get; } = new(StringComparer.Ordinal);
internal Queue<string> 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();