Merge branch 'phase/80-nurse-health-ui'
This commit is contained in:
@@ -195,6 +195,10 @@ internal static class ActivitySystem
|
||||
actionId is not null
|
||||
&& actionId.Equals(DirectorSummons.HearingAction, StringComparison.Ordinal);
|
||||
|
||||
internal static bool IsNurseCare(string? actionId) =>
|
||||
actionId is not null
|
||||
&& actionId.Equals(NurseCare.CareAction, StringComparison.Ordinal);
|
||||
|
||||
internal static bool IsParentMeeting(string? actionId) =>
|
||||
actionId is not null
|
||||
&& actionId.Equals(ParentMeetings.MeetingAction, StringComparison.Ordinal);
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
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;
|
||||
}
|
||||
@@ -332,7 +332,8 @@ internal static class PresenceSystem
|
||||
? slot.Kind != DaySlotKind.Outside
|
||||
: slot.Kind == DaySlotKind.Lesson && lessons.Any(lesson => lesson.Period == slot.Index);
|
||||
if (DirectorSummonSystem.IsSummoned(school, person.Id)
|
||||
|| ParentMeetingSystem.IsInMeeting(school, person.Id))
|
||||
|| ParentMeetingSystem.IsInMeeting(school, person.Id)
|
||||
|| NurseCareSystem.IsVisiting(school, person.Id))
|
||||
{
|
||||
bound = true;
|
||||
}
|
||||
@@ -353,6 +354,11 @@ internal static class PresenceSystem
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activity.IsActive && ActivitySystem.IsNurseCare(activity.ActionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activity.IsActive && ActivitySystem.IsParentMeeting(activity.ActionId))
|
||||
{
|
||||
return null;
|
||||
@@ -371,6 +377,16 @@ internal static class PresenceSystem
|
||||
return null;
|
||||
}
|
||||
|
||||
if (NurseCareSystem.TryDutyRoom(school, person.Id, out var nurseArrive))
|
||||
{
|
||||
presence = PresenceStepper.StartWalk(
|
||||
Presence.OffCampus,
|
||||
walks,
|
||||
nurseArrive,
|
||||
headingHome: false);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (plan.AppearAt is { } appear && now >= appear && (plan.WalkHomeAt is null || now < plan.WalkHomeAt))
|
||||
{
|
||||
var dest = plan.FirstRoom ?? Duty.RoomAt(
|
||||
@@ -384,6 +400,10 @@ internal static class PresenceSystem
|
||||
{
|
||||
dest = summonArrive;
|
||||
}
|
||||
else if (NurseCareSystem.TryDutyRoom(school, person.Id, out var nurseDest))
|
||||
{
|
||||
dest = nurseDest;
|
||||
}
|
||||
|
||||
presence = dest is null
|
||||
? Presence.OffCampus
|
||||
@@ -398,7 +418,8 @@ internal static class PresenceSystem
|
||||
if (plan.WalkHomeAt is { } leave
|
||||
&& now >= leave
|
||||
&& !DirectorSummonSystem.IsSummoned(school, person.Id)
|
||||
&& !ParentMeetingSystem.IsInMeeting(school, person.Id))
|
||||
&& !ParentMeetingSystem.IsInMeeting(school, person.Id)
|
||||
&& !NurseCareSystem.IsVisiting(school, person.Id))
|
||||
{
|
||||
activity = PersonActivity.Idle;
|
||||
intent = Intent.None;
|
||||
@@ -423,6 +444,11 @@ internal static class PresenceSystem
|
||||
duty = meetingRoom;
|
||||
bound = true;
|
||||
}
|
||||
else if (NurseCareSystem.TryDutyRoom(school, person.Id, out var nurseRoom))
|
||||
{
|
||||
duty = nurseRoom;
|
||||
bound = true;
|
||||
}
|
||||
|
||||
if (duty is null)
|
||||
{
|
||||
@@ -437,6 +463,9 @@ internal static class PresenceSystem
|
||||
&& SchoolDay.IsLunchWindow(frame, slot, ClassOf(school, person)?.Year);
|
||||
var apparel = ApparelPresence.Build(school, person, ClassOf(school, person));
|
||||
var talk = BuildTalkContext(school, person, bound, lunchOpen, slot);
|
||||
var socialWeight = school.Catalog is null
|
||||
? 1f
|
||||
: DiseaseEffects.TalkWeightFactor(person, school.Catalog, now);
|
||||
var state = new ActorState(
|
||||
presence.NodeId,
|
||||
presence.DestinationId,
|
||||
@@ -451,7 +480,8 @@ internal static class PresenceSystem
|
||||
intent,
|
||||
lunchOpen,
|
||||
apparel,
|
||||
talk);
|
||||
talk,
|
||||
socialWeight);
|
||||
var decision = DecisionPlanner.Decide(
|
||||
school.Catalog!,
|
||||
school.Map!,
|
||||
@@ -462,19 +492,21 @@ internal static class PresenceSystem
|
||||
var changing = activity.IsActive && ActivitySystem.IsChangeClothes(activity.ActionId);
|
||||
var inTalk = activity.IsActive && TalkActions.IsTalk(activity.ActionId);
|
||||
var inHearing = activity.IsActive && ActivitySystem.IsPrincipalHearing(activity.ActionId);
|
||||
var inNurseCare = activity.IsActive && ActivitySystem.IsNurseCare(activity.ActionId);
|
||||
|
||||
if (decision.WalkTo is not null
|
||||
&& !decision.WalkTo.Equals(presence.NodeId, StringComparison.Ordinal)
|
||||
&& activity.IsActive
|
||||
&& !changing
|
||||
&& !inTalk
|
||||
&& !inHearing)
|
||||
&& !inHearing
|
||||
&& !inNurseCare)
|
||||
{
|
||||
activity = PersonActivity.Idle;
|
||||
}
|
||||
|
||||
intent = decision.Intent;
|
||||
if (decision.WalkTo is not null && !changing && !inTalk && !inHearing)
|
||||
if (decision.WalkTo is not null && !changing && !inTalk && !inHearing && !inNurseCare)
|
||||
{
|
||||
presence = PresenceStepper.StartWalk(presence, walks, decision.WalkTo, headingHome: false);
|
||||
}
|
||||
|
||||
@@ -158,6 +158,13 @@ public sealed class School : IDisposable
|
||||
|
||||
internal int NextSummonOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Nurse visit FIFO. Worker thread only — never HTTP. Cleared on morning / skip.
|
||||
/// </summary>
|
||||
internal List<NurseVisitTicket> NurseVisits { get; } = [];
|
||||
|
||||
internal int NextNurseVisitOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Active parent meetings. Worker thread only — never HTTP. Cleared on morning / skip.
|
||||
/// </summary>
|
||||
@@ -348,6 +355,7 @@ public sealed class School : IDisposable
|
||||
Clock.JumpTo(next.Value);
|
||||
TalkCircleSystem.AbandonAll(this);
|
||||
DirectorSummonSystem.ClearAll(this);
|
||||
NurseCareSystem.ClearAll(this);
|
||||
ParentMeetingSystem.ClearAll(this);
|
||||
ResetDayLog();
|
||||
var peopleChanged = TryYearlyIntake(before, next.Value);
|
||||
@@ -423,7 +431,8 @@ public sealed class School : IDisposable
|
||||
ResetDayLog();
|
||||
// Hung summons do not survive the work morning — same clear as skip empty.
|
||||
DirectorSummonSystem.ClearAll(this);
|
||||
ParentMeetingSystem.ClearAll(this);
|
||||
NurseCareSystem.ClearAll(this);
|
||||
ParentMeetingSystem.ClearAll(this);
|
||||
}
|
||||
|
||||
peopleChanged = TryYearlyIntake(before, Clock.Time);
|
||||
@@ -481,6 +490,12 @@ public sealed class School : IDisposable
|
||||
public string? DirectorSummonPresenceActionId(string personId) =>
|
||||
DirectorSummonSystem.PresenceActionId(DirectorSummonPresencePhase(personId));
|
||||
|
||||
public byte NurseCarePresencePhase(string personId) =>
|
||||
NurseCareSystem.ResolvePresencePhase(this, personId);
|
||||
|
||||
public string? NurseCarePresenceActionId(string personId) =>
|
||||
NurseCareSystem.PresenceActionId(NurseCarePresencePhase(personId));
|
||||
|
||||
/// <summary>
|
||||
/// Card label while walking to / sitting in a parent meeting without a live activity yet.
|
||||
/// </summary>
|
||||
@@ -528,6 +543,7 @@ public sealed class School : IDisposable
|
||||
}
|
||||
|
||||
DirectorSummonSystem.Apply(this);
|
||||
NurseCareSystem.Apply(this, gameMinutes);
|
||||
ParentMeetingSystem.Apply(this);
|
||||
|
||||
PersonDayLog.Sync(this);
|
||||
|
||||
@@ -57,6 +57,7 @@ internal static class TalkCircleSystem
|
||||
|
||||
school.ApologyDebts.Clear();
|
||||
DirectorSummonSystem.ClearAll(school);
|
||||
NurseCareSystem.ClearAll(school);
|
||||
ParentMeetingSystem.ClearAll(school);
|
||||
school.World.Query(in People, (ref PersonActivity activity) =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user