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>
This commit is contained in:
Leonid Pershin
2026-08-20 10:35:31 +03:00
co-authored by Cursor
parent cb869e4977
commit f7d5e0c042
22 changed files with 1061 additions and 36 deletions
+193 -7
View File
@@ -18,6 +18,9 @@ internal sealed class ActiveTalkCircle
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>
@@ -59,7 +62,7 @@ internal static class TalkCircleSystem
}
var person = school.Roster.People.FirstOrDefault(row => row.Id.Equals(personId, StringComparison.Ordinal));
if (person is null)
if (person is null || !RoleFits(action, person))
{
return false;
}
@@ -115,6 +118,11 @@ internal static class TalkCircleSystem
{
circle.RemainingMinutes -= minutes;
SyncActivity(school, circle);
if (TalkActions.IsWhisper(circle.ActionId) && TryCatchWhisper(school, circle))
{
continue;
}
if (circle.RemainingMinutes > 0)
{
continue;
@@ -173,7 +181,8 @@ internal static class TalkCircleSystem
{
var rules = school.Catalog!.BehaviorRules;
var max = TalkCircles.MaxSize(action.DefName, rules, initiator.Traits, school.Catalog);
var candidates = GatherCandidates(school, initiator.Id, nodeId, requirePhone: false);
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)
@@ -186,7 +195,7 @@ internal static class TalkCircleSystem
members.Add(id);
}
var min = rules?.TalkCircleMin ?? 2;
var min = TalkActions.IsTeacherTalk(action.DefName) ? 3 : rules?.TalkCircleMin ?? 2;
if (members.Count < min)
{
return false;
@@ -196,8 +205,17 @@ internal static class TalkCircleSystem
school.Catalog,
initiator,
initiator.AgeOn(school.Clock.Time),
Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 21))
?? school.Catalog.Topics.Values.First(topic => !topic.Abstract).DefName;
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
{
@@ -216,6 +234,11 @@ internal static class TalkCircleSystem
{
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))
{
@@ -238,7 +261,7 @@ internal static class TalkCircleSystem
&& circle.RemainingMinutes > 0)
{
var rules = school.Catalog!.BehaviorRules;
var max = rules?.TalkCircleMax ?? 4;
var max = TalkCircles.MaxSize(actionId, rules, [], school.Catalog);
if (circle.Members.Count < max)
{
return circle;
@@ -253,7 +276,8 @@ internal static class TalkCircleSystem
School school,
string exceptId,
string nodeId,
bool requirePhone)
bool requirePhone,
bool studentsOnly = false)
{
var roster = school.Roster!.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var list = new List<TalkCircles.Candidate>();
@@ -268,6 +292,11 @@ internal static class TalkCircleSystem
return;
}
if (studentsOnly && !roles.IsStudent)
{
return;
}
if (!roster.TryGetValue(identity.Id, out var person))
{
return;
@@ -296,6 +325,14 @@ internal static class TalkCircleSystem
{
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));
}
}
}
@@ -392,6 +429,11 @@ internal static class TalkCircleSystem
appearance,
rules,
catalog);
if (person.IsStudent && other.IsStaff)
{
delta = TalkCircles.ScaleByPedagogy(delta, SkillOf(school, other.Id, "Pedagogy"), rules);
}
if (delta == 0)
{
continue;
@@ -417,6 +459,150 @@ internal static class TalkCircleSystem
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