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
+5
View File
@@ -38,6 +38,11 @@ public static class DayPlans
return new DayPlan(day, null, null, null);
}
if (DiseaseEffects.ShouldStayHome(person, catalog, schoolSeed, utc))
{
return new DayPlan(day, null, null, null);
}
var weekday = SchoolDay.WeekdayIndex(utc);
var firstRoom = FirstRoom(person, schoolClass, timetable, catalog, weekday);
if (firstRoom is null)
+8 -1
View File
@@ -319,6 +319,7 @@ public sealed class CatalogLoader
var orientations = new Dictionary<string, OrientationDef>(StringComparer.Ordinal);
var affinity = new Dictionary<string, AffinityRulesDef>(StringComparer.Ordinal);
var events = new Dictionary<string, EventDef>(StringComparer.Ordinal);
var diseases = new Dictionary<string, DiseaseDef>(StringComparer.Ordinal);
foreach (var (key, json) in resolved)
{
@@ -396,6 +397,9 @@ public sealed class CatalogLoader
case DefKind.Event:
events[key.Name] = Jsonc.Deserialize<EventDef>(json);
break;
case DefKind.Disease:
diseases[key.Name] = Jsonc.Deserialize<DiseaseDef>(json);
break;
}
}
@@ -425,6 +429,7 @@ public sealed class CatalogLoader
orientations,
affinity,
events,
diseases,
ru,
en);
}
@@ -547,6 +552,7 @@ public sealed class CatalogLoader
PeopleDefValidator.Validate(catalog, log);
EventDefValidator.Validate(catalog);
DiseaseDefValidator.Validate(catalog);
}
private static void WarnMissingLabels(DefCatalog catalog, IContentLog log)
@@ -585,7 +591,8 @@ public sealed class CatalogLoader
.Concat(Enumerate(catalog.Topics.Values))
.Concat(Enumerate(catalog.Orientations.Values))
.Concat(Enumerate(catalog.Affinity.Values))
.Concat(Enumerate(catalog.Events.Values));
.Concat(Enumerate(catalog.Events.Values))
.Concat(Enumerate(catalog.Diseases.Values));
static IEnumerable<Def> Enumerate(IEnumerable<Def> defs) => defs.Where(def => !def.Abstract);
}
+6
View File
@@ -32,6 +32,7 @@ public sealed class DefCatalog
IReadOnlyDictionary<string, OrientationDef> orientations,
IReadOnlyDictionary<string, AffinityRulesDef> affinity,
IReadOnlyDictionary<string, EventDef> events,
IReadOnlyDictionary<string, DiseaseDef> diseases,
IReadOnlyDictionary<string, string> ru,
IReadOnlyDictionary<string, string> en)
{
@@ -60,6 +61,7 @@ public sealed class DefCatalog
Orientations = orientations;
Affinity = affinity;
Events = events;
Diseases = diseases;
_ru = ru;
_en = en;
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
@@ -125,6 +127,8 @@ public sealed class DefCatalog
public IReadOnlyDictionary<string, EventDef> Events { get; }
public IReadOnlyDictionary<string, DiseaseDef> Diseases { get; }
/// <summary>The one concrete staffing ruleset, or null when a pack has not defined it.</summary>
public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract);
@@ -168,6 +172,7 @@ public sealed class DefCatalog
DefKind.Orientation => Orientations.GetValueOrDefault(defName),
DefKind.AffinityRules => Affinity.GetValueOrDefault(defName),
DefKind.Event => Events.GetValueOrDefault(defName),
DefKind.Disease => Diseases.GetValueOrDefault(defName),
_ => null,
};
@@ -252,6 +257,7 @@ public sealed class DefCatalog
OrientationDef => DefKind.Orientation,
AffinityRulesDef => DefKind.AffinityRules,
EventDef => DefKind.Event,
DiseaseDef => DefKind.Disease,
_ => throw new ArgumentOutOfRangeException(nameof(def)),
};
+1
View File
@@ -26,6 +26,7 @@ public enum DefKind
Orientation,
AffinityRules,
Event,
Disease,
}
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
@@ -0,0 +1,91 @@
namespace HSchool.Content;
internal static class DiseaseDefValidator
{
private static readonly HashSet<string> Families = new(StringComparer.Ordinal)
{
DiseaseFamilies.Respiratory,
DiseaseFamilies.Gastrointestinal,
DiseaseFamilies.Ent,
};
public static void Validate(DefCatalog catalog)
{
foreach (var def in catalog.Diseases.Values)
{
if (def.Abstract)
{
continue;
}
if (string.IsNullOrWhiteSpace(def.Family))
{
throw new ContentLoadException($"DiseaseDef '{def.DefName}' family is required.");
}
if (!Families.Contains(def.Family))
{
throw new ContentLoadException($"DiseaseDef '{def.DefName}' has unknown family '{def.Family}'.");
}
if (def.Stages.Count == 0)
{
throw new ContentLoadException($"DiseaseDef '{def.DefName}' stages are required.");
}
if (def.IncubationDays < 0f)
{
throw new ContentLoadException($"DiseaseDef '{def.DefName}' incubationDays cannot be negative.");
}
if (def.ImmunityDays < 0f)
{
throw new ContentLoadException($"DiseaseDef '{def.DefName}' immunityDays cannot be negative.");
}
if (def.BaseChancePerDay < 0f
|| def.ColdChancePerDay < 0f
|| def.RainChancePerDay < 0f
|| def.SnowChancePerDay < 0f)
{
throw new ContentLoadException($"DiseaseDef '{def.DefName}' chance fields cannot be negative.");
}
DiseaseStage? previous = null;
foreach (var stage in def.Stages)
{
if (stage.MinSeverity < 0f || stage.MinSeverity > 1f)
{
throw new ContentLoadException(
$"DiseaseDef '{def.DefName}' stage minSeverity must be 01.");
}
if (previous is not null && stage.MinSeverity < previous.MinSeverity)
{
throw new ContentLoadException(
$"DiseaseDef '{def.DefName}' stages must be ascending by minSeverity.");
}
if (stage.LessonLearningFactor < 0f || stage.LessonLearningFactor > 1f)
{
throw new ContentLoadException(
$"DiseaseDef '{def.DefName}' lessonLearningFactor must be 01.");
}
if (stage.StayHomeChance < 0f || stage.StayHomeChance > 1f)
{
throw new ContentLoadException(
$"DiseaseDef '{def.DefName}' stayHomeChance must be 01.");
}
if (stage.WarmthDecayFactor < 0f)
{
throw new ContentLoadException(
$"DiseaseDef '{def.DefName}' warmthDecayFactor cannot be negative.");
}
previous = stage;
}
}
}
}
+61
View File
@@ -0,0 +1,61 @@
namespace HSchool.Content;
/// <summary>Stable family ids for immunity grouping and realism labels.</summary>
public static class DiseaseFamilies
{
public const string Respiratory = "respiratory";
public const string Gastrointestinal = "gastrointestinal";
public const string Ent = "ent";
}
/// <summary>One severity band on a <see cref="DiseaseDef"/> curve.</summary>
public sealed class DiseaseStage
{
/// <summary>Inclusive lower bound of severity for this stage.</summary>
public float MinSeverity { get; init; }
public float SeverityPerDay { get; init; }
public float ProgressPerDay { get; init; }
/// <summary>Multiplies lesson skill gain while this stage is active (after incubation).</summary>
public float LessonLearningFactor { get; init; } = 1f;
/// <summary>Per-day chance the person stays off campus while in this stage (after incubation).</summary>
public float StayHomeChance { get; init; }
/// <summary>Multiplies Warmth decay. 1 = unchanged.</summary>
public float WarmthDecayFactor { get; init; } = 1f;
}
/// <summary>
/// A named disease: stages, weather vectors, lesson/attendance effects, temporary immunity.
/// Contagion between people is phase 79 — these defs may omit it.
/// </summary>
public sealed class DiseaseDef : Def
{
/// <summary><see cref="DiseaseFamilies"/> id. Required on concrete defs.</summary>
public string Family { get; init; } = "";
/// <summary>Game days after onset before stage effects (lesson / stay-home) apply.</summary>
public float IncubationDays { get; init; }
public IReadOnlyList<DiseaseStage> Stages { get; init; } = [];
/// <summary>Base daily onset chance before weather vectors and <see cref="BehaviorDef.DiseaseVectorScale"/>.</summary>
public float BaseChancePerDay { get; init; }
/// <summary>Street °C at or below this adds <see cref="ColdChancePerDay"/>. Null — no cold vector.</summary>
public float? ColdBelowC { get; init; }
public float ColdChancePerDay { get; init; }
public float RainChancePerDay { get; init; }
public float SnowChancePerDay { get; init; }
/// <summary>Days of immunity to this def after recovery.</summary>
public float ImmunityDays { get; init; }
}
+3
View File
@@ -141,6 +141,9 @@ internal static class PackPaths
case "events":
kind = DefKind.Event;
return true;
case "diseases":
kind = DefKind.Disease;
return true;
default:
kind = default;
return false;
@@ -733,6 +733,12 @@ internal static class PeopleDefValidator
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' lessonLateMinutes cannot be negative.");
}
if (behavior.DiseaseVectorScale < 0f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' diseaseVectorScale cannot be negative.");
}
}
private static void ValidateTopic(TopicDef topic, DefCatalog catalog)
+5
View File
@@ -636,6 +636,11 @@ public sealed class BehaviorDef : Def
/// </summary>
public bool LessonMarkWhenLate { get; init; } = true;
/// <summary>
/// Scales DiseaseDef weather/base onset chances. 0 disables natural onset; missing keeps 1.
/// </summary>
public float DiseaseVectorScale { get; init; } = 1f;
public static IReadOnlyList<float> DefaultLessonMarkThresholds { get; } =
[
0.85f,
+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
@@ -23,6 +23,7 @@ public static class Seed
public const int HomeSalt = 15;
public const int SummonSalt = 16;
public const int HealthSalt = 17;
public const int DiseaseSalt = 18;
/// <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);
@@ -116,4 +116,6 @@
"lessonLateMinutes": 5,
// Late still gets a mark when standing (stage A). False would skip the mark for late arrivals.
"lessonMarkWhenLate": true,
// Scales DiseaseDef onset from weather/base (slice 14 phase 78). 0 disables natural onset.
"diseaseVectorScale": 1,
}
@@ -0,0 +1,61 @@
[
{
"defName": "CommonCold",
"family": "respiratory",
"incubationDays": 0.5,
"immunityDays": 14,
"baseChancePerDay": 0.01,
"coldBelowC": 5,
"coldChancePerDay": 0.08,
"rainChancePerDay": 0.03,
"snowChancePerDay": 0.04,
"stages": [
{ "minSeverity": 0, "severityPerDay": 0.25, "progressPerDay": 0.05, "lessonLearningFactor": 0.85, "stayHomeChance": 0.15, "warmthDecayFactor": 1.1 },
{ "minSeverity": 0.4, "severityPerDay": 0.1, "progressPerDay": 0.12, "lessonLearningFactor": 0.65, "stayHomeChance": 0.45, "warmthDecayFactor": 1.2 },
{ "minSeverity": 0.7, "severityPerDay": -0.05, "progressPerDay": 0.2, "lessonLearningFactor": 0.45, "stayHomeChance": 0.75, "warmthDecayFactor": 1.3 },
],
},
{
"defName": "Influenza",
"family": "respiratory",
"incubationDays": 1,
"immunityDays": 30,
"baseChancePerDay": 0.004,
"coldBelowC": 0,
"coldChancePerDay": 0.06,
"rainChancePerDay": 0.02,
"snowChancePerDay": 0.05,
"stages": [
{ "minSeverity": 0, "severityPerDay": 0.35, "progressPerDay": 0.03, "lessonLearningFactor": 0.7, "stayHomeChance": 0.35, "warmthDecayFactor": 1.2 },
{ "minSeverity": 0.35, "severityPerDay": 0.15, "progressPerDay": 0.08, "lessonLearningFactor": 0.4, "stayHomeChance": 0.7, "warmthDecayFactor": 1.4 },
{ "minSeverity": 0.65, "severityPerDay": -0.08, "progressPerDay": 0.18, "lessonLearningFactor": 0.25, "stayHomeChance": 0.95, "warmthDecayFactor": 1.5 },
],
},
{
"defName": "StomachBug",
"family": "gastrointestinal",
"incubationDays": 0.25,
"immunityDays": 10,
"baseChancePerDay": 0.012,
"rainChancePerDay": 0.01,
"stages": [
{ "minSeverity": 0, "severityPerDay": 0.4, "progressPerDay": 0.08, "lessonLearningFactor": 0.6, "stayHomeChance": 0.5, "warmthDecayFactor": 1 },
{ "minSeverity": 0.5, "severityPerDay": -0.1, "progressPerDay": 0.25, "lessonLearningFactor": 0.35, "stayHomeChance": 0.85, "warmthDecayFactor": 1 },
],
},
{
"defName": "Otitis",
"family": "ent",
"incubationDays": 0.75,
"immunityDays": 21,
"baseChancePerDay": 0.006,
"coldBelowC": 8,
"coldChancePerDay": 0.05,
"snowChancePerDay": 0.03,
"stages": [
{ "minSeverity": 0, "severityPerDay": 0.2, "progressPerDay": 0.04, "lessonLearningFactor": 0.8, "stayHomeChance": 0.2, "warmthDecayFactor": 1.05 },
{ "minSeverity": 0.45, "severityPerDay": 0.05, "progressPerDay": 0.1, "lessonLearningFactor": 0.55, "stayHomeChance": 0.55, "warmthDecayFactor": 1.1 },
{ "minSeverity": 0.75, "severityPerDay": -0.06, "progressPerDay": 0.22, "lessonLearningFactor": 0.4, "stayHomeChance": 0.8, "warmthDecayFactor": 1.15 },
],
},
]
@@ -234,5 +234,9 @@
"DirectorSummoned": "A pupil was summoned to the principal",
"GoingToPrincipal": "going to the principal",
"WaitForPrincipal": "waiting at the principal's office",
"CommonCold": "Common cold",
"Influenza": "Influenza",
"StomachBug": "Stomach bug",
"Otitis": "Ear infection",
"core": "Core",
}
@@ -234,5 +234,9 @@
"DirectorSummoned": "Ученика вызвали к директору",
"GoingToPrincipal": "идёт к директору",
"WaitForPrincipal": "ждёт у кабинета директора",
"CommonCold": "ОРВИ",
"Influenza": "Грипп",
"StomachBug": "Кишечная инфекция",
"Otitis": "Отит",
"core": "Базовая игра",
}
+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();
@@ -219,6 +222,7 @@ public sealed class School : IDisposable
PlanDay = null;
LastDecisionSlot = null;
LastAttendanceSlot = null;
LastDiseaseDay = int.MinValue;
Plans.Clear();
DecisionQueue.Clear();
LoggedActivity.Clear();