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
+3
View File
@@ -19,6 +19,9 @@ public static class AttendanceStatuses
public static class AbsenceReasons
{
public const string Truancy = "truancy";
/// <summary>Excused absence while a DiseaseDef keeps the pupil home (slice 14).</summary>
public const string Illness = "illness";
}
/// <summary>One lesson-slot attendance row. Sparse list on the person — not a year journal.</summary>
+163
View File
@@ -0,0 +1,163 @@
using HSchool.Content;
namespace HSchool.People;
/// <summary>
/// Reads DiseaseDef stages for lesson gain, stay-home rolls and warmth decay.
/// Contagion between people is phase 79.
/// </summary>
public static class DiseaseEffects
{
public static bool TryStage(DiseaseDef disease, float severity, out DiseaseStage stage)
{
ArgumentNullException.ThrowIfNull(disease);
DiseaseStage? best = null;
foreach (var candidate in disease.Stages)
{
if (severity + 1e-6f < candidate.MinSeverity)
{
continue;
}
if (best is null || candidate.MinSeverity >= best.MinSeverity)
{
best = candidate;
}
}
if (best is null)
{
stage = null!;
return false;
}
stage = best;
return true;
}
public static bool IsIncubated(HealthCondition condition, DiseaseDef disease, DateTime now)
{
ArgumentNullException.ThrowIfNull(condition);
ArgumentNullException.ThrowIfNull(disease);
var elapsed = (DateTime.SpecifyKind(now, DateTimeKind.Utc) - condition.StartedAt).TotalDays;
return elapsed >= disease.IncubationDays;
}
/// <summary>Product of active post-incubation lesson factors (1 when healthy).</summary>
public static float LessonLearningFactor(Person person, DefCatalog catalog, DateTime now)
{
ArgumentNullException.ThrowIfNull(person);
ArgumentNullException.ThrowIfNull(catalog);
var list = person.Conditions;
if (list is null || list.Count == 0)
{
return 1f;
}
var factor = 1f;
foreach (var condition in list)
{
if (!catalog.Diseases.TryGetValue(condition.DefName, out var disease) || disease.Abstract)
{
continue;
}
if (!IsIncubated(condition, disease, now) || !TryStage(disease, condition.Severity, out var stage))
{
continue;
}
factor *= stage.LessonLearningFactor;
}
return factor;
}
/// <summary>Highest stay-home chance among incubated conditions.</summary>
public static float StayHomeChance(Person person, DefCatalog catalog, DateTime now)
{
ArgumentNullException.ThrowIfNull(person);
ArgumentNullException.ThrowIfNull(catalog);
var list = person.Conditions;
if (list is null || list.Count == 0)
{
return 0f;
}
var chance = 0f;
foreach (var condition in list)
{
if (!catalog.Diseases.TryGetValue(condition.DefName, out var disease) || disease.Abstract)
{
continue;
}
if (!IsIncubated(condition, disease, now) || !TryStage(disease, condition.Severity, out var stage))
{
continue;
}
if (stage.StayHomeChance > chance)
{
chance = stage.StayHomeChance;
}
}
return chance;
}
/// <summary>
/// Deterministic per person+day: whether illness keeps them off campus today.
/// </summary>
public static bool ShouldStayHome(Person person, DefCatalog catalog, int peopleSeed, DateTime now)
{
var chance = StayHomeChance(person, catalog, now);
if (chance <= 0f)
{
return false;
}
if (chance >= 1f)
{
return true;
}
var dayNumber = DateOnly.FromDateTime(DateTime.SpecifyKind(now, DateTimeKind.Utc)).DayNumber;
var roll = Seed.Mix(peopleSeed, person.Id, dayNumber, Seed.DiseaseSalt);
var unit = (roll & int.MaxValue) / (float)int.MaxValue;
return unit < chance;
}
/// <summary>Max Warmth decay multiplier from incubated stages (1 when healthy).</summary>
public static float WarmthDecayFactor(Person person, DefCatalog catalog, DateTime now)
{
ArgumentNullException.ThrowIfNull(person);
ArgumentNullException.ThrowIfNull(catalog);
var list = person.Conditions;
if (list is null || list.Count == 0)
{
return 1f;
}
var factor = 1f;
foreach (var condition in list)
{
if (!catalog.Diseases.TryGetValue(condition.DefName, out var disease) || disease.Abstract)
{
continue;
}
if (!IsIncubated(condition, disease, now) || !TryStage(disease, condition.Severity, out var stage))
{
continue;
}
if (stage.WarmthDecayFactor > factor)
{
factor = stage.WarmthDecayFactor;
}
}
return factor;
}
}
+170 -8
View File
@@ -1,19 +1,20 @@
using System.Text.Json.Serialization;
using HSchool.Content;
namespace HSchool.People;
/// <summary>
/// One medical condition on a person (hediff-like). Needs stay separate — a disease may
/// later modify decay, but hunger is still a need.
/// modify decay or lesson gain, but hunger is still a need.
/// </summary>
public sealed class HealthCondition
{
public required string DefName { get; init; }
/// <summary>0…1. Stages and effects read this; DiseaseDef curves land in phase 78.</summary>
/// <summary>0…1. <see cref="DiseaseDef"/> stages read this when the catalog knows the def.</summary>
public float Severity { get; set; }
/// <summary>Immunity / treatment progress 0…1.</summary>
/// <summary>Immunity / treatment progress 0…1. At 1 the condition clears.</summary>
public float Progress { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
@@ -23,7 +24,7 @@ public sealed class HealthCondition
/// <summary>
/// Base severity change per game day before the person+day seed factor.
/// Artificial conditions carry this until DiseaseDef owns the curve (phase 78).
/// Used when no DiseaseDef is in the catalog (tests / artificial conditions).
/// </summary>
public float SeverityPerDay { get; init; }
@@ -31,6 +32,14 @@ public sealed class HealthCondition
public float ProgressPerDay { get; init; }
}
/// <summary>Temporary post-recovery immunity to one DiseaseDef.</summary>
public sealed class DiseaseImmunityRecord
{
public required string DefName { get; init; }
public DateTime Until { get; init; }
}
/// <summary>Mutates the sparse <see cref="Person.Conditions"/> list and ticks severity deterministically.</summary>
public static class HealthConditions
{
@@ -49,11 +58,39 @@ public static class HealthConditions
list.Add(condition);
}
public static bool Has(Person person, string defName)
{
ArgumentNullException.ThrowIfNull(person);
ArgumentException.ThrowIfNullOrWhiteSpace(defName);
var list = person.Conditions;
if (list is null)
{
return false;
}
foreach (var row in list)
{
if (row.DefName.Equals(defName, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
/// <summary>
/// Advances severity/progress for everyone with conditions. Same
/// <paramref name="peopleSeed"/>, person id and <paramref name="dayNumber"/> yield the same curve.
/// When <paramref name="catalog"/> has a DiseaseDef, stage rates replace the artificial per-day fields.
/// </summary>
public static bool Tick(Person person, int peopleSeed, int dayNumber, double gameMinutes)
public static bool Tick(
Person person,
int peopleSeed,
int dayNumber,
double gameMinutes,
DefCatalog? catalog = null,
DateTime? now = null)
{
ArgumentNullException.ThrowIfNull(person);
var list = person.Conditions;
@@ -67,11 +104,24 @@ public static class HealthConditions
var dayFactor = 0.5f + unit;
var days = gameMinutes / (24d * 60d);
var changed = false;
List<HealthCondition>? recovered = null;
var clock = now ?? DateTime.SpecifyKind(DateTime.UnixEpoch.AddDays(dayNumber), DateTimeKind.Utc);
foreach (var condition in list)
{
var severityDelta = (float)(condition.SeverityPerDay * dayFactor * days);
var progressDelta = (float)(condition.ProgressPerDay * dayFactor * days);
var severityPerDay = condition.SeverityPerDay;
var progressPerDay = condition.ProgressPerDay;
if (catalog is not null
&& catalog.Diseases.TryGetValue(condition.DefName, out var disease)
&& !disease.Abstract
&& DiseaseEffects.TryStage(disease, condition.Severity, out var stage))
{
severityPerDay = stage.SeverityPerDay;
progressPerDay = stage.ProgressPerDay;
}
var severityDelta = (float)(severityPerDay * dayFactor * days);
var progressDelta = (float)(progressPerDay * dayFactor * days);
if (severityDelta == 0f && progressDelta == 0f)
{
continue;
@@ -87,8 +137,120 @@ public static class HealthConditions
condition.Severity = nextSeverity;
condition.Progress = nextProgress;
changed = true;
if (nextProgress >= 1f)
{
recovered ??= [];
recovered.Add(condition);
}
}
return changed;
if (recovered is null)
{
return changed;
}
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)
{
DiseaseImmunities.Grant(
person,
done.DefName,
clock.AddDays(disease.ImmunityDays));
}
}
if (list.Count == 0)
{
person.Conditions = null;
}
return true;
}
}
/// <summary>Sparse post-recovery immunity list on the person.</summary>
public static class DiseaseImmunities
{
public static void Grant(Person person, string defName, DateTime until)
{
ArgumentNullException.ThrowIfNull(person);
ArgumentException.ThrowIfNullOrWhiteSpace(defName);
var list = person.DiseaseImmunities;
if (list is null)
{
list = [];
person.DiseaseImmunities = list;
}
for (var i = 0; i < list.Count; i++)
{
if (list[i].DefName.Equals(defName, StringComparison.Ordinal))
{
if (until > list[i].Until)
{
list[i] = new DiseaseImmunityRecord
{
DefName = defName,
Until = DateTime.SpecifyKind(until, DateTimeKind.Utc),
};
}
return;
}
}
list.Add(new DiseaseImmunityRecord
{
DefName = defName,
Until = DateTime.SpecifyKind(until, DateTimeKind.Utc),
});
}
public static bool IsImmune(Person person, string defName, DateTime now)
{
ArgumentNullException.ThrowIfNull(person);
ArgumentException.ThrowIfNullOrWhiteSpace(defName);
var list = person.DiseaseImmunities;
if (list is null || list.Count == 0)
{
return false;
}
var utc = DateTime.SpecifyKind(now, DateTimeKind.Utc);
foreach (var row in list)
{
if (row.DefName.Equals(defName, StringComparison.Ordinal) && row.Until > utc)
{
return true;
}
}
return false;
}
public static bool Expire(Person person, DateTime now)
{
ArgumentNullException.ThrowIfNull(person);
var list = person.DiseaseImmunities;
if (list is null || list.Count == 0)
{
return false;
}
var utc = DateTime.SpecifyKind(now, DateTimeKind.Utc);
var removed = list.RemoveAll(row => row.Until <= utc);
if (list.Count == 0)
{
person.DiseaseImmunities = null;
}
return removed > 0;
}
}
+5
View File
@@ -93,6 +93,11 @@ public sealed record Person
/// </summary>
public List<HealthCondition>? Conditions { get; set; }
/// <summary>
/// Temporary disease immunities after recovery. Null when empty so people.json stays compact.
/// </summary>
public List<DiseaseImmunityRecord>? DiseaseImmunities { get; set; }
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
}
+1
View File
@@ -25,6 +25,7 @@ public static class Seed
public const int HealthSalt = 17;
public const int MeetingSalt = 18;
public const int MeetingAttendSalt = 19;
public const int DiseaseSalt = 20;
/// <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);