Files
h-school/src/HSchool.Simulation/TalkCircleSystem.cs
T
Leonid PershinandCursor f7d5e0c042 Add lesson whispers, teacher catch chance, and after-bell teacher talk.
Whispering cuts lesson skill gain from BehaviorDef; a teacher in the room may interrupt into a discipline circle, and pedagogy scales the pupil's opinion of the teacher.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 10:35:31 +03:00

674 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string> Members { get; init; }
public float RemainingMinutes { get; set; }
/// <summary>Set once a teacher in the room has rolled to notice this whisper.</summary>
public bool CatchChecked { get; set; }
}
/// <summary>Forms 24 person talk circles, applies outcomes, writes topic log lines.</summary>
internal static class TalkCircleSystem
{
private static readonly QueryDescription People =
new QueryDescription().WithAll<PersonIdentity, PersonRoles, PersonTraits, PersonSkills, PersonNeeds, Presence, PersonActivity>();
public static bool IsInCircle(School school, string personId) =>
school.TalkCircleByPerson.ContainsKey(personId);
/// <summary>Ends an active circle when duty overrides break talk (bell rang).</summary>
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<string> 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<string>();
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<string> { 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<TalkCircles.Candidate> 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<TalkCircles.Candidate>();
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<string>();
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<string> { 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;
}
});
}
}