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 <cursoragent@cursor.com>
This commit is contained in:
@@ -11,18 +11,18 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] Поля заразности на `DiseaseDef`: узел и/или класс, шанс, носительство в инкубации
|
- [x] Поля заразности на `DiseaseDef`: узел и/или класс, шанс, носительство в инкубации
|
||||||
- [ ] Контакт считает поток школы; детерминизм от сида пары/узла/дня
|
- [x] Контакт считает поток школы; детерминизм от сида пары/узла/дня
|
||||||
- [ ] Не один глобальный `Random` на мир
|
- [x] Не один глобальный `Random` на мир
|
||||||
- [ ] Info-тост на вспышку (порог числа новых случаев), не на каждый чих
|
- [x] Info-тост на вспышку (порог числа новых случаев), не на каждый чих
|
||||||
- [ ] Иммунитет и уже болеющие не заражаются вопреки def
|
- [x] Иммунитет и уже болеющие не заражаются вопреки def
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] Заразный и здоровый в одном узле → здоровый заболевает чаще, чем изолированный контроль
|
- [x] Заразный и здоровый в одном узле → здоровый заболевает чаще, чем изолированный контроль
|
||||||
- [ ] Тот же сид → те же передачи в золотом сценарии
|
- [x] Тот же сид → те же передачи в золотом сценарии
|
||||||
- [ ] Def без заразности не прыгает по классу
|
- [x] Def без заразности не прыгает по классу
|
||||||
- [ ] Notice вспышки эмитится при пороге
|
- [x] Notice вспышки эмитится при пороге
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -403,6 +403,7 @@ const ru = {
|
|||||||
GenerationFailed: 'Не удалось нарисовать портрет',
|
GenerationFailed: 'Не удалось нарисовать портрет',
|
||||||
DirectorSummoned: 'Ученика вызвали к директору',
|
DirectorSummoned: 'Ученика вызвали к директору',
|
||||||
ParentMeeting: 'Идёт родительское собрание',
|
ParentMeeting: 'Идёт родительское собрание',
|
||||||
|
DiseaseOutbreak: 'В школе распространяется болезнь',
|
||||||
noticeDismiss: 'Закрыть',
|
noticeDismiss: 'Закрыть',
|
||||||
noticeGenerateImage: 'Создать картинку',
|
noticeGenerateImage: 'Создать картинку',
|
||||||
noticeGenerateImageBusy: 'Рисуем…',
|
noticeGenerateImageBusy: 'Рисуем…',
|
||||||
@@ -814,6 +815,7 @@ const en: Messages = {
|
|||||||
GenerationFailed: 'Portrait generation failed',
|
GenerationFailed: 'Portrait generation failed',
|
||||||
DirectorSummoned: 'A pupil was summoned to the principal',
|
DirectorSummoned: 'A pupil was summoned to the principal',
|
||||||
ParentMeeting: 'A parent meeting is under way',
|
ParentMeeting: 'A parent meeting is under way',
|
||||||
|
DiseaseOutbreak: 'An illness is spreading at school',
|
||||||
noticeDismiss: 'Close',
|
noticeDismiss: 'Close',
|
||||||
noticeGenerateImage: 'Create image',
|
noticeGenerateImage: 'Create image',
|
||||||
noticeGenerateImageBusy: 'Drawing…',
|
noticeGenerateImageBusy: 'Drawing…',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function noticeLabel(defName: string): string {
|
|||||||
case 'GenerationFailed':
|
case 'GenerationFailed':
|
||||||
case 'DirectorSummoned':
|
case 'DirectorSummoned':
|
||||||
case 'ParentMeeting':
|
case 'ParentMeeting':
|
||||||
|
case 'DiseaseOutbreak':
|
||||||
return t(defName satisfies MessageKey);
|
return t(defName satisfies MessageKey);
|
||||||
default:
|
default:
|
||||||
return defName;
|
return defName;
|
||||||
|
|||||||
@@ -46,11 +46,19 @@ internal static class DiseaseDefValidator
|
|||||||
if (def.BaseChancePerDay < 0f
|
if (def.BaseChancePerDay < 0f
|
||||||
|| def.ColdChancePerDay < 0f
|
|| def.ColdChancePerDay < 0f
|
||||||
|| def.RainChancePerDay < 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.");
|
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;
|
DiseaseStage? previous = null;
|
||||||
foreach (var stage in def.Stages)
|
foreach (var stage in def.Stages)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ public sealed class DiseaseStage
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A named disease: stages, weather vectors, lesson/attendance effects, temporary immunity.
|
/// A named disease: stages, weather vectors, lesson/attendance effects, temporary immunity,
|
||||||
/// Contagion between people is phase 79 — these defs may omit it.
|
/// and optional person-to-person contagion (node and/or class).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DiseaseDef : Def
|
public sealed class DiseaseDef : Def
|
||||||
{
|
{
|
||||||
@@ -58,4 +58,18 @@ public sealed class DiseaseDef : Def
|
|||||||
|
|
||||||
/// <summary>Days of immunity to this def after recovery.</summary>
|
/// <summary>Days of immunity to this def after recovery.</summary>
|
||||||
public float ImmunityDays { get; init; }
|
public float ImmunityDays { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Daily chance to infect each other person sharing the same map node. 0 — no node contagion.
|
||||||
|
/// </summary>
|
||||||
|
public float NodeContagionChancePerDay { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Daily chance to infect each classmate (same <c>ClassId</c>) while both are on campus.
|
||||||
|
/// 0 — no class contagion.
|
||||||
|
/// </summary>
|
||||||
|
public float ClassContagionChancePerDay { get; init; }
|
||||||
|
|
||||||
|
/// <summary>When true, the carrier can infect during incubation before stage effects apply.</summary>
|
||||||
|
public bool ContagiousDuringIncubation { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ internal static class EventDefValidator
|
|||||||
EventTriggers.GenerationFailed,
|
EventTriggers.GenerationFailed,
|
||||||
EventTriggers.DirectorSummon,
|
EventTriggers.DirectorSummon,
|
||||||
EventTriggers.ParentMeeting,
|
EventTriggers.ParentMeeting,
|
||||||
|
EventTriggers.DiseaseOutbreak,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly HashSet<string> Actions = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> Actions = new(StringComparer.Ordinal)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public static class EventTriggers
|
|||||||
public const string GenerationFailed = "generationFailed";
|
public const string GenerationFailed = "generationFailed";
|
||||||
public const string DirectorSummon = "directorSummon";
|
public const string DirectorSummon = "directorSummon";
|
||||||
public const string ParentMeeting = "parentMeeting";
|
public const string ParentMeeting = "parentMeeting";
|
||||||
|
public const string DiseaseOutbreak = "diseaseOutbreak";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class EventActions
|
public static class EventActions
|
||||||
|
|||||||
@@ -757,6 +757,12 @@ internal static class PeopleDefValidator
|
|||||||
throw new ContentLoadException(
|
throw new ContentLoadException(
|
||||||
$"BehaviorDef '{behavior.DefName}' diseaseVectorScale cannot be negative.");
|
$"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)
|
private static void ValidateTopic(TopicDef topic, DefCatalog catalog)
|
||||||
|
|||||||
@@ -660,6 +660,12 @@ public sealed class BehaviorDef : Def
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public float DiseaseVectorScale { get; init; } = 1f;
|
public float DiseaseVectorScale { get; init; } = 1f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// New contagion cases in one day at or above this raise a diseaseOutbreak world event.
|
||||||
|
/// 0 disables the toast.
|
||||||
|
/// </summary>
|
||||||
|
public int DiseaseOutbreakMinNewCases { get; init; } = 3;
|
||||||
|
|
||||||
public static IReadOnlyList<float> DefaultLessonMarkThresholds { get; } =
|
public static IReadOnlyList<float> DefaultLessonMarkThresholds { get; } =
|
||||||
[
|
[
|
||||||
0.85f,
|
0.85f,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace HSchool.People;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads DiseaseDef stages for lesson gain, stay-home rolls and warmth decay.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DiseaseEffects
|
public static class DiseaseEffects
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public static class Seed
|
|||||||
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;
|
public const int DiseaseSalt = 20;
|
||||||
|
public const int ContagionSalt = 21;
|
||||||
|
|
||||||
/// <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);
|
||||||
@@ -49,6 +50,19 @@ public static class Seed
|
|||||||
return (int)z;
|
return (int)z;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
private static uint Stable(string value)
|
||||||
{
|
{
|
||||||
ulong z = 0;
|
ulong z = 0;
|
||||||
|
|||||||
@@ -123,4 +123,6 @@
|
|||||||
"lessonMarkWhenLate": true,
|
"lessonMarkWhenLate": true,
|
||||||
// Scales DiseaseDef onset from weather/base (slice 14 phase 78). 0 disables natural onset.
|
// Scales DiseaseDef onset from weather/base (slice 14 phase 78). 0 disables natural onset.
|
||||||
"diseaseVectorScale": 1,
|
"diseaseVectorScale": 1,
|
||||||
|
// Contagion cases in one day at or above this raise diseaseOutbreak (slice 14 phase 79).
|
||||||
|
"diseaseOutbreakMinNewCases": 3,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
"coldChancePerDay": 0.08,
|
"coldChancePerDay": 0.08,
|
||||||
"rainChancePerDay": 0.03,
|
"rainChancePerDay": 0.03,
|
||||||
"snowChancePerDay": 0.04,
|
"snowChancePerDay": 0.04,
|
||||||
|
"nodeContagionChancePerDay": 0.55,
|
||||||
|
"classContagionChancePerDay": 0.25,
|
||||||
|
"contagiousDuringIncubation": true,
|
||||||
"stages": [
|
"stages": [
|
||||||
{ "minSeverity": 0, "severityPerDay": 0.25, "progressPerDay": 0.05, "lessonLearningFactor": 0.85, "stayHomeChance": 0.15, "warmthDecayFactor": 1.1 },
|
{ "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.4, "severityPerDay": 0.1, "progressPerDay": 0.12, "lessonLearningFactor": 0.65, "stayHomeChance": 0.45, "warmthDecayFactor": 1.2 },
|
||||||
@@ -25,6 +28,9 @@
|
|||||||
"coldChancePerDay": 0.06,
|
"coldChancePerDay": 0.06,
|
||||||
"rainChancePerDay": 0.02,
|
"rainChancePerDay": 0.02,
|
||||||
"snowChancePerDay": 0.05,
|
"snowChancePerDay": 0.05,
|
||||||
|
"nodeContagionChancePerDay": 0.65,
|
||||||
|
"classContagionChancePerDay": 0.35,
|
||||||
|
"contagiousDuringIncubation": true,
|
||||||
"stages": [
|
"stages": [
|
||||||
{ "minSeverity": 0, "severityPerDay": 0.35, "progressPerDay": 0.03, "lessonLearningFactor": 0.7, "stayHomeChance": 0.35, "warmthDecayFactor": 1.2 },
|
{ "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.35, "severityPerDay": 0.15, "progressPerDay": 0.08, "lessonLearningFactor": 0.4, "stayHomeChance": 0.7, "warmthDecayFactor": 1.4 },
|
||||||
@@ -38,6 +44,9 @@
|
|||||||
"immunityDays": 10,
|
"immunityDays": 10,
|
||||||
"baseChancePerDay": 0.012,
|
"baseChancePerDay": 0.012,
|
||||||
"rainChancePerDay": 0.01,
|
"rainChancePerDay": 0.01,
|
||||||
|
"nodeContagionChancePerDay": 0.3,
|
||||||
|
"classContagionChancePerDay": 0,
|
||||||
|
"contagiousDuringIncubation": false,
|
||||||
"stages": [
|
"stages": [
|
||||||
{ "minSeverity": 0, "severityPerDay": 0.4, "progressPerDay": 0.08, "lessonLearningFactor": 0.6, "stayHomeChance": 0.5, "warmthDecayFactor": 1 },
|
{ "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 },
|
{ "minSeverity": 0.5, "severityPerDay": -0.1, "progressPerDay": 0.25, "lessonLearningFactor": 0.35, "stayHomeChance": 0.85, "warmthDecayFactor": 1 },
|
||||||
|
|||||||
@@ -39,4 +39,12 @@
|
|||||||
"trigger": "parentMeeting",
|
"trigger": "parentMeeting",
|
||||||
"action": "none",
|
"action": "none",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"defName": "DiseaseOutbreak",
|
||||||
|
"severity": "info",
|
||||||
|
"pause": false,
|
||||||
|
"ttlMs": 8000,
|
||||||
|
"trigger": "diseaseOutbreak",
|
||||||
|
"action": "none",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"GenerationFailed": "Portrait generation failed",
|
"GenerationFailed": "Portrait generation failed",
|
||||||
"DirectorSummoned": "A pupil was summoned to the principal",
|
"DirectorSummoned": "A pupil was summoned to the principal",
|
||||||
"ParentMeeting": "A parent meeting is under way",
|
"ParentMeeting": "A parent meeting is under way",
|
||||||
|
"DiseaseOutbreak": "An illness is spreading at school",
|
||||||
"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",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"GenerationFailed": "Не удалось нарисовать портрет",
|
"GenerationFailed": "Не удалось нарисовать портрет",
|
||||||
"DirectorSummoned": "Ученика вызвали к директору",
|
"DirectorSummoned": "Ученика вызвали к директору",
|
||||||
"ParentMeeting": "Идёт родительское собрание",
|
"ParentMeeting": "Идёт родительское собрание",
|
||||||
|
"DiseaseOutbreak": "В школе распространяется болезнь",
|
||||||
"GoingToPrincipal": "идёт к директору",
|
"GoingToPrincipal": "идёт к директору",
|
||||||
"WaitForPrincipal": "ждёт у кабинета директора",
|
"WaitForPrincipal": "ждёт у кабинета директора",
|
||||||
"GoingToParentMeeting": "идёт на собрание",
|
"GoingToParentMeeting": "идёт на собрание",
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
|
using Arch.Core;
|
||||||
|
using HSchool.Ai;
|
||||||
using HSchool.Content;
|
using HSchool.Content;
|
||||||
using HSchool.People;
|
using HSchool.People;
|
||||||
|
|
||||||
namespace HSchool.Simulation;
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DiseaseDef onset from weather vectors and ticks that follow stage curves.
|
/// DiseaseDef onset from weather vectors, person-to-person contagion, and stage-curve ticks.
|
||||||
/// Person-to-person contagion is phase 79.
|
/// Contagion rolls use pair+day seeds — never a shared world <see cref="Random"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class DiseaseSystem
|
internal static class DiseaseSystem
|
||||||
{
|
{
|
||||||
|
private const int NodeChannelSalt = 0;
|
||||||
|
private const int ClassChannelSalt = 1;
|
||||||
|
|
||||||
|
private static readonly QueryDescription PresenceQuery =
|
||||||
|
new QueryDescription().WithAll<PersonIdentity, Presence>();
|
||||||
|
|
||||||
public static bool Apply(School school, double gameMinutes)
|
public static bool Apply(School school, double gameMinutes)
|
||||||
{
|
{
|
||||||
if (school.Roster is null || school.Catalog is null)
|
if (school.Roster is null || school.Catalog is null)
|
||||||
@@ -30,6 +38,7 @@ internal static class DiseaseSystem
|
|||||||
{
|
{
|
||||||
school.LastDiseaseDay = dayNumber;
|
school.LastDiseaseDay = dayNumber;
|
||||||
changed |= TryOnset(school);
|
changed |= TryOnset(school);
|
||||||
|
changed |= TryContagion(school);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gameMinutes > 0)
|
if (gameMinutes > 0)
|
||||||
@@ -109,6 +118,236 @@ internal static class DiseaseSystem
|
|||||||
return changed;
|
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<string, string?> 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<string, string?> 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<string, string?> CaptureOccupancy(School school)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, string?>(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)
|
internal static float OnsetChance(DiseaseDef disease, OutdoorWeather weather, float vectorScale)
|
||||||
{
|
{
|
||||||
if (vectorScale <= 0f)
|
if (vectorScale <= 0f)
|
||||||
|
|||||||
@@ -22,10 +22,19 @@ public class DiseaseDefTests
|
|||||||
Assert.True(cold.Stages.Count >= 2);
|
Assert.True(cold.Stages.Count >= 2);
|
||||||
Assert.Equal(0.5f, cold.IncubationDays);
|
Assert.Equal(0.5f, cold.IncubationDays);
|
||||||
Assert.Equal(14f, cold.ImmunityDays);
|
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("ОРВИ", catalog.Label("ru", cold));
|
||||||
Assert.Equal("Common cold", catalog.Label("en", cold));
|
Assert.Equal("Common cold", catalog.Label("en", cold));
|
||||||
Assert.Equal(1f, catalog.BehaviorRules!.DiseaseVectorScale);
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -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<string> 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<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 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<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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user