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:
Leonid Pershin
2026-08-21 13:55:24 +03:00
co-authored by Cursor
parent a369308116
commit 4a52107bd2
19 changed files with 648 additions and 15 deletions
+2
View File
@@ -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…',
@@ -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;
+9 -1
View File
@@ -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 01.");
}
DiseaseStage? previous = null;
foreach (var stage in def.Stages)
{
+16 -2
View File
@@ -31,8 +31,8 @@ public sealed class DiseaseStage
}
/// <summary>
/// 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).
/// </summary>
public sealed class DiseaseDef : Def
{
@@ -58,4 +58,18 @@ public sealed class DiseaseDef : Def
/// <summary>Days of immunity to this def after recovery.</summary>
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; }
}
+1
View File
@@ -16,6 +16,7 @@ internal static class EventDefValidator
EventTriggers.GenerationFailed,
EventTriggers.DirectorSummon,
EventTriggers.ParentMeeting,
EventTriggers.DiseaseOutbreak,
};
private static readonly HashSet<string> Actions = new(StringComparer.Ordinal)
+1
View File
@@ -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
@@ -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)
+6
View File
@@ -660,6 +660,12 @@ public sealed class BehaviorDef : Def
/// </summary>
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; } =
[
0.85f,
+1 -1
View File
@@ -4,7 +4,7 @@ namespace HSchool.People;
/// <summary>
/// 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>
public static class DiseaseEffects
{
+14
View File
@@ -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;
/// <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);
@@ -49,6 +50,19 @@ public static class Seed
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)
{
ulong z = 0;
@@ -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,
}
@@ -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 },
@@ -39,4 +39,12 @@
"trigger": "parentMeeting",
"action": "none",
},
{
"defName": "DiseaseOutbreak",
"severity": "info",
"pause": false,
"ttlMs": 8000,
"trigger": "diseaseOutbreak",
"action": "none",
},
]
@@ -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",
@@ -233,6 +233,7 @@
"GenerationFailed": "Не удалось нарисовать портрет",
"DirectorSummoned": "Ученика вызвали к директору",
"ParentMeeting": "Идёт родительское собрание",
"DiseaseOutbreak": "В школе распространяется болезнь",
"GoingToPrincipal": "идёт к директору",
"WaitForPrincipal": "ждёт у кабинета директора",
"GoingToParentMeeting": "идёт на собрание",
+241 -2
View File
@@ -1,14 +1,22 @@
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
namespace HSchool.Simulation;
/// <summary>
/// DiseaseDef onset from weather vectors and ticks that follow stage curves.
/// Person-to-person contagion is phase 79.
/// DiseaseDef onset from weather vectors, person-to-person contagion, and stage-curve ticks.
/// Contagion rolls use pair+day seeds — never a shared world <see cref="Random"/>.
/// </summary>
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)
{
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<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)
{
if (vectorScale <= 0f)