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.Weather.ExtraCommuteMinutes(school.Catalog!.BehaviorRules)); } 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!; 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); if (DirectorSummonSystem.IsSummoned(school, person.Id) || ParentMeetingSystem.IsInMeeting(school, person.Id)) { bound = true; } if (activity.IsActive && TalkActions.IsTalk(activity.ActionId)) { if (!bound || TalkActions.IsWhisper(activity.ActionId) || TalkActions.IsFight(activity.ActionId)) { return null; } TalkCircleSystem.Interrupt(school, person.Id); activity = PersonActivity.Idle; } if (activity.IsActive && ActivitySystem.IsPrincipalHearing(activity.ActionId)) { return null; } if (activity.IsActive && ActivitySystem.IsParentMeeting(activity.ActionId)) { return null; } if (!presence.IsOnCampus) { intent = Intent.None; if (ParentMeetingSystem.TryDutyRoom(school, person.Id, out var meetingArrive)) { presence = PresenceStepper.StartWalk( Presence.OffCampus, walks, meetingArrive, headingHome: false); return null; } 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); if (DirectorSummonSystem.TryDutyRoom(school, person.Id, out var summonArrive)) { dest = summonArrive; } 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 && !DirectorSummonSystem.IsSummoned(school, person.Id) && !ParentMeetingSystem.IsInMeeting(school, person.Id)) { 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 (DirectorSummonSystem.TryDutyRoom(school, person.Id, out var summonRoom)) { duty = summonRoom; bound = true; } else if (ParentMeetingSystem.TryDutyRoom(school, person.Id, out var meetingRoom)) { duty = meetingRoom; bound = true; } if (duty is null) { activity = PersonActivity.Idle; intent = Intent.None; presence = PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true); return null; } var frame = school.Catalog!.DayFrame; var lunchOpen = frame is not null && 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 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, apparel, talk); var decision = DecisionPlanner.Decide( school.Catalog!, school.Map!, walks, state, (node, thing) => occupied.GetValueOrDefault((node, thing))); var changing = activity.IsActive && ActivitySystem.IsChangeClothes(activity.ActionId); var inTalk = activity.IsActive && TalkActions.IsTalk(activity.ActionId); var inHearing = activity.IsActive && ActivitySystem.IsPrincipalHearing(activity.ActionId); if (decision.WalkTo is not null && !decision.WalkTo.Equals(presence.NodeId, StringComparison.Ordinal) && activity.IsActive && !changing && !inTalk && !inHearing) { activity = PersonActivity.Idle; } intent = decision.Intent; if (decision.WalkTo is not null && !changing && !inTalk && !inHearing) { presence = PresenceStepper.StartWalk(presence, walks, decision.WalkTo, headingHome: false); } if (decision.StartAction is not null && !activity.IsActive) { var start = decision.StartAction; var location = state.NodeId is null ? null : school.Map?.NodeDef(state.NodeId); if (TalkActions.IsQuarrel(start) && location is not null && Conflict.CanFightHere(location) && Conflict.RollFight( person.Traits, school.Catalog!, school.Catalog.BehaviorRules, Seed.Mix( school.PeopleSeed, person.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ConflictSalt + 2))) { start = Conflict.FightActionFor(location); } return start; } if (!activity.IsActive && decision.WalkTo is null && ShouldStartWhisper(school, person, state)) { return TalkActions.Whisper; } if (!activity.IsActive && decision.WalkTo is null && ShouldStartFightOnPe(school, person, state)) { return TalkActions.FightGym; } if (!activity.IsActive && decision.WalkTo is null && ShouldStartApology(school, person, state)) { return TalkActions.Apologize; } return null; } private static bool ShouldStartWhisper(School school, Person person, ActorState state) { if (!state.BoundToLesson || !person.IsStudent || state.IsWalking || state.NodeId is null || state.DutyRoom is null || !state.NodeId.Equals(state.DutyRoom, StringComparison.Ordinal)) { return false; } var location = school.Map?.NodeDef(state.NodeId); if (location is null || !location.Equals("Classroom", StringComparison.Ordinal)) { return false; } var rules = school.Catalog?.BehaviorRules; var chance = (rules?.WhisperStartChance ?? 0.12f) * TalkCircles.Initiative(person.Traits, school.Catalog!); var roll = TalkCircles.Roll01( Seed.Mix( school.PeopleSeed, person.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.WhisperSalt + (school.Clock.Time.Minute / 2))); return TalkCircles.WhisperCaught(roll, chance, 0.5f, 1f); } private static bool ShouldStartFightOnPe(School school, Person person, ActorState state) { if (!state.BoundToLesson || !person.IsStudent || state.IsWalking || state.NodeId is null || school.Catalog is null) { return false; } var location = school.Map?.NodeDef(state.NodeId); if (location is null || !location.Equals("GymHall", StringComparison.Ordinal)) { return false; } if (!Conflict.HasRival(state.Talk)) { return false; } return Conflict.RollFight( person.Traits, school.Catalog, school.Catalog.BehaviorRules, Seed.Mix( school.PeopleSeed, person.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ConflictSalt + 3 + (school.Clock.Time.Minute / 2))); } private static bool ShouldStartApology(School school, Person person, ActorState state) { if (!person.IsStudent || state.IsWalking || state.NodeId is null || school.Catalog is null) { return false; } var hasDebtHere = false; foreach (var debt in school.ApologyDebts.Values) { if (!debt.FromId.Equals(person.Id, StringComparison.Ordinal)) { continue; } if (!state.Talk.PersonNodes.TryGetValue(debt.ToId, out var node) || !node.Equals(state.NodeId, StringComparison.Ordinal)) { continue; } hasDebtHere = true; break; } if (!hasDebtHere) { return false; } var chance = Math.Clamp( Conflict.ApologyChance(person.Traits, school.Catalog), 0f, 1f); return Conflict.RollStarts( chance, Seed.Mix( school.PeopleSeed, person.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ConflictSalt + 4)); } 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 TalkPlannerContext BuildTalkContext( School school, Person person, bool boundToLesson, bool lunchOpen, DaySlot slot) { var opinions = person.Opinions as IReadOnlyDictionary ?? new Dictionary(StringComparer.Ordinal); var nodes = SnapshotPersonNodes(school); var friendPull = !boundToLesson && (slot.Kind == DaySlotKind.Break || lunchOpen); return new TalkPlannerContext( person.Id, opinions, nodes, person.ClassId, TalkCircles.HasPhone(person.Items), friendPull, school.Catalog?.BehaviorRules, person.BullyVictimId); } private static Dictionary SnapshotPersonNodes(School school) { var nodes = new Dictionary(StringComparer.Ordinal); var world = school.World; world.Query(in People, (ref PersonIdentity identity, ref Presence presence) => { if (presence.IsOnCampus && presence.NodeId is not null) { nodes[identity.Id] = presence.NodeId; } }); return nodes; } 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); /// Ids of an active talk circle. Names are resolved by the client, not here. public sealed record TalkCirclePresence(string TopicId, IReadOnlyList MemberIds);