diff --git a/docs/phases/14-health/79-contagion.md b/docs/phases/14-health/79-contagion.md
index bb4e452..855e2f2 100644
--- a/docs/phases/14-health/79-contagion.md
+++ b/docs/phases/14-health/79-contagion.md
@@ -11,18 +11,18 @@
## Задачи
-- [ ] Поля заразности на `DiseaseDef`: узел и/или класс, шанс, носительство в инкубации
-- [ ] Контакт считает поток школы; детерминизм от сида пары/узла/дня
-- [ ] Не один глобальный `Random` на мир
-- [ ] Info-тост на вспышку (порог числа новых случаев), не на каждый чих
-- [ ] Иммунитет и уже болеющие не заражаются вопреки def
+- [x] Поля заразности на `DiseaseDef`: узел и/или класс, шанс, носительство в инкубации
+- [x] Контакт считает поток школы; детерминизм от сида пары/узла/дня
+- [x] Не один глобальный `Random` на мир
+- [x] Info-тост на вспышку (порог числа новых случаев), не на каждый чих
+- [x] Иммунитет и уже болеющие не заражаются вопреки def
## Тесты, без которых фаза не закрыта
-- [ ] Заразный и здоровый в одном узле → здоровый заболевает чаще, чем изолированный контроль
-- [ ] Тот же сид → те же передачи в золотом сценарии
-- [ ] Def без заразности не прыгает по классу
-- [ ] Notice вспышки эмитится при пороге
+- [x] Заразный и здоровый в одном узле → здоровый заболевает чаще, чем изолированный контроль
+- [x] Тот же сид → те же передачи в золотом сценарии
+- [x] Def без заразности не прыгает по классу
+- [x] Notice вспышки эмитится при пороге
## Критерий готовности
diff --git a/docs/phases/14-health/README.md b/docs/phases/14-health/README.md
index 35724cd..c74b7d0 100644
--- a/docs/phases/14-health/README.md
+++ b/docs/phases/14-health/README.md
@@ -24,7 +24,7 @@
| Фаза | Статус | Зачем |
| --- | --- | --- |
-| [79. Заразность](79-contagion.md) | 🔄 | Узел/класс, детерминизм, тост вспышки |
+| [79. Заразность](79-contagion.md) | ✅ | Узел/класс, детерминизм, тост вспышки |
**Этап D — уход и экран.**
diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts
index 352e8d1..85b98c0 100644
--- a/src/HSchool.Client/src/i18n/strings.ts
+++ b/src/HSchool.Client/src/i18n/strings.ts
@@ -403,6 +403,7 @@ const ru = {
GenerationFailed: 'Не удалось нарисовать портрет',
DirectorSummoned: 'Ученика вызвали к директору',
ParentMeeting: 'Идёт родительское собрание',
+ DiseaseOutbreak: 'В школе распространяется болезнь',
noticeDismiss: 'Закрыть',
noticeGenerateImage: 'Создать картинку',
noticeGenerateImageBusy: 'Рисуем…',
@@ -814,6 +815,7 @@ const en: Messages = {
GenerationFailed: 'Portrait generation failed',
DirectorSummoned: 'A pupil was summoned to the principal',
ParentMeeting: 'A parent meeting is under way',
+ DiseaseOutbreak: 'An illness is spreading at school',
noticeDismiss: 'Close',
noticeGenerateImage: 'Create image',
noticeGenerateImageBusy: 'Drawing…',
diff --git a/src/HSchool.Client/src/ui/noticeToasts.ts b/src/HSchool.Client/src/ui/noticeToasts.ts
index 53a7502..53f8253 100644
--- a/src/HSchool.Client/src/ui/noticeToasts.ts
+++ b/src/HSchool.Client/src/ui/noticeToasts.ts
@@ -11,6 +11,7 @@ export function noticeLabel(defName: string): string {
case 'GenerationFailed':
case 'DirectorSummoned':
case 'ParentMeeting':
+ case 'DiseaseOutbreak':
return t(defName satisfies MessageKey);
default:
return defName;
diff --git a/src/HSchool.Content/DiseaseDefValidator.cs b/src/HSchool.Content/DiseaseDefValidator.cs
index 77102cf..d2004e5 100644
--- a/src/HSchool.Content/DiseaseDefValidator.cs
+++ b/src/HSchool.Content/DiseaseDefValidator.cs
@@ -46,11 +46,19 @@ internal static class DiseaseDefValidator
if (def.BaseChancePerDay < 0f
|| def.ColdChancePerDay < 0f
|| def.RainChancePerDay < 0f
- || def.SnowChancePerDay < 0f)
+ || def.SnowChancePerDay < 0f
+ || def.NodeContagionChancePerDay < 0f
+ || def.ClassContagionChancePerDay < 0f)
{
throw new ContentLoadException($"DiseaseDef '{def.DefName}' chance fields cannot be negative.");
}
+ if (def.NodeContagionChancePerDay > 1f || def.ClassContagionChancePerDay > 1f)
+ {
+ throw new ContentLoadException(
+ $"DiseaseDef '{def.DefName}' contagion chances must be 0–1.");
+ }
+
DiseaseStage? previous = null;
foreach (var stage in def.Stages)
{
diff --git a/src/HSchool.Content/DiseaseDefs.cs b/src/HSchool.Content/DiseaseDefs.cs
index 91a86c4..6f282f1 100644
--- a/src/HSchool.Content/DiseaseDefs.cs
+++ b/src/HSchool.Content/DiseaseDefs.cs
@@ -31,8 +31,8 @@ public sealed class DiseaseStage
}
///
-/// A named disease: stages, weather vectors, lesson/attendance effects, temporary immunity.
-/// Contagion between people is phase 79 — these defs may omit it.
+/// A named disease: stages, weather vectors, lesson/attendance effects, temporary immunity,
+/// and optional person-to-person contagion (node and/or class).
///
public sealed class DiseaseDef : Def
{
@@ -58,4 +58,18 @@ public sealed class DiseaseDef : Def
/// Days of immunity to this def after recovery.
public float ImmunityDays { get; init; }
+
+ ///
+ /// Daily chance to infect each other person sharing the same map node. 0 — no node contagion.
+ ///
+ public float NodeContagionChancePerDay { get; init; }
+
+ ///
+ /// Daily chance to infect each classmate (same ClassId) while both are on campus.
+ /// 0 — no class contagion.
+ ///
+ public float ClassContagionChancePerDay { get; init; }
+
+ /// When true, the carrier can infect during incubation before stage effects apply.
+ public bool ContagiousDuringIncubation { get; init; }
}
diff --git a/src/HSchool.Content/EventDefValidator.cs b/src/HSchool.Content/EventDefValidator.cs
index 5c4b3e7..de59416 100644
--- a/src/HSchool.Content/EventDefValidator.cs
+++ b/src/HSchool.Content/EventDefValidator.cs
@@ -16,6 +16,7 @@ internal static class EventDefValidator
EventTriggers.GenerationFailed,
EventTriggers.DirectorSummon,
EventTriggers.ParentMeeting,
+ EventTriggers.DiseaseOutbreak,
};
private static readonly HashSet Actions = new(StringComparer.Ordinal)
diff --git a/src/HSchool.Content/EventDefs.cs b/src/HSchool.Content/EventDefs.cs
index a011bee..bf97aa4 100644
--- a/src/HSchool.Content/EventDefs.cs
+++ b/src/HSchool.Content/EventDefs.cs
@@ -14,6 +14,7 @@ public static class EventTriggers
public const string GenerationFailed = "generationFailed";
public const string DirectorSummon = "directorSummon";
public const string ParentMeeting = "parentMeeting";
+ public const string DiseaseOutbreak = "diseaseOutbreak";
}
public static class EventActions
diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs
index b4bfe39..d837a77 100644
--- a/src/HSchool.Content/PeopleDefValidator.cs
+++ b/src/HSchool.Content/PeopleDefValidator.cs
@@ -757,6 +757,12 @@ internal static class PeopleDefValidator
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' diseaseVectorScale cannot be negative.");
}
+
+ if (behavior.DiseaseOutbreakMinNewCases < 0)
+ {
+ throw new ContentLoadException(
+ $"BehaviorDef '{behavior.DefName}' diseaseOutbreakMinNewCases cannot be negative.");
+ }
}
private static void ValidateTopic(TopicDef topic, DefCatalog catalog)
diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs
index 5beeb69..bd7c8c6 100644
--- a/src/HSchool.Content/PeopleDefs.cs
+++ b/src/HSchool.Content/PeopleDefs.cs
@@ -660,6 +660,12 @@ public sealed class BehaviorDef : Def
///
public float DiseaseVectorScale { get; init; } = 1f;
+ ///
+ /// New contagion cases in one day at or above this raise a diseaseOutbreak world event.
+ /// 0 disables the toast.
+ ///
+ public int DiseaseOutbreakMinNewCases { get; init; } = 3;
+
public static IReadOnlyList DefaultLessonMarkThresholds { get; } =
[
0.85f,
diff --git a/src/HSchool.People/DiseaseEffects.cs b/src/HSchool.People/DiseaseEffects.cs
index 9d3d24d..7c92e47 100644
--- a/src/HSchool.People/DiseaseEffects.cs
+++ b/src/HSchool.People/DiseaseEffects.cs
@@ -4,7 +4,7 @@ namespace HSchool.People;
///
/// Reads DiseaseDef stages for lesson gain, stay-home rolls and warmth decay.
-/// Contagion between people is phase 79.
+/// Contagion rolls live in DiseaseSystem; this type only answers stage questions.
///
public static class DiseaseEffects
{
diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs
index 3ce9ebe..f595e71 100644
--- a/src/HSchool.People/Seed.cs
+++ b/src/HSchool.People/Seed.cs
@@ -26,6 +26,7 @@ public static class Seed
public const int MeetingSalt = 18;
public const int MeetingAttendSalt = 19;
public const int DiseaseSalt = 20;
+ public const int ContagionSalt = 21;
/// A stream that belongs to the school rather than to one family.
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
@@ -49,6 +50,19 @@ public static class Seed
return (int)z;
}
+ ///
+ /// Contact roll for an ordered person pair on one day (contagion). Ids are sorted so
+ /// A→B and B→A share the stream; channel salt separates node vs class.
+ ///
+ public static int MixPair(int schoolSeed, string personA, string personB, int dayNumber, int salt)
+ {
+ ArgumentNullException.ThrowIfNull(personA);
+ ArgumentNullException.ThrowIfNull(personB);
+ var first = string.CompareOrdinal(personA, personB) <= 0 ? personA : personB;
+ var second = string.CompareOrdinal(personA, personB) <= 0 ? personB : personA;
+ return Mix(schoolSeed, first + "\u001f" + second, dayNumber, salt);
+ }
+
private static uint Stable(string value)
{
ulong z = 0;
diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
index c232479..f848727 100644
--- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
+++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
@@ -123,4 +123,6 @@
"lessonMarkWhenLate": true,
// Scales DiseaseDef onset from weather/base (slice 14 phase 78). 0 disables natural onset.
"diseaseVectorScale": 1,
+ // Contagion cases in one day at or above this raise diseaseOutbreak (slice 14 phase 79).
+ "diseaseOutbreakMinNewCases": 3,
}
diff --git a/src/HSchool.Server/mods/core/defs/diseases/vanilla.jsonc b/src/HSchool.Server/mods/core/defs/diseases/vanilla.jsonc
index 118a2b2..e63bf35 100644
--- a/src/HSchool.Server/mods/core/defs/diseases/vanilla.jsonc
+++ b/src/HSchool.Server/mods/core/defs/diseases/vanilla.jsonc
@@ -9,6 +9,9 @@
"coldChancePerDay": 0.08,
"rainChancePerDay": 0.03,
"snowChancePerDay": 0.04,
+ "nodeContagionChancePerDay": 0.55,
+ "classContagionChancePerDay": 0.25,
+ "contagiousDuringIncubation": true,
"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 },
@@ -25,6 +28,9 @@
"coldChancePerDay": 0.06,
"rainChancePerDay": 0.02,
"snowChancePerDay": 0.05,
+ "nodeContagionChancePerDay": 0.65,
+ "classContagionChancePerDay": 0.35,
+ "contagiousDuringIncubation": true,
"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 },
@@ -38,6 +44,9 @@
"immunityDays": 10,
"baseChancePerDay": 0.012,
"rainChancePerDay": 0.01,
+ "nodeContagionChancePerDay": 0.3,
+ "classContagionChancePerDay": 0,
+ "contagiousDuringIncubation": false,
"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 },
diff --git a/src/HSchool.Server/mods/core/defs/events/notices.jsonc b/src/HSchool.Server/mods/core/defs/events/notices.jsonc
index ac1d731..c81e154 100644
--- a/src/HSchool.Server/mods/core/defs/events/notices.jsonc
+++ b/src/HSchool.Server/mods/core/defs/events/notices.jsonc
@@ -39,4 +39,12 @@
"trigger": "parentMeeting",
"action": "none",
},
+ {
+ "defName": "DiseaseOutbreak",
+ "severity": "info",
+ "pause": false,
+ "ttlMs": 8000,
+ "trigger": "diseaseOutbreak",
+ "action": "none",
+ },
]
diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc
index cceb340..3fee8e2 100644
--- a/src/HSchool.Server/mods/core/localizations/en.jsonc
+++ b/src/HSchool.Server/mods/core/localizations/en.jsonc
@@ -233,6 +233,7 @@
"GenerationFailed": "Portrait generation failed",
"DirectorSummoned": "A pupil was summoned to the principal",
"ParentMeeting": "A parent meeting is under way",
+ "DiseaseOutbreak": "An illness is spreading at school",
"GoingToPrincipal": "going to the principal",
"WaitForPrincipal": "waiting at the principal's office",
"GoingToParentMeeting": "going to a parent meeting",
diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc
index f00100e..915d922 100644
--- a/src/HSchool.Server/mods/core/localizations/ru.jsonc
+++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc
@@ -233,6 +233,7 @@
"GenerationFailed": "Не удалось нарисовать портрет",
"DirectorSummoned": "Ученика вызвали к директору",
"ParentMeeting": "Идёт родительское собрание",
+ "DiseaseOutbreak": "В школе распространяется болезнь",
"GoingToPrincipal": "идёт к директору",
"WaitForPrincipal": "ждёт у кабинета директора",
"GoingToParentMeeting": "идёт на собрание",
diff --git a/src/HSchool.Simulation/DiseaseSystem.cs b/src/HSchool.Simulation/DiseaseSystem.cs
index 54d23bb..afcc1be 100644
--- a/src/HSchool.Simulation/DiseaseSystem.cs
+++ b/src/HSchool.Simulation/DiseaseSystem.cs
@@ -1,14 +1,22 @@
+using Arch.Core;
+using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
namespace HSchool.Simulation;
///
-/// DiseaseDef onset from weather vectors and ticks that follow stage curves.
-/// Person-to-person contagion is phase 79.
+/// DiseaseDef onset from weather vectors, person-to-person contagion, and stage-curve ticks.
+/// Contagion rolls use pair+day seeds — never a shared world .
///
internal static class DiseaseSystem
{
+ private const int NodeChannelSalt = 0;
+ private const int ClassChannelSalt = 1;
+
+ private static readonly QueryDescription PresenceQuery =
+ new QueryDescription().WithAll();
+
public static bool Apply(School school, double gameMinutes)
{
if (school.Roster is null || school.Catalog is null)
@@ -30,6 +38,7 @@ internal static class DiseaseSystem
{
school.LastDiseaseDay = dayNumber;
changed |= TryOnset(school);
+ changed |= TryContagion(school);
}
if (gameMinutes > 0)
@@ -109,6 +118,236 @@ internal static class DiseaseSystem
return changed;
}
+ private static bool TryContagion(School school)
+ {
+ var catalog = school.Catalog!;
+ if (catalog.Diseases.Count == 0 || school.Roster is null)
+ {
+ return false;
+ }
+
+ var occupancy = CaptureOccupancy(school);
+ var dayNumber = DateOnly.FromDateTime(school.Clock.Time).DayNumber;
+ var now = school.Clock.Time;
+ var newCases = 0;
+ var changed = false;
+
+ foreach (var carrier in school.Roster.People)
+ {
+ if (carrier.Conditions is null || carrier.Conditions.Count == 0)
+ {
+ continue;
+ }
+
+ occupancy.TryGetValue(carrier.Id, out var carrierNode);
+
+ foreach (var condition in carrier.Conditions)
+ {
+ if (!catalog.Diseases.TryGetValue(condition.DefName, out var disease)
+ || disease.Abstract
+ || !CanTransmit(condition, disease, now))
+ {
+ continue;
+ }
+
+ if (disease.NodeContagionChancePerDay > 0f
+ && carrierNode is not null
+ && TryInfectNodeContacts(
+ school,
+ carrier,
+ disease,
+ carrierNode,
+ occupancy,
+ dayNumber,
+ ref newCases))
+ {
+ changed = true;
+ }
+
+ if (disease.ClassContagionChancePerDay > 0f
+ && carrier.ClassId is not null
+ && TryInfectClassContacts(
+ school,
+ carrier,
+ disease,
+ occupancy,
+ dayNumber,
+ ref newCases))
+ {
+ changed = true;
+ }
+ }
+ }
+
+ var threshold = catalog.BehaviorRules?.DiseaseOutbreakMinNewCases ?? 0;
+ if (threshold > 0 && newCases >= threshold)
+ {
+ school.RaiseWorldEvent(new WorldEvent(EventTriggers.DiseaseOutbreak));
+ }
+
+ return changed;
+ }
+
+ private static bool TryInfectNodeContacts(
+ School school,
+ Person carrier,
+ DiseaseDef disease,
+ string nodeId,
+ Dictionary occupancy,
+ int dayNumber,
+ ref int newCases)
+ {
+ var changed = false;
+ foreach (var (targetId, targetNode) in occupancy)
+ {
+ if (targetId.Equals(carrier.Id, StringComparison.Ordinal)
+ || targetNode is null
+ || !targetNode.Equals(nodeId, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var target = school.Roster!.People.FirstOrDefault(row =>
+ row.Id.Equals(targetId, StringComparison.Ordinal));
+ if (target is null)
+ {
+ continue;
+ }
+
+ if (TryInfect(
+ school,
+ carrier,
+ target,
+ disease,
+ dayNumber,
+ disease.NodeContagionChancePerDay,
+ NodeChannelSalt + Stable(nodeId),
+ ref newCases))
+ {
+ changed = true;
+ }
+ }
+
+ return changed;
+ }
+
+ private static bool TryInfectClassContacts(
+ School school,
+ Person carrier,
+ DiseaseDef disease,
+ Dictionary occupancy,
+ int dayNumber,
+ ref int newCases)
+ {
+ var classId = carrier.ClassId!;
+ var changed = false;
+ foreach (var target in school.Roster!.People)
+ {
+ if (target.Id.Equals(carrier.Id, StringComparison.Ordinal)
+ || target.ClassId is null
+ || !target.ClassId.Equals(classId, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ // Class contagion is a school contact: both must be on campus today.
+ if (!occupancy.TryGetValue(target.Id, out var targetNode) || targetNode is null)
+ {
+ continue;
+ }
+
+ if (!occupancy.TryGetValue(carrier.Id, out var carrierNode) || carrierNode is null)
+ {
+ continue;
+ }
+
+ if (TryInfect(
+ school,
+ carrier,
+ target,
+ disease,
+ dayNumber,
+ disease.ClassContagionChancePerDay,
+ ClassChannelSalt + Stable(classId),
+ ref newCases))
+ {
+ changed = true;
+ }
+ }
+
+ return changed;
+ }
+
+ private static bool TryInfect(
+ School school,
+ Person carrier,
+ Person target,
+ DiseaseDef disease,
+ int dayNumber,
+ float chance,
+ int channelSalt,
+ ref int newCases)
+ {
+ if (!target.IsStudent && !target.IsStaff)
+ {
+ return false;
+ }
+
+ if (HealthConditions.Has(target, disease.DefName)
+ || DiseaseImmunities.IsImmune(target, disease.DefName, school.Clock.Time))
+ {
+ return false;
+ }
+
+ var salt = Seed.ContagionSalt + Stable(disease.DefName) + channelSalt;
+ var roll = Seed.MixPair(school.PeopleSeed, carrier.Id, target.Id, dayNumber, salt);
+ var unit = (roll & int.MaxValue) / (float)int.MaxValue;
+ if (unit >= chance)
+ {
+ return false;
+ }
+
+ HealthConditions.Add(
+ target,
+ new HealthCondition
+ {
+ DefName = disease.DefName,
+ Severity = 0.05f,
+ Progress = 0f,
+ Source = "contagion",
+ StartedAt = school.Clock.Time,
+ });
+ newCases++;
+ return true;
+ }
+
+ private static bool CanTransmit(HealthCondition condition, DiseaseDef disease, DateTime now)
+ {
+ if (disease.NodeContagionChancePerDay <= 0f && disease.ClassContagionChancePerDay <= 0f)
+ {
+ return false;
+ }
+
+ if (disease.ContagiousDuringIncubation)
+ {
+ return true;
+ }
+
+ return DiseaseEffects.IsIncubated(condition, disease, now);
+ }
+
+ private static Dictionary CaptureOccupancy(School school)
+ {
+ var map = new Dictionary(StringComparer.Ordinal);
+ school.World.Query(
+ in PresenceQuery,
+ (ref PersonIdentity identity, ref Presence presence) =>
+ {
+ map[identity.Id] = presence.NodeId;
+ });
+ return map;
+ }
+
internal static float OnsetChance(DiseaseDef disease, OutdoorWeather weather, float vectorScale)
{
if (vectorScale <= 0f)
diff --git a/tests/HSchool.Content.Tests/DiseaseDefTests.cs b/tests/HSchool.Content.Tests/DiseaseDefTests.cs
index dbcd4a0..fdad46d 100644
--- a/tests/HSchool.Content.Tests/DiseaseDefTests.cs
+++ b/tests/HSchool.Content.Tests/DiseaseDefTests.cs
@@ -22,10 +22,19 @@ public class DiseaseDefTests
Assert.True(cold.Stages.Count >= 2);
Assert.Equal(0.5f, cold.IncubationDays);
Assert.Equal(14f, cold.ImmunityDays);
+ Assert.Equal(0.55f, cold.NodeContagionChancePerDay);
+ Assert.Equal(0.25f, cold.ClassContagionChancePerDay);
+ Assert.True(cold.ContagiousDuringIncubation);
+ Assert.Equal(0f, catalog.Diseases["Otitis"].NodeContagionChancePerDay);
+ Assert.Equal(0f, catalog.Diseases["Otitis"].ClassContagionChancePerDay);
Assert.Equal("ОРВИ", catalog.Label("ru", cold));
Assert.Equal("Common cold", catalog.Label("en", cold));
Assert.Equal(1f, catalog.BehaviorRules!.DiseaseVectorScale);
+ Assert.Equal(3, catalog.BehaviorRules.DiseaseOutbreakMinNewCases);
+ Assert.True(catalog.Events.ContainsKey("DiseaseOutbreak"));
+ Assert.Equal(EventTriggers.DiseaseOutbreak, catalog.Events["DiseaseOutbreak"].Trigger);
+ Assert.Equal("В школе распространяется болезнь", catalog.Label("ru", catalog.Events["DiseaseOutbreak"]));
}
[Fact]
diff --git a/tests/HSchool.Simulation.Tests/ContagionSimulationTests.cs b/tests/HSchool.Simulation.Tests/ContagionSimulationTests.cs
new file mode 100644
index 0000000..c006357
--- /dev/null
+++ b/tests/HSchool.Simulation.Tests/ContagionSimulationTests.cs
@@ -0,0 +1,311 @@
+using Arch.Core;
+using HSchool.Ai;
+using HSchool.Content;
+using HSchool.People;
+
+namespace HSchool.Simulation.Tests;
+
+public class ContagionSimulationTests
+{
+ private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 10, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void ContagiousCarrier_InSameNode_InfectsMoreThanIsolatedControl()
+ {
+ using var exposed = OpenSchool(seed: 7);
+ using var isolated = OpenSchool(seed: 7);
+
+ var (carrierId, exposedId) = PickTwoPupils(exposed);
+ var (_, controlId) = PickTwoPupils(isolated);
+ SeedCarrier(exposed, carrierId, "TestPlague");
+ SeedCarrier(isolated, carrierId, "TestPlague");
+
+ SetPlace(exposed, carrierId, "classroom-101");
+ SetPlace(exposed, exposedId, "classroom-101");
+ SetPlace(isolated, carrierId, "classroom-101");
+ SetPlace(isolated, controlId, "classroom-102");
+
+ RunContagionDay(exposed);
+ RunContagionDay(isolated);
+
+ Assert.True(HasContagion(exposed, exposedId, "TestPlague"));
+ Assert.False(HasContagion(isolated, controlId, "TestPlague"));
+ }
+
+ [Fact]
+ public void SameSeed_SameContagionTransfers()
+ {
+ var first = RunGoldTransfers(seed: 11);
+ var second = RunGoldTransfers(seed: 11);
+ Assert.Equal(first, second);
+ Assert.NotEmpty(first);
+ }
+
+ [Fact]
+ public void NonContagiousDef_DoesNotJumpByClass()
+ {
+ using var school = OpenSchool(seed: 19);
+
+ var schoolClass = school.Roster!.Classes.First(row => row.PupilIds.Count >= 3);
+ var carrierId = schoolClass.PupilIds[0];
+ var classmateIds = schoolClass.PupilIds.Skip(1).Take(2).ToArray();
+
+ SeedCarrier(school, carrierId, "Otitis");
+ foreach (var id in classmateIds.Append(carrierId))
+ {
+ SetPlace(school, id, schoolClass.RoomId);
+ }
+
+ RunContagionDay(school);
+
+ foreach (var id in classmateIds)
+ {
+ Assert.False(HealthConditions.Has(Person(school, id), "Otitis"));
+ }
+ }
+
+ [Fact]
+ public void OutbreakNotice_EmitsAtThreshold()
+ {
+ using var school = OpenSchool(seed: 3);
+ Assert.Equal(3, school.Catalog!.BehaviorRules!.DiseaseOutbreakMinNewCases);
+
+ var room = "classroom-101";
+ var pupils = school.Roster!.People
+ .Where(person => person.IsStudent)
+ .Take(5)
+ .ToArray();
+ Assert.True(pupils.Length >= 5);
+
+ SeedCarrier(school, pupils[0].Id, "TestPlague");
+ foreach (var pupil in pupils)
+ {
+ SetPlace(school, pupil.Id, room);
+ }
+
+ school.DrainWorldEvents();
+ RunContagionDay(school);
+
+ var contagionHits = pupils
+ .Skip(1)
+ .Count(person => HasContagion(school, person.Id, "TestPlague"));
+ Assert.True(contagionHits >= 3);
+
+ Assert.Contains(
+ school.DrainWorldEvents(),
+ row => row.Trigger.Equals(EventTriggers.DiseaseOutbreak, StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void ClassContagion_ChainsAcrossDays()
+ {
+ using var school = OpenSchool(seed: 23);
+ var schoolClass = school.Roster!.Classes.First(row => row.PupilIds.Count >= 3);
+ var a = schoolClass.PupilIds[0];
+ var b = schoolClass.PupilIds[1];
+ var c = schoolClass.PupilIds[2];
+
+ SeedCarrier(school, a, "TestClassPlague");
+ foreach (var id in new[] { a, b, c })
+ {
+ SetPlace(school, id, schoolClass.RoomId);
+ }
+
+ RunContagionDay(school);
+ Assert.True(HasContagion(school, b, "TestClassPlague") || HasContagion(school, c, "TestClassPlague"));
+
+ // Next calendar day: newly infected classmates can pass it on.
+ school.Clock.JumpTo(school.Clock.Time.AddDays(1));
+ foreach (var id in new[] { a, b, c })
+ {
+ SetPlace(school, id, schoolClass.RoomId);
+ }
+
+ RunContagionDay(school);
+ var infected = new[] { b, c }.Count(id =>
+ HealthConditions.Has(Person(school, id), "TestClassPlague"));
+ Assert.Equal(2, infected);
+ }
+
+ [Fact]
+ public void ImmuneAndAlreadySick_AreNotInfected()
+ {
+ using var school = OpenSchool(seed: 5);
+ var (carrierId, targetId) = PickTwoPupils(school);
+ SeedCarrier(school, carrierId, "TestPlague");
+ SeedCarrier(school, targetId, "TestPlague");
+ DiseaseImmunities.Grant(Person(school, targetId), "TestPlague", school.Clock.Time.AddDays(10));
+
+ // Second healthy classmate for the immunity case after clearing the already-sick one.
+ var healthy = school.Roster!.People
+ .Where(person => person.IsStudent && person.Id != carrierId && person.Id != targetId)
+ .First();
+ DiseaseImmunities.Grant(healthy, "TestPlague", school.Clock.Time.AddDays(10));
+
+ SetPlace(school, carrierId, "classroom-101");
+ SetPlace(school, targetId, "classroom-101");
+ SetPlace(school, healthy.Id, "classroom-101");
+
+ var beforeSick = Person(school, targetId).Conditions!.Count;
+ RunContagionDay(school);
+
+ Assert.Equal(beforeSick, Person(school, targetId).Conditions!.Count);
+ Assert.False(HasContagion(school, targetId, "TestPlague"));
+ Assert.False(HasContagion(school, healthy.Id, "TestPlague"));
+ Assert.False(HealthConditions.Has(healthy, "TestPlague"));
+ }
+
+ private static HashSet RunGoldTransfers(int seed)
+ {
+ using var school = OpenSchool(seed);
+ var (carrierId, exposedId) = PickTwoPupils(school);
+ SeedCarrier(school, carrierId, "TestPlague");
+ SetPlace(school, carrierId, "classroom-101");
+ SetPlace(school, exposedId, "classroom-101");
+ RunContagionDay(school);
+
+ return school.Roster!.People
+ .Where(person => HasContagion(school, person.Id, "TestPlague"))
+ .Select(person => person.Id)
+ .OrderBy(id => id, StringComparer.Ordinal)
+ .ToHashSet(StringComparer.Ordinal);
+ }
+
+ private static void RunContagionDay(School school)
+ {
+ school.LastDiseaseDay = int.MinValue;
+ DiseaseSystem.Apply(school, gameMinutes: 0);
+ }
+
+ private static void SeedCarrier(School school, string personId, string defName)
+ {
+ HealthConditions.Add(
+ Person(school, personId),
+ new HealthCondition
+ {
+ DefName = defName,
+ Severity = 0.2f,
+ Progress = 0.05f,
+ Source = "seed",
+ StartedAt = school.Clock.Time.AddHours(-6),
+ });
+ }
+
+ private static bool HasContagion(School school, string personId, string defName)
+ {
+ var list = Person(school, personId).Conditions;
+ return list is not null
+ && list.Any(row =>
+ row.DefName.Equals(defName, StringComparison.Ordinal)
+ && string.Equals(row.Source, "contagion", StringComparison.Ordinal));
+ }
+
+ private static (string CarrierId, string OtherId) PickTwoPupils(School school)
+ {
+ var pupils = school.Roster!.People.Where(person => person.IsStudent).Take(2).ToArray();
+ Assert.Equal(2, pupils.Length);
+ return (pupils[0].Id, pupils[1].Id);
+ }
+
+ private static Person Person(School school, string id) =>
+ school.Roster!.People.First(row => row.Id.Equals(id, StringComparison.Ordinal));
+
+ private static void SetPlace(School school, string personId, string? nodeId)
+ {
+ var query = new QueryDescription().WithAll();
+ 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 School OpenSchool(int seed)
+ {
+ var (catalog, map) = ContagionCatalog();
+ var roster = RosterGenerator.Generate(catalog, map, schoolSeed: seed, "Russia", TuesdayMorning);
+ var pool = ApplicantPool.Create(catalog, roster, schoolSeed: seed, "Russia", TuesdayMorning);
+ var school = School.Create(1, "Зараза", TuesdayMorning, catalog, map);
+ school.InstallPeople(roster, seed, "Russia", pool);
+ school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
+ return school;
+ }
+
+ private static (DefCatalog Catalog, MapLayout Map) ContagionCatalog()
+ {
+ var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
+ var documents = new List();
+ 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)));
+ }
+
+ documents.Add(
+ new ContentDocument(
+ CatalogLoader.CorePackId,
+ "defs/diseases/test-plague.jsonc",
+ """
+ {
+ "defName": "TestPlague",
+ "family": "respiratory",
+ "incubationDays": 0,
+ "immunityDays": 1,
+ "baseChancePerDay": 0,
+ "nodeContagionChancePerDay": 1,
+ "classContagionChancePerDay": 0,
+ "contagiousDuringIncubation": true,
+ "stages": [
+ { "minSeverity": 0, "severityPerDay": 0.1, "progressPerDay": 0.1, "lessonLearningFactor": 1, "stayHomeChance": 0 }
+ ]
+ }
+ """));
+ documents.Add(
+ new ContentDocument(
+ CatalogLoader.CorePackId,
+ "defs/diseases/test-class-plague.jsonc",
+ """
+ {
+ "defName": "TestClassPlague",
+ "family": "respiratory",
+ "incubationDays": 0,
+ "immunityDays": 1,
+ "baseChancePerDay": 0,
+ "nodeContagionChancePerDay": 0,
+ "classContagionChancePerDay": 1,
+ "contagiousDuringIncubation": true,
+ "stages": [
+ { "minSeverity": 0, "severityPerDay": 0.1, "progressPerDay": 0.1, "lessonLearningFactor": 1, "stayHomeChance": 0 }
+ ]
+ }
+ """));
+ documents.Add(
+ new ContentDocument(
+ CatalogLoader.CorePackId,
+ "patches/disable-disease-onset.jsonc",
+ """
+ { "target": "Behavior", "ops": [ { "op": "replace", "path": "/diseaseVectorScale", "value": 0 } ] }
+ """));
+
+ var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
+ var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
+ Assert.NotNull(map);
+ Assert.Equal(0f, catalog.BehaviorRules!.DiseaseVectorScale);
+ Assert.True(catalog.Diseases.ContainsKey("TestPlague"));
+ return (catalog, map);
+ }
+}