Files
h-school/src/HSchool.Simulation/NurseCareSystem.cs
T
Leonid PershinandCursor 33f253c9e3 Add nurse care queue and person-card health tab.
Sick people seek MedicalOffice for tend by Medicine skill; symptoms cut talk weight; no heal API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:17:51 +03:00

374 lines
12 KiB
C#

using Arch.Core;
using HSchool.Ai;
using HSchool.People;
namespace HSchool.Simulation;
/// <summary>
/// Sick people seek the medical office; FIFO corridor wait; nurse tend while co-located.
/// Only the school worker thread mutates <see cref="School.NurseVisits"/>.
/// </summary>
internal static class NurseCareSystem
{
private static readonly QueryDescription PresenceQuery =
new QueryDescription().WithAll<PersonIdentity, Presence, PersonActivity, Intent>();
public static bool IsVisiting(School school, string personId) =>
school.NurseVisits.Any(row => row.PersonId.Equals(personId, StringComparison.Ordinal));
public static bool TryDutyRoom(School school, string personId, out string roomId)
{
roomId = null!;
var ticket = school.NurseVisits.FirstOrDefault(row =>
row.PersonId.Equals(personId, StringComparison.Ordinal));
if (ticket is null || school.Map is null)
{
return false;
}
var office = NurseCare.OfficeNodeId(school.Map);
var wait = NurseCare.WaitNodeId(school.Map, office);
if (office is null || wait is null)
{
return false;
}
roomId = ticket.Admitted ? office : wait;
return true;
}
public static byte ResolvePresencePhase(School school, string personId)
{
var ticket = school.NurseVisits.FirstOrDefault(row =>
row.PersonId.Equals(personId, StringComparison.Ordinal));
if (ticket is null)
{
return NurseCare.PresenceNone;
}
var walking = false;
var caring = false;
string? nodeId = null;
school.World.Query(
in PresenceQuery,
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent _) =>
{
if (!identity.Id.Equals(personId, StringComparison.Ordinal))
{
return;
}
nodeId = presence.NodeId;
walking = presence.Path.Length > 0 || presence.RemainingMinutes > 0
|| (presence.DestinationId is not null
&& !presence.DestinationId.Equals(presence.NodeId, StringComparison.Ordinal));
caring = activity.IsActive && ActivitySystem.IsNurseCare(activity.ActionId);
});
if (caring)
{
return NurseCare.PresenceCare;
}
if (walking)
{
return NurseCare.PresenceGoing;
}
var office = NurseCare.OfficeNodeId(school.Map!);
if (ticket.Admitted
&& office is not null
&& nodeId is not null
&& nodeId.Equals(office, StringComparison.Ordinal))
{
return NurseCare.PresenceCare;
}
return NurseCare.PresenceWaiting;
}
public static string? PresenceActionId(byte phase) => phase switch
{
NurseCare.PresenceGoing => NurseCare.GoingAction,
NurseCare.PresenceWaiting => NurseCare.WaitingAction,
NurseCare.PresenceCare => NurseCare.CareAction,
_ => null,
};
public static void ClearAll(School school)
{
if (school.NurseVisits.Count == 0)
{
return;
}
var ids = school.NurseVisits.Select(row => row.PersonId).ToArray();
school.NurseVisits.Clear();
foreach (var id in ids)
{
PresenceSystem.Enqueue(school, id);
}
}
public static void Apply(School school, double gameMinutes)
{
if (school.Roster is null || school.Map is null || school.Catalog is null)
{
return;
}
if (!NurseCare.CanStart(school.Roster, school.Map))
{
ClearAll(school);
return;
}
EnqueueNeedingCare(school);
DropRecovered(school);
AdmitHead(school);
ApplyTend(school, gameMinutes);
TryStartCareActivity(school);
}
private static void EnqueueNeedingCare(School school)
{
var catalog = school.Catalog!;
var now = school.Clock.Time;
foreach (var person in school.Roster!.People.OrderBy(row => row.Id, StringComparer.Ordinal))
{
if (IsVisiting(school, person.Id))
{
continue;
}
if (DiseaseEffects.ConditionNeedingNurse(person, catalog, now) is null)
{
continue;
}
// Pupils and staff on campus can seek care; parents off-roster roles skip.
if (!person.IsStudent && !person.IsStaff)
{
continue;
}
school.NurseVisits.Add(new NurseVisitTicket(person.Id, school.NextNurseVisitOrder++, admitted: false));
PresenceSystem.Enqueue(school, person.Id);
}
}
private static void DropRecovered(School school)
{
var catalog = school.Catalog!;
var now = school.Clock.Time;
for (var i = school.NurseVisits.Count - 1; i >= 0; i--)
{
var ticket = school.NurseVisits[i];
var person = school.Roster!.People.FirstOrDefault(row =>
row.Id.Equals(ticket.PersonId, StringComparison.Ordinal));
if (person is not null
&& DiseaseEffects.ConditionNeedingNurse(person, catalog, now) is not null)
{
continue;
}
school.NurseVisits.RemoveAt(i);
PresenceSystem.Enqueue(school, ticket.PersonId);
}
}
private static void AdmitHead(School school)
{
if (school.NurseVisits.Any(row => row.Admitted))
{
return;
}
var head = school.NurseVisits.OrderBy(row => row.Order).FirstOrDefault();
if (head is null)
{
return;
}
head.Admitted = true;
PresenceSystem.Enqueue(school, head.PersonId);
}
private static void ApplyTend(School school, double gameMinutes)
{
if (gameMinutes <= 0)
{
return;
}
var nurseId = NurseCare.NurseId(school.Roster!);
if (nurseId is null)
{
return;
}
var office = NurseCare.OfficeNodeId(school.Map!);
if (office is null || !IsIdleOrCaringIn(school, nurseId, office))
{
return;
}
var nurse = school.Roster!.People.First(row => row.Id.Equals(nurseId, StringComparison.Ordinal));
var medicine = LiveMedicine(school, nurseId) ?? NurseCare.MedicineOf(nurse);
var scale = school.Catalog!.BehaviorRules?.NurseTendMedicineScale ?? 1f;
var dayNumber = DateOnly.FromDateTime(school.Clock.Time).DayNumber;
var changed = false;
foreach (var ticket in school.NurseVisits.Where(row => row.Admitted).ToArray())
{
if (!IsInNode(school, ticket.PersonId, office))
{
continue;
}
var patient = school.Roster.People.FirstOrDefault(row =>
row.Id.Equals(ticket.PersonId, StringComparison.Ordinal));
if (patient is null)
{
continue;
}
if (DiseaseEffects.Tend(
patient,
school.PeopleSeed,
dayNumber,
gameMinutes,
school.Catalog,
medicine,
school.Clock.Time,
scale))
{
changed = true;
}
}
if (changed)
{
school.RosterTalkDirty = true;
}
}
private static void TryStartCareActivity(School school)
{
var admitted = school.NurseVisits.FirstOrDefault(row => row.Admitted);
if (admitted is null || school.Catalog is null)
{
return;
}
var office = NurseCare.OfficeNodeId(school.Map!);
if (office is null
|| !IsInNode(school, admitted.PersonId, office)
|| !school.Catalog.Actions.TryGetValue(NurseCare.CareAction, out var action)
|| action.Abstract)
{
return;
}
var minutes = action.Minutes > 0f ? action.Minutes : 5f;
school.World.Query(
in PresenceQuery,
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
{
if (!identity.Id.Equals(admitted.PersonId, StringComparison.Ordinal))
{
return;
}
if (activity.IsActive || presence.Path.Length > 0 || presence.RemainingMinutes > 0)
{
return;
}
if (presence.NodeId is null
|| !presence.NodeId.Equals(office, StringComparison.Ordinal))
{
return;
}
activity = new PersonActivity(NurseCare.CareAction, null, minutes);
intent = Intent.None;
});
}
private static bool IsInNode(School school, string personId, string nodeId)
{
var found = false;
school.World.Query(
in PresenceQuery,
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity _, ref Intent _) =>
{
if (identity.Id.Equals(personId, StringComparison.Ordinal)
&& presence.NodeId is not null
&& presence.NodeId.Equals(nodeId, StringComparison.Ordinal)
&& presence.Path.Length == 0
&& presence.RemainingMinutes <= 0)
{
found = true;
}
});
return found;
}
private static bool IsIdleOrCaringIn(School school, string personId, string nodeId)
{
var found = false;
school.World.Query(
in PresenceQuery,
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent _) =>
{
if (!identity.Id.Equals(personId, StringComparison.Ordinal))
{
return;
}
if (presence.NodeId is null
|| !presence.NodeId.Equals(nodeId, StringComparison.Ordinal)
|| presence.Path.Length > 0
|| presence.RemainingMinutes > 0)
{
return;
}
// Nurse on MedicalDuty stands in the office; any active non-care action blocks tend.
if (activity.IsActive && !ActivitySystem.IsNurseCare(activity.ActionId))
{
return;
}
found = true;
});
return found;
}
private static float? LiveMedicine(School school, string personId)
{
float? found = null;
var query = new QueryDescription().WithAll<PersonIdentity, PersonSkills>();
school.World.Query(in query, (ref PersonIdentity identity, ref PersonSkills skills) =>
{
if (identity.Id.Equals(personId, StringComparison.Ordinal)
&& skills.Values.TryGetValue(NurseCare.MedicineSkill, out var level))
{
found = level;
}
});
return found;
}
}
/// <summary>One nurse-visit ticket. <see cref="Order"/> is FIFO; lower goes first.</summary>
internal sealed class NurseVisitTicket(string personId, int order, bool admitted)
{
public string PersonId { get; } = personId;
public int Order { get; } = order;
public bool Admitted { get; set; } = admitted;
}