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
+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)