using Arch.Core; using HSchool.Ai; using HSchool.Content; using HSchool.People; namespace HSchool.Simulation; internal sealed class ActiveTalkCircle { public required string Id { get; init; } public required string ActionId { get; init; } public required string TopicId { get; init; } public required string NodeId { get; init; } public required List Members { get; init; } public float RemainingMinutes { get; set; } /// Set once a teacher in the room has rolled to notice this whisper. public bool CatchChecked { get; set; } } /// Forms 2–4 person talk circles, applies outcomes, writes topic log lines. internal static class TalkCircleSystem { private static readonly QueryDescription People = new QueryDescription().WithAll(); public static bool IsInCircle(School school, string personId) => school.TalkCircleByPerson.ContainsKey(personId); /// Ends an active circle when duty overrides break talk (bell rang). public static void Interrupt(School school, string personId) { if (!school.TalkCircleByPerson.TryGetValue(personId, out var circleId) || !school.TalkCirclesById.TryGetValue(circleId, out var circle)) { return; } Finish(school, circle); } public static bool TryStart(School school, string personId, string actionId) { if (school.Catalog is null || school.Map is null || school.Roster is null) { return false; } if (!TalkActions.IsTalk(actionId) || !school.Catalog.Actions.TryGetValue(actionId, out var action) || action.Abstract) { return false; } if (school.TalkCircleByPerson.ContainsKey(personId)) { return true; } var person = school.Roster.People.FirstOrDefault(row => row.Id.Equals(personId, StringComparison.Ordinal)); if (person is null || !RoleFits(action, person)) { return false; } string? nodeId = null; var busy = false; school.World.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity) => { if (!identity.Id.Equals(personId, StringComparison.Ordinal)) { return; } nodeId = presence.NodeId; busy = activity.IsActive; }); if (nodeId is null || busy) { return false; } var location = school.Map.NodeDef(nodeId); if (location is null || !location.Equals(action.Room, StringComparison.Ordinal)) { return false; } if (action.DefName.Equals(TalkActions.PhoneChat, StringComparison.Ordinal)) { return TryStartPhone(school, person, nodeId, action); } var existing = FindOpenCircle(school, nodeId, actionId); if (existing is not null) { return JoinCircle(school, existing, personId, action); } return TryStartGroup(school, person, nodeId, action); } public static IReadOnlyList Apply(School school, double gameMinutes) { if (gameMinutes <= 0 || school.Catalog is null || school.Roster is null) { return []; } var minutes = (float)gameMinutes; var completed = new List(); foreach (var circle in school.TalkCirclesById.Values.ToArray()) { circle.RemainingMinutes -= minutes; SyncActivity(school, circle); if (TalkActions.IsWhisper(circle.ActionId) && TryCatchWhisper(school, circle)) { continue; } if (circle.RemainingMinutes > 0) { continue; } Finish(school, circle); completed.AddRange(circle.Members); } completed.Sort(StringComparer.Ordinal); return completed; } private static bool TryStartPhone(School school, Person initiator, string nodeId, ActionDef action) { if (!TalkCircles.HasPhone(initiator.Items)) { return false; } var candidates = GatherCandidates(school, initiator.Id, nodeId, requirePhone: true); var ranked = TalkCircles.RankInvitees(initiator, candidates, school.Catalog!.BehaviorRules); if (ranked.Count == 0) { return false; } var partnerId = ranked[0]; var partner = school.Roster!.People.First(row => row.Id.Equals(partnerId, StringComparison.Ordinal)); if (!TalkCircles.HasPhone(partner.Items)) { return false; } var topicId = TalkCircles.PickTopic( school.Catalog!, initiator, initiator.AgeOn(school.Clock.Time), Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 20)) ?? school.Catalog!.Topics.Values.First(topic => !topic.Abstract).DefName; var circle = new ActiveTalkCircle { Id = $"phone-{initiator.Id}-{partnerId}", ActionId = action.DefName, TopicId = topicId, NodeId = nodeId, Members = [initiator.Id, partnerId], RemainingMinutes = action.Minutes, }; Register(school, circle, action); return true; } private static bool TryStartGroup(School school, Person initiator, string nodeId, ActionDef action) { var rules = school.Catalog!.BehaviorRules; var max = TalkCircles.MaxSize(action.DefName, rules, initiator.Traits, school.Catalog); var studentsOnly = TalkActions.IsWhisper(action.DefName) || TalkActions.IsTeacherTalk(action.DefName); var candidates = GatherCandidates(school, initiator.Id, nodeId, requirePhone: false, studentsOnly); var ranked = TalkCircles.RankInvitees(initiator, candidates, rules); var members = new List { initiator.Id }; foreach (var id in ranked) { if (members.Count >= max) { break; } members.Add(id); } var min = TalkActions.IsTeacherTalk(action.DefName) ? 3 : rules?.TalkCircleMin ?? 2; if (members.Count < min) { return false; } var topicId = TalkCircles.PickTopic( school.Catalog, initiator, initiator.AgeOn(school.Clock.Time), Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 21), whisperOnLesson: TalkActions.IsWhisper(action.DefName)); if (TalkActions.IsTeacherTalk(action.DefName)) { topicId = TalkCircles.PickTeacherTopic( school.Catalog, rules, Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.WhisperSalt)); } topicId ??= school.Catalog.Topics.Values.First(topic => !topic.Abstract).DefName; var circle = new ActiveTalkCircle { Id = $"talk-{initiator.Id}-{nodeId}-{school.Clock.Time.Ticks}", ActionId = action.DefName, TopicId = topicId, NodeId = nodeId, Members = members, RemainingMinutes = action.Minutes, }; Register(school, circle, action); return true; } private static bool JoinCircle(School school, ActiveTalkCircle circle, string personId, ActionDef action) { var rules = school.Catalog!.BehaviorRules; var person = school.Roster!.People.First(row => row.Id.Equals(personId, StringComparison.Ordinal)); if ((TalkActions.IsWhisper(action.DefName) || TalkActions.IsTeacherTalk(action.DefName)) && !person.IsStudent) { return false; } var max = TalkCircles.MaxSize(action.DefName, rules, person.Traits, school.Catalog); if (circle.Members.Count >= max || circle.Members.Contains(personId)) { return false; } circle.Members.Add(personId); circle.Members.Sort(StringComparer.Ordinal); school.TalkCircleByPerson[personId] = circle.Id; SetActivity(school, personId, action.DefName, circle.TopicId, circle.RemainingMinutes); return true; } private static ActiveTalkCircle? FindOpenCircle(School school, string nodeId, string actionId) { foreach (var circle in school.TalkCirclesById.Values) { if (circle.NodeId.Equals(nodeId, StringComparison.Ordinal) && circle.ActionId.Equals(actionId, StringComparison.Ordinal) && circle.RemainingMinutes > 0) { var rules = school.Catalog!.BehaviorRules; var max = TalkCircles.MaxSize(actionId, rules, [], school.Catalog); if (circle.Members.Count < max) { return circle; } } } return null; } private static IReadOnlyList GatherCandidates( School school, string exceptId, string nodeId, bool requirePhone, bool studentsOnly = false) { var roster = school.Roster!.People.ToDictionary(person => person.Id, StringComparer.Ordinal); var list = new List(); school.World.Query( in People, (ref PersonIdentity identity, ref PersonRoles roles, ref Presence presence, ref PersonActivity activity) => { if (identity.Id.Equals(exceptId, StringComparison.Ordinal) || presence.NodeId is null || !presence.NodeId.Equals(nodeId, StringComparison.Ordinal)) { return; } if (studentsOnly && !roles.IsStudent) { return; } if (!roster.TryGetValue(identity.Id, out var person)) { return; } if (requirePhone && !TalkCircles.HasPhone(person.Items)) { return; } list.Add(new TalkCircles.Candidate( identity.Id, roles.ClassId, !activity.IsActive && !school.TalkCircleByPerson.ContainsKey(identity.Id), school.TalkCircleByPerson.ContainsKey(identity.Id), TalkCircles.HasPhone(person.Items))); }); return list.Where(candidate => candidate.IsIdle).ToArray(); } private static void Register(School school, ActiveTalkCircle circle, ActionDef action) { school.TalkCirclesById[circle.Id] = circle; foreach (var member in circle.Members) { school.TalkCircleByPerson[member] = circle.Id; SetActivity(school, member, action.DefName, circle.TopicId, circle.RemainingMinutes); if (TalkActions.IsWhisper(action.DefName)) { school.AppendDayLog(new PersonLogEvent( member, school.Clock.Time, PersonLogTypes.Whispered, circle.TopicId)); } } } private static void SetActivity(School school, string personId, string actionId, string topicId, float remaining) { school.World.Query( in People, (ref PersonIdentity identity, ref PersonActivity activity) => { if (identity.Id.Equals(personId, StringComparison.Ordinal)) { activity = new PersonActivity(actionId, topicId, remaining); } }); } private static void SyncActivity(School school, ActiveTalkCircle circle) { foreach (var member in circle.Members) { SetActivity(school, member, circle.ActionId, circle.TopicId, Math.Max(0f, circle.RemainingMinutes)); } } private static void Finish(School school, ActiveTalkCircle circle) { var catalog = school.Catalog!; var rules = catalog.BehaviorRules; var roster = school.Roster!; var people = circle.Members .Select(id => roster.People.First(person => person.Id.Equals(id, StringComparison.Ordinal))) .ToArray(); if (!catalog.Topics.TryGetValue(circle.TopicId, out var topic)) { topic = catalog.Topics.Values.First(def => !def.Abstract); } if (!catalog.Actions.TryGetValue(circle.ActionId, out var action)) { action = catalog.Actions[TalkActions.Chat]; } var hours = action.Minutes / 60f; var sharedLanguage = TalkCircles.SharedLanguage(catalog, people, rules) is not null; var language = TalkCircles.SharedLanguage(catalog, people, rules); foreach (var person in people) { if (!string.IsNullOrWhiteSpace(action.Need) && catalog.Needs.TryGetValue(action.Need, out var need)) { var current = NeedOf(school, person.Id, action.Need); if (float.IsNaN(current)) { current = need.Min; } var next = ActionStepper.ApplyNeedGain(current, action, need); MutateNeed(school, person.Id, action.Need, next); } if (catalog.Skills.TryGetValue("Communication", out var communicationSkill)) { var level = SkillOf(school, person.Id, "Communication"); var next = TalkCircles.CommunicationGain(level, communicationSkill, hours, rules); MutateSkill(school, person.Id, "Communication", next); } if (language is not null && catalog.Skills.TryGetValue(language, out var languageSkill) && SkillOf(school, person.Id, language) < languageSkill.Range.Max) { var level = SkillOf(school, person.Id, language); var next = TalkCircles.LanguageGain(level, languageSkill, hours, rules); MutateSkill(school, person.Id, language, next); } foreach (var other in people) { if (other.Id.Equals(person.Id, StringComparison.Ordinal)) { continue; } var communication = SkillOf(school, person.Id, "Communication"); var appearance = AppearanceOf(school, other); var delta = TalkCircles.OpinionDelta( person, other, topic, communication, sharedLanguage, circle.ActionId, appearance, rules, catalog); if (person.IsStudent && other.IsStaff) { delta = TalkCircles.ScaleByPedagogy(delta, SkillOf(school, other.Id, "Pedagogy"), rules); } if (delta == 0) { continue; } var current = OpinionStore.Get(person, other.Id) ?? 0; OpinionStore.Set(person, other.Id, current + delta); } school.AppendDayLog(new PersonLogEvent( person.Id, school.Clock.Time, PersonLogTypes.TalkEnded, circle.TopicId)); } foreach (var member in circle.Members) { school.TalkCircleByPerson.Remove(member); ClearActivity(school, member); } school.TalkCirclesById.Remove(circle.Id); } private static bool TryCatchWhisper(School school, ActiveTalkCircle circle) { if (circle.CatchChecked) { return false; } var teacherId = StaffStandingIn(school, circle.NodeId); if (teacherId is null) { return false; } circle.CatchChecked = true; var rules = school.Catalog!.BehaviorRules; var traits = new List(); foreach (var memberId in circle.Members) { var member = school.Roster!.People.FirstOrDefault(person => person.Id.Equals(memberId, StringComparison.Ordinal)); if (member is not null) { traits.AddRange(member.Traits); } } var roll = TalkCircles.Roll01( Seed.Mix(school.PeopleSeed, circle.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.WhisperSalt)); var multiplier = TalkCircles.WhisperCatchMultiplier(traits, school.Catalog); if (!TalkCircles.WhisperCaught( roll, rules?.WhisperCatchChance ?? 0.4f, rules?.WhisperCatchMax ?? 0.85f, multiplier)) { return false; } ConvertToReprimand(school, circle, teacherId); return true; } private static void ConvertToReprimand(School school, ActiveTalkCircle circle, string teacherId) { var pupils = circle.Members.ToArray(); foreach (var member in pupils) { school.AppendDayLog(new PersonLogEvent( member, school.Clock.Time, PersonLogTypes.TeacherInterrupted, circle.TopicId)); school.TalkCircleByPerson.Remove(member); ClearActivity(school, member); } school.TalkCirclesById.Remove(circle.Id); if (!school.Catalog!.Actions.TryGetValue(TalkActions.TeacherTalk, out var action)) { return; } var members = new List { teacherId }; var max = TalkCircles.MaxSize(TalkActions.TeacherTalk, school.Catalog.BehaviorRules, [], school.Catalog); foreach (var id in pupils.OrderBy(value => value, StringComparer.Ordinal)) { if (members.Count >= max) { break; } if (!members.Contains(id, StringComparer.Ordinal)) { members.Add(id); } } if (members.Count < 3) { return; } var reprimand = new ActiveTalkCircle { Id = $"reprimand-{teacherId}-{circle.NodeId}-{school.Clock.Time.Ticks}", ActionId = TalkActions.TeacherTalk, TopicId = TeacherTopics.Discipline, NodeId = circle.NodeId, Members = members, RemainingMinutes = action.Minutes, CatchChecked = true, }; Register(school, reprimand, action); } private static string? StaffStandingIn(School school, string nodeId) { string? found = null; school.World.Query( in People, (ref PersonIdentity identity, ref PersonRoles roles, ref Presence presence, ref PersonActivity activity) => { if (found is not null || !roles.IsStaff || presence.NodeId is null || !presence.NodeId.Equals(nodeId, StringComparison.Ordinal) || presence.Path.Length > 0 || presence.RemainingMinutes > 0) { return; } found = identity.Id; }); return found; } private static bool RoleFits(ActionDef action, Person person) { if (action.Roles.Count == 0) { return true; } foreach (var role in action.Roles) { if (role.Equals(HSchool.Content.PersonRoles.Student, StringComparison.OrdinalIgnoreCase) && person.IsStudent) { return true; } if (role.Equals(HSchool.Content.PersonRoles.Staff, StringComparison.OrdinalIgnoreCase) && person.IsStaff) { return true; } if (role.Equals(HSchool.Content.PersonRoles.Parent, StringComparison.OrdinalIgnoreCase) && person.IsParent) { return true; } } return false; } private static ApparelIssue AppearanceOf(School school, Person person) { var schoolClass = person.ClassId is { } classId ? school.Roster!.Classes.FirstOrDefault(row => row.Id.Equals(classId, StringComparison.Ordinal)) : null; var mode = ApparelPresence.ModeAt(school, person, schoolClass); return ApparelDresser.CurrentIssues(school, person, mode); } private static float NeedOf(School school, string personId, string need) { var value = float.NaN; school.World.Query(in People, (ref PersonIdentity identity, ref PersonNeeds needs) => { if (identity.Id.Equals(personId, StringComparison.Ordinal)) { value = needs.Values.GetValueOrDefault(need, float.NaN); } }); return value; } private static float SkillOf(School school, string personId, string skill) { var value = 0f; school.World.Query(in People, (ref PersonIdentity identity, ref PersonSkills skills) => { if (identity.Id.Equals(personId, StringComparison.Ordinal)) { value = skills.Values.GetValueOrDefault(skill); } }); return value; } private static void MutateSkill(School school, string personId, string skill, float value) { school.World.Query(in People, (ref PersonIdentity identity, ref PersonSkills skills) => { if (identity.Id.Equals(personId, StringComparison.Ordinal)) { skills.Values[skill] = value; } }); } private static void MutateNeed(School school, string personId, string need, float value) { school.World.Query(in People, (ref PersonIdentity identity, ref PersonNeeds needs) => { if (identity.Id.Equals(personId, StringComparison.Ordinal)) { needs.Values[need] = value; } }); } private static void ClearActivity(School school, string personId) { school.World.Query(in People, (ref PersonIdentity identity, ref PersonActivity activity) => { if (identity.Id.Equals(personId, StringComparison.Ordinal)) { activity = PersonActivity.Idle; } }); } }