Add health condition list, tick, and save for phase 77.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-21 11:41:25 +03:00
co-authored by Cursor
parent 76eb882285
commit 2d31a59a81
8 changed files with 440 additions and 11 deletions
+94
View File
@@ -0,0 +1,94 @@
using System.Text.Json.Serialization;
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.
/// </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>
public float Severity { get; set; }
/// <summary>Immunity / treatment progress 0…1.</summary>
public float Progress { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Source { get; init; }
public DateTime StartedAt { get; init; }
/// <summary>
/// Base severity change per game day before the person+day seed factor.
/// Artificial conditions carry this until DiseaseDef owns the curve (phase 78).
/// </summary>
public float SeverityPerDay { get; init; }
/// <summary>Base progress change per game day before the person+day seed factor.</summary>
public float ProgressPerDay { get; init; }
}
/// <summary>Mutates the sparse <see cref="Person.Conditions"/> list and ticks severity deterministically.</summary>
public static class HealthConditions
{
public static void Add(Person person, HealthCondition condition)
{
ArgumentNullException.ThrowIfNull(person);
ArgumentNullException.ThrowIfNull(condition);
var list = person.Conditions;
if (list is null)
{
list = [];
person.Conditions = list;
}
list.Add(condition);
}
/// <summary>
/// Advances severity/progress for everyone with conditions. Same
/// <paramref name="peopleSeed"/>, person id and <paramref name="dayNumber"/> yield the same curve.
/// </summary>
public static bool Tick(Person person, int peopleSeed, int dayNumber, double gameMinutes)
{
ArgumentNullException.ThrowIfNull(person);
var list = person.Conditions;
if (list is null || list.Count == 0 || gameMinutes <= 0)
{
return false;
}
var daySeed = Seed.Mix(peopleSeed, person.Id, dayNumber, Seed.HealthSalt);
var unit = (daySeed & int.MaxValue) / (float)int.MaxValue;
var dayFactor = 0.5f + unit;
var days = gameMinutes / (24d * 60d);
var changed = false;
foreach (var condition in list)
{
var severityDelta = (float)(condition.SeverityPerDay * dayFactor * days);
var progressDelta = (float)(condition.ProgressPerDay * dayFactor * days);
if (severityDelta == 0f && progressDelta == 0f)
{
continue;
}
var nextSeverity = Math.Clamp(condition.Severity + severityDelta, 0f, 1f);
var nextProgress = Math.Clamp(condition.Progress + progressDelta, 0f, 1f);
if (nextSeverity == condition.Severity && nextProgress == condition.Progress)
{
continue;
}
condition.Severity = nextSeverity;
condition.Progress = nextProgress;
changed = true;
}
return changed;
}
}
+6
View File
@@ -75,6 +75,12 @@ public sealed record Person
/// </summary>
public List<OffenseRecord>? Offenses { get; set; }
/// <summary>
/// Active medical conditions. Null when healthy (empty) so people.json stays compact.
/// Needs are not replaced by this list.
/// </summary>
public List<HealthCondition>? Conditions { get; set; }
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
}
+1
View File
@@ -21,6 +21,7 @@ public static class Seed
public const int OrientationSalt = 13;
public const int ConflictSalt = 14;
public const int HomeSalt = 15;
public const int HealthSalt = 16;
/// <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);
@@ -0,0 +1,26 @@
using HSchool.People;
namespace HSchool.Simulation;
/// <summary>
/// Ticks medical condition severity on the roster. Contagion and DiseaseDef outbreaks are later phases.
/// </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;
}
}
+2 -1
View File
@@ -348,7 +348,7 @@ public sealed class School : IDisposable
}
}
/// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, presence, actions, need decay, then apparel wear.</summary>
/// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, presence, actions, need decay, apparel wear, then health conditions.</summary>
/// <returns><see langword="true"/> when the roster, applicant pool or wardrobe changed this step.</returns>
public bool Tick(double deltaTime, double gameMinutesPerRealSecond)
{
@@ -442,6 +442,7 @@ public sealed class School : IDisposable
peopleChanged |= AffinitySystem.Apply(this, talked);
}
peopleChanged |= HealthConditionSystem.Apply(this, gameMinutes);
peopleChanged |= RosterTalkDirty;
RosterTalkDirty = false;
return peopleChanged;