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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user