Merge branch 'phase/78-diseases'
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,21 +12,21 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] `DiseaseDef` (или согласованное имя): семейство, стадии, инкубация, эффекты на нужды /
|
- [x] `DiseaseDef` (или согласованное имя): семейство, стадии, инкубация, эффекты на нужды /
|
||||||
`LessonLearning` / шанс не прийти
|
`LessonLearning` / шанс не прийти
|
||||||
- [ ] Ваниль **несколько** разных болезней в `core` (не один def «Болезнь»)
|
- [x] Ваниль **несколько** разных болезней в `core` (не один def «Болезнь»)
|
||||||
- [ ] Векторы: холод ниже порога, дождь/снег — поля на def и BehaviorDef
|
- [x] Векторы: холод ниже порога, дождь/снег — поля на def и BehaviorDef
|
||||||
- [ ] Отсутствие по болезни пишет причину для журнала 13 (уважительно)
|
- [x] Отсутствие по болезни пишет причину для журнала 13 (уважительно)
|
||||||
- [ ] Временный иммунитет после выздоровления — поле на def
|
- [x] Временный иммунитет после выздоровления — поле на def
|
||||||
- [ ] Локали подписей болезней
|
- [x] Локали подписей болезней
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] Два разных def дают разные кривые/эффекты при контрольном тике
|
- [x] Два разных def дают разные кривые/эффекты при контрольном тике
|
||||||
- [ ] Холод повышает шанс респираторной ванили относительно контроля
|
- [x] Холод повышает шанс респираторной ванили относительно контроля
|
||||||
- [ ] Больной с тяжёлой стадией чаще «вне школы» на учебный день
|
- [x] Больной с тяжёлой стадией чаще «вне школы» на учебный день
|
||||||
- [ ] Явка с причиной болезнь ≠ прогул в данных 13
|
- [x] Явка с причиной болезнь ≠ прогул в данных 13
|
||||||
- [ ] Каталог без обязательных полей падает загрузкой, не молча
|
- [x] Каталог без обязательных полей падает загрузкой, не молча
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
| Фаза | Статус | Зачем |
|
| Фаза | Статус | Зачем |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [78. Болезни и векторы](78-diseases.md) | 🔄 | Несколько DiseaseDef, холод/погода, урок/явка |
|
| [78. Болезни и векторы](78-diseases.md) | ✅ | Несколько DiseaseDef, холод/погода, урок/явка |
|
||||||
|
|
||||||
**Этап C — зараза.**
|
**Этап C — зараза.**
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ public static class DayPlans
|
|||||||
return new DayPlan(day, null, null, null);
|
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 weekday = SchoolDay.WeekdayIndex(utc);
|
||||||
var firstRoom = FirstRoom(person, schoolClass, timetable, catalog, weekday);
|
var firstRoom = FirstRoom(person, schoolClass, timetable, catalog, weekday);
|
||||||
if (firstRoom is null)
|
if (firstRoom is null)
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ public sealed class CatalogLoader
|
|||||||
var orientations = new Dictionary<string, OrientationDef>(StringComparer.Ordinal);
|
var orientations = new Dictionary<string, OrientationDef>(StringComparer.Ordinal);
|
||||||
var affinity = new Dictionary<string, AffinityRulesDef>(StringComparer.Ordinal);
|
var affinity = new Dictionary<string, AffinityRulesDef>(StringComparer.Ordinal);
|
||||||
var events = new Dictionary<string, EventDef>(StringComparer.Ordinal);
|
var events = new Dictionary<string, EventDef>(StringComparer.Ordinal);
|
||||||
|
var diseases = new Dictionary<string, DiseaseDef>(StringComparer.Ordinal);
|
||||||
|
|
||||||
foreach (var (key, json) in resolved)
|
foreach (var (key, json) in resolved)
|
||||||
{
|
{
|
||||||
@@ -396,6 +397,9 @@ public sealed class CatalogLoader
|
|||||||
case DefKind.Event:
|
case DefKind.Event:
|
||||||
events[key.Name] = Jsonc.Deserialize<EventDef>(json);
|
events[key.Name] = Jsonc.Deserialize<EventDef>(json);
|
||||||
break;
|
break;
|
||||||
|
case DefKind.Disease:
|
||||||
|
diseases[key.Name] = Jsonc.Deserialize<DiseaseDef>(json);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +429,7 @@ public sealed class CatalogLoader
|
|||||||
orientations,
|
orientations,
|
||||||
affinity,
|
affinity,
|
||||||
events,
|
events,
|
||||||
|
diseases,
|
||||||
ru,
|
ru,
|
||||||
en);
|
en);
|
||||||
}
|
}
|
||||||
@@ -547,6 +552,7 @@ public sealed class CatalogLoader
|
|||||||
|
|
||||||
PeopleDefValidator.Validate(catalog, log);
|
PeopleDefValidator.Validate(catalog, log);
|
||||||
EventDefValidator.Validate(catalog);
|
EventDefValidator.Validate(catalog);
|
||||||
|
DiseaseDefValidator.Validate(catalog);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void WarnMissingLabels(DefCatalog catalog, IContentLog log)
|
private static void WarnMissingLabels(DefCatalog catalog, IContentLog log)
|
||||||
@@ -585,7 +591,8 @@ public sealed class CatalogLoader
|
|||||||
.Concat(Enumerate(catalog.Topics.Values))
|
.Concat(Enumerate(catalog.Topics.Values))
|
||||||
.Concat(Enumerate(catalog.Orientations.Values))
|
.Concat(Enumerate(catalog.Orientations.Values))
|
||||||
.Concat(Enumerate(catalog.Affinity.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);
|
static IEnumerable<Def> Enumerate(IEnumerable<Def> defs) => defs.Where(def => !def.Abstract);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public sealed class DefCatalog
|
|||||||
IReadOnlyDictionary<string, OrientationDef> orientations,
|
IReadOnlyDictionary<string, OrientationDef> orientations,
|
||||||
IReadOnlyDictionary<string, AffinityRulesDef> affinity,
|
IReadOnlyDictionary<string, AffinityRulesDef> affinity,
|
||||||
IReadOnlyDictionary<string, EventDef> events,
|
IReadOnlyDictionary<string, EventDef> events,
|
||||||
|
IReadOnlyDictionary<string, DiseaseDef> diseases,
|
||||||
IReadOnlyDictionary<string, string> ru,
|
IReadOnlyDictionary<string, string> ru,
|
||||||
IReadOnlyDictionary<string, string> en)
|
IReadOnlyDictionary<string, string> en)
|
||||||
{
|
{
|
||||||
@@ -60,6 +61,7 @@ public sealed class DefCatalog
|
|||||||
Orientations = orientations;
|
Orientations = orientations;
|
||||||
Affinity = affinity;
|
Affinity = affinity;
|
||||||
Events = events;
|
Events = events;
|
||||||
|
Diseases = diseases;
|
||||||
_ru = ru;
|
_ru = ru;
|
||||||
_en = en;
|
_en = en;
|
||||||
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
|
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, 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>
|
/// <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);
|
public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract);
|
||||||
|
|
||||||
@@ -168,6 +172,7 @@ public sealed class DefCatalog
|
|||||||
DefKind.Orientation => Orientations.GetValueOrDefault(defName),
|
DefKind.Orientation => Orientations.GetValueOrDefault(defName),
|
||||||
DefKind.AffinityRules => Affinity.GetValueOrDefault(defName),
|
DefKind.AffinityRules => Affinity.GetValueOrDefault(defName),
|
||||||
DefKind.Event => Events.GetValueOrDefault(defName),
|
DefKind.Event => Events.GetValueOrDefault(defName),
|
||||||
|
DefKind.Disease => Diseases.GetValueOrDefault(defName),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -252,6 +257,7 @@ public sealed class DefCatalog
|
|||||||
OrientationDef => DefKind.Orientation,
|
OrientationDef => DefKind.Orientation,
|
||||||
AffinityRulesDef => DefKind.AffinityRules,
|
AffinityRulesDef => DefKind.AffinityRules,
|
||||||
EventDef => DefKind.Event,
|
EventDef => DefKind.Event,
|
||||||
|
DiseaseDef => DefKind.Disease,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(def)),
|
_ => throw new ArgumentOutOfRangeException(nameof(def)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public enum DefKind
|
|||||||
Orientation,
|
Orientation,
|
||||||
AffinityRules,
|
AffinityRules,
|
||||||
Event,
|
Event,
|
||||||
|
Disease,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
|
/// <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 0–1.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 0–1.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage.StayHomeChance < 0f || stage.StayHomeChance > 1f)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException(
|
||||||
|
$"DiseaseDef '{def.DefName}' stayHomeChance must be 0–1.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage.WarmthDecayFactor < 0f)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException(
|
||||||
|
$"DiseaseDef '{def.DefName}' warmthDecayFactor cannot be negative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
previous = stage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -141,6 +141,9 @@ internal static class PackPaths
|
|||||||
case "events":
|
case "events":
|
||||||
kind = DefKind.Event;
|
kind = DefKind.Event;
|
||||||
return true;
|
return true;
|
||||||
|
case "diseases":
|
||||||
|
kind = DefKind.Disease;
|
||||||
|
return true;
|
||||||
default:
|
default:
|
||||||
kind = default;
|
kind = default;
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -751,6 +751,12 @@ internal static class PeopleDefValidator
|
|||||||
throw new ContentLoadException(
|
throw new ContentLoadException(
|
||||||
$"BehaviorDef '{behavior.DefName}' lessonLateMinutes cannot be negative.");
|
$"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)
|
private static void ValidateTopic(TopicDef topic, DefCatalog catalog)
|
||||||
|
|||||||
@@ -655,6 +655,11 @@ public sealed class BehaviorDef : Def
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool LessonMarkWhenLate { get; init; } = true;
|
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; } =
|
public static IReadOnlyList<float> DefaultLessonMarkThresholds { get; } =
|
||||||
[
|
[
|
||||||
0.85f,
|
0.85f,
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ public static class AttendanceStatuses
|
|||||||
public static class AbsenceReasons
|
public static class AbsenceReasons
|
||||||
{
|
{
|
||||||
public const string Truancy = "truancy";
|
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>
|
/// <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 System.Text.Json.Serialization;
|
||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
namespace HSchool.People;
|
namespace HSchool.People;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One medical condition on a person (hediff-like). Needs stay separate — a disease may
|
/// 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>
|
/// </summary>
|
||||||
public sealed class HealthCondition
|
public sealed class HealthCondition
|
||||||
{
|
{
|
||||||
public required string DefName { get; init; }
|
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; }
|
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; }
|
public float Progress { get; set; }
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
@@ -23,7 +24,7 @@ public sealed class HealthCondition
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Base severity change per game day before the person+day seed factor.
|
/// 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>
|
/// </summary>
|
||||||
public float SeverityPerDay { get; init; }
|
public float SeverityPerDay { get; init; }
|
||||||
|
|
||||||
@@ -31,6 +32,14 @@ public sealed class HealthCondition
|
|||||||
public float ProgressPerDay { get; init; }
|
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>
|
/// <summary>Mutates the sparse <see cref="Person.Conditions"/> list and ticks severity deterministically.</summary>
|
||||||
public static class HealthConditions
|
public static class HealthConditions
|
||||||
{
|
{
|
||||||
@@ -49,11 +58,39 @@ public static class HealthConditions
|
|||||||
list.Add(condition);
|
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>
|
/// <summary>
|
||||||
/// Advances severity/progress for everyone with conditions. Same
|
/// Advances severity/progress for everyone with conditions. Same
|
||||||
/// <paramref name="peopleSeed"/>, person id and <paramref name="dayNumber"/> yield the same curve.
|
/// <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>
|
/// </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);
|
ArgumentNullException.ThrowIfNull(person);
|
||||||
var list = person.Conditions;
|
var list = person.Conditions;
|
||||||
@@ -67,11 +104,24 @@ public static class HealthConditions
|
|||||||
var dayFactor = 0.5f + unit;
|
var dayFactor = 0.5f + unit;
|
||||||
var days = gameMinutes / (24d * 60d);
|
var days = gameMinutes / (24d * 60d);
|
||||||
var changed = false;
|
var changed = false;
|
||||||
|
List<HealthCondition>? recovered = null;
|
||||||
|
var clock = now ?? DateTime.SpecifyKind(DateTime.UnixEpoch.AddDays(dayNumber), DateTimeKind.Utc);
|
||||||
|
|
||||||
foreach (var condition in list)
|
foreach (var condition in list)
|
||||||
{
|
{
|
||||||
var severityDelta = (float)(condition.SeverityPerDay * dayFactor * days);
|
var severityPerDay = condition.SeverityPerDay;
|
||||||
var progressDelta = (float)(condition.ProgressPerDay * dayFactor * days);
|
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)
|
if (severityDelta == 0f && progressDelta == 0f)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -87,8 +137,120 @@ public static class HealthConditions
|
|||||||
condition.Severity = nextSeverity;
|
condition.Severity = nextSeverity;
|
||||||
condition.Progress = nextProgress;
|
condition.Progress = nextProgress;
|
||||||
changed = true;
|
changed = true;
|
||||||
|
|
||||||
|
if (nextProgress >= 1f)
|
||||||
|
{
|
||||||
|
recovered ??= [];
|
||||||
|
recovered.Add(condition);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (recovered is null)
|
||||||
|
{
|
||||||
return changed;
|
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>
|
/// </summary>
|
||||||
public List<HealthCondition>? Conditions { get; set; }
|
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);
|
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 HealthSalt = 17;
|
||||||
public const int MeetingSalt = 18;
|
public const int MeetingSalt = 18;
|
||||||
public const int MeetingAttendSalt = 19;
|
public const int MeetingAttendSalt = 19;
|
||||||
|
public const int DiseaseSalt = 20;
|
||||||
|
|
||||||
/// <summary>A stream that belongs to the school rather than to one family.</summary>
|
/// <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);
|
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
|
||||||
|
|||||||
@@ -121,4 +121,6 @@
|
|||||||
"lessonLateMinutes": 5,
|
"lessonLateMinutes": 5,
|
||||||
// Late still gets a mark when standing (stage A). False would skip the mark for late arrivals.
|
// Late still gets a mark when standing (stage A). False would skip the mark for late arrivals.
|
||||||
"lessonMarkWhenLate": true,
|
"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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -236,5 +236,9 @@
|
|||||||
"GoingToPrincipal": "going to the principal",
|
"GoingToPrincipal": "going to the principal",
|
||||||
"WaitForPrincipal": "waiting at the principal's office",
|
"WaitForPrincipal": "waiting at the principal's office",
|
||||||
"GoingToParentMeeting": "going to a parent meeting",
|
"GoingToParentMeeting": "going to a parent meeting",
|
||||||
|
"CommonCold": "Common cold",
|
||||||
|
"Influenza": "Influenza",
|
||||||
|
"StomachBug": "Stomach bug",
|
||||||
|
"Otitis": "Ear infection",
|
||||||
"core": "Core",
|
"core": "Core",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,5 +236,9 @@
|
|||||||
"GoingToPrincipal": "идёт к директору",
|
"GoingToPrincipal": "идёт к директору",
|
||||||
"WaitForPrincipal": "ждёт у кабинета директора",
|
"WaitForPrincipal": "ждёт у кабинета директора",
|
||||||
"GoingToParentMeeting": "идёт на собрание",
|
"GoingToParentMeeting": "идёт на собрание",
|
||||||
|
"CommonCold": "ОРВИ",
|
||||||
|
"Influenza": "Грипп",
|
||||||
|
"StomachBug": "Кишечная инфекция",
|
||||||
|
"Otitis": "Отит",
|
||||||
"core": "Базовая игра",
|
"core": "Базовая игра",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,15 @@ internal static class AttendanceSystem
|
|||||||
continue;
|
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(
|
if (AttendanceMemory.Record(
|
||||||
person,
|
person,
|
||||||
lesson.Subject,
|
lesson.Subject,
|
||||||
@@ -124,7 +133,7 @@ internal static class AttendanceSystem
|
|||||||
school.Clock.Time,
|
school.Clock.Time,
|
||||||
period,
|
period,
|
||||||
rules,
|
rules,
|
||||||
AbsenceReasons.Truancy))
|
reason))
|
||||||
{
|
{
|
||||||
school.RosterTalkDirty = true;
|
school.RosterTalkDirty = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
internal static class HealthConditionSystem
|
internal static class HealthConditionSystem
|
||||||
{
|
{
|
||||||
public static bool Apply(School school, double gameMinutes)
|
public static bool Apply(School school, double gameMinutes) => DiseaseSystem.Apply(school, 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ internal static class LessonLearningSystem
|
|||||||
}
|
}
|
||||||
|
|
||||||
textbookFactor *= TalkCircles.LessonWhisperFactor(activity.ActionId, rules);
|
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)
|
IReadOnlyDictionary<string, float> taught = teacherSkills.TryGetValue(lesson.TeacherId, out var found)
|
||||||
? found
|
? found
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ public sealed class School : IDisposable
|
|||||||
/// <summary>Last slot <see cref="AttendanceSystem"/> observed — detects leaving a lesson to finalize absents.</summary>
|
/// <summary>Last slot <see cref="AttendanceSystem"/> observed — detects leaving a lesson to finalize absents.</summary>
|
||||||
internal DaySlot? LastAttendanceSlot { get; set; }
|
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 Dictionary<string, DayPlan> Plans { get; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
internal Queue<string> DecisionQueue { get; } = new();
|
internal Queue<string> DecisionQueue { get; } = new();
|
||||||
@@ -227,6 +230,7 @@ public sealed class School : IDisposable
|
|||||||
PlanDay = null;
|
PlanDay = null;
|
||||||
LastDecisionSlot = null;
|
LastDecisionSlot = null;
|
||||||
LastAttendanceSlot = null;
|
LastAttendanceSlot = null;
|
||||||
|
LastDiseaseDay = int.MinValue;
|
||||||
Plans.Clear();
|
Plans.Clear();
|
||||||
DecisionQueue.Clear();
|
DecisionQueue.Clear();
|
||||||
LoggedActivity.Clear();
|
LoggedActivity.Clear();
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
|
namespace HSchool.Content.Tests;
|
||||||
|
|
||||||
|
public class DiseaseDefTests
|
||||||
|
{
|
||||||
|
private readonly CatalogLoader _loader = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VanillaCore_LoadsSeveralDiseases()
|
||||||
|
{
|
||||||
|
var catalog = LoadVanilla();
|
||||||
|
|
||||||
|
Assert.True(catalog.Diseases.ContainsKey("CommonCold"));
|
||||||
|
Assert.True(catalog.Diseases.ContainsKey("Influenza"));
|
||||||
|
Assert.True(catalog.Diseases.ContainsKey("StomachBug"));
|
||||||
|
Assert.True(catalog.Diseases.ContainsKey("Otitis"));
|
||||||
|
|
||||||
|
var cold = catalog.Diseases["CommonCold"];
|
||||||
|
Assert.Equal(DiseaseFamilies.Respiratory, cold.Family);
|
||||||
|
Assert.Equal(5f, cold.ColdBelowC);
|
||||||
|
Assert.True(cold.Stages.Count >= 2);
|
||||||
|
Assert.Equal(0.5f, cold.IncubationDays);
|
||||||
|
Assert.Equal(14f, cold.ImmunityDays);
|
||||||
|
|
||||||
|
Assert.Equal("ОРВИ", catalog.Label("ru", cold));
|
||||||
|
Assert.Equal("Common cold", catalog.Label("en", cold));
|
||||||
|
Assert.Equal(1f, catalog.BehaviorRules!.DiseaseVectorScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingFamily_FailsTheCatalog()
|
||||||
|
{
|
||||||
|
var documents = PackDocuments.FromDirectory(
|
||||||
|
CatalogLoader.CorePackId,
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "vanilla"))
|
||||||
|
.Append(PackDocuments.Def(
|
||||||
|
CatalogLoader.CorePackId,
|
||||||
|
"diseases",
|
||||||
|
"bad",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"defName": "BadDisease",
|
||||||
|
"incubationDays": 1,
|
||||||
|
"immunityDays": 1,
|
||||||
|
"stages": [{ "minSeverity": 0, "severityPerDay": 0.1, "progressPerDay": 0.1 }]
|
||||||
|
}
|
||||||
|
"""))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load([CatalogLoader.CorePackId], documents));
|
||||||
|
Assert.Contains("family", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingStages_FailsTheCatalog()
|
||||||
|
{
|
||||||
|
var documents = PackDocuments.FromDirectory(
|
||||||
|
CatalogLoader.CorePackId,
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "vanilla"))
|
||||||
|
.Append(PackDocuments.Def(
|
||||||
|
CatalogLoader.CorePackId,
|
||||||
|
"diseases",
|
||||||
|
"bad-stages",
|
||||||
|
"""{ "defName": "NoStages", "family": "respiratory", "incubationDays": 0, "immunityDays": 1 }"""))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load([CatalogLoader.CorePackId], documents));
|
||||||
|
Assert.Contains("stages", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DefCatalog LoadVanilla()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
|
||||||
|
return _loader.Load([CatalogLoader.CorePackId], PackDocuments.FromDirectory(CatalogLoader.CorePackId, root));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
|
namespace HSchool.People.Tests;
|
||||||
|
|
||||||
|
public class DiseaseEffectTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TwoDiseaseDefs_YieldDifferentSeverityCurves()
|
||||||
|
{
|
||||||
|
var catalog = LoadVanilla();
|
||||||
|
var cold = catalog.Diseases["CommonCold"];
|
||||||
|
var flu = catalog.Diseases["Influenza"];
|
||||||
|
var started = new DateTime(2012, 4, 3, 8, 0, 0, DateTimeKind.Utc);
|
||||||
|
var a = Blank("p1");
|
||||||
|
var b = Blank("p1");
|
||||||
|
HealthConditions.Add(a, Onset(cold.DefName, started, severity: 0.1f));
|
||||||
|
HealthConditions.Add(b, Onset(flu.DefName, started, severity: 0.1f));
|
||||||
|
|
||||||
|
Assert.True(HealthConditions.Tick(a, peopleSeed: 7, dayNumber: 100, gameMinutes: 24 * 60, catalog, started));
|
||||||
|
Assert.True(HealthConditions.Tick(b, peopleSeed: 7, dayNumber: 100, gameMinutes: 24 * 60, catalog, started));
|
||||||
|
|
||||||
|
Assert.NotEqual(a.Conditions![0].Severity, b.Conditions![0].Severity);
|
||||||
|
Assert.True(a.Conditions[0].Severity > 0.1f);
|
||||||
|
Assert.True(b.Conditions[0].Severity > 0.1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HeavyStage_StaysHomeMoreOftenThanMild()
|
||||||
|
{
|
||||||
|
var catalog = LoadVanilla();
|
||||||
|
var started = new DateTime(2012, 1, 1, 6, 0, 0, DateTimeKind.Utc);
|
||||||
|
var mild = Blank("pupil");
|
||||||
|
var heavy = Blank("pupil");
|
||||||
|
HealthConditions.Add(mild, Onset("Influenza", started, severity: 0.1f));
|
||||||
|
HealthConditions.Add(heavy, Onset("Influenza", started, severity: 0.8f));
|
||||||
|
|
||||||
|
// Past incubation so stage effects apply. Same person, different days — the day salt varies the roll.
|
||||||
|
var day = started.AddDays(2);
|
||||||
|
var mildHome = 0;
|
||||||
|
var heavyHome = 0;
|
||||||
|
for (var i = 0; i < 200; i++)
|
||||||
|
{
|
||||||
|
var when = day.AddDays(i);
|
||||||
|
if (DiseaseEffects.ShouldStayHome(mild, catalog, peopleSeed: 11, when))
|
||||||
|
{
|
||||||
|
mildHome++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DiseaseEffects.ShouldStayHome(heavy, catalog, peopleSeed: 11, when))
|
||||||
|
{
|
||||||
|
heavyHome++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(heavyHome > mildHome);
|
||||||
|
Assert.True(heavyHome > 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Attendance_IllnessReason_IsNotTruancy()
|
||||||
|
{
|
||||||
|
var person = Blank("p1");
|
||||||
|
var rules = new BehaviorDef { DefName = "Behavior", AttendanceMax = 10 };
|
||||||
|
var time = new DateTime(2012, 4, 3, 9, 15, 0, DateTimeKind.Utc);
|
||||||
|
Assert.True(AttendanceMemory.Record(
|
||||||
|
person,
|
||||||
|
"Mathematics",
|
||||||
|
AttendanceStatuses.Absent,
|
||||||
|
time,
|
||||||
|
period: 1,
|
||||||
|
rules,
|
||||||
|
AbsenceReasons.Illness));
|
||||||
|
|
||||||
|
Assert.Equal(AbsenceReasons.Illness, person.Attendance![0].AbsenceReason);
|
||||||
|
Assert.NotEqual(AbsenceReasons.Truancy, person.Attendance[0].AbsenceReason);
|
||||||
|
|
||||||
|
var json = RosterJson.Serialize(RosterDocument.From(1, new Roster([person], [], [])));
|
||||||
|
var loaded = RosterJson.Parse(json).ToRoster().People[0];
|
||||||
|
Assert.Equal(AbsenceReasons.Illness, loaded.Attendance![0].AbsenceReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recovery_GrantsTemporaryImmunity()
|
||||||
|
{
|
||||||
|
var catalog = LoadVanilla();
|
||||||
|
var started = new DateTime(2012, 4, 3, 8, 0, 0, DateTimeKind.Utc);
|
||||||
|
var person = Blank("p1");
|
||||||
|
HealthConditions.Add(
|
||||||
|
person,
|
||||||
|
new HealthCondition
|
||||||
|
{
|
||||||
|
DefName = "CommonCold",
|
||||||
|
Severity = 0.2f,
|
||||||
|
Progress = 0.99f,
|
||||||
|
Source = "cold",
|
||||||
|
StartedAt = started,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(HealthConditions.Tick(
|
||||||
|
person,
|
||||||
|
peopleSeed: 3,
|
||||||
|
dayNumber: 50,
|
||||||
|
gameMinutes: 24 * 60 * 3,
|
||||||
|
catalog,
|
||||||
|
started.AddDays(1)));
|
||||||
|
|
||||||
|
Assert.Null(person.Conditions);
|
||||||
|
Assert.NotNull(person.DiseaseImmunities);
|
||||||
|
Assert.Equal("CommonCold", person.DiseaseImmunities![0].DefName);
|
||||||
|
Assert.True(person.DiseaseImmunities[0].Until > started);
|
||||||
|
Assert.True(DiseaseImmunities.IsImmune(person, "CommonCold", started.AddDays(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HealthCondition Onset(string defName, DateTime started, float severity) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
DefName = defName,
|
||||||
|
Severity = severity,
|
||||||
|
Progress = 0f,
|
||||||
|
Source = "cold",
|
||||||
|
StartedAt = DateTime.SpecifyKind(started, DateTimeKind.Utc),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static DefCatalog LoadVanilla()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
|
||||||
|
return new CatalogLoader().Load(
|
||||||
|
[CatalogLoader.CorePackId],
|
||||||
|
PackDocuments.FromDirectory(CatalogLoader.CorePackId, root));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Person Blank(string id)
|
||||||
|
{
|
||||||
|
var cases = new CaseTable
|
||||||
|
{
|
||||||
|
Nom = id,
|
||||||
|
Gen = id,
|
||||||
|
Dat = id,
|
||||||
|
Acc = id,
|
||||||
|
Ins = id,
|
||||||
|
Pre = id,
|
||||||
|
};
|
||||||
|
return new Person
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
FamilyId = "f",
|
||||||
|
Female = false,
|
||||||
|
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||||
|
Name = new PersonName(id, id, id, cases, cases, cases),
|
||||||
|
IsStudent = true,
|
||||||
|
IsStaff = false,
|
||||||
|
IsParent = false,
|
||||||
|
Numbers = new Dictionary<string, int>(StringComparer.Ordinal),
|
||||||
|
Choices = new Dictionary<string, string>(StringComparer.Ordinal),
|
||||||
|
Skills = new Dictionary<string, int>(StringComparer.Ordinal),
|
||||||
|
Traits = [],
|
||||||
|
Needs = new Dictionary<string, float>(StringComparer.Ordinal),
|
||||||
|
Opinions = new Dictionary<string, int>(StringComparer.Ordinal),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using Arch.Core;
|
||||||
|
using HSchool.Ai;
|
||||||
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
|
using HSchool.Schedule;
|
||||||
|
|
||||||
|
namespace HSchool.Simulation.Tests;
|
||||||
|
|
||||||
|
public class DiseaseSimulationTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
|
||||||
|
private static readonly DateTime LessonStart = new(2012, 4, 3, 8, 30, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ColdWeather_RaisesRespiratoryOnsetVsWarmControl()
|
||||||
|
{
|
||||||
|
using var coldSchool = OpenStaffed();
|
||||||
|
using var warmSchool = OpenStaffed();
|
||||||
|
coldSchool.ForceWeather(new OutdoorWeather(-10f, Precipitation.Snow));
|
||||||
|
warmSchool.ForceWeather(new OutdoorWeather(18f, Precipitation.None));
|
||||||
|
|
||||||
|
var coldHits = CountRespiratoryOnset(coldSchool);
|
||||||
|
var warmHits = CountRespiratoryOnset(warmSchool);
|
||||||
|
|
||||||
|
Assert.True(coldHits > warmHits);
|
||||||
|
Assert.True(coldHits > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HeavyInfluenza_MarksAbsenceAsIllness()
|
||||||
|
{
|
||||||
|
var (school, _, pupilId, _) = StaffedMath();
|
||||||
|
using (school)
|
||||||
|
{
|
||||||
|
var pupil = school.Roster!.People.First(row => row.Id == pupilId);
|
||||||
|
HealthConditions.Add(
|
||||||
|
pupil,
|
||||||
|
new HealthCondition
|
||||||
|
{
|
||||||
|
DefName = "Influenza",
|
||||||
|
Severity = 0.85f,
|
||||||
|
Progress = 0.1f,
|
||||||
|
Source = "cold",
|
||||||
|
StartedAt = LessonStart.AddDays(-3),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(DiseaseEffects.StayHomeChance(pupil, school.Catalog!, LessonStart) >= 0.5f);
|
||||||
|
|
||||||
|
school.PlanDay = null;
|
||||||
|
AdvanceTo(school, LessonStart.AddMinutes(-1));
|
||||||
|
SetPlace(school, pupilId, null);
|
||||||
|
school.Clock.JumpTo(LessonStart);
|
||||||
|
AttendanceSystem.Apply(school);
|
||||||
|
school.Clock.JumpTo(LessonStart.AddMinutes(45));
|
||||||
|
AttendanceSystem.Apply(school);
|
||||||
|
|
||||||
|
var row = school.Roster.People.First(p => p.Id == pupilId).Attendance?.LastOrDefault();
|
||||||
|
Assert.NotNull(row);
|
||||||
|
Assert.Equal(AttendanceStatuses.Absent, row!.Status);
|
||||||
|
Assert.Equal(AbsenceReasons.Illness, row.AbsenceReason);
|
||||||
|
Assert.NotEqual(AbsenceReasons.Truancy, row.AbsenceReason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountRespiratoryOnset(School school)
|
||||||
|
{
|
||||||
|
// Force the day-onset pass with fixed weather.
|
||||||
|
school.LastDiseaseDay = int.MinValue;
|
||||||
|
DiseaseSystem.Apply(school, gameMinutes: 0);
|
||||||
|
return school.Roster!.People.Count(person =>
|
||||||
|
person.Conditions is not null
|
||||||
|
&& person.Conditions.Any(row =>
|
||||||
|
row.DefName is "CommonCold" or "Influenza" or "Otitis"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetPlace(School school, string personId, string? nodeId)
|
||||||
|
{
|
||||||
|
var query = new QueryDescription().WithAll<PersonIdentity, Presence>();
|
||||||
|
school.World.Query(
|
||||||
|
in query,
|
||||||
|
(ref PersonIdentity identity, ref Presence presence) =>
|
||||||
|
{
|
||||||
|
if (!identity.Id.Equals(personId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
presence = nodeId is null
|
||||||
|
? Presence.OffCampus
|
||||||
|
: new Presence(nodeId, 0f, nodeId, false, []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AdvanceTo(School school, DateTime until)
|
||||||
|
{
|
||||||
|
while (school.Clock.Time < until)
|
||||||
|
{
|
||||||
|
school.Tick(0.2d, 5d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (School School, string Room, string PupilId, string TeacherId) StaffedMath()
|
||||||
|
{
|
||||||
|
var (catalog, map) = Vanilla();
|
||||||
|
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", TuesdayMorning);
|
||||||
|
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", TuesdayMorning);
|
||||||
|
var hired = Staffing.Hire(catalog, map, roster, pool, pool.Applicants[0].Person.Id, Staffing.TeacherPosition, 1_000_000f);
|
||||||
|
Assert.Equal(StaffingError.None, hired.Error);
|
||||||
|
roster = hired.Roster;
|
||||||
|
pool = hired.Pool;
|
||||||
|
var hiredId = roster.People.First(person => person.IsStaff).Id;
|
||||||
|
var schoolClass = roster.Classes.First(row =>
|
||||||
|
row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104");
|
||||||
|
var school = School.Create(1, "Болезни", TuesdayMorning, catalog, map);
|
||||||
|
school.InstallPeople(roster, seed: 1, "Russia", pool);
|
||||||
|
school.SetTimetable(new Timetable(
|
||||||
|
[new LessonPlacement(schoolClass.Id, "Mathematics", hiredId, schoolClass.RoomId, Day: 1, Period: 1)],
|
||||||
|
[]));
|
||||||
|
school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
|
||||||
|
var pupil = schoolClass.PupilIds
|
||||||
|
.Select(id => school.Roster!.People.First(person => person.Id == id))
|
||||||
|
.First(person => !person.Traits.Contains("Lazy"));
|
||||||
|
return (school, schoolClass.RoomId, pupil.Id, hiredId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static School OpenStaffed()
|
||||||
|
{
|
||||||
|
var start = TuesdayMorning;
|
||||||
|
var (catalog, map) = Vanilla();
|
||||||
|
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 42, "Russia", start);
|
||||||
|
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 42, "Russia", start);
|
||||||
|
var school = School.Create(1, "Болезни", start, catalog, map);
|
||||||
|
school.InstallPeople(roster, seed: 42, "Russia", pool);
|
||||||
|
school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
|
||||||
|
return school;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (DefCatalog Catalog, MapLayout Map) Vanilla()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
|
||||||
|
var documents = new List<ContentDocument>();
|
||||||
|
foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var relative = Path.GetRelativePath(root, path).Replace('\\', '/');
|
||||||
|
documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
|
||||||
|
var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
|
||||||
|
Assert.NotNull(map);
|
||||||
|
return (catalog, map);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user