Merge branch 'phase/78-diseases'
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user