From 4a52107bd238dba5313b7238fe631ed2bfa28165 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 13:55:24 +0300 Subject: [PATCH 1/2] Add person-to-person disease contagion with outbreak notices. Node/class contact rolls use pair+day seeds; vanilla respiratory diseases spread, and a threshold raises an info toast instead of per-sneeze noise. Co-authored-by: Cursor --- docs/phases/14-health/79-contagion.md | 18 +- src/HSchool.Client/src/i18n/strings.ts | 2 + src/HSchool.Client/src/ui/noticeToasts.ts | 1 + src/HSchool.Content/DiseaseDefValidator.cs | 10 +- src/HSchool.Content/DiseaseDefs.cs | 18 +- src/HSchool.Content/EventDefValidator.cs | 1 + src/HSchool.Content/EventDefs.cs | 1 + src/HSchool.Content/PeopleDefValidator.cs | 6 + src/HSchool.Content/PeopleDefs.cs | 6 + src/HSchool.People/DiseaseEffects.cs | 2 +- src/HSchool.People/Seed.cs | 14 + .../mods/core/defs/behavior/rules.jsonc | 2 + .../mods/core/defs/diseases/vanilla.jsonc | 9 + .../mods/core/defs/events/notices.jsonc | 8 + .../mods/core/localizations/en.jsonc | 1 + .../mods/core/localizations/ru.jsonc | 1 + src/HSchool.Simulation/DiseaseSystem.cs | 243 +++++++++++++- .../HSchool.Content.Tests/DiseaseDefTests.cs | 9 + .../ContagionSimulationTests.cs | 311 ++++++++++++++++++ 19 files changed, 648 insertions(+), 15 deletions(-) create mode 100644 tests/HSchool.Simulation.Tests/ContagionSimulationTests.cs 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/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); + } +} From c7ca04d01fb97a4f6b80bc316309d5136c675b1b Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 13:55:48 +0300 Subject: [PATCH 2/2] Mark phase 79 done. Co-authored-by: Cursor --- docs/phases/14-health/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/14-health/README.md b/docs/phases/14-health/README.md index 0e12814..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 — уход и экран.**