using Arch.Core; using HSchool.Ai; using HSchool.Content; using HSchool.People; using HSchool.Schedule; namespace HSchool.Simulation; /// /// Decisions go through ; the per-tick walk does not. Order is the /// roster id list, never Arch's entity order. /// internal static class PresenceSystem { private static readonly QueryDescription People = new QueryDescription().WithAll(); public static void Apply(School school, double gameMinutes) { if (school.Catalog is null || school.Walks is null || school.Roster is null) { return; } EnsurePlans(school); school.DecisionBudget = school.MaxDecisionsPerTick; EnqueueEvents(school); EnqueueTimeEvents(school, (float)gameMinutes); DrainDecisions(school); Move(school, (float)gameMinutes); FinishHome(school); DrainDecisions(school); } public static IReadOnlySet BelowThreshold(School school) { var ids = new HashSet(StringComparer.Ordinal); if (school.Catalog?.BehaviorRules is null) { return ids; } var threshold = school.Catalog.BehaviorRules.NeedThreshold; var world = school.World; var query = new QueryDescription().WithAll(); world.Query(in query, (ref PersonIdentity identity, ref PersonNeeds needs, ref Presence presence) => { if (!presence.IsOnCampus) { return; } foreach (var def in school.Catalog.Needs.Values) { if (!def.Abstract && needs.Values.TryGetValue(def.DefName, out var value) && value < threshold) { ids.Add(identity.Id); return; } } }); return ids; } public static void EnqueueNewlyUrgent(School school, IReadOnlySet previouslyBelow) { foreach (var id in BelowThreshold(school)) { if (!previouslyBelow.Contains(id)) { school.DecisionQueue.Enqueue(id); } } } public static void Enqueue(School school, string personId) => school.DecisionQueue.Enqueue(personId); public static bool IsEmpty(School school) { var empty = true; var world = school.World; world.Query(in People, (ref Presence presence) => { if (presence.IsOnCampus) { empty = false; } }); return empty; } public static IReadOnlyList Capture(School school) { var rows = new List(); var world = school.World; world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) => { rows.Add(new PresenceSnapshot( identity.Id, presence.NodeId, presence.RemainingMinutes, presence.DestinationId, presence.HeadingHome, presence.Path, activity.ActionId, activity.Thing, activity.RemainingMinutes, intent.Kind.ToString(), intent.Id, intent.Weight, intent.ActionId)); }); rows.Sort((left, right) => StringComparer.Ordinal.Compare(left.PersonId, right.PersonId)); return rows; } public static void Restore(School school, IReadOnlyList? saved) { // A new school has no snapshot: people stay off campus and walk in. Missing ids inside // a real snapshot are hires and intake, and those land on their duty room. if (saved is null) { return; } var byId = saved .Where(row => !string.IsNullOrWhiteSpace(row.PersonId)) .ToDictionary(row => row.PersonId, StringComparer.Ordinal); ForEachPerson(school, (person, _, ref presence, ref activity, ref intent) => { if (!byId.TryGetValue(person.Id, out var row)) { presence = PlaceByDuty(school, person); activity = PersonActivity.Idle; intent = Intent.None; return; } if (row.NodeId is null) { presence = Presence.OffCampus; activity = PersonActivity.Idle; intent = Intent.None; return; } presence = new Presence( row.NodeId, row.RemainingMinutes, row.DestinationId, row.HeadingHome, row.Path?.ToArray() ?? []); activity = string.IsNullOrWhiteSpace(row.ActionId) ? PersonActivity.Idle : new PersonActivity(row.ActionId, row.ActionThing, row.ActionRemaining); intent = ParseIntent(row); }); } public static void PlaceMissingByDuty(School school) { ForEachPerson(school, (person, _, ref presence, ref activity, ref intent) => { if (!presence.IsOnCampus) { presence = PlaceByDuty(school, person); activity = PersonActivity.Idle; intent = Intent.None; } }); } public static void DrainDecisions(School school) { while (school.DecisionBudget > 0 && school.DecisionQueue.Count > 0) { var id = school.DecisionQueue.Dequeue(); Decide(school, id); school.DecisionBudget--; } } private static Presence PlaceByDuty(School school, Person person) { var room = Duty.RoomAt( person, ClassOf(school, person), school.Timetable, school.Catalog!, school.Clock.Time, school.SchoolWeekDays); if (room is null || school.Walks is null) { return Presence.OffCampus; } return new Presence(room, 0f, room, false, []); } private static void EnsurePlans(School school) { var day = DateOnly.FromDateTime(school.Clock.Time); if (school.PlanDay == day && school.Plans.Count == school.Roster!.People.Count) { return; } school.PlanDay = day; school.Plans.Clear(); foreach (var person in school.Roster!.People) { school.Plans[person.Id] = DayPlans.Build( school.Catalog!, school.Walks!, person, ClassOf(school, person), school.Timetable, school.Clock.Time, school.SchoolWeekDays, school.PeopleSeed); } school.DecisionQueue.Clear(); foreach (var person in OrderedPeople(school)) { school.DecisionQueue.Enqueue(person.Id); } } private static void EnqueueEvents(School school) { var slot = SchoolDay.At(school.Catalog!, school.Clock.Time, school.SchoolWeekDays); if (school.LastDecisionSlot == slot) { return; } school.LastDecisionSlot = slot; foreach (var person in OrderedPeople(school)) { school.DecisionQueue.Enqueue(person.Id); } } private static void EnqueueTimeEvents(School school, float minutes) { if (minutes <= 0) { return; } var now = school.Clock.Time; var previous = now.AddMinutes(-minutes); foreach (var person in OrderedPeople(school)) { if (!school.Plans.TryGetValue(person.Id, out var plan)) { continue; } if (plan.AppearAt is { } appear && previous < appear && now >= appear) { school.DecisionQueue.Enqueue(person.Id); } if (plan.WalkHomeAt is { } leave && previous < leave && now >= leave) { school.DecisionQueue.Enqueue(person.Id); } } } private static void Decide(School school, string personId) { var person = school.Roster!.People.FirstOrDefault(candidate => candidate.Id.Equals(personId, StringComparison.Ordinal)); if (person is null || !school.Plans.TryGetValue(personId, out var plan)) { return; } var occupied = SnapshotOccupied(school, personId); string? startAction = null; var world = school.World; var found = false; world.Query( in People, (ref PersonIdentity identity, ref PersonRoles roles, ref PersonNeeds needs, ref PersonTraits traits, ref Presence presence, ref PersonActivity activity, ref Intent intent) => { if (found || !identity.Id.Equals(personId, StringComparison.Ordinal)) { return; } found = true; _ = traits; startAction = ApplyDecision( school, person, plan, occupied, ref presence, ref activity, ref intent, roles, needs); }); if (startAction is not null) { ActivitySystem.TryStart(school, person.Id, startAction); } } private static string? ApplyDecision( School school, Person person, DayPlan plan, Dictionary<(string Node, string Thing), int> occupied, ref Presence presence, ref PersonActivity activity, ref Intent intent, PersonRoles roles, PersonNeeds needs) { var now = school.Clock.Time; var walks = school.Walks!; if (!presence.IsOnCampus) { intent = Intent.None; if (plan.AppearAt is { } appear && now >= appear && (plan.WalkHomeAt is null || now < plan.WalkHomeAt)) { var dest = plan.FirstRoom ?? Duty.RoomAt( person, ClassOf(school, person), school.Timetable, school.Catalog!, now, school.SchoolWeekDays); presence = dest is null ? Presence.OffCampus : PresenceStepper.StartWalk(Presence.OffCampus, walks, dest, headingHome: false); return null; } presence = Presence.OffCampus; return null; } if (plan.WalkHomeAt is { } leave && now >= leave) { activity = PersonActivity.Idle; intent = Intent.None; presence = PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true); return null; } var duty = Duty.RoomAt( person, ClassOf(school, person), school.Timetable, school.Catalog!, now, school.SchoolWeekDays); if (duty is null) { activity = PersonActivity.Idle; intent = Intent.None; presence = PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true); return null; } var slot = SchoolDay.At(school.Catalog!, now, school.SchoolWeekDays); var weekday = SchoolDay.WeekdayIndex(now); var lessons = Duty.LessonsToday(person, ClassOf(school, person), school.Timetable, weekday); var bound = Duty.IsOtherStaff(person) ? slot.Kind != DaySlotKind.Outside : slot.Kind == DaySlotKind.Lesson && lessons.Any(lesson => lesson.Period == slot.Index); var frame = school.Catalog!.DayFrame; var lunchOpen = frame is not null && SchoolDay.IsLunchWindow(frame, slot, ClassOf(school, person)?.Year); var state = new ActorState( presence.NodeId, presence.DestinationId, presence.Path.Length > 0 || presence.RemainingMinutes > 0, activity.IsActive, roles.IsStudent, roles.IsStaff, roles.IsParent, bound, duty, needs.Values, intent, lunchOpen); var decision = DecisionPlanner.Decide( school.Catalog!, school.Map!, walks, state, (node, thing) => occupied.GetValueOrDefault((node, thing))); if (decision.WalkTo is not null && !decision.WalkTo.Equals(presence.NodeId, StringComparison.Ordinal) && activity.IsActive) { activity = PersonActivity.Idle; } intent = decision.Intent; if (decision.WalkTo is not null) { presence = PresenceStepper.StartWalk(presence, walks, decision.WalkTo, headingHome: false); } return decision.StartAction is not null && !activity.IsActive ? decision.StartAction : null; } private static Dictionary<(string Node, string Thing), int> SnapshotOccupied(School school, string exceptId) { var occupied = new Dictionary<(string Node, string Thing), int>(); var world = school.World; world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity) => { if (identity.Id.Equals(exceptId, StringComparison.Ordinal) || !activity.IsActive || presence.NodeId is null || activity.Thing is null) { return; } var key = (presence.NodeId, activity.Thing); occupied[key] = occupied.GetValueOrDefault(key) + 1; }); return occupied; } private static void Move(School school, float minutes) { if (minutes <= 0 || school.Walks is null) { return; } var walks = school.Walks; var world = school.World; var arrived = new List(); world.Query(in People, (ref PersonIdentity identity, ref Presence presence) => { if (!presence.IsOnCampus) { return; } var travelling = presence.Path.Length > 0 || presence.RemainingMinutes > 0; var remaining = presence.RemainingMinutes - minutes; var node = presence.NodeId!; var path = presence.Path; var index = 0; while (remaining <= 0 && index < path.Length) { node = path[index]; index++; remaining += walks.TravelMinutes(node); } if (remaining < 0) { remaining = 0; } var leftover = index >= path.Length ? [] : path[index..]; presence = presence with { NodeId = node, RemainingMinutes = remaining, Path = leftover }; if (travelling && leftover.Length == 0 && remaining <= 0) { arrived.Add(identity.Id); } }); foreach (var id in arrived.OrderBy(value => value, StringComparer.Ordinal)) { school.DecisionQueue.Enqueue(id); } } private static void FinishHome(School school) { var yard = school.Walks?.TerritoryId; if (yard is null) { return; } var world = school.World; world.Query(in People, (ref Presence presence, ref PersonActivity activity, ref Intent intent) => { if (presence.HeadingHome && presence.NodeId is not null && presence.Path.Length == 0 && presence.RemainingMinutes <= 0 && presence.NodeId.Equals(yard, StringComparison.Ordinal)) { presence = Presence.OffCampus; activity = PersonActivity.Idle; intent = Intent.None; } }); } private static Intent ParseIntent(PresenceSnapshot row) { if (string.IsNullOrWhiteSpace(row.GoalKind) || !Enum.TryParse(row.GoalKind, out var kind) || kind == GoalKind.None) { return Intent.None; } return new Intent(kind, row.GoalId, row.GoalWeight, row.GoalAction); } private static SchoolClass? ClassOf(School school, Person person) { if (person.ClassId is null) { return null; } return school.Roster?.Classes.FirstOrDefault(row => row.Id.Equals(person.ClassId, StringComparison.Ordinal)); } private static IReadOnlyList OrderedPeople(School school) => school.Roster!.People.OrderBy(person => person.Id, StringComparer.Ordinal).ToArray(); private delegate void PersonAction( Person person, PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent); private static void ForEachPerson(School school, PersonAction action) { var roster = school.Roster!.People.ToDictionary(person => person.Id, StringComparer.Ordinal); var world = school.World; world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) => { if (roster.TryGetValue(identity.Id, out var person)) { action(person, identity, ref presence, ref activity, ref intent); } }); } } public sealed record PresenceSnapshot( string PersonId, string? NodeId, float RemainingMinutes, string? DestinationId, bool HeadingHome, IReadOnlyList Path, string? ActionId = null, string? ActionThing = null, float ActionRemaining = 0f, string? GoalKind = null, string? GoalId = null, float GoalWeight = 0f, string? GoalAction = null);