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
+29 -2
View File
@@ -250,7 +250,7 @@ public static class DecisionPlanner
OccupiedCount occupied,
ActionDef action)
{
if (action.Abstract || action.Weight <= 0 || !RoleFits(action, state))
if (action.Abstract || !RoleFits(action, state))
{
return Intent.None;
}
@@ -260,6 +260,32 @@ public static class DecisionPlanner
return Intent.None;
}
if (TalkActions.IsTeacherTalk(action.DefName))
{
if (state.BoundToLesson)
{
return Intent.None;
}
var teacherWeight = catalog.BehaviorRules?.TeacherAfterLessonWeight ?? 0f;
if (teacherWeight <= 0)
{
return Intent.None;
}
if (RoomFor(catalog, map, walks, state, occupied, action) is null)
{
return Intent.None;
}
return new Intent(GoalKind.Leisure, action.DefName, teacherWeight, action.DefName);
}
if (action.Weight <= 0)
{
return Intent.None;
}
if (action.DefName.Equals(TalkActions.PhoneChat, StringComparison.Ordinal) && !state.Talk.HasPhone)
{
return Intent.None;
@@ -371,7 +397,8 @@ public static class DecisionPlanner
{
if (action.Abstract
|| !need.Equals(action.Need, StringComparison.Ordinal)
|| !RoleFits(action, state))
|| !RoleFits(action, state)
|| (state.BoundToLesson && TalkCircles.BlocksOnLesson(action.DefName)))
{
continue;
}
+108 -3
View File
@@ -18,7 +18,8 @@ public readonly record struct TalkPlannerContext(
/// </summary>
public static class TalkCircles
{
public static bool BlocksOnLesson(string actionId) => TalkActions.IsTalk(actionId);
public static bool BlocksOnLesson(string actionId) =>
TalkActions.IsTalk(actionId) && !TalkActions.IsWhisper(actionId);
public static int MaxSize(string actionId, BehaviorDef? rules, IReadOnlyList<string> traitIds, DefCatalog catalog)
{
@@ -27,6 +28,12 @@ public static class TalkCircles
return 2;
}
if (actionId.Equals(TalkActions.TeacherTalk, StringComparison.Ordinal))
{
// Teacher plus 24 pupils; the live-circle cap of 4 is the pupil side.
return 1 + Math.Clamp(rules?.TalkCircleMax ?? 4, 2, 4);
}
var max = rules?.TalkCircleMax ?? 4;
foreach (var traitId in traitIds)
{
@@ -182,14 +189,16 @@ public static class TalkCircles
DefCatalog catalog,
Person picker,
int age,
int seed)
int seed,
bool whisperOnLesson = false)
{
var candidates = new List<(TopicDef Topic, float Weight)>();
foreach (var topic in catalog.Topics.Values)
{
if (topic.Abstract
|| !RoleFitsTopic(topic, picker.IsStudent, picker.IsStaff, picker.IsParent)
|| !AgeFits(topic, age))
|| !AgeFits(topic, age)
|| (whisperOnLesson && !topic.WhisperOnLesson))
{
continue;
}
@@ -368,6 +377,102 @@ public static class TalkCircles
.ToArray();
}
/// <summary>0…1 from a mixed seed. Same seed, same roll — never <c>string.GetHashCode</c>.</summary>
public static float Roll01(int seed)
{
var unit = (seed & int.MaxValue) / (float)int.MaxValue;
return Math.Clamp(unit, 0f, 1f);
}
/// <summary>Traits multiply: quiet dampens, outgoing raises, missing stays 1.</summary>
public static float WhisperCatchMultiplier(IReadOnlyList<string> traitIds, DefCatalog catalog)
{
var factor = 1f;
foreach (var traitId in traitIds)
{
if (catalog.Traits.TryGetValue(traitId, out var trait) && trait.WhisperCatchMultiplier > 0f)
{
factor *= trait.WhisperCatchMultiplier;
}
}
return factor;
}
/// <summary>
/// True when this roll catches a whisper. <paramref name="catchMax"/> keeps it below 100 %
/// even with loud traits.
/// </summary>
public static bool WhisperCaught(float roll, float baseChance, float catchMax, float traitMultiplier)
{
var ceiling = Math.Clamp(catchMax, 0f, 0.99f);
var chance = Math.Clamp(baseChance * Math.Max(0f, traitMultiplier), 0f, ceiling);
return chance > 0f && roll < chance;
}
/// <summary>
/// Pupil → teacher opinion after talk. Pedagogy 50 is identity; a strong teacher after praise
/// pluses more, a weak reprimand angers more.
/// </summary>
public static int ScaleByPedagogy(int delta, float pedagogy, BehaviorDef? rules)
{
if (delta == 0)
{
return 0;
}
var t = (Math.Clamp(pedagogy, 0f, 100f) - 50f) / 50f;
var k = rules?.TalkPedagogyOpinionScale ?? 0.4f;
var scale = delta > 0 ? 1f + (t * k) : 1f - (t * k);
var scaled = (int)Math.Round(delta * Math.Max(0.1f, scale), MidpointRounding.AwayFromZero);
if (delta > 0)
{
return Math.Max(1, scaled);
}
return Math.Min(-1, scaled);
}
public static string? PickTeacherTopic(DefCatalog catalog, BehaviorDef? rules, int seed)
{
var options = new (string Id, float Weight)[]
{
(TeacherTopics.Question, rules?.TeacherQuestionWeight ?? 2f),
(TeacherTopics.Praise, rules?.TeacherPraiseWeight ?? 2f),
(TeacherTopics.Discipline, rules?.TeacherReprimandWeight ?? 1f),
};
var available = new List<(string Id, float Weight)>();
foreach (var option in options)
{
if (option.Weight > 0f && catalog.Topics.ContainsKey(option.Id))
{
available.Add(option);
}
}
if (available.Count == 0)
{
return catalog.Topics.Values.FirstOrDefault(topic => !topic.Abstract)?.DefName;
}
var total = available.Sum(pair => pair.Weight);
var roll = Roll01(seed) * total;
var cursor = 0f;
foreach (var (id, weight) in available.OrderBy(pair => pair.Id, StringComparer.Ordinal))
{
cursor += weight;
if (roll <= cursor)
{
return id;
}
}
return available[^1].Id;
}
public static float LessonWhisperFactor(string? actionId, BehaviorDef? rules) =>
TalkActions.IsWhisper(actionId) ? rules?.LessonWhisperSkillFactor ?? 0.4f : 1f;
public readonly record struct Candidate(
string Id,
string? ClassId,